diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index cb170a5e..ff4f8e65 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -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:`) 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 `/` 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: { : 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) | diff --git a/packages/providers/src/community/pi/provider.test.ts b/packages/providers/src/community/pi/provider.test.ts index a05c6828..17e6de41 100644 --- a/packages/providers/src/community/pi/provider.test.ts +++ b/packages/providers/src/community/pi/provider.test.ts @@ -870,7 +870,7 @@ describe('PiProvider', () => { | Record | 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 | 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 } }, }) ); diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index 2970f317..f0171df2 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -267,9 +267,14 @@ export class PiProvider implements IAgentProvider { // packages installed via `pi install npm:`). 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 } : {}),