mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
feat(providers/pi): interactive flag binds UIContext for extensions (#1299)
* feat(providers/pi): interactive flag binds UIContext for extensions
Adds `interactive: true` opt-in to Pi provider (in `.archon/config.yaml`
under `assistants.pi`) that binds a minimal `ExtensionUIContext` stub to
each session. Without this, Pi's `ExtensionRunner.hasUI()` reports false,
causing extensions like `@plannotator/pi-extension` to silently auto-approve
every plan instead of opening their browser review UI.
Semantics: clamped to `enableExtensions: true` — no extensions loaded
means nothing would consume `hasUI`, so `interactive` alone is silently
dropped. Stub forwards `notify()` to Archon's event stream; interactive
dialogs (select/confirm/input/editor/custom) resolve to undefined/false;
TUI-only setters (widgets/headers/footers/themes) no-op. Theme access
throws with a clear diagnostic — Pi's theme singleton is coupled to its
own `Symbol.for()` registry which Archon doesn't own.
Trust boundary: only binds when the operator has explicitly enabled
both flags. Extensions gated on `ctx.hasUI` (plannotator and similar)
get a functional UI context; extensions that reach for TUI features
still fail loudly rather than rendering garbage.
Includes smoke-test workflow documenting the integration surface.
End-to-end plannotator UI rendering requires plan-mode activation
(Pi `--plan` CLI flag or `/plannotator` TUI slash command) which is
out of reach for programmatic Archon sessions — manual test only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(providers/pi): end-to-end interactive extension UI
Three fixes that together get plannotator's browser review UI to actually
render from an Archon workflow and reach the reviewer's browser.
1. Call resourceLoader.reload() when enableExtensions is true.
createAgentSession's internal reload is gated on `!resourceLoader`, so
caller-supplied loaders must reload themselves. Without this,
getExtensions() returns the empty default, no ExtensionRunner is built,
and session.extensionRunner.setFlagValue() silently no-ops.
2. Set PLANNOTATOR_REMOTE=1 in interactive mode.
plannotator-browser.ts only calls ctx.ui.notify(url) when openBrowser()
returns { isRemote: true }; otherwise it spawns xdg-open/start on the
Archon server host — invisible to the user and untestable from bash
asserts. From the workflow runner's POV every Archon execution IS
remote; flipping the heuristic routes the URL through notify(), which
the ExtensionUIContext stub forwards into the event stream. Respect
explicit operator overrides.
3. notify() emits as assistant chunks, not system chunks.
The DAG executor's system-chunk filter only forwards warnings/MCP
prefixes, and only assistant chunks accumulate into $nodeId.output.
Emitting as assistant makes the URL available both in the user's
stream and in downstream bash/script nodes via output substitution.
Plus: extensionFlags config pass-through (equivalent to `pi --plan` on the
CLI) applied via ExtensionRunner.setFlagValue() BEFORE bindExtensions
fires session_start, so extensions reading flags in their startup handler
actually see them. Also bind extensions with an empty binding when
enableExtensions is on but interactive is off, so session_start still
fires for flag-driven but UI-less extensions.
Smoke test (.archon/workflows/e2e-plannotator-smoke.yaml) uses
openai-codex/gpt-5.4-mini (ChatGPT Plus OAuth compatible) and bumps
idle_timeout to 600000ms so plannotator's server survives while a human
approves in the browser.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(providers/pi): keep Archon extension-agnostic
Remove the plannotator-specific PLANNOTATOR_REMOTE=1 env var write from
the Pi provider. Archon's provider layer shouldn't know about any
specific extension's internals. Document the env var in the plannotator
smoke test instead — operators who use plannotator set it via their shell
or per-codebase env config.
Workflow smoke test updated with:
- Instructions for setting PLANNOTATOR_REMOTE=1 externally
- Simpler assertion (URL emission only) — validated in a real
reject-revise-approve run: reviewer annotated, clicked Send Feedback,
Pi received the feedback as a tool result, revised the plan (added
aria-label and WCAG contrast per the annotation), resubmitted, and
reviewer approved. Plannotator's tool result signals approval but
doesn't return the plan text, so the bash assertion now only checks
that the review URL reached the stream (not that plan content flowed
into \$nodeId.output — it can't).
- Known-limitation note documenting the tool-result shape so downstream
workflow authors know to Write the plan separately if they need it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(providers/pi): keep e2e-plannotator-smoke workflow local-only
The smoke test is plannotator-specific (calls plannotator_submit_plan,
expects PLAN.md on disk, requires PLANNOTATOR_REMOTE=1) and is better
kept out of the PR while the extension-agnostic infra lands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(providers/pi): trim verbose inline comments
Collapse multi-paragraph SDK explanations to 1-2 line "why" notes across
provider.ts, types.ts, ui-context-stub.ts, and event-bridge.ts. No
behavior change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(providers/pi): wire assistants.pi.env + theme-proxy identity
Two end-to-end fixes discovered while exercising the combined
plannotator + @pi-agents/loop smoke flow:
- PiProviderDefaults gains an optional `env` map; parsePiConfig picks
it up and the provider applies it to process.env at session start
(shell env wins, no override). Needed so extensions like plannotator
can read PLANNOTATOR_REMOTE=1 from config.yaml without requiring a
shell export before `archon workflow run`.
- ui-context-stub theme proxy returns identity decorators instead of
throwing on unknown methods. Styled strings flow into no-op
setStatus/setWidget sinks anyway, so the throw was blocking
plannotator_submit_plan after HTTP approval with no benefit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(providers/pi): flush notify() chunks immediately in batch mode
Batch-mode adapters (CLI) accumulate assistant chunks and only flush on
node completion. That broke plannotator's review-URL flow: Pi's notify()
emitted the URL as an assistant chunk, but the user needed the URL to
POST /api/approve — which is what unblocks the node in the first place.
Adds an optional `flush` flag on assistant MessageChunks. notify() sets
it, and the DAG executor drains pending batched content before surfacing
the flushed chunk so ordering is preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: mention Pi alongside Claude and Codex in README + top-level docs
The AI assistants docs page already covers Pi in depth, but the README
architecture diagram + docs table, overview "Further Reading" section,
and local-deployment .env comment still listed only Claude/Codex.
Left feature-specific mentions alone where Pi genuinely lacks support
(e.g. structured output — Claude + Codex only).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: note Pi structured output (best-effort) in matrix + workflow docs
Pi gained structured output support via prompt augmentation + JSON
extraction (see packages/providers/src/community/pi/capabilities.ts).
Unlike Claude/Codex, which use SDK-enforced JSON mode, Pi appends the
schema to the prompt and parses JSON out of the result text (bare or
fenced). Updates four stale references that still said Claude/Codex-only:
- ai-assistants.md capabilities matrix
- authoring-workflows.md (YAML example + field table)
- workflow-dag.md skill reference
- CLAUDE.md DAG-format node description
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 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>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
45682bd2c8
commit
cb44b96f7b
17 changed files with 812 additions and 22 deletions
|
|
@ -170,7 +170,7 @@ Command/prompt nodes only:
|
|||
required: [issue_type]
|
||||
```
|
||||
|
||||
Enables `$classify.output.issue_type` field access. Works with Claude and Codex.
|
||||
Enables `$classify.output.issue_type` field access. SDK-enforced on Claude and Codex; best-effort on Pi (schema is appended to the prompt and JSON is parsed out of the result text).
|
||||
|
||||
## Per-Node Provider and Model
|
||||
|
||||
|
|
|
|||
|
|
@ -698,7 +698,7 @@ async function createSession(conversationId: string, codebaseId: string) {
|
|||
2. **Workflows** (YAML-based):
|
||||
- Stored in `.archon/workflows/` (searched recursively)
|
||||
- Multi-step AI execution chains, discovered at runtime
|
||||
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
|
||||
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex via SDK enforcement; Pi best-effort via prompt augmentation + JSON extraction), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
|
||||
- Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported
|
||||
- Model and options can be set per workflow or inherited from config defaults
|
||||
- `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI)
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ The Web UI and CLI work out of the box. Optionally connect a chat platform for r
|
|||
▼ ▼ ▼ ▼
|
||||
┌───────────┐ ┌────────────┐ ┌──────────────────────────┐
|
||||
│ Command │ │ Workflow │ │ AI Assistant Clients │
|
||||
│ Handler │ │ Executor │ │ (Claude / Codex) │
|
||||
│ Handler │ │ Executor │ │ (Claude / Codex / Pi) │
|
||||
│ (Slash) │ │ (YAML) │ │ │
|
||||
└───────────┘ └────────────┘ └──────────────────────────┘
|
||||
│ │ │
|
||||
|
|
@ -310,7 +310,7 @@ Full documentation is available at **[archon.diy](https://archon.diy)**.
|
|||
| [Authoring Workflows](https://archon.diy/guides/authoring-workflows/) | Create custom YAML workflows |
|
||||
| [Authoring Commands](https://archon.diy/guides/authoring-commands/) | Create reusable AI commands |
|
||||
| [Configuration](https://archon.diy/reference/configuration/) | All config options, env vars, YAML settings |
|
||||
| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude and Codex setup details |
|
||||
| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude, Codex, and Pi setup details |
|
||||
| [Deployment](https://archon.diy/deployment/) | Docker, VPS, production setup |
|
||||
| [Architecture](https://archon.diy/reference/architecture/) | System design and internals |
|
||||
| [Troubleshooting](https://archon.diy/reference/troubleshooting/) | Common issues and fixes |
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ bun install
|
|||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Add your AI assistant tokens (Claude or Codex)
|
||||
nano .env # Add your AI assistant tokens (Claude, Codex, or Pi)
|
||||
|
||||
# 3. Start server + Web UI (SQLite auto-detected, no database setup needed)
|
||||
bun run dev
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
@ -313,7 +362,7 @@ nodes:
|
|||
| Codebase env vars (`envInjection`) | ✅ | `.archon/config.yaml` `env:` section |
|
||||
| MCP servers | ❌ | Pi rejects MCP by design |
|
||||
| Claude-SDK hooks | ❌ | Claude-specific format |
|
||||
| Structured output | ❌ | uneven across Pi backends; v2 follow-up |
|
||||
| Structured output | ✅ (best-effort) | `output_format:` — schema is appended to the prompt and JSON is parsed out of the assistant text (bare or ```json```-fenced); degrades cleanly when the model emits prose. Not SDK-enforced like Claude/Codex. |
|
||||
| Cost limits (`maxBudgetUsd`) | ❌ | tracked in result chunk, not enforced |
|
||||
| Fallback model | ❌ | not native in Pi |
|
||||
| Sandbox | ❌ | not native in Pi |
|
||||
|
|
|
|||
|
|
@ -601,6 +601,6 @@ For always-on access from any device, see the [Docker Deployment Guide](/deploym
|
|||
## Further Reading
|
||||
|
||||
- [Configuration](/getting-started/configuration/) — All configuration options
|
||||
- [AI Assistants](/getting-started/ai-assistants/) — Claude and Codex setup details
|
||||
- [AI Assistants](/getting-started/ai-assistants/) — Claude, Codex, and Pi setup details
|
||||
- [CLI Reference](/reference/cli/) — Full CLI documentation
|
||||
- [Authoring Workflows](/guides/authoring-workflows/) — Creating custom workflows
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ interactive: true # Web only: run in foreground instead of backgr
|
|||
nodes:
|
||||
- id: classify # Unique node ID (used for dependency refs and $id.output)
|
||||
command: classify-issue # Loads from .archon/commands/classify-issue.md
|
||||
output_format: # Optional: enforce structured JSON output (Claude + Codex)
|
||||
output_format: # Optional: structured JSON output. SDK-enforced on Claude/Codex; best-effort (prompt + JSON extraction) on Pi.
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
|
|
@ -190,7 +190,7 @@ nodes:
|
|||
|-------|------|---------|-------------|
|
||||
| `provider` | string | inherited | Per-node provider override (any registered provider, e.g. `'claude'`, `'codex'`) |
|
||||
| `model` | string | inherited | Per-node model override |
|
||||
| `output_format` | object | — | JSON Schema for structured output (Claude and Codex) |
|
||||
| `output_format` | object | — | JSON Schema for structured output. SDK-enforced on Claude and Codex; best-effort on Pi (schema appended to prompt, JSON extracted from result text) |
|
||||
| `allowed_tools` | string[] | — | Whitelist of built-in tools. `[]` = no tools. Claude only |
|
||||
| `denied_tools` | string[] | — | Tools to remove. Applied after `allowed_tools`. Claude only |
|
||||
| `hooks` | object | — | Per-node SDK hook callbacks. Claude only. See [Hooks](/guides/hooks/) |
|
||||
|
|
|
|||
|
|
@ -52,4 +52,112 @@ describe('parsePiConfig', () => {
|
|||
enableExtensions: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses interactive: true', () => {
|
||||
expect(parsePiConfig({ interactive: true })).toEqual({ interactive: true });
|
||||
});
|
||||
|
||||
test('parses interactive: false', () => {
|
||||
expect(parsePiConfig({ interactive: false })).toEqual({ interactive: false });
|
||||
});
|
||||
|
||||
test('drops non-boolean interactive silently', () => {
|
||||
expect(parsePiConfig({ interactive: 'yes' })).toEqual({});
|
||||
expect(parsePiConfig({ interactive: 1 })).toEqual({});
|
||||
expect(parsePiConfig({ interactive: null })).toEqual({});
|
||||
});
|
||||
|
||||
test('combines all three fields', () => {
|
||||
expect(
|
||||
parsePiConfig({
|
||||
model: 'google/gemini-2.5-pro',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
})
|
||||
).toEqual({
|
||||
model: 'google/gemini-2.5-pro',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses extensionFlags with boolean and string values', () => {
|
||||
expect(parsePiConfig({ extensionFlags: { plan: true, profile: 'Default' } })).toEqual({
|
||||
extensionFlags: { plan: true, profile: 'Default' },
|
||||
});
|
||||
});
|
||||
|
||||
test('drops non-boolean/string extensionFlags values silently', () => {
|
||||
expect(
|
||||
parsePiConfig({
|
||||
extensionFlags: { plan: true, bogus: 42, nested: { x: 1 }, nullish: null },
|
||||
})
|
||||
).toEqual({ extensionFlags: { plan: true } });
|
||||
});
|
||||
|
||||
test('drops extensionFlags when all entries are invalid', () => {
|
||||
expect(parsePiConfig({ extensionFlags: { bogus: 42, nested: {} } })).toEqual({});
|
||||
});
|
||||
|
||||
test('drops non-object extensionFlags silently', () => {
|
||||
expect(parsePiConfig({ extensionFlags: 'plan=true' })).toEqual({});
|
||||
expect(parsePiConfig({ extensionFlags: ['plan', 'true'] })).toEqual({});
|
||||
expect(parsePiConfig({ extensionFlags: null })).toEqual({});
|
||||
});
|
||||
|
||||
test('combines extensionFlags with other fields', () => {
|
||||
expect(
|
||||
parsePiConfig({
|
||||
model: 'openai-codex/gpt-5.1-codex-mini',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
extensionFlags: { plan: true },
|
||||
})
|
||||
).toEqual({
|
||||
model: 'openai-codex/gpt-5.1-codex-mini',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
extensionFlags: { plan: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('parses env with string values', () => {
|
||||
expect(parsePiConfig({ env: { PLANNOTATOR_REMOTE: '1', FOO: 'bar' } })).toEqual({
|
||||
env: { PLANNOTATOR_REMOTE: '1', FOO: 'bar' },
|
||||
});
|
||||
});
|
||||
|
||||
test('drops non-string env values silently', () => {
|
||||
expect(
|
||||
parsePiConfig({ env: { GOOD: 'yes', BOOL: true, NUM: 42, NESTED: { x: 1 }, NULLISH: null } })
|
||||
).toEqual({ env: { GOOD: 'yes' } });
|
||||
});
|
||||
|
||||
test('drops env when all entries are invalid', () => {
|
||||
expect(parsePiConfig({ env: { NUM: 42, NESTED: {} } })).toEqual({});
|
||||
});
|
||||
|
||||
test('drops non-object env silently', () => {
|
||||
expect(parsePiConfig({ env: 'PLANNOTATOR_REMOTE=1' })).toEqual({});
|
||||
expect(parsePiConfig({ env: ['A=1'] })).toEqual({});
|
||||
expect(parsePiConfig({ env: null })).toEqual({});
|
||||
});
|
||||
|
||||
test('combines env with other fields', () => {
|
||||
expect(
|
||||
parsePiConfig({
|
||||
model: 'openai-codex/gpt-5.4-mini',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
extensionFlags: { plan: true },
|
||||
env: { PLANNOTATOR_REMOTE: '1' },
|
||||
})
|
||||
).toEqual({
|
||||
model: 'openai-codex/gpt-5.4-mini',
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
extensionFlags: { plan: true },
|
||||
env: { PLANNOTATOR_REMOTE: '1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,5 +19,37 @@ export function parsePiConfig(raw: Record<string, unknown>): PiProviderDefaults
|
|||
result.enableExtensions = raw.enableExtensions;
|
||||
}
|
||||
|
||||
if (typeof raw.interactive === 'boolean') {
|
||||
result.interactive = raw.interactive;
|
||||
}
|
||||
|
||||
if (
|
||||
raw.extensionFlags &&
|
||||
typeof raw.extensionFlags === 'object' &&
|
||||
!Array.isArray(raw.extensionFlags)
|
||||
) {
|
||||
const flags: Record<string, boolean | string> = {};
|
||||
for (const [key, value] of Object.entries(raw.extensionFlags as Record<string, unknown>)) {
|
||||
if (typeof value === 'boolean' || typeof value === 'string') {
|
||||
flags[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(flags).length > 0) {
|
||||
result.extensionFlags = flags;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env)) {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw.env as Record<string, unknown>)) {
|
||||
if (typeof value === 'string') {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) {
|
||||
result.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -267,13 +267,22 @@ export type BridgeQueueItem =
|
|||
| { kind: 'done' }
|
||||
| { kind: 'error'; error: Error };
|
||||
|
||||
/** Lets the UI stub push notifications into the session's chunk queue. */
|
||||
export interface BridgeNotifier {
|
||||
setEmitter(fn: ((chunk: MessageChunk) => void) | undefined): void;
|
||||
}
|
||||
|
||||
export async function* bridgeSession(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
abortSignal?: AbortSignal,
|
||||
jsonSchema?: Record<string, unknown>
|
||||
jsonSchema?: Record<string, unknown>,
|
||||
uiBridge?: BridgeNotifier
|
||||
): AsyncGenerator<MessageChunk> {
|
||||
const queue = new AsyncQueue<BridgeQueueItem>();
|
||||
uiBridge?.setEmitter(chunk => {
|
||||
queue.push({ kind: 'chunk', chunk });
|
||||
});
|
||||
// Best-effort structured-output buffer. Only accumulates when the caller
|
||||
// requested a JSON schema; otherwise stays empty and the terminal chunk
|
||||
// passes through untouched.
|
||||
|
|
@ -358,6 +367,7 @@ export async function* bridgeSession(
|
|||
// a no-op and pending iterate() waiters resolve — otherwise a consumer
|
||||
// abort mid-iteration would leak this generator on the promise forever.
|
||||
queue.close();
|
||||
uiBridge?.setEmitter(undefined);
|
||||
unsubscribe();
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort);
|
||||
|
|
|
|||
|
|
@ -38,11 +38,18 @@ const mockSubscribe = mock((listener: (event: FakeEvent) => void) => {
|
|||
};
|
||||
});
|
||||
|
||||
const mockBindExtensions = mock(async (_bindings: unknown) => undefined);
|
||||
const mockSetFlagValue = mock((_name: string, _value: boolean | string) => undefined);
|
||||
const mockExtensionRunner = {
|
||||
setFlagValue: mockSetFlagValue,
|
||||
};
|
||||
const mockSession = {
|
||||
subscribe: mockSubscribe,
|
||||
prompt: mockPrompt,
|
||||
abort: mockAbort,
|
||||
dispose: mockDispose,
|
||||
bindExtensions: mockBindExtensions,
|
||||
extensionRunner: mockExtensionRunner,
|
||||
isStreaming: false,
|
||||
sessionId: 'mock-session-uuid',
|
||||
};
|
||||
|
|
@ -86,9 +93,14 @@ const mockSessionList = mock(
|
|||
);
|
||||
|
||||
const mockSettingsManagerInMemory = mock(() => ({}));
|
||||
const MockDefaultResourceLoader = mock(function (_opts: unknown) {
|
||||
// constructor stub — no methods exercised in tests
|
||||
});
|
||||
const mockResourceLoaderReload = mock(async () => undefined);
|
||||
// Return-style constructor: bun's mock() wraps the function such that the
|
||||
// `this`-binding doesn't reliably propagate to `new` call sites. Returning a
|
||||
// plain object from the constructor sidesteps this — ES semantics use the
|
||||
// returned object when a constructor explicitly returns one.
|
||||
const MockDefaultResourceLoader = mock((_opts: unknown) => ({
|
||||
reload: mockResourceLoaderReload,
|
||||
}));
|
||||
|
||||
// Tool factory mocks — each returns an opaque object tagged with the tool
|
||||
// name so assertions can verify which tools the provider selected.
|
||||
|
|
@ -161,6 +173,9 @@ describe('PiProvider', () => {
|
|||
mockAbort.mockClear();
|
||||
mockDispose.mockClear();
|
||||
mockSubscribe.mockClear();
|
||||
mockBindExtensions.mockClear();
|
||||
mockSetFlagValue.mockClear();
|
||||
mockResourceLoaderReload.mockClear();
|
||||
mockCreateAgentSession.mockClear();
|
||||
mockGetModel.mockClear();
|
||||
mockAuthCreate.mockClear();
|
||||
|
|
@ -855,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);
|
||||
});
|
||||
|
||||
|
|
@ -909,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());
|
||||
|
||||
|
|
@ -922,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 () => {
|
||||
|
|
@ -1239,4 +1262,166 @@ describe('PiProvider', () => {
|
|||
);
|
||||
expect(result?.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
// ─── Interactive ExtensionUIContext binding ───────────────────────────
|
||||
|
||||
test('interactive: true with enableExtensions binds a UIContext to the session', 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: { enableExtensions: true, interactive: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockBindExtensions).toHaveBeenCalledTimes(1);
|
||||
const [bindings] = mockBindExtensions.mock.calls[0] as [{ uiContext?: unknown }];
|
||||
expect(bindings.uiContext).toBeDefined();
|
||||
});
|
||||
|
||||
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: { enableExtensions: false, interactive: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockBindExtensions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.). 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: { interactive: false },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockBindExtensions).toHaveBeenCalledTimes(1);
|
||||
const [bindings] = mockBindExtensions.mock.calls[0] as [{ uiContext?: unknown }];
|
||||
expect(bindings.uiContext).toBeUndefined();
|
||||
});
|
||||
|
||||
test('default (nothing set) binds with UIContext — extensions + interactive both on', async () => {
|
||||
process.env.GEMINI_API_KEY = 'sk-test';
|
||||
resetScript(scriptedAgentEnd());
|
||||
|
||||
await consume(
|
||||
new PiProvider().sendQuery('hi', '/tmp', undefined, {
|
||||
model: 'google/gemini-2.5-pro',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockBindExtensions).toHaveBeenCalledTimes(1);
|
||||
const [bindings] = mockBindExtensions.mock.calls[0] as [{ uiContext?: unknown }];
|
||||
expect(bindings.uiContext).toBeDefined();
|
||||
});
|
||||
|
||||
// ─── extensionFlags pass-through ──────────────────────────────────────
|
||||
|
||||
test('extensionFlags sets flag values before bindExtensions fires session_start', async () => {
|
||||
process.env.GEMINI_API_KEY = 'sk-test';
|
||||
resetScript(scriptedAgentEnd());
|
||||
|
||||
// Track call order: setFlagValue must run BEFORE bindExtensions, else
|
||||
// extensions reading flags in their session_start handler miss them.
|
||||
const callOrder: string[] = [];
|
||||
mockSetFlagValue.mockImplementationOnce(() => {
|
||||
callOrder.push('setFlagValue');
|
||||
return undefined;
|
||||
});
|
||||
mockSetFlagValue.mockImplementationOnce(() => {
|
||||
callOrder.push('setFlagValue');
|
||||
return undefined;
|
||||
});
|
||||
mockBindExtensions.mockImplementationOnce(async () => {
|
||||
callOrder.push('bindExtensions');
|
||||
});
|
||||
|
||||
await consume(
|
||||
new PiProvider().sendQuery('hi', '/tmp', undefined, {
|
||||
model: 'google/gemini-2.5-pro',
|
||||
assistantConfig: {
|
||||
enableExtensions: true,
|
||||
interactive: true,
|
||||
extensionFlags: { plan: true, 'plan-file': 'PLAN.md' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockSetFlagValue).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetFlagValue).toHaveBeenCalledWith('plan', true);
|
||||
expect(mockSetFlagValue).toHaveBeenCalledWith('plan-file', 'PLAN.md');
|
||||
expect(callOrder).toEqual(['setFlagValue', 'setFlagValue', 'bindExtensions']);
|
||||
});
|
||||
|
||||
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: { enableExtensions: false, extensionFlags: { plan: true } },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockSetFlagValue).not.toHaveBeenCalled();
|
||||
expect(mockBindExtensions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('assistantConfig.env applies to process.env when not already set', async () => {
|
||||
process.env.GEMINI_API_KEY = 'sk-test';
|
||||
delete process.env.PI_TEST_ONE;
|
||||
delete process.env.PI_TEST_TWO;
|
||||
resetScript(scriptedAgentEnd());
|
||||
|
||||
try {
|
||||
await consume(
|
||||
new PiProvider().sendQuery('hi', '/tmp', undefined, {
|
||||
model: 'google/gemini-2.5-pro',
|
||||
assistantConfig: { env: { PI_TEST_ONE: 'one', PI_TEST_TWO: 'two' } },
|
||||
})
|
||||
);
|
||||
|
||||
expect(process.env.PI_TEST_ONE).toBe('one');
|
||||
expect(process.env.PI_TEST_TWO).toBe('two');
|
||||
} finally {
|
||||
delete process.env.PI_TEST_ONE;
|
||||
delete process.env.PI_TEST_TWO;
|
||||
}
|
||||
});
|
||||
|
||||
test('shell env wins over assistantConfig.env (no override)', async () => {
|
||||
process.env.GEMINI_API_KEY = 'sk-test';
|
||||
process.env.PI_TEST_SHELL_WINS = 'shell-value';
|
||||
resetScript(scriptedAgentEnd());
|
||||
|
||||
try {
|
||||
await consume(
|
||||
new PiProvider().sendQuery('hi', '/tmp', undefined, {
|
||||
model: 'google/gemini-2.5-pro',
|
||||
assistantConfig: { env: { PI_TEST_SHELL_WINS: 'config-value' } },
|
||||
})
|
||||
);
|
||||
|
||||
expect(process.env.PI_TEST_SHELL_WINS).toBe('shell-value');
|
||||
} finally {
|
||||
delete process.env.PI_TEST_SHELL_WINS;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { parsePiModelRef } from './model-ref';
|
|||
import { resolvePiSkills, resolvePiThinkingLevel, resolvePiTools } from './options-translator';
|
||||
import { createNoopResourceLoader } from './resource-loader';
|
||||
import { resolvePiSession } from './session-resolver';
|
||||
import { createArchonUIBridge, createArchonUIContext } from './ui-context-stub';
|
||||
|
||||
/**
|
||||
* Map Pi provider id → env var name used by pi-ai's getEnvApiKey().
|
||||
|
|
@ -110,6 +111,24 @@ export class PiProvider implements IAgentProvider {
|
|||
const assistantConfig = requestOptions?.assistantConfig ?? {};
|
||||
const piConfig = parsePiConfig(assistantConfig);
|
||||
|
||||
// 0. Apply config-level env vars to process.env for in-process extensions
|
||||
// (plannotator reads PLANNOTATOR_REMOTE at session_start, etc.).
|
||||
// Shell env wins: we only set keys not already present. Request-level
|
||||
// `requestOptions.env` remains a separate channel — it flows through
|
||||
// bash spawn hooks for subprocess isolation, not into process.env.
|
||||
if (piConfig.env) {
|
||||
const applied: string[] = [];
|
||||
for (const [key, value] of Object.entries(piConfig.env)) {
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
applied.push(key);
|
||||
}
|
||||
}
|
||||
if (applied.length > 0) {
|
||||
getLog().debug({ keys: applied }, 'pi.config_env_applied');
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Resolve model ref: request (workflow node / chat) → config default
|
||||
const modelRef = requestOptions?.model ?? piConfig.model;
|
||||
if (!modelRef) {
|
||||
|
|
@ -248,13 +267,27 @@ 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 !== false;
|
||||
const resourceLoader = createNoopResourceLoader(cwd, {
|
||||
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
||||
...(skillPaths.length > 0 ? { additionalSkillPaths: skillPaths } : {}),
|
||||
...(enableExtensions ? { enableExtensions: true } : {}),
|
||||
});
|
||||
|
||||
// Required: without reload(), session.extensionRunner is undefined and
|
||||
// setFlagValue silently no-ops. createAgentSession skips this when a
|
||||
// custom resource loader is supplied.
|
||||
if (enableExtensions) {
|
||||
await resourceLoader.reload();
|
||||
}
|
||||
|
||||
getLog().info(
|
||||
{
|
||||
piProvider: parsed.provider,
|
||||
|
|
@ -266,6 +299,7 @@ export class PiProvider implements IAgentProvider {
|
|||
skillCount: skillPaths.length,
|
||||
missingSkillCount: missingSkills.length,
|
||||
extensionsEnabled: enableExtensions,
|
||||
interactive,
|
||||
resumed: resumeSessionId !== undefined && !resumeFailed,
|
||||
},
|
||||
'pi.session_started'
|
||||
|
|
@ -287,6 +321,28 @@ export class PiProvider implements IAgentProvider {
|
|||
yield { type: 'system', content: `⚠️ ${modelFallbackMessage}` };
|
||||
}
|
||||
|
||||
// 4e. Extension flag pass-through. Must happen before bindExtensions
|
||||
// below — extensions read flags inside their session_start handler.
|
||||
if (enableExtensions && piConfig.extensionFlags) {
|
||||
const runner = session.extensionRunner;
|
||||
if (runner) {
|
||||
for (const [name, value] of Object.entries(piConfig.extensionFlags)) {
|
||||
runner.setFlagValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4f. Bind UI context (so ctx.hasUI is true and ctx.ui.notify() forwards
|
||||
// into the chunk stream) or fire session_start with no UI. Must run
|
||||
// after flag pass-through above.
|
||||
const uiBridge = interactive ? createArchonUIBridge() : undefined;
|
||||
if (uiBridge) {
|
||||
const uiContext = createArchonUIContext(uiBridge);
|
||||
await session.bindExtensions({ uiContext });
|
||||
} else if (enableExtensions) {
|
||||
await session.bindExtensions({});
|
||||
}
|
||||
|
||||
// 5. Structured output (best-effort). Pi has no SDK-level JSON schema
|
||||
// mode the way Claude and Codex do, so we implement it via prompt
|
||||
// engineering: append the schema + "JSON only, no fences" instruction,
|
||||
|
|
@ -299,13 +355,16 @@ export class PiProvider implements IAgentProvider {
|
|||
: prompt;
|
||||
|
||||
// 6. Bridge callback-based events to the async generator contract.
|
||||
// bridgeSession owns dispose() and abort wiring.
|
||||
// bridgeSession owns dispose() and abort wiring. When `interactive`
|
||||
// is on, it also binds/unbinds the UI stub's emitter so extension
|
||||
// notifications land on the same queue as Pi events.
|
||||
try {
|
||||
yield* bridgeSession(
|
||||
session,
|
||||
effectivePrompt,
|
||||
requestOptions?.abortSignal,
|
||||
outputFormat?.schema
|
||||
outputFormat?.schema,
|
||||
uiBridge
|
||||
);
|
||||
getLog().info({ piProvider: parsed.provider }, 'pi.prompt_completed');
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ export interface NoopResourceLoaderOptions {
|
|||
* (https://shittycodingagent.ai/packages) — ~540 npm packages registering
|
||||
* custom tools and lifecycle hooks via `pi.registerTool()` / `pi.on()`.
|
||||
* Tools and hooks work fully in programmatic sessions; TUI-only features
|
||||
* (renderers, keybindings, slash commands) silently no-op.
|
||||
* (renderers, keybindings, slash commands) silently no-op. Extensions that
|
||||
* gate on `ctx.hasUI` additionally need `interactive: true` — see
|
||||
* `PiProviderDefaults.interactive`.
|
||||
*
|
||||
* Trust boundary: enabling this loads arbitrary JS code with the Archon
|
||||
* server's OS permissions. Only flip this on when the operator trusts both
|
||||
|
|
|
|||
137
packages/providers/src/community/pi/ui-context-stub.test.ts
Normal file
137
packages/providers/src/community/pi/ui-context-stub.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { MessageChunk } from '../../types';
|
||||
|
||||
import { createArchonUIBridge, createArchonUIContext } from './ui-context-stub';
|
||||
|
||||
describe('createArchonUIBridge', () => {
|
||||
test('drops notifications when no emitter is set', () => {
|
||||
const bridge = createArchonUIBridge();
|
||||
expect(() => bridge.emit({ type: 'system', content: 'x' })).not.toThrow();
|
||||
});
|
||||
|
||||
test('forwards notifications to the configured emitter', () => {
|
||||
const bridge = createArchonUIBridge();
|
||||
const chunks: MessageChunk[] = [];
|
||||
bridge.setEmitter(c => chunks.push(c));
|
||||
bridge.emit({ type: 'system', content: 'hello' });
|
||||
expect(chunks).toEqual([{ type: 'system', content: 'hello' }]);
|
||||
});
|
||||
|
||||
test('detaches emitter when cleared (bridgeSession cleanup path)', () => {
|
||||
const bridge = createArchonUIBridge();
|
||||
const chunks: MessageChunk[] = [];
|
||||
bridge.setEmitter(c => chunks.push(c));
|
||||
bridge.setEmitter(undefined);
|
||||
bridge.emit({ type: 'system', content: 'late' });
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createArchonUIContext', () => {
|
||||
function mk() {
|
||||
const bridge = createArchonUIBridge();
|
||||
const chunks: MessageChunk[] = [];
|
||||
bridge.setEmitter(c => chunks.push(c));
|
||||
const ui = createArchonUIContext(bridge);
|
||||
return { ui, chunks };
|
||||
}
|
||||
|
||||
test('notify("info") forwards as assistant chunk with info glyph and flush:true (captured in nodeOutput, surfaces before node blocks)', () => {
|
||||
const { ui, chunks } = mk();
|
||||
ui.notify('Remote session. Open: http://host:8080/', 'info');
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]).toEqual({
|
||||
type: 'assistant',
|
||||
content: '\n[pi extension ℹ️] Remote session. Open: http://host:8080/\n',
|
||||
flush: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('notify defaults to info when type omitted', () => {
|
||||
const { ui, chunks } = mk();
|
||||
ui.notify('bare message');
|
||||
expect(chunks[0]?.content).toBe('\n[pi extension ℹ️] bare message\n');
|
||||
});
|
||||
|
||||
test('notify("warning") and notify("error") use distinct glyphs', () => {
|
||||
const { ui, chunks } = mk();
|
||||
ui.notify('soft', 'warning');
|
||||
ui.notify('hard', 'error');
|
||||
expect(chunks[0]?.content).toBe('\n[pi extension ⚠️] soft\n');
|
||||
expect(chunks[1]?.content).toBe('\n[pi extension ❌] hard\n');
|
||||
});
|
||||
|
||||
test('select resolves to undefined (no operator to answer)', async () => {
|
||||
const { ui } = mk();
|
||||
await expect(ui.select('pick', ['a', 'b'])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('confirm resolves to false', async () => {
|
||||
const { ui } = mk();
|
||||
await expect(ui.confirm('are you sure?', 'really')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('input and editor resolve to undefined', async () => {
|
||||
const { ui } = mk();
|
||||
await expect(ui.input('title')).resolves.toBeUndefined();
|
||||
await expect(ui.editor('title', 'prefill')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('custom resolves to undefined-cast', async () => {
|
||||
const { ui } = mk();
|
||||
const res = await ui.custom(() => ({}) as never);
|
||||
expect(res).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getEditorText returns empty string', () => {
|
||||
const { ui } = mk();
|
||||
expect(ui.getEditorText()).toBe('');
|
||||
});
|
||||
|
||||
test('getToolsExpanded returns false', () => {
|
||||
const { ui } = mk();
|
||||
expect(ui.getToolsExpanded()).toBe(false);
|
||||
});
|
||||
|
||||
test('getAllThemes returns empty list and getTheme returns undefined', () => {
|
||||
const { ui } = mk();
|
||||
expect(ui.getAllThemes()).toEqual([]);
|
||||
expect(ui.getTheme('anything')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setTheme returns failure result without throwing', () => {
|
||||
const { ui } = mk();
|
||||
const result = ui.setTheme('dark');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
test('theme getter returns a proxy that throws on property access', () => {
|
||||
const { ui } = mk();
|
||||
const themeRef = ui.theme;
|
||||
expect(() => themeRef.fg('accent', 'text')).toThrow(/Archon's remote UI stub/);
|
||||
});
|
||||
|
||||
test('onTerminalInput returns a disposer that is safe to call', () => {
|
||||
const { ui } = mk();
|
||||
const dispose = ui.onTerminalInput(() => undefined);
|
||||
expect(() => dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
test('TUI setters (setStatus/setWidget/setFooter/setHeader/setTitle) are no-ops', () => {
|
||||
const { ui, chunks } = mk();
|
||||
expect(() => ui.setStatus('k', 'v')).not.toThrow();
|
||||
expect(() => ui.setWidget('k', ['line'])).not.toThrow();
|
||||
expect(() => ui.setFooter(undefined)).not.toThrow();
|
||||
expect(() => ui.setHeader(undefined)).not.toThrow();
|
||||
expect(() => ui.setTitle('title')).not.toThrow();
|
||||
expect(() => ui.setWorkingMessage('working')).not.toThrow();
|
||||
expect(() => ui.setHiddenThinkingLabel('label')).not.toThrow();
|
||||
expect(() => ui.pasteToEditor('text')).not.toThrow();
|
||||
expect(() => ui.setEditorText('text')).not.toThrow();
|
||||
expect(() => ui.setEditorComponent(undefined)).not.toThrow();
|
||||
expect(() => ui.setToolsExpanded(true)).not.toThrow();
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
160
packages/providers/src/community/pi/ui-context-stub.ts
Normal file
160
packages/providers/src/community/pi/ui-context-stub.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
TerminalInputHandler,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import { Theme } from '@mariozechner/pi-coding-agent';
|
||||
|
||||
import type { MessageChunk } from '../../types';
|
||||
|
||||
/** Pushes UI notifications into Archon's event stream. Set/cleared by bridgeSession. */
|
||||
export interface ArchonUIBridge {
|
||||
emit(chunk: MessageChunk): void;
|
||||
setEmitter(fn: ((chunk: MessageChunk) => void) | undefined): void;
|
||||
}
|
||||
|
||||
export function createArchonUIBridge(): ArchonUIBridge {
|
||||
let emitter: ((chunk: MessageChunk) => void) | undefined;
|
||||
return {
|
||||
emit(chunk: MessageChunk): void {
|
||||
emitter?.(chunk);
|
||||
},
|
||||
setEmitter(fn: ((chunk: MessageChunk) => void) | undefined): void {
|
||||
emitter = fn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const noop = (): void => {
|
||||
/* no-op — TUI-only setter, nothing to paint into */
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal ExtensionUIContext for Archon's headless Pi sessions. Binding this
|
||||
* (vs Pi's internal `noOpUIContext`) flips `ctx.hasUI` to true so extensions
|
||||
* like plannotator surface UI flows. `notify()` forwards to the event stream;
|
||||
* interactive prompts resolve to undefined/false; TUI setters no-op; `theme`
|
||||
* returns identity decorators — the styled strings get passed into no-op
|
||||
* setStatus/setWidget sinks anyway, so stripping ANSI styling is safe and
|
||||
* keeps extensions like plannotator from crashing mid-tool-call.
|
||||
*/
|
||||
export function createArchonUIContext(bridge: ArchonUIBridge): ExtensionUIContext {
|
||||
// Pick the last string argument — handles `fg(color, text)`, `bold(text)`,
|
||||
// `strikethrough(text)`, etc. in a single handler.
|
||||
const lastStringArg = (...args: unknown[]): string => {
|
||||
for (let i = args.length - 1; i >= 0; i--) {
|
||||
if (typeof args[i] === 'string') return args[i] as string;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const passthroughWrap =
|
||||
(_level: unknown): ((s: string) => string) =>
|
||||
(s: string) =>
|
||||
s;
|
||||
const themeProxy = new Proxy({} as Theme, {
|
||||
get(_target: Theme, prop: string | symbol): unknown {
|
||||
if (prop === 'getColorMode') return () => 'truecolor';
|
||||
if (prop === 'getFgAnsi' || prop === 'getBgAnsi') return () => '';
|
||||
if (prop === 'getThinkingBorderColor' || prop === 'getBashModeBorderColor') {
|
||||
return passthroughWrap;
|
||||
}
|
||||
if (prop === 'name' || prop === 'sourcePath' || prop === 'sourceInfo') return undefined;
|
||||
return lastStringArg;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
select(
|
||||
_title: string,
|
||||
_options: string[],
|
||||
_opts?: ExtensionUIDialogOptions
|
||||
): Promise<string | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
confirm(_title: string, _message: string, _opts?: ExtensionUIDialogOptions): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
input(
|
||||
_title: string,
|
||||
_placeholder?: string,
|
||||
_opts?: ExtensionUIDialogOptions
|
||||
): Promise<string | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
notify(message: string, type: 'info' | 'warning' | 'error' = 'info'): void {
|
||||
// Emit as `assistant` (not `system`) so the content is captured into
|
||||
// `$nodeId.output` for downstream bash/script nodes. System chunks are
|
||||
// filtered to ⚠️/MCP-prefix only by the DAG executor.
|
||||
// `flush: true` forces batch-mode adapters to surface this immediately —
|
||||
// extensions like plannotator print review URLs the user must act on
|
||||
// before the node unblocks, so we can't wait for node completion.
|
||||
const icon = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : 'ℹ️';
|
||||
bridge.emit({
|
||||
type: 'assistant',
|
||||
content: `\n[pi extension ${icon}] ${message}\n`,
|
||||
flush: true,
|
||||
});
|
||||
},
|
||||
onTerminalInput(_handler: TerminalInputHandler): () => void {
|
||||
return noop;
|
||||
},
|
||||
setStatus(_key: string, _text: string | undefined): void {
|
||||
noop();
|
||||
},
|
||||
setWorkingMessage(_message?: string): void {
|
||||
noop();
|
||||
},
|
||||
setHiddenThinkingLabel(_label?: string): void {
|
||||
noop();
|
||||
},
|
||||
setWidget(_key: string, _content: unknown, _options?: ExtensionWidgetOptions): void {
|
||||
noop();
|
||||
},
|
||||
setFooter(_factory: Parameters<ExtensionUIContext['setFooter']>[0]): void {
|
||||
noop();
|
||||
},
|
||||
setHeader(_factory: Parameters<ExtensionUIContext['setHeader']>[0]): void {
|
||||
noop();
|
||||
},
|
||||
setTitle(_title: string): void {
|
||||
noop();
|
||||
},
|
||||
custom<T>(): Promise<T> {
|
||||
return Promise.resolve(undefined as unknown as T);
|
||||
},
|
||||
pasteToEditor(_text: string): void {
|
||||
noop();
|
||||
},
|
||||
setEditorText(_text: string): void {
|
||||
noop();
|
||||
},
|
||||
getEditorText(): string {
|
||||
return '';
|
||||
},
|
||||
editor(_title: string, _prefill?: string): Promise<string | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
setEditorComponent(_factory: Parameters<ExtensionUIContext['setEditorComponent']>[0]): void {
|
||||
noop();
|
||||
},
|
||||
get theme(): Theme {
|
||||
return themeProxy;
|
||||
},
|
||||
getAllThemes(): ReturnType<ExtensionUIContext['getAllThemes']> {
|
||||
return [];
|
||||
},
|
||||
getTheme(_name: string): Theme | undefined {
|
||||
return undefined;
|
||||
},
|
||||
setTheme(_theme: string | Theme): { success: boolean; error?: string } {
|
||||
return { success: false, error: 'Theme switching not supported in Archon remote UI stub' };
|
||||
},
|
||||
getToolsExpanded(): boolean {
|
||||
return false;
|
||||
},
|
||||
setToolsExpanded(_expanded: boolean): void {
|
||||
noop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -52,6 +52,34 @@ export interface PiProviderDefaults {
|
|||
* @default false
|
||||
*/
|
||||
enableExtensions?: boolean;
|
||||
/**
|
||||
* Bind an `ExtensionUIContext` so extensions see `ctx.hasUI === true` and
|
||||
* `ctx.ui.notify()` forwards into the chunk stream. Ignored unless
|
||||
* `enableExtensions` is true.
|
||||
* @default false
|
||||
*/
|
||||
interactive?: boolean;
|
||||
/**
|
||||
* Flag values passed to Pi's ExtensionRunner before `session_start`,
|
||||
* equivalent to `pi --<name>` / `pi --<name>=<value>` on the CLI.
|
||||
* Unknown keys are ignored. Only applied when `enableExtensions` is true.
|
||||
* @default undefined
|
||||
*/
|
||||
extensionFlags?: Record<string, boolean | string>;
|
||||
/**
|
||||
* Environment variables injected into `process.env` at session start so
|
||||
* in-process extensions (which read `process.env` directly) pick them up.
|
||||
* Existing `process.env` entries are NOT overridden — shell env wins over
|
||||
* config. Use for extension-config vars like `PLANNOTATOR_REMOTE=1` that
|
||||
* must be present before the extension's `session_start` hook runs.
|
||||
*
|
||||
* Note: this differs from `requestOptions.env` (codebase-scoped env vars),
|
||||
* which is per-request and only injected into bash subprocesses. Use
|
||||
* codebase env vars for secrets that vary per project; use `assistants.pi.env`
|
||||
* for extension wiring that's global to the Pi provider.
|
||||
* @default undefined
|
||||
*/
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Generic per-provider defaults bag used by config surfaces and UI. */
|
||||
|
|
@ -75,7 +103,15 @@ export interface TokenUsage {
|
|||
* Discriminated union with per-type required fields for type safety.
|
||||
*/
|
||||
export type MessageChunk =
|
||||
| { type: 'assistant'; content: string }
|
||||
| {
|
||||
type: 'assistant';
|
||||
content: string;
|
||||
/** When true, batch-mode adapters flush pending content and this chunk
|
||||
* to the platform immediately. Used by Pi's `notify()` so URLs the
|
||||
* user must act on (e.g. plannotator review) surface before the node
|
||||
* blocks for input. */
|
||||
flush?: boolean;
|
||||
}
|
||||
| { type: 'system'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -656,7 +656,19 @@ async function executeNodeInternal(
|
|||
|
||||
if (msg.type === 'assistant' && msg.content) {
|
||||
nodeOutputText += msg.content; // ALWAYS capture for $node_id.output
|
||||
if (streamingMode === 'stream') {
|
||||
if (streamingMode === 'stream' || msg.flush) {
|
||||
// `flush` chunks (e.g. Pi notify() emitting a plannotator review URL)
|
||||
// must reach the user before the node blocks. Drain any queued batch
|
||||
// content first so order is preserved.
|
||||
if (streamingMode === 'batch' && batchMessages.length > 0) {
|
||||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
batchMessages.join('\n\n'),
|
||||
nodeContext
|
||||
);
|
||||
batchMessages.length = 0;
|
||||
}
|
||||
await safeSendMessage(platform, conversationId, msg.content, nodeContext);
|
||||
} else {
|
||||
batchMessages.push(msg.content);
|
||||
|
|
|
|||
Loading…
Reference in a new issue