feat(providers/pi): default extensions + interactive to on

Extensions (community packages like @plannotator/pi-extension and
user-authored ones) are a core reason users pick Pi. Defaulting
enableExtensions and interactive to false previously silenced installed
extensions with no signal, leading to "did my extension even load?"
confusion.

Opt out in .archon/config.yaml when you want the prior behavior:

  assistants:
    pi:
      enableExtensions: false   # skip extension discovery entirely
      # interactive: false       # load extensions, but no UI bridge

Docs gain a new "Extensions (on by default)" section in
getting-started/ai-assistants.md that documents the three config
surfaces (extensionFlags, env, workflow-level interactive) and uses
plannotator as a concrete walk-through example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cole Medin 2026-04-20 07:32:41 -05:00
parent 2239b65c92
commit f3d6ebc8b9
3 changed files with 81 additions and 16 deletions

View file

@ -264,6 +264,54 @@ Pi supports both OAuth subscriptions and API keys. Archon's adapter reads your e
Additional Pi backends exist (Azure, Bedrock, Vertex, etc.) — file an issue if you need them wired.
### Extensions (on by default)
A major reason to pick Pi is its **extension ecosystem**: community packages (installed via `pi install npm:<package>`) and your own local ones that hook into the agent's lifecycle. Extensions can intercept tool calls, gate execution on human review, post to external systems, render UIs — anything the Pi extension API exposes.
Archon turns extensions **on by default**. To opt out in `.archon/config.yaml`:
```yaml
assistants:
pi:
enableExtensions: false # skip extension discovery entirely
# interactive: false # keep extensions loaded, but give them no UI bridge
```
Most extensions need three config surfaces:
| Surface | Purpose |
|---|---|
| `extensionFlags` | Per-extension feature flags (maps 1:1 to Pi's `--flag` CLI switches) |
| `env` | Env vars the extension reads at runtime (managed via `.archon/config.yaml` or the Web UI codebase env panel) |
| Workflow-level `interactive: true` | Required for **approval-gate extensions** on the web UI — forces foreground execution so the user can respond |
**Example — [plannotator](https://github.com/dmcglinn/plannotator) (human-in-the-loop plan review):**
```bash
# One-time install into your Pi home
pi install npm:@plannotator/pi-extension
```
```yaml
# .archon/config.yaml
assistants:
pi:
model: anthropic/claude-haiku-4-5
extensionFlags:
plan: true # enables the plannotator "plan" flag
env:
PLANNOTATOR_REMOTE: "1" # exposes the review URL on 127.0.0.1:19432 so you can open it from anywhere
```
```yaml
# .archon/workflows/my-piv.yaml
name: my-piv
provider: pi
interactive: true # plannotator gates the node on human approval — required on web UI
```
When the node runs, plannotator prints a review URL and blocks until you click approve/deny in the browser. Archon's CLI/SSE batch buffer flushes that URL to you immediately so you never get stuck waiting on a node that silently wants input.
### Model reference format
Pi models use a `<pi-provider-id>/<model-id>` format:
@ -304,6 +352,7 @@ nodes:
| Feature | Support | YAML field |
|---|---|---|
| Extensions (community + local) | ✅ (default on) | `enableExtensions: false` to disable; `interactive: false` to load without UI bridge; `extensionFlags: { <name>: true }` per extension |
| Session resume | ✅ | automatic (Archon persists `sessionId`) |
| Tool restrictions | ✅ | `allowed_tools` / `denied_tools` (read, bash, edit, write, grep, find, ls) |
| Thinking level | ✅ | `effort: low\|medium\|high\|max` (max → xhigh) |

View file

@ -870,7 +870,7 @@ describe('PiProvider', () => {
| Record<string, unknown>
| undefined;
expect(loaderArgs?.systemPrompt).toBe('You are a careful investigator.');
expect(loaderArgs?.noExtensions).toBe(true);
expect(loaderArgs?.noExtensions).toBe(false);
expect(loaderArgs?.noContextFiles).toBe(true);
});
@ -924,7 +924,7 @@ describe('PiProvider', () => {
expect(caps.hooks).toBe(false);
});
test('extensions are suppressed by default (noExtensions: true)', async () => {
test('extensions are enabled by default (noExtensions: false)', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
@ -937,7 +937,15 @@ describe('PiProvider', () => {
const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(loaderArgs?.noExtensions).toBe(true);
// Extensions (community packages and user-authored) are a core reason
// users run Pi; off-by-default silently broke users who installed or
// authored one and expected it to fire.
expect(loaderArgs?.noExtensions).toBe(false);
// Skills/prompts/themes/context stay suppressed — only extensions flip on.
expect(loaderArgs?.noSkills).toBe(true);
expect(loaderArgs?.noPromptTemplates).toBe(true);
expect(loaderArgs?.noThemes).toBe(true);
expect(loaderArgs?.noContextFiles).toBe(true);
});
test('assistantConfig.enableExtensions: true flips noExtensions to false', async () => {
@ -1273,32 +1281,33 @@ describe('PiProvider', () => {
expect(bindings.uiContext).toBeDefined();
});
test('interactive: true without enableExtensions does NOT bind (clamped silently)', async () => {
test('enableExtensions: false disables binding even if interactive: true is set', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
assistantConfig: { interactive: true },
assistantConfig: { enableExtensions: false, interactive: true },
})
);
expect(mockBindExtensions).not.toHaveBeenCalled();
});
test('enableExtensions alone (no interactive) binds empty to fire session_start', async () => {
test('interactive: false with extensions on binds empty (session_start fires, no UIContext)', async () => {
// When extensions are loaded, session_start MUST fire so each extension's
// startup handler runs (reads flags, registers tools, etc.). We bind with
// no uiContext so Pi's internal noOpUIContext stays active and hasUI
// remains false — plannotator and other UI-gated flows still auto-approve.
// startup handler runs (reads flags, registers tools, etc.). Binding with
// no uiContext keeps Pi's internal noOpUIContext active so hasUI stays
// false — extensions that gate UI flows (like plannotator) will auto-approve
// in this mode.
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
assistantConfig: { enableExtensions: true, interactive: false },
assistantConfig: { interactive: false },
})
);
@ -1307,7 +1316,7 @@ describe('PiProvider', () => {
expect(bindings.uiContext).toBeUndefined();
});
test('default (no enableExtensions) does NOT bind', async () => {
test('default (nothing set) binds with UIContext — extensions + interactive both on', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
@ -1317,7 +1326,9 @@ describe('PiProvider', () => {
})
);
expect(mockBindExtensions).not.toHaveBeenCalled();
expect(mockBindExtensions).toHaveBeenCalledTimes(1);
const [bindings] = mockBindExtensions.mock.calls[0] as [{ uiContext?: unknown }];
expect(bindings.uiContext).toBeDefined();
});
// ─── extensionFlags pass-through ──────────────────────────────────────
@ -1358,14 +1369,14 @@ describe('PiProvider', () => {
expect(callOrder).toEqual(['setFlagValue', 'setFlagValue', 'bindExtensions']);
});
test('extensionFlags is a no-op without enableExtensions', async () => {
test('extensionFlags is a no-op when enableExtensions is explicitly false', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
assistantConfig: { extensionFlags: { plan: true } },
assistantConfig: { enableExtensions: false, extensionFlags: { plan: true } },
})
);

View file

@ -267,9 +267,14 @@ export class PiProvider implements IAgentProvider {
// packages installed via `pi install npm:<pkg>`).
const modelRegistry = ModelRegistry.inMemory(authStorage);
const settingsManager = SettingsManager.inMemory();
const enableExtensions = piConfig.enableExtensions === true;
// Default ON: extensions (community packages like @plannotator/pi-extension
// or your own local ones) are a core reason users run Pi. Opt out with
// `assistants.pi.enableExtensions: false` (or `interactive: false`) in
// `.archon/config.yaml`. Previously default-off, which silently broke
// users who installed or built an extension and expected it to fire.
const enableExtensions = piConfig.enableExtensions !== false;
// Clamp to false without extensions: nothing consumes hasUI without a runner.
const interactive = enableExtensions && piConfig.interactive === true;
const interactive = enableExtensions && piConfig.interactive !== false;
const resourceLoader = createNoopResourceLoader(cwd, {
...(systemPrompt !== undefined ? { systemPrompt } : {}),
...(skillPaths.length > 0 ? { additionalSkillPaths: skillPaths } : {}),