From 47a142edc28cae3f34567fb303f659355ad9f44d Mon Sep 17 00:00:00 2001 From: Caleb Hattingh Date: Sat, 3 Oct 2026 02:05:53 +0200 Subject: [PATCH 1/3] feat: add pi agent support --- .github/workflows/ci.yml | 21 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 + md/SUMMARY.md | 2 + md/design/agent-details/README.md | 12 + md/design/agent-details/pi.md | 108 + md/design/agents.md | 45 +- md/design/common-issues.md | 26 + md/design/module-structure.md | 8 +- md/design/running-tests.md | 15 + md/install.md | 1 + md/reference/agents/pi.md | 74 + md/reference/configuration.md | 4 +- md/reference/plugin-definition.md | 5 +- md/reference/supported-agents.md | 2 +- src/agents/mcp_server_registration.rs | 106 + src/agents/mod.rs | 62 +- src/agents/pi.rs | 113 + src/agents/pi/extension.test.mjs | 376 +++ src/agents/pi/extension.ts | 209 ++ src/agents/pi/package-lock.json | 2142 +++++++++++++++++ src/agents/pi/package.json | 17 + src/agents/pi/tsconfig.json | 13 + src/hook_schema.rs | 3 + src/hook_schema/pi.rs | 151 ++ src/sync.rs | 22 +- symposium-sdk/Cargo.toml | 2 +- symposium-sdk/src/hook.rs | 19 + symposium-sdk/src/manifest.rs | 17 + .../plugins/format-plugin/SYMPOSIUM.toml | 10 +- .../plugins/format-plugin/scripts/pi-hook.sh | 11 + tests/hook_context.rs | 2 + tests/hook_format_priority.rs | 29 + tests/hook_stdout.rs | 1 + tests/pi.rs | 161 ++ 36 files changed, 3747 insertions(+), 48 deletions(-) create mode 100644 md/design/agent-details/pi.md create mode 100644 md/reference/agents/pi.md create mode 100644 src/agents/pi.rs create mode 100644 src/agents/pi/extension.test.mjs create mode 100644 src/agents/pi/extension.ts create mode 100644 src/agents/pi/package-lock.json create mode 100644 src/agents/pi/package.json create mode 100644 src/agents/pi/tsconfig.json create mode 100644 src/hook_schema/pi.rs create mode 100644 tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/scripts/pi-hook.sh create mode 100644 tests/pi.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d6027aa..a2ad54a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,3 +154,24 @@ jobs: - name: Check for orphaned md files run: cargo xtask md-orphan-check + + pi-extension: + name: Check Pi extension (${{ matrix.os }}) + timeout-minutes: 10 + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: src/agents/pi + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + cache: npm + cache-dependency-path: src/agents/pi/package-lock.json + - run: npm ci --ignore-scripts + - run: npm run check + - run: npm test diff --git a/Cargo.lock b/Cargo.lock index 1fd6a230..65ae7032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2481,7 +2481,7 @@ dependencies = [ [[package]] name = "symposium-sdk" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 9cad3f4a..df14a2f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ serde_yaml_ng = "0.10" sha2 = "0.10" tar = "0.4" tempfile = "3.6" -symposium-sdk = { version = "0.1", path = "symposium-sdk", features = ["clap"] } +symposium-sdk = { version = "0.2", path = "symposium-sdk", features = ["clap"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } toml = "0.8" tracing = "0.1" diff --git a/README.md b/README.md index f38bb02a..50e3f0d4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Which agents do you use? (space to select, enter to confirm): [ ] Goose [x] Kiro [x] OpenCode + [ ] Pi ``` - **Global** hook scope (default) registers Symposium for the selected agents in your home directory, so it activates in every Rust project. @@ -133,6 +134,7 @@ Every agent receives skill installation. Hook registration is available for a su | Kiro | `.kiro/skills/` | Yes | | OpenCode | `.agents/skills/` | No | | Goose | `.agents/skills/` | No | +| Pi | `.agents/skills/` | Yes (TypeScript extension) | ## For crate authors diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 7901190c..154a87e9 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -51,6 +51,7 @@ - [Kiro](./reference/agents/kiro.md) - [OpenCode](./reference/agents/opencode.md) - [Goose](./reference/agents/goose.md) + - [Pi](./reference/agents/pi.md) - [Configuration](./reference/configuration.md) - [Plugin sources](./reference/plugin-source.md) - [Plugin definition](./reference/plugin-definition.md) @@ -85,6 +86,7 @@ - [Goose](./design/agent-details/goose.md) - [Kiro](./design/agent-details/kiro.md) - [OpenCode](./design/agent-details/opencode.md) + - [Pi](./design/agent-details/pi.md) - [RFDs](./rfds/README.md) - [Template](./rfds/TEMPLATE/README.md) - [Accepted](./rfds/accepted.md) diff --git a/md/design/agent-details/README.md b/md/design/agent-details/README.md index 550276a3..00846d86 100644 --- a/md/design/agent-details/README.md +++ b/md/design/agent-details/README.md @@ -25,6 +25,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | [Kiro](./kiro.md) | `.kiro/agents/*.json` | `~/.kiro/agents/*.json` | JSON, `hooks` key in agent config | | [OpenCode](./opencode.md) | `.opencode/plugins/` | `~/.config/opencode/plugins/` | JS/TS plugins (not shell hooks) | | [Goose](./goose.md) | *(no hooks)* | *(no hooks)* | N/A | +| [Pi](./pi.md) | `.pi/extensions/symposium.ts` | `~/.pi/agent/extensions/symposium.ts` | TypeScript extension | ### Command field @@ -37,6 +38,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | Kiro | `command` | No | | OpenCode | N/A (JS function) | N/A | | Goose | N/A | N/A | +| Pi extension | `execFile("cargo-agents", ["hook", "pi", event])` | No | ### Timeout defaults @@ -49,6 +51,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | Kiro | 30,000 | milliseconds (`timeout_ms`) | | OpenCode | 60,000 | milliseconds (community hooks plugin) | | Goose | N/A | N/A | +| Pi extension | 30 | seconds | ## Event names @@ -62,6 +65,9 @@ Symposium registers hooks for four events on every agent with shell hooks, plus | session-start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `agentSpawn` | `session.created` | N/A | | stop | `Stop` | `Stop` | *not registered* (`agentStop`) | *not registered* (`Stop`) | *not registered* (`stop`) | N/A | N/A | +Pi uses extension events rather than shell hooks. Its mapping is documented in +[Pi integration](./pi.md#event-mapping-and-wire-format). + ### Blocking support Not all events can block the action in all agents. @@ -75,6 +81,7 @@ Not all events can block the action in all agents. | Kiro | Yes (exit 2) | No | No | No | | OpenCode | Yes (throw Error) | No | No (observe only) | No (observe only) | | Goose | N/A | N/A | N/A | N/A | +| Pi | Yes | No | No | No | ## Hook I/O protocol @@ -89,6 +96,7 @@ Not all events can block the action in all agents. | Kiro | `tool_name` | `tool_input` (object) | `hook_event_name`, `cwd` | | OpenCode | `tool` | `args` (mutable output object) | `sessionID`, `callID` | | Goose | N/A | N/A | N/A | +| Pi bridge | `tool_name` | `tool_input` (object) | `session_id`, `cwd` | ### Output structure (pre-tool-use) @@ -101,6 +109,7 @@ Not all events can block the action in all agents. | Kiro | *(exit code only)* | exit 0 = allow, exit 2 = block | *(not supported)* | N/A | | OpenCode | *(throw to block)* | allow (return) / deny (throw) | mutate `output.args` | JS mutation | | Goose | N/A | N/A | N/A | N/A | +| Pi bridge | `decision` | allow, deny | `updatedInput` | flat | ### Exit codes @@ -127,6 +136,7 @@ All shell-based agents use the same convention (where applicable): | Kiro | `.kiro/skills//SKILL.md` | `~/.kiro/skills//SKILL.md` | | OpenCode | `.agents/skills//SKILL.md` | `~/.agents/skills//SKILL.md` | | Goose | *(N/A — uses MCP extensions)* | *(N/A)* | +| Pi | `.agents/skills//SKILL.md` | `~/.agents/skills//SKILL.md` | Symposium uses the vendor-neutral `.agents/skills/` path whenever the agent supports it, falling back to agent-specific paths (e.g., `.claude/skills/`, `.kiro/skills/`) when required. Codex CLI and OpenCode also support `.agents/skills/` natively. @@ -141,6 +151,7 @@ Symposium uses the vendor-neutral `.agents/skills/` path whenever the agent supp | Kiro | `.kiro/steering/*.md`, `AGENTS.md` | `~/.kiro/steering/*.md` | | OpenCode | `AGENTS.md`, `CLAUDE.md` | `~/.config/opencode/AGENTS.md` | | Goose | `.goosehints`, `AGENTS.md` | `~/.config/goose/.goosehints` | +| Pi | `AGENTS.md`, `CLAUDE.md` | `~/.pi/agent/AGENTS.md` | ## MCP server configuration @@ -162,6 +173,7 @@ symposium reports success for a file the agent never reads. | GitHub Copilot CLI | *(none - user scope only)* | `~/.copilot/mcp-config.json` | `mcpServers.` = `{command, args}` | yes | | Kiro | `.kiro/settings/mcp.json` | `~/.kiro/settings/mcp.json` | `mcpServers.` = `{command, args}` | no (GUI only) | | Goose | *(none - user scope only)* | `~/.config/goose/config.yaml` | `extensions.` = `{name, type: stdio, cmd, args, enabled, envs}` | yes | +| Pi | `.pi/mcp.json` | `~/.pi/agent/mcp.json` | `mcpServers.` = `{command, args}` or `{url, headers}` | yes (`pi mcp list`) | Notes that cost real debugging time: diff --git a/md/design/agent-details/pi.md b/md/design/agent-details/pi.md new file mode 100644 index 00000000..59f1e99b --- /dev/null +++ b/md/design/agent-details/pi.md @@ -0,0 +1,108 @@ +# Pi integration + +Primary sources: [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md), +[skills](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md), +and [MCP](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md). + +## Extension registration + +`src/agents/pi.rs` installs the embedded `src/agents/pi/extension.ts` into +`.pi/extensions/symposium.ts` at project scope or +`~/.pi/agent/extensions/symposium.ts` at user scope. The user path honors +`PI_CODING_AGENT_DIR`. Registration compares content before writing. The generated +header identifies files that Symposium may update or remove. An existing file +without that header is not overwritten or removed. When hook scope changes, +`sync` calls `Agent::register_scoped_hooks`, which removes the generated extension +at the other scope for that workspace. +Run `/reload` after a scope change to replace the active handlers. + +The extension starts no work in its factory. Every handler checks +`ctx.isProjectTrusted()` before dispatch or project resource discovery, including +when the extension is installed globally. Event handlers run `cargo-agents +hook pi ` with JSON on stdin, using `execFile` without a shell. Prompts and +tool inputs are not shell arguments. Each process has a 10-minute timeout and a +1 MiB output limit. All events use this timeout because tools install lazily when +their hook first fires; session-start refresh does not install missing tools. +Active-turn cancellation stops its hook process. + +## Event mapping and wire format + +| Pi event | Symposium event | +|----------|-----------------| +| `session_start` | `SessionStart` | +| `input` | `UserPromptSubmit` | +| `tool_call` | `PreToolUse` | +| `tool_result` | `PostToolUse` | +| `agent_end` | `Stop` | + +`src/hook_schema/pi.rs` uses the SDK's event-specific structs as the Pi bridge +wire format. The command-line event name supplies the tag. Each input carries +`cwd` and `session_id`; tool events also carry `tool_name` and `tool_input`. +Post-tool input includes Pi's content, details, structured content, and error +state in `tool_response`. + +Output uses the SDK's flat fields: `decision`, `updatedInput`, and +`additionalContext`. Denial becomes Pi's `{ block: true, reason }`. Input updates +replace the existing input object's keys. Only an explicit `decision = "deny"` +blocks a tool. Process failures, missing binaries, timeouts, malformed output, +and non-object input updates let the tool run. Only the first hook error in each +session produces a warning. Cancellation produces no warning and does not count +as that first error. Warning state resets on `session_start`, not on turn end. +Warnings use Pi's UI when available, or stderr otherwise; they never use protocol +stdout. + +Pre-tool context is stored by `toolCallId` and appended to that tool's result, +followed by any post-tool context. This lets the model read it on its next request +without waiting for another user prompt. The result keeps its structured content +and error state. A post-hook failure does not discard pre-tool context. Pending +context is removed after the result and cleared on turn end, session start, or +session shutdown. + +Session-start, prompt, and Stop context use hidden custom messages for the next +user prompt, without requesting another turn. + +Symposium's normal hook pipeline handles auto-sync and plugin format conversion. +Native `format = "pi"` plugins receive flat Pi bridge JSON; portable +`format = "symposium"` plugins receive tagged SDK JSON. + +## Generated skill discovery + +Issue [#248](https://github.com/symposium-dev/symposium/issues/248) identifies a +Pi-specific limit: Pi's directory scanner honors `.gitignore`, including the `*` +rule Symposium writes in generated skill directories. + +Keep the shared `.agents/skills/` path and its ignore rules. Pi fires +`resources_discover` after `session_start`, so automatic sync installs skills +before the extension returns their explicit `SKILL.md` paths. Explicit files +bypass the directory scan. The extension checks the current directory and its +ancestors up to the repository root, plus `~/.agents/skills/`. Without a repository +root, it checks ancestors up to the filesystem root. It returns only +skills with the `.symposium` marker. User skills still follow Pi's normal rules. + +A dependency change can install more skills during an active session. Pi needs +`/reload` to discover new resources and MCP entries. Removing a skill removes it +from the next resource-discovery result. + +## MCP registration + +Pi 1.0 reads `mcpServers` from `.pi/mcp.json` and +`~/.pi/agent/mcp.json`. Entries use `command`, `args`, and `env` for stdio, or +`url` and `headers` for streamable HTTP. `register_pi_mcp_servers` updates the +transport fields while preserving Pi-specific user options. SSE is unsupported +and is skipped with a warning. + +## Tests + +```bash +cargo test --test pi --test hook_context +cd src/agents/pi +npm ci --ignore-scripts +npm run check +npm test +``` + +Rust tests cover init, scope selection, automatic sync, cleanup, wire conversion, +and MCP updates. Node tests cover event handling, subprocess errors, input +changes, parallel calls, pre-tool context timing, warning-only hook failures, +skill discovery using Pi's real skill loader, and MCP file discovery using +`pi mcp list` with local test servers. diff --git a/md/design/agents.md b/md/design/agents.md index 80cafddf..73452dd1 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -13,6 +13,7 @@ | `kiro` | Kiro | | `opencode` | OpenCode | | `goose` | Goose | +| `pi` | Pi | The agent name is stored in `[agent] name` in either the user or project config. @@ -31,11 +32,11 @@ When installing skills, `cargo agents` prefers vendor-neutral paths where possib | Scope | Path | Supported by | |-------|------|-------------| -| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose | +| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose, Pi | | Project skills | `.claude/skills//SKILL.md` | Claude Code (does not support `.agents/skills/`) | | Project skills | `.kiro/skills//SKILL.md` | Kiro (uses its own path) | -At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. +At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, Goose, and Pi all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. At the global level, each agent has its own path: @@ -48,6 +49,7 @@ At the global level, each agent has its own path: | Kiro | `~/.kiro/skills//SKILL.md` | | OpenCode | `~/.agents/skills//SKILL.md` | | Goose | `~/.agents/skills//SKILL.md` | +| Pi | `~/.agents/skills//SKILL.md` | --- @@ -395,17 +397,33 @@ Goose is supported as a **skills-only** agent — `cargo agents sync` will insta --- +## Pi + +[Integration reference](./agent-details/pi.md) + +Pi uses TypeScript extensions. Symposium installs `symposium.ts` in +`.pi/extensions/` or `~/.pi/agent/extensions/`, according to hook scope. It maps +session, input, tool, and turn-end events to `cargo-agents hook pi` calls. The +bridge supports automatic sync, context, tool denial, and tool-input changes. + +Pi's skill scanner respects `.gitignore`, which hides generated skills. The +extension supplies explicit paths to managed `SKILL.md` files through +`resources_discover`. This event runs after session-start sync. The shared +`.agents/skills/` path and its ignore rules remain unchanged. + +--- + ## Cross-agent event mapping The following table maps symposium's internal event names to each agent's wire-format event name. `—` means the agent does not support shell-command hooks; *not registered* means symposium does not hook that event there. -| Symposium event | Antigravity | Claude | Copilot | Codex | Kiro | OpenCode | Goose | -|---|---|---|---|---|---|---|---| -| `pre-tool-use` | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | `preToolUse` | — | — | -| `post-tool-use` | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | `postToolUse` | — | — | -| `user-prompt-submit` | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `UserPromptSubmit` | `userPromptSubmit` | — | — | -| `session-start` | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `agentSpawn` | — | — | -| `stop` | `Stop` | `Stop` | *not registered* | *not registered* | *not registered* | — | — | +| Symposium event | Antigravity | Claude | Copilot | Codex | Kiro | OpenCode | Goose | Pi extension | +|---|---|---|---|---|---|---|---|---| +| `pre-tool-use` | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | `preToolUse` | — | — | `tool_call` | +| `post-tool-use` | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | `postToolUse` | — | — | `tool_result` | +| `user-prompt-submit` | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `UserPromptSubmit` | `userPromptSubmit` | — | — | `input` | +| `session-start` | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `agentSpawn` | — | — | `session_start` | +| `stop` | `Stop` | `Stop` | *not registered* | *not registered* | *not registered* | — | — | `agent_end` | Tool names are each agent's own, and symposium passes them to plugin hooks unchanged: the same shell tool is `Bash` in Claude Code and Codex, `bash` in Copilot and `run_command` in Antigravity. A `matcher` that should fire on several agents has to name each of them. @@ -415,7 +433,8 @@ Tool names are each agent's own, and symposium passes them to plugin hooks uncha To add support for a new agent: -1. Add a variant to the `HookAgent` enum in `hook_schema.rs`. -2. Create an agent module (e.g., `hook_schema/newagent.rs`) implementing the `Agent` trait and the event-specific payload/output types. -3. Implement the `AgentHookPayload` and `AgentHookOutput` traits to convert between the agent's wire format and the internal `HookPayload`/`HookOutput` types. -4. Document the agent's hook registration locations and extension file layout in this page. +1. Add variants to `agents::Agent` and `symposium_sdk::hook::HookAgent`. +2. Add skill paths, hook registration, and MCP configuration to `agents`. +3. Create a `hook_schema` module that implements the `Agent` trait and the event-specific input/output types. Add its handler to `hook_schema::agent_event`. +4. Implement `AgentHookInput` and `AgentHookOutput` to convert the wire format to and from the SDK types. +5. Test init, sync, cleanup, and hook dispatch. Document the agent's file layout and protocol. diff --git a/md/design/common-issues.md b/md/design/common-issues.md index 9f2f3613..613dc428 100644 --- a/md/design/common-issues.md +++ b/md/design/common-issues.md @@ -16,6 +16,32 @@ Copilot CLI reads `.claude/settings.json` as well as its own hook files. In a pr An agent parses everything `cargo agents hook` writes to stdout. One line ahead of the JSON and Claude Code falls back to treating it all as plain text (which reaches the model only on `SessionStart` and `UserPromptSubmit`), while Copilot drops the output entirely. The in-process test pipeline cannot see this, because the leaks come from around it: the report layer, which the binary installs (and leaves out for hooks), and child processes that inherit stdout (plugin `install_commands` send theirs to stderr). Anything new that prints during a hook, or spawns a child that might, needs the same care; `tests/hook_stdout.rs` runs the real binary to catch it. +## Pi skips gitignored skills + +Pi's skill-directory scanner respects `.gitignore`, including the `*` rule in +Symposium-generated skill directories. Sharing `.agents/skills/` is not enough +to make those skills visible. The Pi extension returns explicit `SKILL.md` paths +through `resources_discover`, after session-start sync. Keep this registration +when changing the bridge. The Node regression test uses Pi's real skill loader. +See [Pi integration](./agent-details/pi.md#generated-skill-discovery). + +## Pi hook failures and context timing + +Pi blocks a tool if its `tool_call` extension handler throws. Catch Symposium +hook errors, warn only once per session, and let the tool run; only an explicit +`deny` decision blocks it. User cancellation must not produce a warning. A +missing binary or slow first-time install must not disable all tools in a trusted +project. + +Allow enough time for installation on every event, not just `SessionStart`. +Session-start refresh skips tools that are not installed. A tool hook can start +its first installation later. The Pi bridge uses a 10-minute timeout. + +Pi's `deliverAs: "nextTurn"` queue waits for the next user prompt. Do not use it +for pre-tool context. Store that context by `toolCallId` and append it in +`tool_result`, including when the post-hook fails. Clear pending context at turn +and session boundaries. The Node tests cover both rules. + ## Antigravity footguns Two Antigravity behaviours fail silently rather than loudly, so they are worth diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 45d59217..8e23dc82 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -25,22 +25,24 @@ Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[ag The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's module (not a top-level `crate::workspace`) — `cargo metadata` is cargo's ecosystem, not a generic concern. `WorkspaceDeps` is the lazy, cached resolver for the cargo dependency graph: the first `load()` reads a disk cache keyed on `Cargo.lock` mtime, and on miss runs `cargo metadata` (extracting `root`, direct `crates`, and member dirs into a `LoadedWorkspace`) and writes through. The result is memoized in a `OnceLock`, so every accessor reads through a shared `&self` — which is what lets one resolver be shared as an `Arc`: a `CargoPm` holds one and *drives it* (`self.workspace.crates()` runs the metadata call), and core code that needs the workspace root/members reads the same instance, rather than each caller resolving its own. `WorkspaceCrate` carries `path` (the local dir for a path dependency) and `source_dir` (the extracted source `cargo metadata` located, populated for registry crates too), so a workspace dependency's source is served without a fresh probe. `detached()` is a resolver pre-set to "no workspace" for workspace-independent operations (registry listing, search). The types re-export at `crate::pm` for the core consumers of the workspace root/members (registry loading, sync, hook). The config-level `dirs.rs` (`SymposiumDirs`: config/cache paths + the `SYMPOSIUM_CARGO` override) stays in core; `Symposium::workspace_deps(cwd)` is the factory that wires them together. -### `agents.rs` — agent abstraction +### `agents/mod.rs` — agent abstraction -Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose, Pi). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, Goose, and Pi use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes `.agents/hooks.json` but `~/.gemini/config/hooks.json`; Copilot `.github/hooks/` but `~/.copilot/settings.json`), so `sync` calls the project-scoped functions at project scope rather than rooting the global path at the workspace. MCP registration is its own axis, and the one place where guessing is expensive: an agent's MCP file is usually *not* its hooks file, so a wrong target fails **silently** - symposium reports success for a file the agent never reads. `Agent::mcp_config_path(scope, project_root, home)` is the single seam holding that per-agent truth (the table lives in [agent details](./agent-details/README.md#mcp-server-configuration)), with `register_mcp_servers` / `unregister_mcp_servers` as the only entry points; there is no separate project/global pair of functions to keep in sync. `McpScope` is deliberately distinct from `HookScope`: Antigravity, Claude, OpenCode and Kiro have a project-level MCP file, while Codex, the Copilot CLI and Goose read only a user-level one, so `supports_project_mcp_scope` reports when project scope is downgraded rather than writing a file nobody reads. Entry *shape* varies too - OpenCode needs `{type, command: [bin, ...args], enabled}` and rejects its whole config file otherwise, which is why it has its own serializer (`opencode_server_to_json`) instead of the shared `{command, args}` one. +Pi uses `agents/pi.rs` to install an embedded TypeScript extension, `agents/pi/extension.ts`. It forwards Pi events to `cargo-agents hook pi` and supplies explicit paths to generated skills through `resources_discover`, so Pi can read them despite their wildcard `.gitignore`. MCP uses `.pi/mcp.json` at project scope and `~/.pi/agent/mcp.json` at user scope. See [Pi integration](./agent-details/pi.md). + ### `init.rs` — initialization command Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`--remove-agent` flags), hook scope, auto-update behavior, and opt-in [telemetry](./telemetry.md); writes user config; and registers global hooks. ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_skill_dir(source_dir, dest_dir, project_root)`. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. Copying and comparing share one traversal, `walk_tree` — they have to agree entry for entry, since a comparison scan that omitted something the copier writes would see a difference on every run and turn each sync into a full recopy. That traversal **follows symlinks** (see [`dir_walk.rs`](#dir_walkrs--following-symlinks-without-looping)), so a linked file installs as a real file and a linked directory as its contents; a broken link and a link back into the tree being copied are reported and skipped rather than failing the sync. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_skill_dir(source_dir, dest_dir, project_root)`. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. Copying and comparing share one traversal, `walk_tree` — they have to agree entry for entry, since a comparison scan that omitted something the copier writes would see a difference on every run and turn each sync into a full recopy. That traversal **follows symlinks** (see [`dir_walk.rs`](#dir_walkrs--following-symlinks-without-looping)), so a linked file installs as a real file and a linked directory as its contents; a broken link and a link back into the tree being copied are reported and skipped rather than failing the sync. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, including when the final agent has been removed, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. One entry point, `sync(sym, deps, update)`. The caller supplies the `WorkspaceDeps`, so the hook pipeline hands over the resolver its other stages already used rather than resolving the workspace a second time. Callers: the CLI's `sync` arm (after the consent prompt), `use_command` once an enablement is recorded, and the hook pipeline's auto-sync. diff --git a/md/design/running-tests.md b/md/design/running-tests.md index 58ddbffb..bc03d0c9 100644 --- a/md/design/running-tests.md +++ b/md/design/running-tests.md @@ -59,6 +59,21 @@ cargo test --test hook_agent -- --nocapture On failure, the test's temporary directory is preserved and its path is printed to stderr so you can inspect the fixture state. +## Pi extension + +The Pi bridge also has TypeScript and Node tests: + +```bash +cd src/agents/pi +npm ci --ignore-scripts +npm run check +npm test +``` + +Use Node.js 22.19 or later. These tests do not call a model. They check event +mapping and use Pi's skill loader to verify that generated, gitignored skills +load. CI runs them on Linux, macOS, and Windows. + ## Windows CI runs the full test suite on `windows-latest` as part of the `test` matrix (see `.github/workflows/ci.yml`). To run the tests locally on Windows: diff --git a/md/install.md b/md/install.md index d60ca02e..b98eab84 100644 --- a/md/install.md +++ b/md/install.md @@ -35,6 +35,7 @@ Which agents do you use? (space to select, enter to confirm): [ ] Goose [x] Kiro [x] OpenCode + [ ] Pi ``` ### Global vs project hook registration diff --git a/md/reference/agents/pi.md b/md/reference/agents/pi.md new file mode 100644 index 00000000..f8352adc --- /dev/null +++ b/md/reference/agents/pi.md @@ -0,0 +1,74 @@ +# Pi + +Config name: `pi` + +Symposium supports Pi 1.0 or later, with Node.js 22.19 or later. + +```bash +cargo agents init --add-agent pi +``` + +Run `cargo agents sync` and Pi's `/reload` after changing hook scope. + +Start Pi in a Rust workspace. The Symposium extension syncs skills on session +start. It also checks for dependency changes when Pi submits a prompt or runs a +tool. Run `/reload` after adding skills or MCP servers during an active session. + +## Skills + +| Scope | Path | +|-------|------| +| Project | `.agents/skills//SKILL.md` | +| Global | `~/.agents/skills//SKILL.md` | + +Pi's directory scanner respects `.gitignore`. Symposium's generated skills have +an ignore rule of `*`. The extension supplies explicit `SKILL.md` paths through +Pi's `resources_discover` event, so those skills load without changing the ignore +rules. User-managed skills keep Pi's normal discovery rules. + +## Hooks + +Pi uses TypeScript extensions, not shell-command hook settings. Symposium +installs one extension: + +| Scope | File | +|-------|------| +| Project | `.pi/extensions/symposium.ts` | +| Global | `~/.pi/agent/extensions/symposium.ts` | + +`PI_CODING_AGENT_DIR` changes the global agent directory. Project extensions load +only after Pi grants project trust. The bridge also skips sync and hooks in +untrusted projects when the extension is installed globally. If extensions are +disabled, use `cargo agents sync` manually; the generated skills still need the +extension for discovery. + +| Pi event | Symposium event | +|----------|-----------------| +| `session_start` | `session-start` | +| `input` | `user-prompt-submit` | +| `tool_call` | `pre-tool-use` | +| `tool_result` | `post-tool-use` | +| `agent_end` | `stop` | + +The bridge supports tool denial, input changes, and additional context. Only an +explicit `decision = "deny"` blocks a tool. Other hook errors let the tool run, +including a missing `cargo-agents` binary or a timeout. The bridge warns only once +per session. User cancellation produces no warning. + +Each child process has a 10-minute timeout so first-time tool installation has +time to finish, including when installation starts during a tool event. + +Pre-tool context is added to the matching tool result, so the model sees it +without another user prompt. Stop context does not start another agent turn. + +## MCP servers + +| Scope | File | Key | +|-------|------|-----| +| Project | `.pi/mcp.json` | `mcpServers.` | +| Global | `~/.pi/agent/mcp.json` | `mcpServers.` | + +Pi supports stdio and streamable HTTP. Symposium skips SSE servers with a +warning. It preserves user-set enabled state, exposure, and other Pi options. +The built-in MCP extension must be enabled; third-party MCP replacements can +use a different configuration format. diff --git a/md/reference/configuration.md b/md/reference/configuration.md index 6b2706e4..60e29260 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -43,7 +43,7 @@ path = "my-plugins" ### Agents syncing: mirror user-authored skills -Agents such as Antigravity, Copilot, Codex, Goose, and OpenCode all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, every [workspace plugin](../workspace-skills.md) — the workspace root and each member directory — carries a second default skill group, gated by the `workspace-member()` predicate: +Agents such as Antigravity, Copilot, Codex, Goose, OpenCode, and Pi all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, every [workspace plugin](../workspace-skills.md) — the workspace root and each member directory — carries a second default skill group, gated by the `workspace-member()` predicate: ```toml [[skills]] @@ -74,7 +74,7 @@ Each `[[agent]]` entry identifies an agent you use. You can configure multiple a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `name` | string | *(required)* | Agent name: `antigravity`, `claude`, `codex`, `copilot`, `goose`, `kiro`, or `opencode`. | +| `name` | string | *(required)* | Agent name: `antigravity`, `claude`, `codex`, `copilot`, `goose`, `kiro`, `opencode`, or `pi`. | ## `[logging]` diff --git a/md/reference/plugin-definition.md b/md/reference/plugin-definition.md index 3e821749..4ed9ac05 100644 --- a/md/reference/plugin-definition.md +++ b/md/reference/plugin-definition.md @@ -229,7 +229,7 @@ Each `[[hooks]]` entry declares a hook that responds to agent events. For the JS | `args` | array (optional) | Invocation arguments. Forbidden when the installation also declares `args`. | | `requirements` | array (optional) | Installations to acquire before running. Same shape as `command` (string name or inline declaration). | | `agent` | string (optional) | Restrict the hook to a specific agent (`antigravity`, `claude`, `copilot`, `kiro`, …). | -| `format` | string | Wire format the handler expects on stdin. `symposium` (default): symposium converts the agent's event to its canonical format before delivering. Any agent name (`antigravity`, `claude`, `codex`, `copilot`, `kiro`): the handler receives that agent's native wire format. Symposium always intermediates — it never registers plugin hooks directly into agent configs. See [Hooks](../crate-authors/authoring-a-plugin.md#hooks). | +| `format` | string | Wire format the handler expects on stdin. `symposium` (default): symposium converts the agent's event to its canonical format before delivering. Any agent name (`antigravity`, `claude`, `codex`, `copilot`, `kiro`, `pi`): the handler receives that agent's native wire format. Symposium always intermediates — it never registers plugin hooks directly into agent configs. See [Hooks](../crate-authors/authoring-a-plugin.md#hooks). | | `predicates` | array (optional) | Predicates (`depends-on`, `shell`, `path_exists`, `env`, `workspace-member`, `not`, `any`, `all`) that must all hold for the hook to dispatch. Evaluated per-dispatch. See [Predicates](./predicates.md). | ### Examples @@ -417,7 +417,7 @@ Use the CLI to test a hook with sample input: echo '{"tool": "Bash", "input": "cargo test"}' | cargo agents hook claude pre-tool-use ``` -You can also use `antigravity`, `copilot`, `codex`, or `kiro` as the agent name. +You can also use `antigravity`, `copilot`, `codex`, `kiro`, or `pi` as the agent name. ## `[[predicate]]` @@ -557,6 +557,7 @@ All supported agents have MCP server configuration. Symposium handles the format | Kiro | `.kiro/settings/mcp.json` | `mcpServers.` | | OpenCode | `opencode.json` | `mcp.` | | Goose | `~/.config/goose/config.yaml` (no project file) | `extensions.` | +| Pi | `.pi/mcp.json` | `mcpServers.` | See each agent's page under [Supported agents](./supported-agents.md) for the global locations. diff --git a/md/reference/supported-agents.md b/md/reference/supported-agents.md index 2a91eb45..ea7675f5 100644 --- a/md/reference/supported-agents.md +++ b/md/reference/supported-agents.md @@ -1,3 +1,3 @@ # Supported agents -Symposium supports seven AI coding agents. Each agent gets skill installation; hook support varies by agent. +Symposium supports eight AI coding agents. Each agent gets skill installation; hook support varies by agent. diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index 31750c50..e22583a9 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -314,6 +314,61 @@ pub(super) fn unregister_antigravity_mcp_servers( unregister_json_mcp_servers(path, names, Some("mcpServers"), out) } +/// Pi: `mcpServers.` in `mcp.json`. Preserve user-set exposure, +/// timeouts, authentication, and enabled state when refreshing an entry. +pub(super) fn register_pi_mcp_servers( + path: &Path, + servers: &[McpServer], + out: &Output, +) -> Result<()> { + let mut config = load_json_or_empty(path)?; + anyhow::ensure!(config.is_object(), "Pi MCP config must be an object"); + if !config.get("mcpServers").is_some_and(|v| v.is_object()) { + config["mcpServers"] = json!({}); + } + let mut changed = false; + for server in servers { + if matches!(server, McpServer::Sse(_)) { + out.warn(format!( + "Pi does not support SSE MCP servers; skipping {}", + server_name(server) + )); + continue; + } + let name = server_name(server); + let mut expected = config["mcpServers"][name].clone(); + if !expected.is_object() { + expected = json!({}); + } + let entry = expected.as_object_mut().unwrap(); + for key in ["command", "args", "env", "url", "headers", "type"] { + entry.remove(key); + } + entry.extend(server_to_json(server).as_object().unwrap().clone()); + if config["mcpServers"][name] != expected { + config["mcpServers"][name] = expected; + changed = true; + out.done(format!( + "{}: configured {name} MCP server", + display_path(path) + )); + } else { + out.already_ok(format!( + "{}: {name} MCP server already configured", + display_path(path) + )); + } + } + if changed { + save_json(path, &config)?; + } + Ok(()) +} + +pub(super) fn unregister_pi_mcp_servers(path: &Path, names: &[&str], out: &Output) -> Result<()> { + unregister_json_mcp_servers(path, names, Some("mcpServers"), out) +} + /// Codex CLI: `[mcp_servers.]` in config.toml pub(super) fn register_codex_mcp_servers( config_path: &Path, @@ -795,6 +850,57 @@ mod tests { ); } + #[test] + fn pi_mcp_preserves_user_options_and_other_servers() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("mcp.json"); + fs::write(&path, json!({ + "autoEnableCodemode": false, + "mcpServers": { + "withenv": {"command": "old", "enabled": false, "exposure": "direct", "timeout": 10}, + "personal": {"command": "personal-server"} + } + }).to_string()).unwrap(); + register_pi_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + let config: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(config["mcpServers"]["withenv"]["env"]["TOKEN"], "abc"); + assert_eq!(config["mcpServers"]["withenv"]["enabled"], false); + assert_eq!(config["mcpServers"]["withenv"]["exposure"], "direct"); + assert_eq!(config["mcpServers"]["withenv"]["timeout"], 10); + assert_eq!( + config["mcpServers"]["remote"]["headers"]["Authorization"], + "Bearer t" + ); + assert_eq!(config["autoEnableCodemode"], false); + register_pi_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + unregister_pi_mcp_servers(&path, &["withenv", "remote"], &Output::quiet()).unwrap(); + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!( + config["mcpServers"]["personal"]["command"], + "personal-server" + ); + assert!(config["mcpServers"].get("withenv").is_none()); + assert!(config["mcpServers"].get("remote").is_none()); + } + + #[test] + fn pi_mcp_skips_unsupported_sse() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("mcp.json"); + let server = McpServer::Sse(sacp::schema::McpServerSse::new( + "legacy", + "https://example.com/sse", + )); + register_pi_mcp_servers(&path, &[server], &Output::quiet()).unwrap(); + assert!( + !path.exists(), + "Pi must not receive an unsupported transport" + ); + } + /// The Copilot CLI needs an explicit `type`; without it a remote entry is /// not picked up at all. #[test] diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 51ed66c9..fefbc1ad 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,7 @@ //! that knowledge. mod mcp_server_registration; +mod pi; use std::fs; use std::path::{Path, PathBuf}; @@ -12,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, bail}; use serde_json::json; -use crate::config::Symposium; +use crate::config::{HookScope, Symposium}; use crate::output::{Output, display_path}; /// Which of an agent's two MCP configuration levels to write. @@ -38,6 +39,7 @@ pub enum Agent { Goose, Kiro, OpenCode, + Pi, } impl Agent { @@ -51,8 +53,9 @@ impl Agent { "goose" => Ok(Agent::Goose), "kiro" => Ok(Agent::Kiro), "opencode" => Ok(Agent::OpenCode), + "pi" => Ok(Agent::Pi), other => bail!( - "unknown agent: {other} (expected antigravity, claude, codex, copilot, goose, kiro, or opencode)" + "unknown agent: {other} (expected antigravity, claude, codex, copilot, goose, kiro, opencode, or pi)" ), } } @@ -67,6 +70,7 @@ impl Agent { Agent::Goose => "goose", Agent::Kiro => "kiro", Agent::OpenCode => "opencode", + Agent::Pi => "pi", } } @@ -80,6 +84,7 @@ impl Agent { Agent::Goose => "Goose", Agent::Kiro => "Kiro", Agent::OpenCode => "OpenCode", + Agent::Pi => "Pi", } } @@ -93,6 +98,7 @@ impl Agent { Agent::Goose, Agent::Kiro, Agent::OpenCode, + Agent::Pi, ] } @@ -102,9 +108,8 @@ impl Agent { /// Project-level skill directory for a given skill name. /// - /// Claude Code requires `.claude/skills/`, while Antigravity, Copilot and - /// Antigravity, Codex and Copilot support the vendor-neutral - /// `.agents/skills/` path. + /// Claude Code and Kiro use agent-specific paths. Other agents, including + /// Pi, support the vendor-neutral `.agents/skills/` path. pub fn project_skill_dir(&self, project_root: &Path, skill_name: &str) -> PathBuf { match self { Agent::Claude => project_root.join(".claude").join("skills").join(skill_name), @@ -113,7 +118,9 @@ impl Agent { } Agent::Goose => project_root.join(".agents").join("skills").join(skill_name), Agent::Kiro => project_root.join(".kiro").join("skills").join(skill_name), - Agent::OpenCode => project_root.join(".agents").join("skills").join(skill_name), + Agent::OpenCode | Agent::Pi => { + project_root.join(".agents").join("skills").join(skill_name) + } } } @@ -133,7 +140,9 @@ impl Agent { Agent::Copilot => None, // no global skills path Agent::Goose => Some(home.join(".agents").join("skills").join(skill_name)), Agent::Kiro => Some(home.join(".kiro").join("skills").join(skill_name)), - Agent::OpenCode => Some(home.join(".agents").join("skills").join(skill_name)), + Agent::OpenCode | Agent::Pi => { + Some(home.join(".agents").join("skills").join(skill_name)) + } } } @@ -141,6 +150,28 @@ impl Agent { // Hook registration // ----------------------------------------------------------------------- + /// Register hooks at the configured scope. + /// + /// Pi loads both extension locations. Remove the other generated copy + /// before registration so a scope change does not duplicate its handlers. + pub fn register_scoped_hooks( + &self, + project_root: &Path, + sym: &Symposium, + out: &Output, + ) -> Result<()> { + if *self == Agent::Pi { + match sym.config.hook_scope { + HookScope::Global => self.unregister_project_hooks(project_root, sym, out), + HookScope::Project => self.unregister_hooks(sym.home_dir(), sym, out), + } + } + match sym.config.hook_scope { + HookScope::Global => self.register_hooks(sym.home_dir(), sym, out), + HookScope::Project => self.register_project_hooks(project_root, sym, out), + } + } + /// Register hooks in the project-level agent config. pub fn register_project_hooks( &self, @@ -162,6 +193,7 @@ impl Agent { register_copilot_hooks(&project_root.join(".github").join("hooks"), out) } Agent::Kiro => register_kiro_hooks(&project_root.join(".kiro").join("agents"), out), + Agent::Pi => pi::register_extension(&project_root.join(".pi"), out), Agent::Goose => { out.info( "Goose uses MCP extensions for hooks; skipping hook registration (skills only)", @@ -194,6 +226,7 @@ impl Agent { register_copilot_hooks_global(&home.join(".copilot").join("settings.json"), out) } Agent::Kiro => register_kiro_hooks(&home.join(".kiro").join("agents"), out), + Agent::Pi => pi::register_extension(&pi::agent_dir(home), out), Agent::Goose => { out.info( "Goose uses MCP extensions for hooks; skipping hook registration (skills only)", @@ -265,6 +298,8 @@ impl Agent { (Agent::Codex, _) => home.join(".codex").join("config.toml"), (Agent::Copilot, _) => home.join(".copilot").join("mcp-config.json"), (Agent::Goose, _) => xdg_config().join("goose").join("config.yaml"), + (Agent::Pi, McpScope::Project) => project_root.join(".pi").join("mcp.json"), + (Agent::Pi, McpScope::User) => pi::agent_dir(home).join("mcp.json"), } } @@ -275,7 +310,7 @@ impl Agent { pub fn supports_project_mcp_scope(&self) -> bool { matches!( self, - Agent::Antigravity | Agent::Claude | Agent::OpenCode | Agent::Kiro + Agent::Antigravity | Agent::Claude | Agent::OpenCode | Agent::Kiro | Agent::Pi ) } @@ -310,6 +345,7 @@ impl Agent { Agent::OpenCode => { mcp_server_registration::register_opencode_mcp_servers(&path, servers, out) } + Agent::Pi => mcp_server_registration::register_pi_mcp_servers(&path, servers, out), } } @@ -343,6 +379,7 @@ impl Agent { Agent::OpenCode => { mcp_server_registration::unregister_opencode_mcp_servers(&path, names, out) } + Agent::Pi => mcp_server_registration::unregister_pi_mcp_servers(&path, names, out), } } @@ -362,6 +399,7 @@ impl Agent { unregister_copilot_hooks(&project_root.join(".github").join("hooks"), out) } Agent::Kiro => unregister_kiro_hooks(&project_root.join(".kiro").join("agents"), out), + Agent::Pi => pi::unregister_extension(&project_root.join(".pi"), out), Agent::Goose => {} // no hooks to unregister Agent::OpenCode => {} // no hooks to unregister } @@ -382,6 +420,7 @@ impl Agent { unregister_copilot_hooks_global(&home.join(".copilot").join("settings.json"), out) } Agent::Kiro => unregister_kiro_hooks(&home.join(".kiro").join("agents"), out), + Agent::Pi => pi::unregister_extension(&pi::agent_dir(home), out), Agent::Goose => {} // no hooks to unregister Agent::OpenCode => {} // no hooks to unregister } @@ -1085,6 +1124,11 @@ mod tests { "/project/.kiro/settings/mcp.json", "/home/user/.kiro/settings/mcp.json", ), + ( + Agent::Pi, + "/project/.pi/mcp.json", + "/home/user/.pi/agent/mcp.json", + ), ]; for (agent, project_path, user_path) in cases { assert_eq!( @@ -1110,6 +1154,7 @@ mod tests { match agent { Agent::Claude => set("CLAUDE_CONFIG_DIR"), Agent::OpenCode | Agent::Goose => set("XDG_CONFIG_HOME"), + Agent::Pi => set("PI_CODING_AGENT_DIR"), _ => false, } } @@ -1187,6 +1232,7 @@ mod tests { root.join(".gemini").join("config").join("hooks.json"), ], Agent::Kiro => vec![root.join(".kiro").join("agents")], + Agent::Pi => vec![root.join(".pi").join("extensions").join("symposium.ts")], // Skills-only agents register no hooks at all. Agent::Goose | Agent::OpenCode => vec![], } diff --git a/src/agents/pi.rs b/src/agents/pi.rs new file mode 100644 index 00000000..90d2e71a --- /dev/null +++ b/src/agents/pi.rs @@ -0,0 +1,113 @@ +//! Pi loads a TypeScript extension instead of shell-command hook settings. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::output::{Output, display_path}; + +const EXTENSION: &str = include_str!("pi/extension.ts"); +const GENERATED_HEADER: &str = "// Generated by Symposium. Do not edit.\n"; + +pub(super) fn agent_dir(home: &Path) -> PathBuf { + match std::env::var_os("PI_CODING_AGENT_DIR").filter(|v| !v.is_empty()) { + Some(dir) => { + let path = PathBuf::from(dir); + match path.strip_prefix("~") { + Ok(rest) => home.join(rest), + Err(_) => path, + } + } + None => home.join(".pi").join("agent"), + } +} + +pub(super) fn register_extension(root: &Path, out: &Output) -> Result<()> { + let path = root.join("extensions").join("symposium.ts"); + if path.exists() { + let existing = fs::read_to_string(&path)?; + if existing == EXTENSION { + out.already_ok(format!( + "{}: Pi extension already configured", + display_path(&path) + )); + return Ok(()); + } + if !existing.starts_with(GENERATED_HEADER) { + bail!( + "{}: refusing to replace a user-managed Pi extension", + display_path(&path) + ); + } + } + fs::create_dir_all(path.parent().unwrap())?; + let temp = path.with_extension(format!("symposium-tmp-{}", std::process::id())); + fs::write(&temp, EXTENSION).with_context(|| format!("write {}", temp.display()))?; + if let Err(e) = fs::rename(&temp, &path) { + let _ = fs::remove_file(&temp); + return Err(e).with_context(|| format!("replace {}", path.display())); + } + out.done(format!("{}: installed Pi extension", display_path(&path))); + Ok(()) +} + +pub(super) fn unregister_extension(root: &Path, out: &Output) { + let path = root.join("extensions").join("symposium.ts"); + let Ok(existing) = fs::read_to_string(&path) else { + return; + }; + if !existing.starts_with(GENERATED_HEADER) { + return; + } + match fs::remove_file(&path) { + Ok(()) => out.done(format!("{}: removed Pi extension", display_path(&path))), + Err(e) => out.warn(format!( + "{}: failed to remove Pi extension: {e}", + display_path(&path) + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_registration_is_idempotent_and_removable() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let path = root.join("extensions/symposium.ts"); + register_extension(root, &Output::quiet()).unwrap(); + let before = fs::metadata(&path).unwrap().modified().unwrap(); + register_extension(root, &Output::quiet()).unwrap(); + assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), before); + assert_eq!(fs::read_to_string(&path).unwrap(), EXTENSION); + unregister_extension(root, &Output::quiet()); + assert!(!path.exists()); + } + + #[test] + fn user_extension_is_not_overwritten_or_removed() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("extensions/symposium.ts"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "export default function () {}\n").unwrap(); + assert!(register_extension(tmp.path(), &Output::quiet()).is_err()); + unregister_extension(tmp.path(), &Output::quiet()); + assert_eq!( + fs::read_to_string(path).unwrap(), + "export default function () {}\n" + ); + } + + #[test] + fn old_generated_extension_is_updated() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("extensions/symposium.ts"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{GENERATED_HEADER}// old version\n")).unwrap(); + register_extension(tmp.path(), &Output::quiet()).unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), EXTENSION); + } +} diff --git a/src/agents/pi/extension.test.mjs b/src/agents/pi/extension.test.mjs new file mode 100644 index 00000000..f33187e1 --- /dev/null +++ b/src/agents/pi/extension.test.mjs @@ -0,0 +1,376 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { loadSkills, loadSkillsFromDir, ProjectTrustStore } from "@earendil-works/pi-coding-agent"; +import extension, { DEFAULT_HOOK_TIMEOUT_MS, managedSkillPaths, runHook } from "./extension.ts"; + +function setup(t, output = {}) { + const handlers = new Map(); + const messages = []; + const calls = []; + const warnings = []; + const ctx = { + cwd: "/project", sessionManager: { getSessionId: () => "session-1" }, + isProjectTrusted: () => true, hasUI: true, + ui: { notify: (message, level) => warnings.push({ message, level }) }, + }; + const dispatch = async (event, payload, cwd) => { + calls.push({ event, payload, cwd }); + if (output instanceof Error) throw output; + return typeof output === "function" ? output(event, payload, cwd) : output; + }; + extension({ + on: (event, handler) => handlers.set(event, handler), + sendMessage: (...args) => messages.push(args), + }, dispatch); + return { handlers, messages, calls, ctx, warnings }; +} + +test("the default hook timeout allows ten minutes for cold installations", () => { + assert.equal(DEFAULT_HOOK_TIMEOUT_MS, 600_000); +}); + +test("session start syncs before resources are discovered and adds context", async (t) => { + const { handlers, messages, calls, ctx } = setup(t, { additionalContext: "startup context" }); + await handlers.get("session_start")({ reason: "startup" }, ctx); + assert.deepEqual(calls, [{ + event: "session-start", payload: { cwd: "/project", session_id: "session-1" }, cwd: "/project", + }]); + assert.equal(messages[0][0].content, "startup context"); + assert.deepEqual(messages[0][1], { deliverAs: "nextTurn" }); + assert.ok(handlers.has("resources_discover")); +}); + +test("prompt and stop events retain context without starting another turn", async (t) => { + const { handlers, messages, calls, ctx } = setup(t, { additionalContext: "context" }); + await handlers.get("input")({ text: "hello", source: "interactive" }, ctx); + await handlers.get("agent_end")({}, ctx); + assert.equal(calls[0].event, "user-prompt-submit"); + assert.equal(calls[0].payload.prompt, "hello"); + assert.equal(calls[1].event, "stop"); + assert.ok(messages.every(([, options]) => !options.triggerTurn)); +}); + +test("untrusted projects do not sync, dispatch hooks, or expose managed skills", async (t) => { + const { handlers, messages, calls, ctx } = setup(t); + ctx.isProjectTrusted = () => false; + for (const event of ["session_start", "input", "tool_call", "tool_result", "agent_end"]) { + await handlers.get(event)({}, ctx); + } + assert.deepEqual(handlers.get("resources_discover")({ cwd: ctx.cwd }, ctx), { skillPaths: [] }); + assert.deepEqual(calls, []); + assert.deepEqual(messages, []); +}); + +test("pre-tool-use maps denial to a blocked Pi tool", async (t) => { + const { handlers, ctx, calls } = setup(t, { decision: "deny", additionalContext: "not allowed" }); + const event = { toolCallId: "deny-1", toolName: "bash", input: { command: "rm -rf /" } }; + assert.deepEqual(await handlers.get("tool_call")(event, ctx), { block: true, reason: "not allowed" }); + assert.equal(calls[0].payload.tool_name, "bash"); + assert.deepEqual(calls[0].payload.tool_input, event.input); +}); + +test("pre-tool-use replaces inputs and delivers context with the matching result", async (t) => { + const { handlers, messages, ctx } = setup(t, (event) => event === "pre-tool-use" + ? { updatedInput: { command: "pwd" }, additionalContext: "safer input" } : {}); + const input = { command: "ls", timeout: 5 }; + const event = { toolCallId: "rewrite-1", toolName: "bash", input }; + await handlers.get("tool_call")(event, ctx); + assert.deepEqual(input, { command: "pwd" }); + assert.deepEqual(messages, [], "pre-tool context must not wait for another prompt"); + const result = await handlers.get("tool_result")({ + ...event, content: [{ type: "text", text: "result" }], isError: false, + }, ctx); + assert.deepEqual(result.content, [ + { type: "text", text: "result" }, { type: "text", text: "safer input" }, + ]); + assert.deepEqual(messages, []); +}); + +test("invalid updated inputs and hook failures warn without blocking tools", async (t) => { + for (const output of [{ updatedInput: [] }, new Error("hook failed")]) { + const { handlers, ctx, warnings } = setup(t, output); + const input = { command: "pwd" }; + const result = await handlers.get("tool_call")({ toolCallId: "error-1", toolName: "bash", input }, ctx); + assert.equal(result, undefined); + assert.deepEqual(input, { command: "pwd" }); + assert.equal(warnings.length, 1); + assert.equal(warnings[0].level, "warning"); + } +}); + +test("hook failures warn once across events and turns, then reset for a new session", async (t) => { + const { handlers, ctx, warnings } = setup(t, new Error("missing executable")); + const event = { toolCallId: "warning-1", toolName: "bash", input: {}, content: [], isError: false }; + await handlers.get("session_start")({}, ctx); + for (let turn = 0; turn < 2; turn++) { + await handlers.get("input")({ text: "hello" }, ctx); + assert.equal(await handlers.get("tool_call")(event, ctx), undefined); + await handlers.get("tool_result")(event, ctx); + await handlers.get("agent_end")({}, ctx); + } + assert.equal(warnings.length, 1); + ctx.sessionManager.getSessionId = () => "session-2"; + await handlers.get("session_start")({}, ctx); + assert.equal(warnings.length, 2); +}); + +test("aborted hooks are silent and do not consume the session warning", async (t) => { + for (const abortBeforeDispatch of [false, true]) { + const controller = new AbortController(); + if (abortBeforeDispatch) controller.abort(); + const { handlers, ctx, warnings } = setup(t, () => { + controller.abort(); + throw new Error("request aborted"); + }); + ctx.signal = controller.signal; + const event = { toolCallId: "abort-1", toolName: "bash", input: { command: "pwd" } }; + assert.equal(await handlers.get("tool_call")(event, ctx), undefined); + assert.deepEqual(event.input, { command: "pwd" }); + assert.deepEqual(warnings, []); + + // A later error without cancellation must still produce the first warning. + ctx.signal = new AbortController().signal; + await handlers.get("input")({ text: "retry" }, ctx); + assert.equal(warnings.length, 1); + await handlers.get("agent_end")({}, ctx); + assert.equal(warnings.length, 1); + } +}); + +test("hook errors warn on stderr only once when Pi has no UI", async (t) => { + const warnings = []; + t.mock.method(console, "warn", (message) => warnings.push(message)); + const { handlers, ctx } = setup(t, new Error("bad configuration")); + ctx.hasUI = false; + assert.equal(await handlers.get("tool_call")({ toolCallId: "stderr-1", toolName: "bash", input: {} }, ctx), undefined); + await handlers.get("input")({ text: "hello" }, ctx); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /bad configuration/); +}); + +test("a failed warning notification cannot block a tool", async (t) => { + const warnings = []; + t.mock.method(console, "warn", (message) => warnings.push(message)); + const { handlers, ctx } = setup(t, new Error("missing executable")); + ctx.ui.notify = () => { throw new Error("notification failed"); }; + assert.equal(await handlers.get("tool_call")({ toolCallId: "ui-1", toolName: "bash", input: {} }, ctx), undefined); + assert.match(warnings[0], /missing executable/); +}); + +test("post-tool-use preserves result data and adds context", async (t) => { + const { handlers, calls, ctx } = setup(t, { additionalContext: "check the result" }); + const event = { + toolCallId: "post-1", toolName: "custom", input: { value: 1 }, content: [{ type: "text", text: "result" }], + details: { value: 2 }, structuredContent: { value: 2 }, isError: true, + }; + const result = await handlers.get("tool_result")(event, ctx); + assert.deepEqual(result.content, [...event.content, { type: "text", text: "check the result" }]); + assert.deepEqual(result.structuredContent, event.structuredContent); + assert.deepEqual(calls[0].payload.tool_response, { + content: event.content, details: event.details, structuredContent: event.structuredContent, isError: true, + }); +}); + +test("pre- and post-tool context are joined even when a post hook fails", async (t) => { + for (const fail of [false, true]) { + const { handlers, ctx, warnings, messages } = setup(t, (event) => { + if (event === "pre-tool-use") return { additionalContext: "before" }; + if (fail) throw new Error("post hook failed"); + return { additionalContext: "after" }; + }); + const event = { + toolCallId: "join-1", toolName: "custom", input: {}, + content: [{ type: "text", text: "result" }], structuredContent: { value: 2 }, isError: true, + }; + await handlers.get("tool_call")(event, ctx); + const result = await handlers.get("tool_result")(event, ctx); + assert.deepEqual(result.content.map((c) => c.text), fail ? ["result", "before"] : ["result", "before", "after"]); + assert.deepEqual(result.structuredContent, event.structuredContent); + assert.equal(event.isError, true); + assert.deepEqual(messages, []); + assert.equal(warnings.length, fail ? 1 : 0); + // Reusing an ID after its result must not reuse old pre-tool context. + const next = await handlers.get("tool_result")(event, ctx); + assert.ok(!next?.content.some((c) => c.text === "before")); + } +}); + +test("unfinished pre-tool context is cleared at turn and session boundaries", async (t) => { + for (const boundary of ["agent_end", "session_start", "session_shutdown"]) { + const { handlers, ctx } = setup(t, (event) => event === "pre-tool-use" ? { additionalContext: "stale" } : {}); + const event = { toolCallId: "reused-1", toolName: "bash", input: {}, content: [], isError: false }; + await handlers.get("tool_call")(event, ctx); + await handlers.get(boundary)({}, ctx); + assert.equal(await handlers.get("tool_result")(event, ctx), undefined); + } +}); + +test("parallel tool hooks keep their inputs and decisions separate", async () => { + const handlers = new Map(); + extension({ on: (name, fn) => handlers.set(name, fn), sendMessage() {} }, + async (_event, payload) => { + await new Promise((resolve) => setImmediate(resolve)); + return payload.tool_input.command === "blocked" ? { decision: "deny" } : {}; + }); + const ctx = { cwd: "/project", sessionManager: { getSessionId: () => "s" }, isProjectTrusted: () => true }; + const results = await Promise.all(["allowed", "blocked"].map((command) => + handlers.get("tool_call")({ toolCallId: command, toolName: "bash", input: { command } }, ctx))); + assert.equal(results[0], undefined); + assert.equal(results[1].block, true); +}); + +test("parallel tools attach pre-tool context only to their own results", async (t) => { + const { handlers, ctx, messages } = setup(t, async (event, payload) => { + await new Promise((resolve) => setImmediate(resolve)); + return event === "pre-tool-use" ? { additionalContext: `context: ${payload.tool_input.command}` } : {}; + }); + const events = ["first", "second"].map((command) => ({ + toolCallId: command, toolName: "bash", input: { command }, content: [], isError: false, + })); + await Promise.all(events.map((event) => handlers.get("tool_call")(event, ctx))); + const results = await Promise.all(events.toReversed().map((event) => handlers.get("tool_result")(event, ctx))); + assert.deepEqual(results.map((result) => result.content[0].text), ["context: second", "context: first"]); + assert.deepEqual(messages, []); +}); + +function temp(t) { + const root = mkdtempSync(join(tmpdir(), "symposium-pi-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +test("Pi loads generated, gitignored skills through explicit file paths", (t) => { + const root = temp(t); + mkdirSync(join(root, ".git")); + const parent = join(root, ".agents", "skills"); + const managed = join(parent, "managed"); + mkdirSync(managed, { recursive: true }); + writeFileSync(join(managed, "SKILL.md"), "---\nname: managed\ndescription: Managed test skill\n---\nInstructions\n"); + writeFileSync(join(managed, ".symposium"), ""); + writeFileSync(join(managed, ".gitignore"), "*\n"); + const user = join(parent, "user"); + mkdirSync(user); + writeFileSync(join(user, "SKILL.md"), "---\nname: user\ndescription: User test skill\n---\n"); + const member = join(root, "member", "src"); + mkdirSync(member, { recursive: true }); + const paths = managedSkillPaths(member, root); + assert.deepEqual(paths, [join(managed, "SKILL.md")]); + assert.ok(!loadSkillsFromDir({ dir: parent, source: "project" }).skills.some((s) => s.name === "managed")); + const loaded = loadSkills({ cwd: member, agentDir: join(root, "agent"), skillPaths: paths, includeDefaults: false }); + assert.deepEqual(loaded.skills.map((s) => s.name), ["managed"]); + assert.deepEqual(loaded.diagnostics, []); + rmSync(managed, { recursive: true }); + assert.deepEqual(managedSkillPaths(member, root), []); +}); + +test("resource discovery stops at the repository boundary", (t) => { + const root = temp(t); + const outside = join(root, ".agents", "skills", "outside"); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, ".symposium"), ""); + writeFileSync(join(outside, "SKILL.md"), "outside"); + const repo = join(root, "repo"); + mkdirSync(join(repo, ".git"), { recursive: true }); + assert.deepEqual(managedSkillPaths(repo, join(root, "home")), []); +}); + +test("Pi reads the supported user and project MCP files", (t) => { + const root = temp(t); + const agentDir = join(root, "agent"); + mkdirSync(agentDir); + mkdirSync(join(root, ".pi")); + new ProjectTrustStore(agentDir).set(root, true); + const server = join(root, "server.cjs"); + writeFileSync(server, ` + require("node:readline").createInterface({ input: process.stdin }).on("line", line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + const result = request.method === "initialize" + ? { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "smoke", version: "1" } } + : { tools: [] }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); + }); + `); + const config = (name) => JSON.stringify({ mcpServers: { + [name]: { command: process.execPath, args: [server] }, + } }); + writeFileSync(join(agentDir, "mcp.json"), config("global-smoke")); + writeFileSync(join(root, ".pi", "mcp.json"), config("project-smoke")); + const cli = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "cli.js"); + const result = spawnSync(process.execPath, [cli, "mcp", "list"], { + cwd: root, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir }, encoding: "utf8", timeout: 20_000, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /global-smoke/); + assert.match(result.stdout, /project-smoke/); +}); + +test("runHook sends stdin without shell interpolation", async (t) => { + const root = temp(t); + writeFileSync(join(root, "hook"), ` + let input = ""; + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => process.stdout.write(JSON.stringify({ + additionalContext: input, args: process.argv.slice(2) + }))); + `); + const payload = { prompt: '$(echo unsafe); "quoted"\\nline' }; + const output = await runHook("user-prompt-submit", payload, root, process.execPath); + assert.deepEqual(JSON.parse(output.additionalContext), payload); + assert.deepEqual(output.args, ["pi", "user-prompt-submit"]); +}); + +test("missing commands, non-zero exits, malformed output, and timeouts do not block tools", async (t) => { + const root = temp(t); + for (const script of [ + null, + 'process.stderr.write("bad config"); process.exit(1);', + 'process.stderr.write("plugin error"); process.exit(2);', + 'process.stdout.write("not JSON");', + 'setInterval(() => {}, 1000);', + ]) { + if (script !== null) writeFileSync(join(root, "hook"), script); + const command = script === null ? join(root, "missing") : process.execPath; + const { handlers, ctx, warnings } = setup(t, (event, payload) => + runHook(event, payload, root, command, undefined, script?.startsWith("setInterval") ? 40 : 5000)); + const input = { command: "pwd" }; + assert.equal(await handlers.get("tool_call")({ toolCallId: "process-1", toolName: "bash", input }, ctx), undefined); + assert.deepEqual(input, { command: "pwd" }); + assert.equal(warnings.length, 1); + assert.match(warnings[0].message, /Symposium pre-tool-use hook failed/); + } +}); + +test("cancelling a running hook returns without a warning", async (t) => { + const root = temp(t); + writeFileSync(join(root, "hook"), "setInterval(() => {}, 1000);"); + const controller = new AbortController(); + const { handlers, ctx, warnings } = setup(t, (event, payload) => + runHook(event, payload, root, process.execPath, controller.signal, 5000)); + ctx.signal = controller.signal; + const pending = handlers.get("tool_call")({ toolCallId: "cancel-1", toolName: "bash", input: {} }, ctx); + controller.abort(); + assert.equal(await pending, undefined); + assert.deepEqual(warnings, []); +}); + +test("runHook reports command errors and malformed output", async (t) => { + const root = temp(t); + writeFileSync(join(root, "hook"), 'process.stderr.write("denied"); process.exit(2);'); + await assert.rejects(runHook("pre-tool-use", {}, root, process.execPath), /denied/); + writeFileSync(join(root, "hook"), 'process.stdout.write("not JSON");'); + await assert.rejects(runHook("pre-tool-use", {}, root, process.execPath), /invalid hook output/); + writeFileSync(join(root, "hook"), 'process.stdout.write("{}");'); + assert.deepEqual(await runHook("pre-tool-use", {}, root, process.execPath), {}); + writeFileSync(join(root, "hook"), ''); + assert.deepEqual(await runHook("stop", {}, root, process.execPath), {}); + await assert.rejects(runHook("pre-tool-use", {}, root, join(root, "missing")), /Symposium pre-tool-use/); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(runHook("pre-tool-use", {}, root, process.execPath, controller.signal), /aborted/); +}); diff --git a/src/agents/pi/extension.ts b/src/agents/pi/extension.ts new file mode 100644 index 00000000..4962f7ff --- /dev/null +++ b/src/agents/pi/extension.ts @@ -0,0 +1,209 @@ +// Generated by Symposium. Do not edit. +// This extension connects Pi events to `cargo-agents hook pi`. +import { execFile } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +interface HookOutput { + decision?: "allow" | "deny"; + additionalContext?: string; + updatedInput?: Record; +} + +// Hook commands install tools on demand, including during tool events. +export const DEFAULT_HOOK_TIMEOUT_MS = 10 * 60_000; + +export function runHook( + event: string, + payload: Record, + cwd: string, + command = "cargo-agents", + signal?: AbortSignal, + timeout = DEFAULT_HOOK_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + command, + ["hook", "pi", event], + { cwd, timeout, maxBuffer: 1024 * 1024, windowsHide: true, signal }, + (error, stdout, stderr) => { + if (error) { + const reason = error.killed && error.code == null && !signal?.aborted + ? `timed out after ${timeout} ms` : stderr.trim() || error.message; + reject(new Error(`Symposium ${event}: ${reason}`)); + return; + } + try { + const output = stdout.trim() ? JSON.parse(stdout) : {}; + if (!output || typeof output !== "object" || Array.isArray(output)) { + throw new Error("expected a hook output object"); + } + resolve(output); + } catch (error) { + reject(new Error(`Symposium ${event}: invalid hook output: ${error}`)); + } + }, + ); + // Do not use a shell: prompts and tool inputs are untrusted text. + child.stdin?.on("error", () => {}); // execFile reports early process exits. + child.stdin?.end(JSON.stringify(payload)); + }); +} + +// Explicit SKILL.md paths bypass Pi's gitignore-aware directory scan. +// Only include Symposium-managed skills. User skills keep Pi's normal rules. +export function managedSkillPaths(cwd: string, home = homedir()): string[] { + const parents = new Set([join(home, ".agents", "skills")]); + for (let dir = cwd; ; dir = dirname(dir)) { + parents.add(join(dir, ".agents", "skills")); + if (existsSync(join(dir, ".git")) || dirname(dir) === dir) break; + } + const paths: string[] = []; + for (const parent of parents) { + if (!existsSync(parent)) continue; + for (const entry of readdirSync(parent, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skill = join(parent, entry.name); + if (existsSync(join(skill, ".symposium")) && existsSync(join(skill, "SKILL.md"))) { + paths.push(join(skill, "SKILL.md")); + } + } + } + return paths.sort(); +} + +type HookDispatcher = ( + event: string, + payload: Record, + cwd: string, + signal?: AbortSignal, +) => Promise; + +export default function ( + pi: ExtensionAPI, + dispatch: HookDispatcher = (event, payload, cwd, signal) => + runHook(event, payload, cwd, "cargo-agents", signal), +) { + const pendingContext = new Map(); + let warned = false; + const warn = (ctx: ExtensionContext, event: string, error: unknown) => { + if (ctx.signal?.aborted || warned) return; + warned = true; + const message = `Symposium ${event} hook failed: ${error instanceof Error ? error.message : String(error)}`; + try { + if (ctx.hasUI) { + ctx.ui.notify(message, "warning"); + return; + } + } catch { + // A notification failure must not block a tool either. + } + console.warn(message); // Keep warnings off Pi's protocol stdout. + }; + const invoke = async ( + event: string, + ctx: ExtensionContext, + fields: Record = {}, + ): Promise => { + try { + return await dispatch(event, { + cwd: ctx.cwd, + session_id: ctx.sessionManager.getSessionId(), + ...fields, + }, ctx.cwd, ctx.signal); + } catch (error) { + warn(ctx, event, error); + return {}; + } + }; + const addContext = (output: HookOutput) => { + if (output.additionalContext) { + pi.sendMessage( + { customType: "symposium", content: output.additionalContext, display: false }, + { deliverAs: "nextTurn" }, + ); + } + }; + + // Pi fires resources_discover after session_start, so the first prompt can + // use the skills installed by the session-start auto-sync. + pi.on("session_start", async (_event, ctx) => { + pendingContext.clear(); + warned = false; + if (!ctx.isProjectTrusted()) return; + addContext(await invoke("session-start", ctx)); + }); + pi.on("resources_discover", (event, ctx) => ({ + skillPaths: ctx.isProjectTrusted() ? managedSkillPaths(event.cwd) : [], + })); + + pi.on("input", async (event, ctx) => { + if (!ctx.isProjectTrusted()) return; + addContext(await invoke("user-prompt-submit", ctx, { prompt: event.text })); + }); + + pi.on("tool_call", async (event, ctx) => { + pendingContext.delete(event.toolCallId); + if (!ctx.isProjectTrusted()) return; + try { + const output = await invoke("pre-tool-use", ctx, { + tool_name: event.toolName, tool_input: event.input, + }); + if (output.decision === "deny") { + return { + block: true, + reason: typeof output.additionalContext === "string" && output.additionalContext + ? output.additionalContext : "Blocked by Symposium", + }; + } + if (output.updatedInput !== undefined) { + if (!output.updatedInput || typeof output.updatedInput !== "object" || Array.isArray(output.updatedInput)) { + throw new Error("Symposium updatedInput must be an object"); + } + const input = event.input as Record; + for (const key of Object.keys(input)) delete input[key]; + Object.assign(input, output.updatedInput); + } + if (typeof output.additionalContext === "string" && output.additionalContext) { + pendingContext.set(event.toolCallId, output.additionalContext); + } + } catch (error) { + warn(ctx, "pre-tool-use", error); + } + }); + + pi.on("tool_result", async (event, ctx) => { + const before = pendingContext.get(event.toolCallId); + pendingContext.delete(event.toolCallId); + if (!ctx.isProjectTrusted()) return; + const output = await invoke("post-tool-use", ctx, { + tool_name: event.toolName, + tool_input: event.input, + tool_response: { + content: event.content, + details: event.details, + structuredContent: event.structuredContent, + isError: event.isError, + }, + }); + const context = [before, output.additionalContext].filter( + (text): text is string => typeof text === "string" && text.length > 0, + ); + if (context.length) { + return { + content: [...event.content, ...context.map((text) => ({ type: "text" as const, text }))], + structuredContent: event.structuredContent, + }; + } + }); + + pi.on("agent_end", async (_event, ctx) => { + pendingContext.clear(); + if (!ctx.isProjectTrusted()) return; + // No triggerTurn: a Stop hook must not start another agent turn. + addContext(await invoke("stop", ctx)); + }); + pi.on("session_shutdown", () => { pendingContext.clear(); }); +} diff --git a/src/agents/pi/package-lock.json b/src/agents/pi/package-lock.json new file mode 100644 index 00000000..83e5b89b --- /dev/null +++ b/src/agents/pi/package-lock.json @@ -0,0 +1,2142 @@ +{ + "name": "symposium-pi-extension", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "symposium-pi-extension", + "devDependencies": { + "@earendil-works/pi-coding-agent": "1.0.0", + "@types/node": "^22.19.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.0.0.tgz", + "integrity": "sha512-/FtbxoSQU/mEv1QnichJjRjqteqaIaMWxmhB4G367+MwZfX7/DI5B9YAg5lqbN7nztFskBEtUSZ+FlmMBECtMw==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.0.0", + "@earendil-works/pi-agent-core": "^1.0.0", + "@earendil-works/pi-ai": "^1.0.0", + "@earendil-works/pi-codemode": "^1.0.0", + "@earendil-works/pi-mcp": "^1.0.0", + "@earendil-works/pi-tui": "^1.0.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.0.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.124.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.0.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-codemode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-mcp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.0.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.0.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/agents/pi/package.json b/src/agents/pi/package.json new file mode 100644 index 00000000..46af164a --- /dev/null +++ b/src/agents/pi/package.json @@ -0,0 +1,17 @@ +{ + "name": "symposium-pi-extension", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "check": "tsc --noEmit", + "test": "node --experimental-strip-types --test extension.test.mjs" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "1.0.0", + "@types/node": "^22.19.0", + "typescript": "^6.0.3" + } +} diff --git a/src/agents/pi/tsconfig.json b/src/agents/pi/tsconfig.json new file mode 100644 index 00000000..3bacb2d7 --- /dev/null +++ b/src/agents/pi/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "extension.ts" + ] +} diff --git a/src/hook_schema.rs b/src/hook_schema.rs index 89c37159..dad6f2f4 100644 --- a/src/hook_schema.rs +++ b/src/hook_schema.rs @@ -10,6 +10,7 @@ pub mod copilot; pub mod goose; pub mod kiro; pub mod opencode; +pub mod pi; pub mod symposium; /// [`HookAgent`] and [`HookEvent`] are the SDK's: a plugin manifest names both @@ -29,6 +30,8 @@ pub fn agent_event(agent: HookAgent, event: HookEvent) -> Option goose::Goose.event(event), HookAgent::Kiro => kiro::Kiro.event(event), HookAgent::OpenCode => opencode::OpenCode.event(event), + HookAgent::Pi => pi::Pi.event(event), + _ => None, } } diff --git a/src/hook_schema/pi.rs b/src/hook_schema/pi.rs new file mode 100644 index 00000000..9740f050 --- /dev/null +++ b/src/hook_schema/pi.rs @@ -0,0 +1,151 @@ +//! The Pi extension uses the SDK's event-specific JSON fields directly. +//! The command-line event name supplies the event tag. + +use crate::hook_schema::symposium::*; +use crate::hook_schema::{ + Agent, AgentHookEvent, AgentHookInput, AgentHookOutput, ErasedAgentHookEvent, HookEvent, + erase_agent_hook_event, +}; + +pub struct Pi; + +impl Agent for Pi { + fn event(&self, event: HookEvent) -> Option> { + Some(match event { + HookEvent::PreToolUse => erase_agent_hook_event(PreToolUse), + HookEvent::PostToolUse => erase_agent_hook_event(PostToolUse), + HookEvent::UserPromptSubmit => erase_agent_hook_event(UserPromptSubmit), + HookEvent::SessionStart => erase_agent_hook_event(SessionStart), + HookEvent::Stop => erase_agent_hook_event(Stop), + _ => return None, + }) + } +} + +macro_rules! pi_event { + ($event:ident, $input:ident, $output:ident) => { + struct $event; + impl AgentHookEvent for $event { + type Input = $input; + type Output = $output; + } + impl AgentHookInput for $input { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> InputEvent { + InputEvent::$event(self.clone()) + } + fn from_symposium(event: &InputEvent) -> Self { + match event { + InputEvent::$event(input) => input.clone(), + _ => unreachable!("Pi input event mismatch"), + } + } + fn to_string(&self) -> anyhow::Result { + Ok(serde_json::to_string(self)?) + } + fn into_any(self: Box) -> Box { + self + } + } + impl AgentHookOutput for $output { + fn parse_output(output: &[u8]) -> anyhow::Result { + if output.iter().all(u8::is_ascii_whitespace) { + return Ok(Self::default()); + } + Ok(serde_json::from_slice(output)?) + } + fn from_symposium(event: &OutputEvent) -> Self { + match event { + OutputEvent::$event(output) => output.clone(), + _ => unreachable!("Pi output event mismatch"), + } + } + fn to_symposium(&self) -> OutputEvent { + OutputEvent::$event(self.clone()) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::to_value(self).expect("Pi output serialization") + } + fn into_any(self: Box) -> Box { + self + } + } + }; +} + +pi_event!(PreToolUse, PreToolUseInput, PreToolUseOutput); +pi_event!(PostToolUse, PostToolUseInput, PostToolUseOutput); +pi_event!( + UserPromptSubmit, + UserPromptSubmitInput, + UserPromptSubmitOutput +); +pi_event!(SessionStart, SessionStartInput, SessionStartOutput); +pi_event!(Stop, StopInput, StopOutput); + +#[cfg(test)] +mod tests { + use super::*; + use crate::hook_schema::HookAgent; + + #[test] + fn pi_events_convert_sdk_payloads_and_outputs() { + for (event, input) in [ + ( + HookEvent::SessionStart, + serde_json::json!({"cwd": "/project", "session_id": "s1"}), + ), + ( + HookEvent::UserPromptSubmit, + serde_json::json!({"prompt": "hello"}), + ), + ( + HookEvent::PreToolUse, + serde_json::json!({"tool_name": "bash", "tool_input": {"command": "ls"}}), + ), + ( + HookEvent::PostToolUse, + serde_json::json!({"tool_name": "bash", "tool_input": {}, "tool_response": {"isError": false}}), + ), + (HookEvent::Stop, serde_json::json!({"session_id": "s1"})), + ] { + let handler = crate::hook_schema::agent_event(HookAgent::Pi, event).unwrap(); + let parsed = handler.parse_input(&input.to_string()).unwrap(); + assert_eq!(parsed.to_symposium().event(), event); + let translated = handler.translate_input(&parsed.to_symposium()); + assert_eq!(parsed.to_string().unwrap(), translated.to_string().unwrap()); + let output = OutputEvent::with_context(event, "context".into()); + let native = handler.translate_output(&output).to_hook_output(); + assert_eq!(native["additionalContext"], "context"); + let roundtrip = handler + .parse_output(&serde_json::to_vec(&native).unwrap()) + .unwrap(); + assert_eq!( + roundtrip.to_symposium().additional_context(), + Some("context") + ); + assert!( + handler + .parse_output(b"") + .unwrap() + .to_symposium() + .additional_context() + .is_none() + ); + } + } + + #[test] + fn pi_pre_tool_output_keeps_denials_and_input_changes() { + let handler = Pi.event(HookEvent::PreToolUse).unwrap(); + let mut output = PreToolUseOutput::deny("blocked"); + output.updated_input = Some(serde_json::json!({"command": "pwd"})); + let native = handler + .translate_output(&OutputEvent::PreToolUse(output)) + .to_hook_output(); + assert_eq!(native["decision"], "deny"); + assert_eq!(native["updatedInput"]["command"], "pwd"); + } +} diff --git a/src/sync.rs b/src/sync.rs index 71fd434b..83e666a5 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -454,7 +454,8 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve message: "no agents configured, run `cargo agents init` to add one".into(), }, ); - return Ok(()); + // Still run cleanup so removing the final agent also removes its + // project hooks and managed skills. } // Track every skill directory we (re)install during this sync. Anything @@ -464,19 +465,10 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; - // Hooks: the project and global locations are genuinely different files - // for some agents - Antigravity writes `.agents/hooks.json` but - // `~/.gemini/config/hooks.json`, and Copilot `.github/hooks/` but - // `~/.copilot/settings.json` - so project scope cannot be produced by - // rooting the global path at the workspace. - match sym.config.hook_scope { - crate::config::HookScope::Global => agent - .register_hooks(sym.home_dir(), sym, out) - .context("failed to register hooks")?, - crate::config::HookScope::Project => agent - .register_project_hooks(&project_root, sym, out) - .context("failed to register hooks")?, - } + // Agent-specific paths and scope cleanup live in the agent module. + agent + .register_scoped_hooks(&project_root, sym, out) + .context("failed to register hooks")?; // MCP does not follow the hook scope: an agent's MCP file differs from // its hooks file, so the target is resolved per agent instead. @@ -652,7 +644,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } - if to_install.is_empty() { + if !agent_names.is_empty() && to_install.is_empty() { tracing::info!( report = %crate::report::ReportEvent::Info { message: "no applicable skills found for workspace dependencies".into(), diff --git a/symposium-sdk/Cargo.toml b/symposium-sdk/Cargo.toml index 446bb36e..42550f71 100644 --- a/symposium-sdk/Cargo.toml +++ b/symposium-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "symposium-sdk" -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" description = "SDK for writing symposium plugins (hooks, custom predicates, subcommands)" diff --git a/symposium-sdk/src/hook.rs b/symposium-sdk/src/hook.rs index 7748be18..88c25831 100644 --- a/symposium-sdk/src/hook.rs +++ b/symposium-sdk/src/hook.rs @@ -40,6 +40,21 @@ use std::process::ExitCode; /// Lives here rather than in Symposium because a plugin manifest names an /// agent (`[[hooks]] agent = "claude"`), and manifests cross the package-manager /// boundary. Dispatching an event to an agent's wire format stays in Symposium. +/// Match with a fallback so future agents do not break your code. +/// +/// An exhaustive match in another crate is not supported: +/// +/// ```compile_fail +/// use symposium_sdk::hook::HookAgent; +/// fn known_agent(agent: HookAgent) { +/// match agent { +/// HookAgent::Antigravity | HookAgent::Claude | HookAgent::Codex +/// | HookAgent::Copilot | HookAgent::Goose | HookAgent::Kiro +/// | HookAgent::OpenCode | HookAgent::Pi => (), +/// } +/// } +/// ``` +#[non_exhaustive] #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum HookAgent { @@ -64,6 +79,9 @@ pub enum HookAgent { #[cfg_attr(feature = "clap", value(name = "opencode"))] #[serde(rename = "opencode")] OpenCode, + #[cfg_attr(feature = "clap", value(name = "pi"))] + #[serde(rename = "pi")] + Pi, } impl HookAgent { @@ -77,6 +95,7 @@ impl HookAgent { HookAgent::Goose => "goose", HookAgent::Kiro => "kiro", HookAgent::OpenCode => "opencode", + HookAgent::Pi => "pi", } } } diff --git a/symposium-sdk/src/manifest.rs b/symposium-sdk/src/manifest.rs index fd39785b..c68a172a 100644 --- a/symposium-sdk/src/manifest.rs +++ b/symposium-sdk/src/manifest.rs @@ -363,6 +363,21 @@ pub struct RawSubcommand { /// /// Distinct from [`HookAgent`] because `Symposium` is a wire format but not an /// agent, and not every agent has a shell-hook JSON format. +/// Match with a fallback so future formats do not break your code. +/// +/// An exhaustive match in another crate is not supported: +/// +/// ```compile_fail +/// use symposium_sdk::manifest::HookFormat; +/// fn known_format(format: HookFormat) { +/// match format { +/// HookFormat::Symposium | HookFormat::Antigravity | HookFormat::Claude +/// | HookFormat::Codex | HookFormat::Copilot | HookFormat::Kiro +/// | HookFormat::Pi => (), +/// } +/// } +/// ``` +#[non_exhaustive] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum HookFormat { @@ -375,6 +390,7 @@ pub enum HookFormat { Codex, Copilot, Kiro, + Pi, } impl HookFormat { @@ -387,6 +403,7 @@ impl HookFormat { HookFormat::Codex => Some(HookAgent::Codex), HookFormat::Copilot => Some(HookAgent::Copilot), HookFormat::Kiro => Some(HookAgent::Kiro), + HookFormat::Pi => Some(HookAgent::Pi), } } } diff --git a/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/SYMPOSIUM.toml b/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/SYMPOSIUM.toml index f52e203b..baad4501 100644 --- a/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/SYMPOSIUM.toml +++ b/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/SYMPOSIUM.toml @@ -9,7 +9,15 @@ matcher = "Bash" format = "claude" command = { script = "$TEST_DIR/dot-symposium/plugins/format-plugin/scripts/claude-hook.sh" } -# Symposium fallback — should fire on non-Claude agents. Tool names are each +# Pi-specific hook — should take priority over the portable fallback on Pi. +[[hooks]] +name = "pi-specific" +event = "PreToolUse" +matcher = "bash" +format = "pi" +command = { script = "$TEST_DIR/dot-symposium/plugins/format-plugin/scripts/pi-hook.sh" } + +# Symposium fallback — should fire when no native format matches. Tool names are each # agent's own, so it names the shell tool as both Claude (`Bash`) and Copilot # (`bash`) do. [[hooks]] diff --git a/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/scripts/pi-hook.sh b/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/scripts/pi-hook.sh new file mode 100644 index 00000000..0894173b --- /dev/null +++ b/tests/fixtures/plugin-hooks-format/dot-symposium/plugins/format-plugin/scripts/pi-hook.sh @@ -0,0 +1,11 @@ +#!/bin/sh +input=$(cat) +case "$input" in + *'"tool_name":"bash"'*'"tool_input":{"command":"ls"}'*) + printf '%s\n' '{"decision":"deny","additionalContext":"pi-hook-fired","updatedInput":{"command":"pwd"}}' + ;; + *) + printf '%s\n' "unexpected Pi payload" >&2 + exit 1 + ;; +esac diff --git a/tests/hook_context.rs b/tests/hook_context.rs index 8c89e261..69d48c6b 100644 --- a/tests/hook_context.rs +++ b/tests/hook_context.rs @@ -45,6 +45,7 @@ async fn session_start_keeps_symposium_and_plugin_context() { HookAgent::Copilot, HookAgent::Kiro, HookAgent::Antigravity, + HookAgent::Pi, ] { let stdout = respond(&ctx, agent, HookStep::session_start()).await; assert_in_order( @@ -73,6 +74,7 @@ async fn post_tool_use_keeps_every_plugin_context() { HookAgent::Codex, HookAgent::Copilot, HookAgent::Kiro, + HookAgent::Pi, ] { let stdout = respond(&ctx, agent, step()).await; assert_in_order( diff --git a/tests/hook_format_priority.rs b/tests/hook_format_priority.rs index bece9036..8bece3fa 100644 --- a/tests/hook_format_priority.rs +++ b/tests/hook_format_priority.rs @@ -44,6 +44,35 @@ async fn native_hook_takes_priority_over_symposium() { .unwrap(); } +/// Pi native hooks receive flat fields and retain decisions and input changes. +#[tokio::test(flavor = "multi_thread")] +async fn pi_native_hook_takes_priority_over_symposium() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-hooks-format"], + async |ctx| { + let output = ctx + .invoke_hook( + HookAgent::Pi, + HookEvent::PreToolUse, + &json!({ + "cwd": ctx.tempdir.display().to_string(), + "tool_name": "bash", + "tool_input": {"command": "ls"}, + }), + ) + .await?; + let output: serde_json::Value = serde_json::from_slice(&output)?; + assert_eq!(output["decision"], "deny"); + assert_eq!(output["updatedInput"]["command"], "pwd"); + assert_eq!(output["additionalContext"], "pi-hook-fired"); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// When running on Copilot and the plugin has both `format = "claude"` and /// `format = "symposium"` hooks, the symposium hook fires (no native match). /// Copilot names its shell tool `bash`, not Claude's `Bash`. diff --git a/tests/hook_stdout.rs b/tests/hook_stdout.rs index c2e80ee6..3ea6fc52 100644 --- a/tests/hook_stdout.rs +++ b/tests/hook_stdout.rs @@ -19,6 +19,7 @@ async fn sync_progress_stays_off_hook_stdout() { HookAgent::Copilot, HookAgent::Antigravity, HookAgent::Kiro, + HookAgent::Pi, ] { let output = run_session_start(&ctx, agent, &workspace); let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/tests/pi.rs b/tests/pi.rs new file mode 100644 index 00000000..33b317fc --- /dev/null +++ b/tests/pi.rs @@ -0,0 +1,161 @@ +//! Pi initialization, extension registration, and automatic skill sync. + +use symposium::agents::{Agent, McpScope}; +use symposium::hook_schema::HookAgent; +use symposium_testlib::{HookStep, TestMode, with_fixture}; + +#[test] +fn pi_is_available_in_init_and_uses_shared_skills() { + assert_eq!(Agent::from_config_name("pi").unwrap(), Agent::Pi); + assert!(Agent::all().contains(&Agent::Pi)); + assert_eq!(Agent::Pi.config_name(), "pi"); + assert_eq!(Agent::Pi.display_name(), "Pi"); + let root = std::path::Path::new("/project"); + assert_eq!( + Agent::Pi.project_skill_dir(root, "test"), + root.join(".agents/skills/test") + ); + assert_eq!( + Agent::Pi.global_skill_dir(root, "test"), + Some(root.join(".agents/skills/test")) + ); + assert_eq!(serde_json::to_string(&HookAgent::Pi).unwrap(), "\"pi\""); + assert_eq!(HookAgent::Pi.as_str(), "pi"); + let format: symposium_sdk::manifest::HookFormat = serde_json::from_str("\"pi\"").unwrap(); + assert_eq!(format.as_agent(), Some(HookAgent::Pi)); + assert_eq!(serde_json::to_string(&format).unwrap(), "\"pi\""); +} + +#[tokio::test] +async fn pi_session_start_installs_skills_automatically() { + with_fixture( + TestMode::SimulationOnly, + &["workspace-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "pi"]).await?; + ctx.sym.config.auto_update = symposium::config::AutoUpdate::Off; + let root = ctx.workspace_root.as_ref().unwrap().clone(); + let global = Agent::Pi.mcp_config_path(McpScope::User, &root, ctx.sym.home_dir()); + let extension = global.parent().unwrap().join("extensions/symposium.ts"); + assert!(extension.exists(), "init installs the Pi extension"); + assert!(!root.join(".agents/skills/ws-hello").exists()); + ctx.prompt_or_hook("hello", &[HookStep::session_start()], HookAgent::Pi) + .await?; + let skill = root.join(".agents/skills/ws-hello"); + assert!( + skill.join("SKILL.md").exists(), + "session-start auto-sync installs skills" + ); + assert!(skill.join(".symposium").exists()); + assert_eq!(std::fs::read_to_string(skill.join(".gitignore"))?, "*\n"); + let installed = std::fs::read_to_string(&extension)?; + assert!(installed.contains("resources_discover")); + assert!(installed.contains("managedSkillPaths(event.cwd)")); + ctx.symposium(&["init", "--remove-agent", "pi"]).await?; + assert!( + !extension.exists(), + "removal unregisters the generated extension" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn removing_the_final_project_agent_reaps_its_extension_and_skills() { + with_fixture( + TestMode::SimulationOnly, + &["workspace-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "pi", "--hook-scope", "project"]) + .await?; + ctx.symposium(&["sync"]).await?; + let root = ctx.workspace_root.as_ref().unwrap().clone(); + assert!(root.join(".pi/extensions/symposium.ts").exists()); + assert!(root.join(".agents/skills/ws-hello/SKILL.md").exists()); + let personal = root.join(".agents/skills/personal"); + std::fs::create_dir_all(&personal)?; + std::fs::write(personal.join("SKILL.md"), "user skill")?; + ctx.symposium(&["init", "--remove-agent", "pi"]).await?; + ctx.symposium(&["sync"]).await?; + assert!(!root.join(".pi/extensions/symposium.ts").exists()); + assert!(!root.join(".agents/skills/ws-hello").exists()); + assert!(personal.join("SKILL.md").exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn pi_scope_changes_leave_one_generated_extension() { + with_fixture( + TestMode::SimulationOnly, + &["workspace-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "pi"]).await?; + let root = ctx.workspace_root.as_ref().unwrap().clone(); + let global = Agent::Pi.mcp_config_path(McpScope::User, &root, ctx.sym.home_dir()); + let global = global.parent().unwrap().join("extensions/symposium.ts"); + let project = root.join(".pi/extensions/symposium.ts"); + assert!(global.exists()); + ctx.symposium(&["init", "--hook-scope", "project"]).await?; + ctx.symposium(&["sync"]).await?; + assert!(project.exists()); + assert!(!global.exists()); + ctx.symposium(&["init", "--hook-scope", "global"]).await?; + ctx.symposium(&["sync"]).await?; + assert!(global.exists()); + assert!(!project.exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn pi_project_scope_and_shared_skill_cleanup() { + with_fixture( + TestMode::SimulationOnly, + &["workspace-plugin0"], + async |mut ctx| { + ctx.symposium(&[ + "init", + "--add-agent", + "pi", + "--add-agent", + "codex", + "--hook-scope", + "project", + ]) + .await?; + let root = ctx.workspace_root.as_ref().unwrap().clone(); + let extension = root.join(".pi/extensions/symposium.ts"); + assert!(!extension.exists(), "project registration waits for sync"); + ctx.symposium(&["sync"]).await?; + assert!(extension.exists()); + let skill = root.join(".agents/skills/ws-hello"); + assert!(skill.join("SKILL.md").exists()); + let user_extension = root.join(".pi/extensions/user.ts"); + std::fs::write(&user_extension, "export default function () {}\n")?; + ctx.symposium(&["init", "--remove-agent", "pi"]).await?; + ctx.symposium(&["sync"]).await?; + assert!(!extension.exists()); + assert!( + user_extension.exists(), + "other Pi extensions are not removed" + ); + assert!( + skill.join("SKILL.md").exists(), + "Codex still uses the shared skill" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} From 921a88e21add8359a7e919953944ca9e4f8fea6c Mon Sep 17 00:00:00 2001 From: Caleb Hattingh Date: Sat, 3 Oct 2026 19:38:53 +0200 Subject: [PATCH 2/3] fix: preserve event type in empty built-in hook outputs --- md/design/hook-flow.md | 2 +- src/hook.rs | 16 +++++++++++- tests/pi.rs | 57 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/md/design/hook-flow.md b/md/design/hook-flow.md index d6acf0cb..d47c7a36 100644 --- a/md/design/hook-flow.md +++ b/md/design/hook-flow.md @@ -10,7 +10,7 @@ The payload is parsed first, and the agent's parser can declare it not an occurr **`SessionStart` is the refresh point.** Because it fires once per agent session, it does the expensive work that other events skip: it bypasses the `Cargo.lock` freshness gate (so skills re-sync even when the workspace's dependencies are unchanged) and passes `UpdateLevel::Check` so git registries and `source.git` skill groups are re-fetched if their upstream moved. Every other event keeps the cheap, `Cargo.lock`-gated path with `UpdateLevel::None` (debounced) to avoid per-event network and `cargo metadata` cost. The registry refresh on `SessionStart` (`ensure_registries` with `Check`, decided in the binary entry point from the event) still honors each registry's `auto-update` toggle. `SessionStart` also runs `prewarm_hook_sources`, which *refreshes already-installed* hook binaries/scripts (the `cargo`/`github` sources backing plugin hooks) — refresh-only, so it never eagerly installs a tool a hook may never use; first install still happens lazily at dispatch. -2. **Built-in dispatch** — symposium's own handling, before plugin hooks. `PreToolUse` produces nothing. On `SessionStart` three fragments are computed independently and, when present, joined into one `additionalContext`: +2. **Built-in dispatch** — symposium's own handling, before plugin hooks. `PreToolUse` and `Stop` have no built-in content, but their empty outputs retain the input event type. A `Stop` input must return a `Stop` output, not a `PreToolUse` output. Pi's event-specific adapter checks this type before plugin dispatch. On `SessionStart` three fragments are computed independently and, when present, joined into one `additionalContext`: - **Discovery hint** — when the active workspace exposes plugin-vended subcommands (the same workspace-filtered set listed by [`cargo agents --help`](./subcommands.md#help-text-grouping)), a line suggesting the agent run `cargo agents --help` to find them. Computed independently of the update-check throttle, so it fires whenever there is something to discover. - **Consent hint**: the dependency plugins awaiting consent, telling the agent to point the user at `cargo agents sync` or `cargo agents use`. - **Update nudge** — when `auto-update = "warn"`, the 24-hour check throttle has elapsed, and the registry reports a newer version: a line suggesting `cargo agents self-update`. diff --git a/src/hook.rs b/src/hook.rs index 3bae4b84..a109b974 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -531,7 +531,7 @@ pub async fn dispatch_builtin( symposium::InputEvent::SessionStart(session) => { handle_session_start(sym, session, deps).await } - _ => symposium::OutputEvent::empty_for(HookEvent::PreToolUse), + _ => symposium::OutputEvent::empty_for(input.event()), } } @@ -1153,6 +1153,20 @@ mod tests { assert!(output.additional_context().is_none()); } + #[tokio::test] + async fn builtin_stop_returns_matching_empty_output() { + let tmp = tempfile::tempdir().unwrap(); + let sym = Symposium::from_dir(tmp.path()); + let deps = sym.workspace_deps(tmp.path()); + let input = symposium::InputEvent::Stop(symposium::StopInput::new(None, None)); + + for notify in [false, true] { + let output = dispatch_builtin(&sym, &input, &deps, notify).await; + assert!(matches!(&output, symposium::OutputEvent::Stop(_))); + assert!(output.additional_context().is_none()); + } + } + #[tokio::test] async fn builtin_post_tool_use_returns_empty_when_dependencies_are_unchanged() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/pi.rs b/tests/pi.rs index 33b317fc..98d27897 100644 --- a/tests/pi.rs +++ b/tests/pi.rs @@ -1,7 +1,7 @@ //! Pi initialization, extension registration, and automatic skill sync. use symposium::agents::{Agent, McpScope}; -use symposium::hook_schema::HookAgent; +use symposium::hook_schema::{HookAgent, HookEvent}; use symposium_testlib::{HookStep, TestMode, with_fixture}; #[test] @@ -26,6 +26,61 @@ fn pi_is_available_in_init_and_uses_shared_skills() { assert_eq!(serde_json::to_string(&format).unwrap(), "\"pi\""); } +#[tokio::test] +async fn pi_stop_returns_empty_output_in_pipeline_and_cli() { + use std::io::Write; + + with_fixture( + TestMode::SimulationOnly, + &["workspace-empty0"], + async |mut ctx| { + ctx.sym.config.auto_sync = false; + ctx.sym.config.auto_update = symposium::config::AutoUpdate::Off; + ctx.sym.save_config()?; + let root = ctx.workspace_root.as_ref().unwrap(); + let payload = serde_json::json!({ + "cwd": root, + "session_id": "test-session", + }); + + let output = ctx + .invoke_hook(HookAgent::Pi, HookEvent::Stop, &payload) + .await?; + assert_eq!( + serde_json::from_slice::(&output)?, + serde_json::json!({}) + ); + + let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_cargo-agents")) + .args(["hook", "pi", "stop"]) + .env("SYMPOSIUM_HOME", ctx.sym.config_dir()) + .current_dir(root) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + child + .stdin + .take() + .unwrap() + .write_all(&serde_json::to_vec(&payload)?)?; + let output = child.wait_with_output()?; + assert!( + output.status.success(), + "Pi stop failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_slice::(&output.stdout)?, + serde_json::json!({}) + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + #[tokio::test] async fn pi_session_start_installs_skills_automatically() { with_fixture( From 002e211f9916faa2d4a46c54d68d71470d3b8bef Mon Sep 17 00:00:00 2001 From: Caleb Hattingh Date: Sat, 3 Oct 2026 21:20:55 +0200 Subject: [PATCH 3/3] fix: skip plugin hook outputs for the wrong event Validate canonical output events before agent conversion. Log and skip mismatches instead of panicking. Keep Pi's type checks strict and let later plugins run. Add SDK and pipeline/CLI regression tests. --- md/design/common-issues.md | 8 +++ md/design/hook-flow.md | 1 + src/hook.rs | 11 ++++ symposium-sdk/src/hook.rs | 30 ++++++++++ .../dot-symposium/config.toml | 7 +++ .../a-mismatched-output/SYMPOSIUM.toml | 10 ++++ .../a-mismatched-output/scripts/output.sh | 3 + .../plugins/z-valid-output/SYMPOSIUM.toml | 10 ++++ .../plugins/z-valid-output/scripts/output.sh | 3 + tests/hook_format_priority.rs | 56 +++++++++++++++++++ 10 files changed, 139 insertions(+) create mode 100644 tests/fixtures/plugin-hooks-output-event/dot-symposium/config.toml create mode 100644 tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/SYMPOSIUM.toml create mode 100644 tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/scripts/output.sh create mode 100644 tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/SYMPOSIUM.toml create mode 100644 tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/scripts/output.sh diff --git a/md/design/common-issues.md b/md/design/common-issues.md index 613dc428..0df18cbd 100644 --- a/md/design/common-issues.md +++ b/md/design/common-issues.md @@ -12,6 +12,14 @@ The following issues were identified by auditing our hook implementations agains Copilot CLI reads `.claude/settings.json` as well as its own hook files. In a project where both agents are configured, every Copilot event runs `cargo agents hook` twice: as `copilot` from `.github/hooks/symposium.json`, and as `claude` from Claude's settings. Plugin hooks with side effects run twice, and whatever symposium tells the agent once (such as a notice) is taken by whichever invocation runs first. +## Hook output event types + +Keep input and output event types equal. Empty built-in outputs must use the +input event type. Before agent conversion, check tagged canonical plugin outputs +with the SDK's `Output::event()`. Log and skip a mismatch so later plugins can +run. Valid JSON can still have the wrong event tag. Keep agent adapter type checks +strict. `tests/hook_format_priority.rs` covers the pipeline and real CLI. + ## Hook stdout belongs to the agent An agent parses everything `cargo agents hook` writes to stdout. One line ahead of the JSON and Claude Code falls back to treating it all as plain text (which reaches the model only on `SessionStart` and `UserPromptSubmit`), while Copilot drops the output entirely. The in-process test pipeline cannot see this, because the leaks come from around it: the report layer, which the binary installs (and leaves out for hooks), and child processes that inherit stdout (plugin `install_commands` send theirs to stderr). Anything new that prints during a hook, or spawns a child that might, needs the same care; `tests/hook_stdout.rs` runs the real binary to catch it. diff --git a/md/design/hook-flow.md b/md/design/hook-flow.md index d47c7a36..de439b36 100644 --- a/md/design/hook-flow.md +++ b/md/design/hook-flow.md @@ -30,6 +30,7 @@ The payload is parsed first, and the agent's parser can declare it not an occurr - Spawn `path args…` directly for `Exec`, or `sh path args…` for `Script`. - Pass the event JSON (in the selected format) on stdin to the plugin's hook. - Collect output from each handler. + - Check that a tagged canonical output has the same event type as the input. If it does not, log a warning and skip that output. Keep the agent adapter's type checks strict and continue to later plugins. - Convert output back to the agent's wire format. - Merge results (e.g., allow/block decisions, output text) across all handlers. `merge` lets a later value replace an earlier one, so on `SessionStart`, `UserPromptSubmit` and `PostToolUse` a `JoinedContext` keeps every source's context (the builtin fragments first, then each hook) and writes the joined text back after each merge. `PreToolUse` and `Stop` are left out: there some agents keep context in the field that holds the decision. - Return the merged result to the agent. diff --git a/src/hook.rs b/src/hook.rs index a109b974..409a8ad4 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -808,6 +808,17 @@ pub async fn dispatch_plugin_hooks( if let Ok(sym_out) = serde_json::from_value::(v.clone()) { + let output_event = sym_out.event(); + if output_event != event { + tracing::warn!( + plugin = %hook.plugin_name, + hook = %hook.hook_name, + expected_event = ?event, + output_event = ?output_event, + "plugin output event does not match input event" + ); + continue; + } let host_out = host_h.translate_output(&sym_out); (host_out.to_hook_output(), Some(sym_out)) } else { diff --git a/symposium-sdk/src/hook.rs b/symposium-sdk/src/hook.rs index 88c25831..307532c9 100644 --- a/symposium-sdk/src/hook.rs +++ b/symposium-sdk/src/hook.rs @@ -323,6 +323,17 @@ pub enum Output { } impl Output { + /// Returns the event type for this output. + pub fn event(&self) -> HookEvent { + match self { + Output::PreToolUse(_) => HookEvent::PreToolUse, + Output::PostToolUse(_) => HookEvent::PostToolUse, + Output::UserPromptSubmit(_) => HookEvent::UserPromptSubmit, + Output::SessionStart(_) => HookEvent::SessionStart, + Output::Stop(_) => HookEvent::Stop, + } + } + /// Create an empty output for the given event type. pub fn empty_for(event: HookEvent) -> Self { match event { @@ -664,3 +675,22 @@ fn is_empty_output(output: &Output) -> bool { Output::Stop(o) => o.additional_context.is_none(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_preserves_event_type() { + for event in [ + HookEvent::PreToolUse, + HookEvent::PostToolUse, + HookEvent::UserPromptSubmit, + HookEvent::SessionStart, + HookEvent::Stop, + ] { + assert_eq!(Output::empty_for(event).event(), event); + assert_eq!(Output::with_context(event, "context".into()).event(), event); + } + } +} diff --git a/tests/fixtures/plugin-hooks-output-event/dot-symposium/config.toml b/tests/fixtures/plugin-hooks-output-event/dot-symposium/config.toml new file mode 100644 index 00000000..6b5e56e9 --- /dev/null +++ b/tests/fixtures/plugin-hooks-output-event/dot-symposium/config.toml @@ -0,0 +1,7 @@ +hook-scope = "project" +auto-sync = false +auto-update = "off" + +[defaults] +symposium-recommendations = false +user-plugins = true diff --git a/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/SYMPOSIUM.toml b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/SYMPOSIUM.toml new file mode 100644 index 00000000..fdeacd1f --- /dev/null +++ b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/SYMPOSIUM.toml @@ -0,0 +1,10 @@ +name = "a-mismatched-output" +depends-on = ["*"] + +# A valid JSON output with the wrong event must not reach the Pi adapter. +[[hooks]] +name = "mismatched-stop" +event = "Stop" +format = "symposium" +command = { script = "$TEST_DIR/dot-symposium/plugins/a-mismatched-output/scripts/output.sh" } +args = ['{"PreToolUse":{"decision":"deny","additionalContext":"wrong-event","updatedInput":{"command":"should-not-run"}}}'] diff --git a/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/scripts/output.sh b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/scripts/output.sh new file mode 100644 index 00000000..ef282ab3 --- /dev/null +++ b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/a-mismatched-output/scripts/output.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cat >/dev/null +printf '%s\n' "$1" diff --git a/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/SYMPOSIUM.toml b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/SYMPOSIUM.toml new file mode 100644 index 00000000..85bd6e83 --- /dev/null +++ b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/SYMPOSIUM.toml @@ -0,0 +1,10 @@ +name = "z-valid-output" +depends-on = ["*"] + +# Dispatch must continue to the next plugin after rejecting the mismatch. +[[hooks]] +name = "valid-stop" +event = "Stop" +format = "symposium" +command = { script = "$TEST_DIR/dot-symposium/plugins/z-valid-output/scripts/output.sh" } +args = ['{"Stop":{"additionalContext":"valid-stop-output"}}'] diff --git a/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/scripts/output.sh b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/scripts/output.sh new file mode 100644 index 00000000..ef282ab3 --- /dev/null +++ b/tests/fixtures/plugin-hooks-output-event/dot-symposium/plugins/z-valid-output/scripts/output.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cat >/dev/null +printf '%s\n' "$1" diff --git a/tests/hook_format_priority.rs b/tests/hook_format_priority.rs index 8bece3fa..b68452e4 100644 --- a/tests/hook_format_priority.rs +++ b/tests/hook_format_priority.rs @@ -44,6 +44,62 @@ async fn native_hook_takes_priority_over_symposium() { .unwrap(); } +/// A canonical output for the wrong event is skipped before agent conversion. +#[tokio::test(flavor = "multi_thread")] +async fn mismatched_output_is_skipped_in_pipeline_and_cli() { + use std::io::Write; + + with_fixture( + TestMode::SimulationOnly, + &["workspace-empty0", "plugin-hooks-output-event"], + async |ctx| { + let root = ctx.workspace_root.as_ref().unwrap(); + let payload = json!({"cwd": root, "session_id": "test-session"}); + let expected = json!({"additionalContext": "valid-stop-output"}); + + let output = ctx + .invoke_hook(HookAgent::Pi, HookEvent::Stop, &payload) + .await?; + assert_eq!( + serde_json::from_slice::(&output)?, + expected + ); + + let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_cargo-agents")) + .args(["hook", "pi", "stop"]) + .env("SYMPOSIUM_HOME", ctx.sym.config_dir()) + .current_dir(root) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + child + .stdin + .take() + .unwrap() + .write_all(&serde_json::to_vec(&payload)?)?; + let output = child.wait_with_output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "Pi Stop failed: {stderr}"); + assert_eq!( + serde_json::from_slice::(&output.stdout)?, + expected + ); + let logs = std::fs::read_dir(ctx.sym.config_dir().join("logs"))? + .map(|entry| std::fs::read_to_string(entry?.path())) + .collect::, _>>()? + .join("\n"); + assert!( + logs.contains("plugin output event does not match input event"), + "missing event mismatch warning: {logs}" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// Pi native hooks receive flat fields and retain decisions and input changes. #[tokio::test(flavor = "multi_thread")] async fn pi_native_hook_takes_priority_over_symposium() {