From 7571ba6dd9362a18ef83f202462bbcec8ee0d958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E7=AB=8B?= Date: Wed, 26 Aug 2026 10:49:26 +0800 Subject: [PATCH] feat: enable permission-aware subagents --- AGENTS.md | 21 +- README.md | 1 + extensions/subagents.ts | 13 + package-lock.json | 1968 +++++- package.json | 3 + pi-permission-system/README.md | 2 +- .../docs/architecture/architecture.md | 8 +- .../docs/cross-extension-api.md | 2 +- .../docs/subagent-integration.md | 16 +- pi-subagents/.gitignore | 39 + pi-subagents/.npmignore | 33 + pi-subagents/AGENTS.md | 103 + pi-subagents/CHANGELOG.md | 752 +++ pi-subagents/CONTRIBUTING.md | 68 + pi-subagents/LICENSE | 21 + pi-subagents/README.md | 866 +++ pi-subagents/SECURITY.md | 95 + pi-subagents/UPSTREAM.md | 16 + pi-subagents/biome.json | 26 + .../examples/agent-tool-description.md | 42 + pi-subagents/media/screenshot.png | Bin 0 -> 168646 bytes pi-subagents/package-lock.json | 5380 +++++++++++++++++ pi-subagents/package.json | 75 + pi-subagents/src/abortable.ts | 43 + pi-subagents/src/agent-color.ts | 161 + pi-subagents/src/agent-file-toggle.ts | 261 + pi-subagents/src/agent-manager.ts | 1109 ++++ pi-subagents/src/agent-runner.ts | 1225 ++++ pi-subagents/src/agent-types.ts | 346 ++ pi-subagents/src/child-context.ts | 15 + pi-subagents/src/context.ts | 58 + pi-subagents/src/cross-extension-rpc.ts | 122 + pi-subagents/src/custom-agents.ts | 305 + pi-subagents/src/default-agents.ts | 126 + pi-subagents/src/enabled-models.ts | 180 + pi-subagents/src/env.ts | 33 + pi-subagents/src/group-join.ts | 141 + pi-subagents/src/index.ts | 3243 ++++++++++ pi-subagents/src/invocation-config.ts | 130 + pi-subagents/src/mandatory-extensions.ts | 65 + pi-subagents/src/memory.ts | 179 + pi-subagents/src/mention-clone.ts | 211 + pi-subagents/src/mention.ts | 141 + pi-subagents/src/model-resolver.ts | 100 + pi-subagents/src/model-scope.ts | 70 + pi-subagents/src/nested-tools.ts | 417 ++ pi-subagents/src/output-file.ts | 145 + pi-subagents/src/permission-system-bridge.ts | 90 + pi-subagents/src/prompts.ts | 116 + pi-subagents/src/schedule-store.ts | 153 + pi-subagents/src/schedule.ts | 374 ++ pi-subagents/src/settings.ts | 490 ++ pi-subagents/src/skill-loader.ts | 102 + pi-subagents/src/status-note.ts | 90 + pi-subagents/src/types.ts | 307 + pi-subagents/src/ui/agent-mention.ts | 216 + pi-subagents/src/ui/agent-widget.ts | 629 ++ pi-subagents/src/ui/conversation-viewer.ts | 373 ++ pi-subagents/src/ui/fleet-list.ts | 402 ++ pi-subagents/src/ui/schedule-menu.ts | 105 + pi-subagents/src/ui/select-item.ts | 45 + pi-subagents/src/ui/viewer-keys.ts | 39 + pi-subagents/src/usage.ts | 167 + pi-subagents/src/worktree.ts | 211 + pi-subagents/test/abortable.test.ts | 164 + .../test/agent-color-surfaces.test.ts | 242 + pi-subagents/test/agent-color.test.ts | 69 + pi-subagents/test/agent-file-toggle.test.ts | 386 ++ pi-subagents/test/agent-manager-gc.test.ts | 323 + pi-subagents/test/agent-manager.test.ts | 2046 +++++++ .../test/agent-mention-provider.test.ts | 616 ++ .../test/agent-mention-wiring.test.ts | 1370 +++++ pi-subagents/test/agent-runner-e2e.test.ts | 173 + .../test/agent-runner-settings.test.ts | 96 + pi-subagents/test/agent-runner.test.ts | 2672 ++++++++ pi-subagents/test/agent-startup-error.test.ts | 94 + .../test/agent-tool-error-rendering.test.ts | 60 + pi-subagents/test/agent-types.test.ts | 493 ++ pi-subagents/test/agent-widget.test.ts | 446 ++ .../test/background-by-default.test.ts | 114 + .../test/background-resume-wiring.test.ts | 287 + pi-subagents/test/child-context.test.ts | 27 + .../test/child-session-shutdown.test.ts | 187 + .../test/clear-completed-wiring.test.ts | 172 + pi-subagents/test/context.test.ts | 129 + .../conversation-viewer-keybindings.test.ts | 140 + pi-subagents/test/conversation-viewer.test.ts | 527 ++ pi-subagents/test/cost-display.test.ts | 217 + pi-subagents/test/cross-extension-rpc.test.ts | 320 + pi-subagents/test/custom-agents.test.ts | 1166 ++++ pi-subagents/test/documented-defaults.test.ts | 71 + .../test/e2e/isolated-provider.e2e.test.ts | 81 + ...ention-clone-tool-reachability.e2e.test.ts | 97 + .../e2e/tool-veto-reachability.e2e.test.ts | 145 + .../usage-reaches-session-stats.e2e.test.ts | 148 + pi-subagents/test/enabled-models.test.ts | 206 + pi-subagents/test/env.test.ts | 61 + pi-subagents/test/ext-templates-e2e.test.ts | 173 + .../test/fallback-subagent-wiring.test.ts | 246 + .../.pi/agents/all-and-alpha-selected.md | 9 + .../.pi/agents/beta-selected-mutes-alpha.md | 9 + .../.pi/agents/disallow-alpha-write.md | 10 + .../.pi/agents/exclude-beats-ext-selector.md | 12 + .../test/fixtures/.pi/agents/exclude-beta.md | 12 + .../.pi/agents/extensions-disabled.md | 8 + .../test/fixtures/.pi/agents/fmt-array.md | 8 + .../fixtures/.pi/agents/fmt-quoted-csv.md | 8 + .../fixtures/.pi/agents/fmt-unquoted-csv.md | 8 + .../.pi/agents/isolated-overrides-all.md | 11 + .../fixtures/.pi/agents/lazy-ext-selected.md | 10 + .../.pi/agents/lazy-ext-unselected.md | 10 + .../fixtures/.pi/agents/lazy-no-selector.md | 9 + .../.pi/agents/memory-denied-write.md | 25 + .../fixtures/.pi/agents/memory-readonly.md | 13 + .../fixtures/.pi/agents/memory-readwrite.md | 12 + .../test/fixtures/.pi/agents/minimal.md | 9 + .../fixtures/.pi/agents/narrow-alpha-read.md | 9 + .../.pi/agents/no-selector-all-surface.md | 8 + .../fixtures/.pi/agents/prompt-mode-append.md | 8 + .../.pi/agents/prompt-mode-replace.md | 9 + .../fixtures/.pi/agents/skills-preload.md | 8 + .../test/fixtures/.pi/agents/tools-narrow.md | 9 + .../test/fixtures/.pi/agents/tools-none.md | 9 + .../.pi/agents/tools-omitted-ext-loaded.md | 7 + .../test/fixtures/.pi/skills/probe-skill.md | 5 + pi-subagents/test/fixtures/e2e-probe-ext.mjs | 25 + pi-subagents/test/fixtures/ext-alpha.mjs | 23 + pi-subagents/test/fixtures/ext-beta.mjs | 18 + pi-subagents/test/fixtures/ext-lazy.mjs | 27 + pi-subagents/test/fleet-list.test.ts | 553 ++ pi-subagents/test/fleet-wiring.test.ts | 141 + .../test/foreground-result-retrieval.test.ts | 220 + pi-subagents/test/group-join.test.ts | 153 + pi-subagents/test/helpers/boot-extension.ts | 115 + .../test/helpers/faux-model-backend.ts | 70 + pi-subagents/test/helpers/pi-ai.ts | 7 + .../test/helpers/print-mode-runner.ts | 616 ++ pi-subagents/test/invocation-config.test.ts | 150 + pi-subagents/test/isolation-param.test.ts | 46 + .../test/manager-registry-guard.test.ts | 164 + .../test/mandatory-extensions.test.ts | 55 + .../test/memory-legacy-fallback.test.ts | 81 + pi-subagents/test/memory.test.ts | 343 ++ pi-subagents/test/mention-clone.test.ts | 401 ++ .../test/mention-start-notification.test.ts | 108 + pi-subagents/test/mention.test.ts | 202 + pi-subagents/test/model-resolver.test.ts | 259 + pi-subagents/test/model-scope.test.ts | 147 + .../test/nested-delegation-e2e.test.ts | 264 + pi-subagents/test/nested-tools.test.ts | 543 ++ .../test/output-file-compaction-e2e.test.ts | 146 + pi-subagents/test/output-file-path.test.ts | 86 + pi-subagents/test/output-file.test.ts | 296 + .../test/output-transcript-wiring.test.ts | 195 + .../test/permission-system-bridge.test.ts | 61 + pi-subagents/test/print-mode.test.ts | 106 + pi-subagents/test/prompts.test.ts | 436 ++ .../test/rpc-lifecycle-gating.test.ts | 234 + pi-subagents/test/schedule-e2e.test.ts | 237 + pi-subagents/test/schedule-menu.test.ts | 187 + .../test/schedule-params-wiring.test.ts | 232 + pi-subagents/test/schedule-store.test.ts | 179 + pi-subagents/test/schedule.test.ts | 460 ++ pi-subagents/test/settings.test.ts | 811 +++ pi-subagents/test/skill-loader.test.ts | 237 + pi-subagents/test/status-note-wiring.test.ts | 317 + .../test/steer-subagent-wiring.test.ts | 176 + .../test/strict-agent-files-wiring.test.ts | 124 + .../test/subagent-error-status-e2e.test.ts | 108 + .../subagents-nested-print-mode-e2e.test.ts | 467 ++ .../test/subagents-print-mode-e2e.test.ts | 388 ++ .../test/tool-description-mode.test.ts | 411 ++ pi-subagents/test/usage-reporting.test.ts | 354 ++ pi-subagents/test/usage.test.ts | 203 + pi-subagents/test/wait-queued.test.ts | 228 + .../test/worktree-isolation-e2e.test.ts | 189 + pi-subagents/test/worktree.test.ts | 373 ++ pi-subagents/tsconfig.json | 14 + pi-subagents/vitest.config.ts | 47 + pi-tool-search/CHANGELOG.md | 1 + pi-tool-search/README.md | 1 + pi-tool-search/docs/dynamic-tool-loading.md | 2 +- pi-tool-search/extensions/bundle-groups.ts | 9 + pi-tool-search/test/bundle-groups.test.ts | 3 + tests/extension-load.test.ts | 38 +- 185 files changed, 48365 insertions(+), 26 deletions(-) create mode 100644 extensions/subagents.ts create mode 100644 pi-subagents/.gitignore create mode 100644 pi-subagents/.npmignore create mode 100644 pi-subagents/AGENTS.md create mode 100644 pi-subagents/CHANGELOG.md create mode 100644 pi-subagents/CONTRIBUTING.md create mode 100644 pi-subagents/LICENSE create mode 100644 pi-subagents/README.md create mode 100644 pi-subagents/SECURITY.md create mode 100644 pi-subagents/UPSTREAM.md create mode 100644 pi-subagents/biome.json create mode 100644 pi-subagents/examples/agent-tool-description.md create mode 100644 pi-subagents/media/screenshot.png create mode 100644 pi-subagents/package-lock.json create mode 100644 pi-subagents/package.json create mode 100644 pi-subagents/src/abortable.ts create mode 100644 pi-subagents/src/agent-color.ts create mode 100644 pi-subagents/src/agent-file-toggle.ts create mode 100644 pi-subagents/src/agent-manager.ts create mode 100644 pi-subagents/src/agent-runner.ts create mode 100644 pi-subagents/src/agent-types.ts create mode 100644 pi-subagents/src/child-context.ts create mode 100644 pi-subagents/src/context.ts create mode 100644 pi-subagents/src/cross-extension-rpc.ts create mode 100644 pi-subagents/src/custom-agents.ts create mode 100644 pi-subagents/src/default-agents.ts create mode 100644 pi-subagents/src/enabled-models.ts create mode 100644 pi-subagents/src/env.ts create mode 100644 pi-subagents/src/group-join.ts create mode 100644 pi-subagents/src/index.ts create mode 100644 pi-subagents/src/invocation-config.ts create mode 100644 pi-subagents/src/mandatory-extensions.ts create mode 100644 pi-subagents/src/memory.ts create mode 100644 pi-subagents/src/mention-clone.ts create mode 100644 pi-subagents/src/mention.ts create mode 100644 pi-subagents/src/model-resolver.ts create mode 100644 pi-subagents/src/model-scope.ts create mode 100644 pi-subagents/src/nested-tools.ts create mode 100644 pi-subagents/src/output-file.ts create mode 100644 pi-subagents/src/permission-system-bridge.ts create mode 100644 pi-subagents/src/prompts.ts create mode 100644 pi-subagents/src/schedule-store.ts create mode 100644 pi-subagents/src/schedule.ts create mode 100644 pi-subagents/src/settings.ts create mode 100644 pi-subagents/src/skill-loader.ts create mode 100644 pi-subagents/src/status-note.ts create mode 100644 pi-subagents/src/types.ts create mode 100644 pi-subagents/src/ui/agent-mention.ts create mode 100644 pi-subagents/src/ui/agent-widget.ts create mode 100644 pi-subagents/src/ui/conversation-viewer.ts create mode 100644 pi-subagents/src/ui/fleet-list.ts create mode 100644 pi-subagents/src/ui/schedule-menu.ts create mode 100644 pi-subagents/src/ui/select-item.ts create mode 100644 pi-subagents/src/ui/viewer-keys.ts create mode 100644 pi-subagents/src/usage.ts create mode 100644 pi-subagents/src/worktree.ts create mode 100644 pi-subagents/test/abortable.test.ts create mode 100644 pi-subagents/test/agent-color-surfaces.test.ts create mode 100644 pi-subagents/test/agent-color.test.ts create mode 100644 pi-subagents/test/agent-file-toggle.test.ts create mode 100644 pi-subagents/test/agent-manager-gc.test.ts create mode 100644 pi-subagents/test/agent-manager.test.ts create mode 100644 pi-subagents/test/agent-mention-provider.test.ts create mode 100644 pi-subagents/test/agent-mention-wiring.test.ts create mode 100644 pi-subagents/test/agent-runner-e2e.test.ts create mode 100644 pi-subagents/test/agent-runner-settings.test.ts create mode 100644 pi-subagents/test/agent-runner.test.ts create mode 100644 pi-subagents/test/agent-startup-error.test.ts create mode 100644 pi-subagents/test/agent-tool-error-rendering.test.ts create mode 100644 pi-subagents/test/agent-types.test.ts create mode 100644 pi-subagents/test/agent-widget.test.ts create mode 100644 pi-subagents/test/background-by-default.test.ts create mode 100644 pi-subagents/test/background-resume-wiring.test.ts create mode 100644 pi-subagents/test/child-context.test.ts create mode 100644 pi-subagents/test/child-session-shutdown.test.ts create mode 100644 pi-subagents/test/clear-completed-wiring.test.ts create mode 100644 pi-subagents/test/context.test.ts create mode 100644 pi-subagents/test/conversation-viewer-keybindings.test.ts create mode 100644 pi-subagents/test/conversation-viewer.test.ts create mode 100644 pi-subagents/test/cost-display.test.ts create mode 100644 pi-subagents/test/cross-extension-rpc.test.ts create mode 100644 pi-subagents/test/custom-agents.test.ts create mode 100644 pi-subagents/test/documented-defaults.test.ts create mode 100644 pi-subagents/test/e2e/isolated-provider.e2e.test.ts create mode 100644 pi-subagents/test/e2e/mention-clone-tool-reachability.e2e.test.ts create mode 100644 pi-subagents/test/e2e/tool-veto-reachability.e2e.test.ts create mode 100644 pi-subagents/test/e2e/usage-reaches-session-stats.e2e.test.ts create mode 100644 pi-subagents/test/enabled-models.test.ts create mode 100644 pi-subagents/test/env.test.ts create mode 100644 pi-subagents/test/ext-templates-e2e.test.ts create mode 100644 pi-subagents/test/fallback-subagent-wiring.test.ts create mode 100644 pi-subagents/test/fixtures/.pi/agents/all-and-alpha-selected.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/beta-selected-mutes-alpha.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/disallow-alpha-write.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/exclude-beats-ext-selector.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/exclude-beta.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/extensions-disabled.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/fmt-array.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/fmt-quoted-csv.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/fmt-unquoted-csv.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/isolated-overrides-all.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/lazy-ext-selected.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/lazy-ext-unselected.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/lazy-no-selector.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/memory-denied-write.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/memory-readonly.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/memory-readwrite.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/minimal.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/narrow-alpha-read.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/no-selector-all-surface.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/prompt-mode-append.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/prompt-mode-replace.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/skills-preload.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/tools-narrow.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/tools-none.md create mode 100644 pi-subagents/test/fixtures/.pi/agents/tools-omitted-ext-loaded.md create mode 100644 pi-subagents/test/fixtures/.pi/skills/probe-skill.md create mode 100644 pi-subagents/test/fixtures/e2e-probe-ext.mjs create mode 100644 pi-subagents/test/fixtures/ext-alpha.mjs create mode 100644 pi-subagents/test/fixtures/ext-beta.mjs create mode 100644 pi-subagents/test/fixtures/ext-lazy.mjs create mode 100644 pi-subagents/test/fleet-list.test.ts create mode 100644 pi-subagents/test/fleet-wiring.test.ts create mode 100644 pi-subagents/test/foreground-result-retrieval.test.ts create mode 100644 pi-subagents/test/group-join.test.ts create mode 100644 pi-subagents/test/helpers/boot-extension.ts create mode 100644 pi-subagents/test/helpers/faux-model-backend.ts create mode 100644 pi-subagents/test/helpers/pi-ai.ts create mode 100644 pi-subagents/test/helpers/print-mode-runner.ts create mode 100644 pi-subagents/test/invocation-config.test.ts create mode 100644 pi-subagents/test/isolation-param.test.ts create mode 100644 pi-subagents/test/manager-registry-guard.test.ts create mode 100644 pi-subagents/test/mandatory-extensions.test.ts create mode 100644 pi-subagents/test/memory-legacy-fallback.test.ts create mode 100644 pi-subagents/test/memory.test.ts create mode 100644 pi-subagents/test/mention-clone.test.ts create mode 100644 pi-subagents/test/mention-start-notification.test.ts create mode 100644 pi-subagents/test/mention.test.ts create mode 100644 pi-subagents/test/model-resolver.test.ts create mode 100644 pi-subagents/test/model-scope.test.ts create mode 100644 pi-subagents/test/nested-delegation-e2e.test.ts create mode 100644 pi-subagents/test/nested-tools.test.ts create mode 100644 pi-subagents/test/output-file-compaction-e2e.test.ts create mode 100644 pi-subagents/test/output-file-path.test.ts create mode 100644 pi-subagents/test/output-file.test.ts create mode 100644 pi-subagents/test/output-transcript-wiring.test.ts create mode 100644 pi-subagents/test/permission-system-bridge.test.ts create mode 100644 pi-subagents/test/print-mode.test.ts create mode 100644 pi-subagents/test/prompts.test.ts create mode 100644 pi-subagents/test/rpc-lifecycle-gating.test.ts create mode 100644 pi-subagents/test/schedule-e2e.test.ts create mode 100644 pi-subagents/test/schedule-menu.test.ts create mode 100644 pi-subagents/test/schedule-params-wiring.test.ts create mode 100644 pi-subagents/test/schedule-store.test.ts create mode 100644 pi-subagents/test/schedule.test.ts create mode 100644 pi-subagents/test/settings.test.ts create mode 100644 pi-subagents/test/skill-loader.test.ts create mode 100644 pi-subagents/test/status-note-wiring.test.ts create mode 100644 pi-subagents/test/steer-subagent-wiring.test.ts create mode 100644 pi-subagents/test/strict-agent-files-wiring.test.ts create mode 100644 pi-subagents/test/subagent-error-status-e2e.test.ts create mode 100644 pi-subagents/test/subagents-nested-print-mode-e2e.test.ts create mode 100644 pi-subagents/test/subagents-print-mode-e2e.test.ts create mode 100644 pi-subagents/test/tool-description-mode.test.ts create mode 100644 pi-subagents/test/usage-reporting.test.ts create mode 100644 pi-subagents/test/usage.test.ts create mode 100644 pi-subagents/test/wait-queued.test.ts create mode 100644 pi-subagents/test/worktree-isolation-e2e.test.ts create mode 100644 pi-subagents/test/worktree.test.ts create mode 100644 pi-subagents/tsconfig.json create mode 100644 pi-subagents/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index e1dd1c9..fb58bf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ - 上游来源: - 初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。 - 该目录从明确 tag 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 +- `pi-subagents/`:提供 Claude Code 风格的隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent。 +- 上游来源: +- 初始导入快照:`3f9d35cd078d18a141eb5a6d8f4fc5010d756280`(`v0.18.0`)。 +- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;当前通过本地 `file:` 根依赖和 `extensions/subagents.ts` 默认加载,已实现权限 child lifecycle、根 authority `ask` 转发、根 transcript auto-review 身份桥接和 exact-path mandatory permission wrapper,3 个根编排工具由 Tool Search 的 `subagents` 组按需加载。 - `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 - `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。 - `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。 @@ -45,7 +49,7 @@ - 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。 - 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 - `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。 -- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`pi-minimal-footer` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。 +- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@tintinweb/pi-subagents`、`pi-minimal-footer` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。 - `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 - `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。 @@ -96,6 +100,8 @@ - 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。官方 Hippo CLI 是独立机器级 npm 包,不得因此向根包批量批准其他 install scripts。 - Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。 - `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。 +- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。 +- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。 - 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。 - `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。 - 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 @@ -155,6 +161,19 @@ - 修改该扩展后至少运行包内 `npm test`、`npm run check`、`npm run smoke` 与 `npm pack --dry-run --json`;加载或依赖入口变化还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。 +## `pi-subagents` 开发约定 + +- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。 +- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。 +- 根包必须通过 `file:./pi-subagents` 提供运行时依赖,并只通过 `extensions/subagents.ts` wrapper 直接加载仓库源码;不得绕过 wrapper 直接列出上游入口,不得同时安装或加载 npm 预编译入口,也不得把 Pi host runtime 作为组合包开发依赖带入根安装。 +- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。 +- `extensions/subagents.ts` 是组合包配置入口:它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。 +- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。 +- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。 +- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。 +- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。 + + ## `pi-tool-search` 开发约定 - 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`。 diff --git a/README.md b/README.md index 57f445f..da90d5e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 - 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。 +- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 ## 安装、升级与卸载 diff --git a/extensions/subagents.ts b/extensions/subagents.ts new file mode 100644 index 0000000..33f9357 --- /dev/null +++ b/extensions/subagents.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts"; + +// Bundle authority: Agent frontmatter may disable optional extensions, but it +// cannot remove the exact wrapper that deploys my-pi's permission baseline and +// binds the maintained permission-system source. +const BUNDLED_PERMISSION_EXTENSION = fileURLToPath( + new URL("./permission-system.ts", import.meta.url), +); + +export default createPiSubagentsExtension({ + mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION], +}); diff --git a/package-lock.json b/package-lock.json index e771a0b..d78874d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@ogulcancelik/pi-codex-compaction": "0.1.3", "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@tavily/pi-extension": "0.1.2", + "@tintinweb/pi-subagents": "file:./pi-subagents", "context-mode": "1.0.169", "pi-condense": "2.9.1", "pi-context-view": "0.4.2", @@ -50,13 +51,492 @@ } } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/@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/@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/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "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/@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/@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/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "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/@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/@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/@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/@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/@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/@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/@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/@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/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "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/@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/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/@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/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -66,17 +546,436 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.10.tgz", + "integrity": "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.10", + "@biomejs/cli-darwin-x64": "2.5.10", + "@biomejs/cli-linux-arm64": "2.5.10", + "@biomejs/cli-linux-arm64-musl": "2.5.10", + "@biomejs/cli-linux-x64": "2.5.10", + "@biomejs/cli-linux-x64-musl": "2.5.10", + "@biomejs/cli-win32-arm64": "2.5.10", + "@biomejs/cli-win32-x64": "2.5.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz", + "integrity": "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz", + "integrity": "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz", + "integrity": "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz", + "integrity": "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz", + "integrity": "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz", + "integrity": "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz", + "integrity": "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz", + "integrity": "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", @@ -115,6 +1014,32 @@ "node": ">= 20.12.0" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "integrity": "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.2", + "@google/genai": "1.52.0", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", @@ -2067,6 +2992,30 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", + "integrity": "sha512-sgEkWoKrvSGaKn+YfLLFZmn+/A7B/w62eLwTD57nI+C9to8ITlFFVbgC2OtwvPnT3NFGHdCd53qhBEMIlptD1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@ff-labs/fff-bin-android-arm64": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-android-arm64/-/fff-bin-android-arm64-0.10.5.tgz", @@ -2266,6 +3215,31 @@ "resolved": "pi-extension-codex-fast-mode", "link": true }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "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/@gotgenes/pi-permission-system": { "resolved": "pi-permission-system", "link": true @@ -2282,6 +3256,16 @@ "hono": "^4" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2685,6 +3669,16 @@ "resolved": "pi-minimal-footer", "link": true }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -2707,6 +3701,72 @@ "url": "https://opencollective.com/unts" } }, + "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/@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/@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/@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/@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/@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/@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/@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/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", @@ -3359,6 +4419,140 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "license": "MIT" + }, + "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/@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/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "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/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3388,6 +4582,10 @@ "@earendil-works/pi-coding-agent": "*" } }, + "node_modules/@tintinweb/pi-subagents": { + "resolved": "pi-subagents", + "link": true + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -3446,6 +4644,38 @@ "undici-types": "~7.16.0" } }, + "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/@vitest/coverage-istanbul": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.11.tgz", + "integrity": "sha512-pK9LJtOS534bmUwdwLpGD9qL52DeqVWSInrXFMNqrvWLbbAhuIPAlRup3UbsjFOMqCC/p3MmmX7uXV7ZTkcFfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.11" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -3864,6 +5094,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/better-sqlite3": { "version": "12.11.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", @@ -3878,6 +5121,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "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/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3935,6 +5188,47 @@ "url": "https://opencollective.com/express" } }, + "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/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3959,6 +5253,13 @@ "ieee754": "^1.1.13" } }, + "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/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -4012,6 +5313,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -4136,6 +5458,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4156,6 +5497,16 @@ "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", "license": "MIT" }, + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4287,12 +5638,29 @@ "node": ">= 0.4" } }, + "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/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4363,6 +5731,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -4490,6 +5868,13 @@ "express": ">= 4.11" } }, + "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/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4554,6 +5939,30 @@ } } }, + "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/ffi-rs": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.7.tgz", @@ -4675,6 +6084,19 @@ "node": ">= 0.6" } }, + "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/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4723,6 +6145,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "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/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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "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", @@ -4779,6 +6241,34 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "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/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/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4791,6 +6281,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4839,6 +6339,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4859,6 +6366,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4998,6 +6519,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.9", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", @@ -5021,8 +6581,44 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", - "optional": true + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/json-schema-traverse": { "version": "1.0.0", @@ -5036,6 +6632,42 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -5309,6 +6941,23 @@ "url": "https://opencollective.com/parcel" } }, + "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/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5319,6 +6968,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", @@ -5479,6 +7156,46 @@ "node": "^18 || ^20 || >= 21" } }, + "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/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/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5490,6 +7207,16 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5564,6 +7291,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5573,6 +7333,13 @@ "node": ">= 0.8" } }, + "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/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5797,6 +7564,30 @@ "node": ">=10" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5998,6 +7789,16 @@ "node": ">=0.10.0" } }, + "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/rolldown": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", @@ -6452,6 +8253,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/synckit": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", @@ -6586,6 +8400,13 @@ } } }, + "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/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6714,6 +8535,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6931,6 +8783,16 @@ "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", "license": "MIT" }, + "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/web-tree-sitter": { "version": "0.26.12", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.12.tgz", @@ -6975,6 +8837,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -6999,6 +8883,13 @@ "cuint": "^0.2.2" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -7062,20 +8953,77 @@ "@earendil-works/pi-tui": "*" } }, - "pi-permission-system/node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", - "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==", + "pi-subagents": { + "name": "@tintinweb/pi-subagents", + "version": "0.18.0", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "croner": "^10.0.1", + "nanoid": "^5.1.16" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@earendil-works/pi-ai": "0.84.2", + "@earendil-works/pi-coding-agent": "0.84.2", + "@earendil-works/pi-tui": "0.84.2", + "@types/node": "^25.5.0", + "@vitest/coverage-istanbul": "^4.1.10", + "typescript": "^6.0.0", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@earendil-works/pi-ai": ">=0.81.0", + "@earendil-works/pi-coding-agent": ">=0.81.0", + "@earendil-works/pi-tui": ">=0.81.0" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-ai": { + "optional": true + }, + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-tui": { + "optional": true + } + } + }, + "pi-subagents/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "pi-subagents/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" }, "engines": { - "node": ">=22.19.0" + "node": "^18 || >=20" } }, + "pi-subagents/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "pi-tool-search": { "version": "0.3.6", "license": "MIT", diff --git a/package.json b/package.json index 23f8a73..36670bb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "pi-extension-codex-fast-mode", "pi-rtk-optimizer", "pi-minimal-footer", + "pi-subagents", "pi-tool-search/extensions", "pi-tool-search/docs", "pi-tool-search/package.json", @@ -57,6 +58,7 @@ "./extensions/condense.ts", "./pi-permission-auto-review/index.ts", "./extensions/permission-system.ts", + "./extensions/subagents.ts", "./extensions/tool-routing.ts", "./pi-tool-search/extensions/index.ts" ], @@ -73,6 +75,7 @@ "@ogulcancelik/pi-codex-compaction": "0.1.3", "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@tavily/pi-extension": "0.1.2", + "@tintinweb/pi-subagents": "file:./pi-subagents", "context-mode": "1.0.169", "pi-condense": "2.9.1", "pi-context-view": "0.4.2", diff --git a/pi-permission-system/README.md b/pi-permission-system/README.md index 51e6daf..dcc1737 100644 --- a/pi-permission-system/README.md +++ b/pi-permission-system/README.md @@ -24,7 +24,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI -- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration +- **Native in-process subagent integration** — [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) and the source-maintained `my-pi` fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) register child sessions automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration ## Install diff --git a/pi-permission-system/docs/architecture/architecture.md b/pi-permission-system/docs/architecture/architecture.md index 3015705..93fd364 100644 --- a/pi-permission-system/docs/architecture/architecture.md +++ b/pi-permission-system/docs/architecture/architecture.md @@ -428,14 +428,14 @@ This requires two detections: | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------- | | pi-agent-router (original) | `PI_IS_SUBAGENT`, `PI_SUBAGENT_SESSION_ID`, `PI_AGENT_ROUTER_SUBAGENT` | `PI_AGENT_ROUTER_PARENT_SESSION_ID` | | [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_RUN_ID`, `PI_SUBAGENT_CHILD_AGENT`, `PI_SUBAGENT_DEPTH` | none set (see #98) | -| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()` | n/a - deferred to #29 | +| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()`; the maintained `my-pi` fork uses the registry lifecycle | n/a | | [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | `PI_SUBAGENT_NAME`, `PI_SUBAGENT_ID`, `PI_SUBAGENT_SESSION`, `PI_SUBAGENT_ACTIVITY_FILE` | none set (see #98) | ### Detection (`isSubagentExecutionContext`) `isSubagentExecutionContext()` checks three sources in priority order: -1. **Explicit registry** - `@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously. +1. **Explicit registry** - a compatible in-process spawner (`@gotgenes/pi-subagents` or the maintained `my-pi` Tintinweb fork) emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously. The registry (keyed by **child session id**) is checked first. Each concurrent sibling child of the same parent receives a unique session id from `sessionManager.newSession()`, so siblings occupy distinct keys - one sibling's `disposed` event cannot evict another's entry (fixes #298). The registry is a process-global singleton (via `getSubagentSessionRegistry()`, backed by `globalThis` + `Symbol.for()`) because each session's `ResourceLoader` creates its own `pi.events` bus: the parent's instance registers the child over the parent bus, while the child's separate jiti instance reads the same global store to detect itself and resolve its forwarding target. @@ -457,8 +457,8 @@ Adding a new env var candidate when an extension adopts the convention is a one- ### In-process case (resolved) -In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) call `createAgentSession()` directly - no child process is spawned and no env vars are ever set. -`@gotgenes/pi-subagents` publishes `subagents:child:session-created` (before `bindExtensions()`) and `subagents:child:disposed` (in the run's `finally`); `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously. +In-process subagent extensions call `createAgentSession()` directly - no child process is spawned and no env vars are ever set. +`@gotgenes/pi-subagents` and the maintained `my-pi` Tintinweb fork publish `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously. The registry is process-global (see `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`) so the child's separate jiti instance reads the same store as the parent. See `src/authority/subagent-registry.ts` and [Subagent Integration](../subagent-integration.md) for details. diff --git a/pi-permission-system/docs/cross-extension-api.md b/pi-permission-system/docs/cross-extension-api.md index e41b750..f9a9166 100644 --- a/pi-permission-system/docs/cross-extension-api.md +++ b/pi-permission-system/docs/cross-extension-api.md @@ -266,7 +266,7 @@ The extractor must not throw — guard your parsing and return `undefined` on an #### Subagent session registration In-process subagent registration is event-driven. -`@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` in the run's `finally`; the permission system subscribes automatically — no service call from the spawner is required. +A compatible in-process spawner emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; the permission system subscribes automatically — no service call from the spawner is required. Current publishers are `@gotgenes/pi-subagents` and the source-maintained `my-pi` fork of `tintinweb/pi-subagents`. See [Subagent Integration](subagent-integration.md) for details. ### Reload Safety diff --git a/pi-permission-system/docs/subagent-integration.md b/pi-permission-system/docs/subagent-integration.md index 295d377..ac910f6 100644 --- a/pi-permission-system/docs/subagent-integration.md +++ b/pi-permission-system/docs/subagent-integration.md @@ -1,9 +1,8 @@ # Subagent Integration -## Native integration with `@gotgenes/pi-subagents` +## Native in-process integration -[`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) is the only subagent extension with native permission-system integration. -It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`. +[`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) and the `my-pi` maintained fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) publish the same child-execution lifecycle on `pi.events`. This package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`. Because the event bus dispatches synchronously, the synchronous registration completes before binding proceeds. This inverts the former dependency direction: the core no longer looks up this package's service ([ADR-0002] / pi-subagents [#261]). @@ -19,8 +18,10 @@ The integration enables: The parent approves or denies, and the child resumes. When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting. -No configuration is required - the integration is automatic when both extensions are installed. -When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents` emits its lifecycle events with no subscriber - a harmless no-op. +No configuration is required - the integration is automatic when the permission system and a lifecycle-compatible subagent extension are installed. +When the permission system is not installed, lifecycle events have no subscriber and are a harmless no-op. + +Lifecycle registration alone is not a tool-call gate. A host that requires permission enforcement must also make the permission extension itself mandatory in every child loader. The maintained `my-pi` Tintinweb fork receives the exact bundle-owned `extensions/permission-system.ts` path from its host wrapper; Agent `extensions: false`, `isolated`, and `exclude_extensions` policy cannot remove it, and a missing or substituted entry aborts child startup. Mandatory binding does not automatically expose extension tools. ## Permission Forwarding @@ -116,7 +117,8 @@ These compose correctly with the permission system because the two operate at di | Extension | Type | Permission integration | Frontmatter key | | ----------------------------------------------------------------------------------- | ---------- | -------------------------------- | ---------------------------------- | | [@gotgenes/pi-subagents](https://github.com/gotgenes/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) | -| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) | +| my-pi maintained [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) | +| upstream [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) | | [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | subprocess | ✗ Missing env vars | `tools:` (CSV allowlist) | | [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | subprocess | ✗ Missing env vars | `deny-tools:` (CSV denylist) | @@ -124,7 +126,7 @@ Process-based subagent extensions (nicobailon, HazAT) spawn child processes but Without that env var, `ask` permissions in child processes are auto-denied. See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the convention that subagent extension authors should follow. -The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) does not publish the `subagents:child:session-created` lifecycle event, so it lacks deterministic child detection and `ask`-state forwarding. +The upstream `tintinweb/pi-subagents` does not currently publish this lifecycle. The source-maintained `my-pi` snapshot adds the same event contract without importing permission-system internals; this row must not be read as compatibility for the unmodified npm/GitHub release. ### Interaction Rules diff --git a/pi-subagents/.gitignore b/pi-subagents/.gitignore new file mode 100644 index 0000000..26ebc5b --- /dev/null +++ b/pi-subagents/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo +plan/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS files +.DS_Store +Thumbs.db + +# Editor directories +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Temporary files +*.tmp +.temp/ +.pi/readcache +.claude +.vscode +coverage/ +.gitnexus +.pi/subagents.json + +# Pi internal files +progress.md +CLAUDE.md diff --git a/pi-subagents/.npmignore b/pi-subagents/.npmignore new file mode 100644 index 0000000..81f50fb --- /dev/null +++ b/pi-subagents/.npmignore @@ -0,0 +1,33 @@ +# Development files +tsconfig.json +.vscode/ +.git/ +.gitignore + +# Documentation +PLAN.md +SUMMARY.md +plan/ +media/ + +# Internal contributor/agent instructions — not for distribution +AGENTS.md +CLAUDE.md + +# Build artifacts +*.log +npm-debug.log* +.DS_Store + +# Tests (if any) +test/ +tests/ +*.test.ts +*.spec.ts + +.pi +.claude +.vscode +.github/ +biome.json +coverage/ \ No newline at end of file diff --git a/pi-subagents/AGENTS.md b/pi-subagents/AGENTS.md new file mode 100644 index 0000000..674871e --- /dev/null +++ b/pi-subagents/AGENTS.md @@ -0,0 +1,103 @@ +# Development Rules + +## Conversational Style + +- Keep answers short and concise +- No emojis in issues, PR comments, or code +- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") +- Technical prose only, be direct +- When the user asks a question, answer it first before making edits or running implementation commands. +- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. + +## Code Quality + +- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes. +- No `any` unless absolutely necessary. +- Inline single-line helpers that have only one call site. +- Check `node_modules` for external API types (`@earendil-works/pi-*`, `@sinclair/typebox`, etc.); don't guess. +- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only. +- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead. +- Match the surrounding code style — it is enforced by biome (`biome.json`). +- Always ask before removing functionality or code that appears intentional. +- Do not preserve backward compatibility unless the user asks for it. +- This is a pi extension. Respect the Claude Code-compatible tool names, calling conventions, and UI patterns the extension deliberately mirrors; don't diverge from them without a stated reason. +- When reviewing a diff, favor solutions that are elegant, not overengineered — flag needless abstraction, layering, or defensive code that the change doesn't warrant. + +## Commands + +- After code changes (not docs), run the full check suite and fix all errors and warnings: + ```bash + npm run lint # biome + npm run typecheck # tsc --noEmit + npm run test # vitest run + ``` +- `npm run lint:fix` auto-fixes most style issues. +- `npm run test` runs the whole suite, including `*-e2e.test.ts` files. To iterate on a single file, run it directly: `npx vitest run test/.test.ts`. +- If you create or modify a test file, run it and iterate on the test or implementation until it passes. +- `npm run build` compiles with `tsc`; run it only when verifying the build output or when requested. +- For ad-hoc scripts, write them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands. + +## Git + +- **Never commit.** The user commits manually. At most, suggest a concise commit message as text. +- **Never push**, tag, or create branches unless the user explicitly asks. +- Never run history- or worktree-destroying commands: `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`, or any force push. +- Leave the working tree as the user left it — don't stage, stash, or revert files you didn't change. + +## Issues and PRs + +See `CONTRIBUTING.md` for the contributor guidelines and quality bar. + +When reviewing PRs: + +- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks. +- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches. +- If you need PR file contents, fetch/read them into temporary files or use `git show :` without switching branches. + +When posting issue/PR comments: + +- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`). +- Keep comments concise, technical, and in the user's tone. + +## Changelog + +Location: `CHANGELOG.md` (single file, [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format). + +- All new entries go under `## [Unreleased]`, in the right subsection (`### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Security`, `### Refactored`). Read the section first and append to existing subsections; never duplicate them. +- One bullet per issue/PR. Never combine separate issues or pull requests into a single entry, even when they touch the same or similar components. (A PR together with the issue it closes or that diagnosed it is one change — one bullet citing both.) +- Breaking changes are not a separate subsection. Call them out with a `> **⚠️ Breaking: …**` blockquote at the top of the version section, and/or a bold `**BREAKING:**` bullet under `### Changed`, with a migration note. +- Entries are concise — a bold lead-in stating what changed, then a sentence or two on why it changed and anything a user must do about it. Aim for 2–4 sentences; a genuinely intricate change may run longer, but length is never the goal. Do not match the density of older entries, several of which are far too long. +- Cut what the reader doesn't need: narration of the investigation, alternatives considered and rejected, restatements of the diff, and detail recoverable from the code or the linked issue. Name a file or symbol only when it helps someone find the change. +- Released version sections (e.g. `## [0.12.0]`) are immutable; never modify them. +- Attribute external contributions: `... ([#456](https://github.com/tintinweb/pi-subagents/pull/456) — thanks [@username](https://github.com/username))`. + +## Releasing + +**Versioning** (all releases are `0.x`, no major bumps): + +- `minor` (`0.x.0`) — a notable new feature, or any breaking change. +- `patch` (`0.x.y`) — bug fixes and smaller additions. + +Before a release: + +- Update `CHANGELOG.md` — move the `## [Unreleased]` entries under a new `## [X.Y.Z]` version section, and add a fresh empty `## [Unreleased]` for the next cycle. +- Update `README.md` if user-facing behavior changed (features list, settings, usage). +- Run the full check suite plus the e2e tests, and fix anything that fails: + ```bash + npm run lint + npm run typecheck + npm run test:e2e # faux/scripted e2e — no network, no keys + npm run build + ``` +- For a real pre-publish smoke test, run the **live** e2e against an actual model: + ```bash + PI_E2E_LIVE=1 npm run test:e2e # uses your local `pi` login; optional PI_PROVIDER / PI_MODEL + ``` + `PI_E2E_LIVE=1` swaps the scripted faux suite for the live one (the faux suite is `skipIf(LIVE)`). + (`prepublishOnly` runs lint + typecheck + test + build; the live e2e is the smoke test to run by hand before publishing.) + +**Never publish.** The user runs `npm version` / `npm publish` and any tagging manually. Do not run those commands unless the user explicitly asks. + +## User Override + +If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions. diff --git a/pi-subagents/CHANGELOG.md b/pi-subagents/CHANGELOG.md new file mode 100644 index 0000000..aef307a --- /dev/null +++ b/pi-subagents/CHANGELOG.md @@ -0,0 +1,752 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Native `my-pi` permission child lifecycle bridge.** Child sessions now publish `subagents:child:session-created` synchronously before `bindExtensions()` and `subagents:child:disposed` after actual teardown. Every descendant keeps the interactive root session as its permission authority, and child prompts replace inherited identity tags with their stable agent key. This lets `pi-permission-system` apply root whole-session grants to child-fixed access facts, run auto-review against the trusted root transcript, and forward unresolved `ask` decisions to the root UI without treating model-generated child prompts as direct user authorization. +- **Host-owned mandatory extensions with fail-closed loading.** `createPiSubagentsExtension({ mandatoryExtensionPaths })` lets a bundle protect exact canonical extension entries from Agent-controlled `extensions: false`, `isolated`, and `exclude_extensions`. Missing or substituted entries abort before session creation. Mandatory handlers bind without automatically surfacing their tools, preserving the separation between security lifecycle and Agent tool visibility. + +### Changed +- **Enabled in the `my-pi` bundle.** The root package now installs this source through `file:./pi-subagents`, loads the host-owned wrapper by default, and assigns the three orchestration tools to Tool Search's checked-in `subagents` group. Pi host peers are optional in this maintained package so the bundle continues to use the host runtime instead of installing a duplicate. The off-screen mention clone now disables extension/resource discovery so Tool Search and other active-tool owners cannot remove its single synthetic `Agent` tool during `session_start`. + +## [0.18.0] - 2026-08-20 + +> **⚠️ Breaking — an `Agent` call that doesn't say now runs in the background** (`backgroundByDefault`). Following Claude Code, where the agent backgrounds unless the caller passes `run_in_background: false`. An unqualified spawn no longer blocks the turn and no longer returns the agent's output inline: it returns an ID immediately, and the completion notification carries a preview (500 chars solo, 300 grouped) with the full text behind `get_subagent_result`. Set `backgroundByDefault: false` to restore the previous behaviour globally; an explicit `run_in_background` on the call or in frontmatter overrides the setting in both directions. **Nested spawns are unchanged** — an agent spawning its own agent still defaults to foreground, because a detached child is stopped by `abortOwnedChildren` when its parent settles and has no notification path of its own. Two consequences worth planning for: `maxConcurrent` now applies to nearly every spawn where foreground used to bypass it (its default is raised 4 → 10 to compensate), and Esc interrupts the turn without stopping the agents it started — stop those from `/agents → Running agents`, which is also how Claude Code separates the two. + +### Added +- **`reportUsage` — subagent tokens and cost count towards this session's own totals** ([#193](https://github.com/tintinweb/pi-subagents/issues/193), [#203](https://github.com/tintinweb/pi-subagents/issues/203) — thanks [@johnstegeman](https://github.com/johnstegeman) and [@unrelentingfox](https://github.com/unrelentingfox)). Subagents run in their own pi sessions, so nothing they spent reached the parent's `getSessionStats()`: a session that delegated most of its work read as nearly free in the footer, the statusline and `/cost`. With the setting on, each `Agent` / `get_subagent_result` / `steer_subagent` result carries the spend accumulated since the last one, which pi folds into the session totals and attributes to `/cost`'s "Tools/summaries" bucket. Off by default — it changes numbers you may already be tracking. Agents that finish in the background have no result of their own to ride on, so their spend lands on the next call rather than the moment they finish; nested agents are covered, counted once each, on the top-level result. Cost is pi's own per-message figure, and the context-window percentage is unaffected. Implemented independently in [#202](https://github.com/tintinweb/pi-subagents/pull/202), whose `cacheRead` reasoning corrected this one: the cached prefix is genuinely re-read and re-billed per call, and pi counts it that way for the session's own messages, so the report includes it even though the extension's own token displays still exclude it ([#38](https://github.com/tintinweb/pi-subagents/issues/38)). +- **`showCost` — an estimated cost beside subagent token counts** ([#194](https://github.com/tintinweb/pi-subagents/pull/194) — thanks [@daanzu](https://github.com/daanzu), whose PR is the display design here). Shown in the widget, FleetView, the conversation viewer, foreground results, `get_subagent_result` and completion notifications, with a batch total when several background agents finish together. Off by default, and shown only when there is a cost to show: a model pi has no pricing data for reports zero, and `$0.00` beside its tokens would say the run was measured and found free rather than never measured. Figures keep cents at minimum and four decimals at most (`~$0.0042`, `~$1.24`), because rounding everything to cents printed one number for runs that differed fourfold. +- **Lifecycle events carry the run's spend as a pi `Usage`** ([#137](https://github.com/tintinweb/pi-subagents/issues/137), [#138](https://github.com/tintinweb/pi-subagents/pull/138) — thanks [@leset0ng](https://github.com/leset0ng)). `subagents:completed` and `subagents:failed` gain a `usage` field alongside `tokens`, carrying the token components — `cacheRead` included — and `cost.total` in USD. It is pi's own `Usage`, the shape pi puts on `ToolResultEvent` and `AssistantMessage`, so the money is where a listener already looks and anything pi adds to `Usage` needs no change here. Omitted when nothing was spent, so *spent nothing* stays distinguishable from *never ran*. `tokens` is unchanged and still the display total, which excludes `cacheRead` ([#38](https://github.com/tintinweb/pi-subagents/issues/38)) — the two answer different questions and neither derives from the other. This is the aggregate only — the per-message `{timestamp, provider, model, usage}` records those asked for, and the per-model attribution they enable, remain out of scope (#137 was declined and #138 withdrawn by its author). + +### Changed +- **The pi peer floor moves from `>=0.80.0` to `>=0.81.0`.** `reportUsage` is built on pi folding `toolResult.usage` into `getSessionStats()`, which pi only started doing in 0.81.0 — every 0.80.x sums assistant messages alone and drops the field, so the setting would have been present and silently inert there. The CI floor job installs 0.81.0 and runs the suite against it, which is what caught this; the e2e that pins the behaviour runs unconditionally rather than skipping, so the range cannot quietly stop being true. Nothing else in the extension needed 0.81. +- **Top-level `Agent` spawns default to background, behind a new `backgroundByDefault` setting** (see the breaking note above). Configurable at `/agents → Settings → Background by default` and in `subagents.json`; applied live. Nested spawns pass `false` unconditionally and ignore the setting. +- **The background concurrency limit's default is raised from 4 to 10.** Foreground agents bypass the `maxConcurrent` pool entirely, so while foreground was the default a six-way fan-out ran six; with background as the default every top-level agent takes a slot, and a limit of 4 would have silently queued the tail of exactly the parallel fan-outs the `Agent` tool description tells the model to send. Still configurable — the ceiling is unchanged. +- **The agent-file writer emits `run_in_background: false` rather than dropping it.** It wrote the field only when truthy, which was lossless while omission and `false` meant the same thing; under `backgroundByDefault` they no longer do. No current path reaches it — `serializeAgentFile`'s only caller is Eject, which is offered for built-in defaults, and those omit the field — so this is the writer half of the format keeping pace with the loader, not a fix for observable behaviour. + +### Fixed +- **Subagent sessions now close the extension lifecycle they open** ([#242](https://github.com/tintinweb/pi-subagents/issues/242) — thanks [@bsv9](https://github.com/bsv9)). Every child session binds extensions so `session_start` fires and they can set up per-session state, but nothing emitted the matching `session_shutdown`: `AgentSession.dispose()` only invalidates the `ExtensionRunner`. Whatever an extension armed in `session_start` — timers, fs watchers, sockets, temp dirs — therefore leaked one set per spawn, teardown a `session_shutdown` handler owns (killing runs started by that child, say) never ran, and the 10-minute record sweep turned the leak into a crash: disposing invalidated the runner while a leaked timer was still armed, so its next tick threw `assertActive()` from a bare `Timeout._onTimeout` — an `uncaughtException` that took interactive pi down with it. Both eviction and quit now emit `session_shutdown` (reason `quit`) before disposing, which is what pi itself does in `AgentSessionRuntime.dispose()`. Quit waits for those handlers rather than exiting past them, bounded at three seconds per session so one that hangs cannot strand you at a torn-down TUI; a session whose extensions register no handler is untouched and still disposes synchronously. Independently fixed in [#228](https://github.com/tintinweb/pi-subagents/pull/228) and [#170](https://github.com/tintinweb/pi-subagents/pull/170) — thanks [@v-kuskov](https://github.com/v-kuskov) and [@liu-qingyuan](https://github.com/liu-qingyuan). +- **The `Agent` tool no longer tells the model that foreground agents run one at a time** ([#232](https://github.com/tintinweb/pi-subagents/issues/232) — thanks [@willfenton](https://github.com/willfenton)). *"Foreground calls run sequentially — only one executes at a time"* was never true: pi's agent loop dispatches a message's tool calls through `Promise.all` unless the whole batch opts out via `toolExecution: "sequential"` or some tool in it declares `executionMode: "sequential"`, and this extension sets neither — two foreground `Agent` calls in one message start within microseconds of each other. Nothing serialized them at this layer either; `agent-manager` deliberately exempts foreground agents from the `maxConcurrent` queue, since they block the parent anyway. The claim was self-inflicted rather than inherited: `d0cb511` replaced a correct bullet with it, and the later upstream-alignment pass grafted it onto the end of Claude Code's real sentence instead of replacing it, which also left an invented `with run_in_background: true on each` qualifier in the middle — upstream says a single message with multiple tool uses runs concurrently, full stop. Both are gone, restoring upstream's wording (minus its build-validator/test-runner example, consistent with the examples already omitted for token cost). The cost of the error was steering: an orchestrator that wanted parallelism was pushed into background spawns it did not need, paying a queue slot and a notification round-trip for concurrency it already had. Investigating where the sentence came from is what surfaced the default divergence in the breaking note above, so the two ship together — the remaining foreground/background prose is now Claude Code's own, including the `Don't race` bullet that only earns its place once background is the default. +- **Scheduled and RPC-spawned agents show their token counts in the widget, and finished agents keep theirs in the conversation viewer.** Both surfaces read spend from the live activity tracker, which only `Agent`-tool spawns get and which is deleted the moment an agent finishes — so those agents rendered with no token stats at all. Every surface now reads it from the agent's record instead: the record is the only total that outlives the run and the only one a nested child's spend is folded into, so the figure no longer jumped upward at completion as the read switched from one to the other. The tracker keeps what is genuinely live — tool activity, turn count, context percentage — and no longer accumulates a second copy of the totals. Spotted in [#194](https://github.com/tintinweb/pi-subagents/pull/194) — thanks [@daanzu](https://github.com/daanzu). +- **Agents started outside the `Agent` tool say what they are doing, instead of `thinking…` for their whole run** ([#181](https://github.com/tintinweb/pi-subagents/pull/181) — thanks [@xz-dev](https://github.com/xz-dev)). The widget's activity line and turn counter come from an activity tracker that only the `Agent` tool handler created, so an agent started through cross-extension RPC (the path `TaskExecute` uses), through an `@handle` mention, or through the `Symbol.for("pi-subagents:manager")` registry showed a permanent `thinking…` while the same row's tool-use count climbed beside it and the conversation viewer showed the real work. The tracker now belongs to the one funnel all three spawn paths pass through, so none can be missed and none can supply half-wired callbacks of its own; the `Agent` tool reaches the manager directly, so nothing is double-tracked. Its turn ceiling is resolved exactly the way the run resolves the limit it enforces — explicit value, else the agent's own `max_turns`, else the project default — rather than read off the caller's options, which a mention deliberately omits so the agent file can decide: the row would otherwise render `↻3` where the tool renders `↻3≤20`. Scheduled jobs still spawn through the manager and so still render without per-tool detail. + +## [0.17.1] - 2026-08-18 + +### Fixed + +- **A `model`-mode mention now actually reaches the agent it starts.** The off-screen clone was built with an empty tool allowlist, which stripped its own `Agent` tool, so every start fell back to a direct spawn with a warning. Fixing that surfaced a second bug behind it: the clone spawned in the foreground, answering into a session disposed moments later, so the agent ran and reached nobody. Its spawn is now forced to the background. +- **`@` offers files again.** Handle rows are merged into pi's own suggestion list instead of replacing it — the provider asked pi only when no agent matched, and prefix matching meant an empty token matched every handle, so a bare `@` listed no files at all. + +## [0.17.0] - 2026-08-17 + +> **⚠️ Note — an agent file's frontmatter `name:` now substitutes for the filename as its `subagent_type`.** Following Claude Code, the declared name is the dispatch identity and the filename is only the fallback, so `blubb.md` with `name: code-review` is spawned, mentioned and listed as `code-review`. Any value is accepted except one containing `:`, which Claude Code reserves for plugin scoping; such a file — like any unparseable one — is skipped with a warning rather than loaded under a name nothing honours, and `strictAgentFiles` turns that into a startup failure. + +> **⚠️ Breaking — subagent sessions now persist to disk by default** (`rememberAgents`). Transcripts that used to be in-memory are written to the session dir and appear nested under their spawner in pi's `/resume`. Per-agent `persist_session:` overrides it in both directions — `false` keeps that agent in memory, `true` persists it even with the setting off. Set `rememberAgents: false` to restore the previous behaviour globally. + +### Added +- **Subagents are addressable from the prompt: `@handle message` goes to that agent instead of the main model.** Reaching one previously meant spending a main-model turn on `steer_subagent`, or walking the FleetView. The handle names the agent, not one process, so a running or queued agent is messaged, a finished one is resumed in the background, and one that never ran is started — the reply arrives as the ordinary background-completion notification either way. Every agent gets a handle from its type, numbered on collision (`explore`, `explore-2`), offered by `@` completion alongside pi's file completion. Only a leading `@handle` followed by a message is a send, so a bare `@explore`, a mid-sentence mention and `@src/index.ts` still reach the model; toggle with `agentMentions`. +- **Mentioning an agent that isn't running now starts it through an off-screen clone of the conversation.** A throwaway copy of the session takes a turn holding only the `Agent` tool, so the model writes the agent's prompt from the conversation instead of forwarding your typed line — the context-aware prompt Claude Code gets from its own mention flow, without a visible turn narrating a decision the handle already made. Nothing enters the chat but a `Starting @plan…` toast, and what starts is an ordinary top-level agent with a transcript, widget detail and a handle. `agentMentions` accordingly takes `"model"` (default), `"direct"` — the previous behaviour, started from your text with no model call — or `"off"`; the old booleans still read as `"model"`/`"off"`. Messaging a running agent and resuming a finished one are direct in both modes, a clone that cannot run falls back to a direct start rather than losing the mention, and `"model"` lifts the TUI-only restriction for starts, so `pi -p '@plan the migration'` now works. +- **`@handle` keeps working after the agent's record is gone.** Handles used to expire with the in-memory record ~10 minutes past completion, silently flipping `@explore anything else?` from *resume* to *start fresh*. An evicted agent now leaves a tombstone and the mention reopens its session from disk (requires `rememberAgents`, below). Only the definition is re-resolved, so a continuation runs under the type's current frontmatter — and a resume whose type has since been deleted or disabled is refused rather than falling back to another agent. Tombstones cap at 100 and clear on `/new` and session switch. +- **The model can `name` an agent, and names work wherever ids do.** `Agent` takes an optional `name`, so an agent can be `@auth-audit` instead of leaving you to tell `@explore-2` from `@explore-3`. Naming is additive — the type-derived handle is still assigned, and both draw from one namespace, so neither can shadow the other. `steer_subagent` and `get_subagent_result` now accept a handle as well as an id, ids first, so existing calls are unchanged. +- **`@main ` forces text to the main model; `@agent-` is a synonym.** `main` is reserved and can never be allocated to an agent, so a leading `@main` is stripped and the rest passes through with its attachments — the escape hatch for text that only looks like a mention. `@agent-explore fix it` is Claude Code's manual spelling; the handle as written wins, so an agent actually called `agent-explore` stays reachable. Handles cap at 64 characters. +- **`worktreeIsolation` project setting — turn worktree isolation off for a whole repo** ([#184](https://github.com/tintinweb/pi-subagents/issues/184) — thanks [@tinysnake](https://github.com/tinysnake)). On a repo large enough that every worktree costs real time and disk, the only defence was asking the model not to request one. Set `"worktreeIsolation": false` in `subagents.json` (or `/agents → Settings → Worktree isolation`) and the `Agent` tool's `isolation` parameter is dropped from the schema entirely, along with the usage-note bullet describing it and the `/agents` generator's frontmatter field — the same trade `schedule` already makes when scheduling is disabled, and necessary, since prose left behind teaches the model to pass a parameter that is no longer declared and to report a branch that was never created. The refusal is enforced below the tool boundary as well, so it also covers agent files, scheduled jobs and the cross-extension RPC path, and a requested worktree is downgraded to a normal run rather than failing the call. Default is unchanged (`true`); the parameter and its prose appear or disappear on the next pi session, since both are built at tool registration, while the refusal applies immediately. + +### Fixed +- **The `Agent` tool no longer tells the model a worktree's path is returned in the result.** It never was: `cleanupWorktree` removes the worktree directory before returning and the record keeps only `{ hasChanges, branch }`, so a model acting on the promised path would have been sent to a deleted directory. The neighbouring claim that the worktree is "automatically cleaned up if the agent makes no changes" was misleading in the same way — the directory is removed in both cases; what is conditional is whether a branch survives. Both had been in the tool description since worktree isolation shipped, inherited from Claude Code's wording; the README's completion summary said the same thing loosely and has been corrected too. +- **`isolation` accepts `"off"`, so a model that fills every optional parameter can decline a worktree** ([#231](https://github.com/tintinweb/pi-subagents/issues/231), [#184](https://github.com/tintinweb/pi-subagents/issues/184), via [#201](https://github.com/tintinweb/pi-subagents/pull/201) — thanks [@Munsen](https://github.com/Munsen)). As a single-value optional literal, `isolation` was the one parameter whose only expressible value had an expensive side effect, and every other optional field has an inert filler — so a model that fills everything in reflexively ended up reviewing a staged diff inside a fresh copy of the repo that contained none of the work, over and over, against its own stated intent to omit the parameter. `"off"` is now a legal value — listed first and described as the default — and is also accepted in agent frontmatter (`none`, `no` and `false` too), where it becomes a genuine veto: frontmatter outranks tool-call parameters, so it refuses a worktree even when the caller asks for one, which no value could previously do. The parameter description now also warns that a worktree cannot see uncommitted or staged work, the specific trap here. One behaviour change to note: an agent file already carrying `isolation: none`/`no`/`false` used to parse to nothing at all, leaving a caller free to supply `worktree` anyway; it now parses to `off` and refuses one. + +### Changed +- **Frontmatter `name:` is now the agent's *type* rather than a display-label alias** (see the note above). `display_name:` still sets the label, and a file setting only `name:` badges as that name, since the label falls back to the type. A file whose `name:` differs from its filename changes `subagent_type`; rename the field to `display_name:` to keep the old identity. +- **Subagent sessions are persisted by default (`rememberAgents`)** (see the note above). They previously ran in-memory unless the agent file set `persist_session: true`, which left nothing for a handle to reopen once the record was evicted — the reason handles expired. Nested children are excluded, since nothing can address them and so nothing could reopen their transcript. +- **The selected FleetView row renders in the theme's primary `text` color** ([#230](https://github.com/tintinweb/pi-subagents/issues/230), [#234](https://github.com/tintinweb/pi-subagents/pull/234) — thanks [@kunaaal13](https://github.com/kunaaal13)). Agent name, description and the elapsed/token stats previously kept their muted/dim tokens while selected, so the highlighted row read as several emphasis levels rather than one selection; the `●` marker keeps its accent. A configured agent color still badges on the selected row and is bolded there, matching Claude Code's own FleetView — the color is what identifies the row you are reading, and leaving the badge in place keeps the row's width fixed as the selection moves. + +## [0.16.1] - 2026-08-15 + +### Changed +- **The pi compile baseline is declared instead of inferred.** The `@earendil-works/pi-*` packages were peers only, so 0.80.6 came from npm's peer auto-install and the committed lockfile rather than from any decision — the emergent pin the 0.14.0 entry below had to diagnose. They are now exact devDependencies, pinned to the current release (`0.84.2`); the peer range stays `>=0.80.0` and consumers are unaffected. +- **`compat-latest-pi` now runs typecheck and the full suite** instead of two hand-picked e2e files, so drift can't hide in an unlisted test — against pi 0.84.2 the job reported green while typecheck and four e2e suites were failing. Still `continue-on-error`, but both steps pass as of the new baseline, so a failure there is now fresh breakage rather than known debt. +- **The suite runs against pi 0.84.2, so the `modelRegistry` → `modelRuntime` migration is exercised by default rather than only by a `continue-on-error` job.** Two places had been carrying it as known-broken: the `modelRuntime` compat shim in `src/agent-runner.ts` typechecked only pre-migration (an opaque `unknown` read off the private facade field can't satisfy the newer `ModelRuntime` parameter type), and the e2e faux backend faked auth through a structural `modelRegistry` that post-migration pi no longer accepts, so every faux run died with "No API key found for faux". The faux registry and its post-migration counterpart — auth via `getAuth`/`hasConfiguredAuth`, the turn itself streaming through `modelRuntime.streamSimple` — now live together in `test/helpers/faux-model-backend.ts`, and both are passed to `createAgentSession`, each pi ignoring the option it doesn't know. +- **CI covers the floor of the declared peer range, not just the pin.** `>=0.80.0` used to be tested incidentally, because the inferred pin *was* a 0.80.x; moving the baseline to 0.84.2 would have left the range asserting support for versions nothing built against. A `compat-floor-pi` job now installs 0.80.1 — the earliest published release satisfying the range — and runs typecheck plus the full suite. Unlike the latest-pi canary it is blocking: that run is the only evidence behind the range, and if the floor becomes unsupportable the peer range is what should change. +- **`publishConfig.access` is set to `public`**, so publishing this scoped package no longer depends on `--access public` or on the access already set from a prior publish. + + +## [0.16.0] - 2026-08-14 + +### Added +- **Custom agents can set `name` and `color`, so agent files shared with Claude Code keep their display identity** ([#216](https://github.com/tintinweb/pi-subagents/issues/216) — thanks [@HerbertGao](https://github.com/HerbertGao)). `name:` is accepted as a display-name fallback (`display_name:` still wins, and the filename still determines the `subagent_type` and dispatch identity), and `color:` renders the agent name as a badge — the color is the background — in the `Agent` tool header, the widget, FleetView, and the conversation viewer. Claude Code's eight color names render with Claude Code's own values; six-digit hex and the Agency Agents palette aliases are accepted too, quantized to the xterm-256 palette on non-truecolor terminals the way pi's own theme does. A missing or invalid color preserves each surface's existing theme styling. Badge text is black or white by WCAG contrast against the rendered background, where Claude Code uses a single inverse color for every badge. In the `Agent` tool header the line paints the tool block's row tint itself and leaves it open: the badge has to close its own background, so it restores that tint, and the TUI pads a line to width *after* rendering it, so closing the line would leave that padding untinted. The tint is painted only when there is a badge, so an uncolored agent's header renders exactly as it did before. +- **`run_in_background` now works on `resume`, so continuing an agent no longer blocks the session** ([#214](https://github.com/tintinweb/pi-subagents/pull/214) — thanks [@akram-ahrardi](https://github.com/akram-ahrardi)). The `Agent` tool's resume branch returned before its background branch, so the flag was accepted and silently ignored: every resume ran inline and held the main loop for the whole turn, however long that agent took. A resume with `run_in_background: true` now detaches exactly like a background spawn — it returns immediately, queues behind `maxConcurrent`, streams to the widget and FleetView, and notifies on completion — while a resume without the flag still runs inline and returns its result, unchanged. Detaching required the differences from a spawn to be handled rather than inherited: the run is *not* wired to the tool-call abort signal (a background spawn omits it too, and forwarding it would kill the agent when the parent turn is interrupted while spawns from that same turn kept going); a second resume of an agent whose run is still in flight is refused instead of replacing the live run's abort controller, which would have put it beyond the reach of `/agents` stop; the transcript is appended to rather than truncated, since the path is deterministic per agent and the previous run's turns — including any the session has since compacted away — are what the completion notification points at; output streaming is wired when the run actually starts, so a resume stopped while still queued leaves no subscription behind; and the `subagents:created` event reports the record's own type, since resume ignores `subagent_type` and a mirror keyed by id would otherwise re-register the agent under a type it never had. The widget's finished-agent age is cleared on resume, or an agent that had already completed once would vanish instead of showing the new run's completion line. + +### Fixed +- **RPC-spawned agents now appear in the live Widget and FleetView while they are running** ([#224](https://github.com/tintinweb/pi-subagents/pull/224) — thanks [@dereknex](https://github.com/dereknex)). Cross-extension RPC calls entered through `AgentManager.spawn()` instead of the `Agent` tool handler, but only that handler started the Widget/Fleet refresh timers. The manager still tracked RPC agents and the completion callback refreshed the UI, so a long-running RPC agent was invisible until it finished and could appear only as a terminal row. The shared manager-start callback now starts and refreshes both native surfaces for every top-level spawn source, including RPC and scheduled agents; nested agents remain intentionally hidden. + +### Documentation +- **The cross-extension RPC spawn example named an option that does not exist**. It passed `run_in_background: true`, the `Agent` *tool's* parameter name; the RPC handler forwards `options` verbatim to `AgentManager.spawn`, whose field is `isBackground`, so the flag was silently dropped — an RPC agent nobody meant to exempt never occupied a `maxConcurrent` slot and never queued behind one, and `subagents:created` reported no background flag for it. The example now passes `isBackground`, which the RPC test suite has always pinned, and the surrounding text says what the flag does and does not change: every RPC spawn returns its id immediately and runs detached either way. It also states the visibility rule that changed above, so the widget/FleetView behavior is documented where callers read about spawning rather than only in this file. + +## [0.15.2] - 2026-08-14 + +### Documentation +- **Three README statements corrected against the code they describe**, each now pinned by a test so the doc and the behavior can't drift apart again. (1) Scheduling was documented as forcing `run_in_background` to `true`; it does not — an explicit `run_in_background: false` alongside `schedule` is *refused*, and always has been. The 0.10.0 entry below carries the same error: `git log -S` shows the refusal shipped in the very commit that introduced scheduling, so both documents were written from the design sketch and never matched what was released. Refusing is also the intended behavior — silently flipping a parameter the caller explicitly set is the failure mode [#37](https://github.com/tintinweb/pi-subagents/issues/37) was filed about. (2) The completion-notification example showed a `transcript: .pi/output/agent-abc123.jsonl` line; that path was replaced by the OS-tmpdir layout in [#146](https://github.com/tintinweb/pi-subagents/pull/146) and the example was never updated, so it contradicted the paragraph six lines above it. (3) The `skills` frontmatter row read as though naming skills preloaded them *in addition to* the parent's; naming any list turns skill inheritance off and the agent gets only the named ones. + +### Fixed +- **`/agents → Create agent` produced an unloadable agent when the description contained a colon**. The wizard interpolated the description and the custom model straight into YAML frontmatter, both of which come from unvalidated free-text prompts. A description like `Scout: find things` made the generated file unparseable — and since [#212](https://github.com/tintinweb/pi-subagents/issues/212) an unparseable agent file is *skipped* with a warning, so the wizard reported `Created ` for an agent that then silently did not exist — and, under `strictAgentFiles`, aborted the next startup too, that setting gating startup only. A description containing a `#` was quietly truncated at the `#`, YAML treating the rest as a comment. Both values are now quoted, the same way the eject serializer already quoted its description. A `provider/model:thinking` suffix is unaffected either way — YAML only splits on a colon *followed by a space* — and is pinned so the quoting cannot mangle it. +- **`/agents → Scheduled jobs` cancelled the wrong job when two names looked alike**. The menu formats each job into a row, then resolves the user's pick by matching the returned string back against the row list. Rows truncate the job name to 18 characters for column alignment, and job names come from the `Agent` call's `description`, which is LLM-authored and routinely shares a prefix — so two jobs like `review the auth module A` and `review the auth module B`, on the same schedule and agent type, produced byte-identical rows and the match always resolved to the first. Picking the second cancelled the first, and because the confirmation dialog was built from the same wrong job, its details looked correct and the user had no way to notice before confirming. Rows are now numbered and carried alongside the job they describe rather than in a parallel array, so labels are unique by construction and no data-dependent collision path exists. The same string-matching pattern in `/agents → Running agents` — where the consequence was only opening the wrong agent's read-only viewer — now goes through the same helper. Pi's dialog API returns the selected string with no index or value form, so uniqueness at the call site is the only available fix. The whole menu had no test coverage at all, which is how a destructive action shipped with this in it. +- **The agent widget hid queued agents without counting them**. Past its 12-line cap the widget hands out a line budget — running agents first, then the one-line `N queued` summary, then finished agents — and increments a hidden-count for running and finished rows that don't fit. The queued summary was dropped with no counter at all, so with five running agents, one queued and one finished the widget rendered `+1 more (1 finished)`: two things hidden, one reported, and no indication anywhere that work was waiting to start. That is precisely the state the queue matters in, since it only appears once `maxConcurrent` is saturated. The queued line's row is now reserved before the running agents are laid out, so it can no longer be dropped; the trade is that one running agent may yield its two rows, and that agent *is* counted in the overflow total. Counting the queued line instead was rejected: it summarizes N agents, so counting it as one under-reports and counting it as N inflates a total denominated in agent rows. +- **`/agents` Enable/Disable now agree with the loader about `enabled: false`** (found while closing test-suite gaps). Loading parses frontmatter with a real YAML parser; the menu edited it with regex, and the two disagreed. Enable's pattern was anchored to byte 0, so it only matched when `enabled: false` was the *first* frontmatter line — the shape this extension writes, not the shape README tells users to hand-author, where `description:` comes first — leaving a hand-written agent disabled forever while reporting `Enabled `. Disable's idempotence check was byte-exact, so a trailing space defeated it and inserted a *second* `enabled: false`; duplicate keys are a YAML error, and since [#212](https://github.com/tintinweb/pi-subagents/issues/212) an unparseable agent file is skipped, so the agent vanished from `/agents` instead of showing as disabled — and would abort the next startup under `strictAgentFiles`, which gates startup only, every mid-session reload skipping whatever it is set to. Deciding whether a file is disabled now *calls* the parser rather than mirroring it — which was always a read — fixing both the spellings YAML also accepts (`False`, `FALSE`, a trailing `# comment`, a quoted key) and pi's fence scan, which ends the block at any line merely starting `---`: below a `----` line the regex read body text as frontmatter and refused to disable a *running* agent. Editing stays line-wise by design, since re-serializing a parsed document would strip the comments, key order, and quoting of a file users hand-author. That leaves removal recognizing only a lowercase bare `false`; the other spellings are detected but not yet rewritable, and now say so instead of claiming success, with `/agents → Edit` as the way through. +- **The `Agent` tool description no longer tells the orchestrator that a zero-tool agent has every built-in**. `tools: none` and a `tools:` listing only `ext:` selectors both parse to an empty built-in list, and the runtime honors that — the shipped e2e fixture pins that such an agent really is handed no `read`, `bash`, `edit`, `write`, `grep`, `find`, or `ls`. The description builder collapsed "empty list" into the same `(Tools: *)` it uses for an *absent* `tools:`, which genuinely does mean all built-ins, so the only capability statement the orchestrator sees before spawning contradicted what the agent would actually get, and work was routed to agents that could only fail at it. An omitted `tools:` still renders `*`. An empty one renders `none` only when the agent can genuinely call nothing — `isolated`, or `extensions: false` — because zero built-ins is not zero tools: `tools: none` alongside `extensions:` still surfaces every extension tool, and the shipped fixture for that case expects three of them. That configuration now renders `no built-ins, extension tools only`, since describing it as `none` would understate the agent and route work away from the only one able to do it — the same failure as the original bug, pointed the other way. The suffix has only ever described *built-in* scope and still does: extension tools are resolved when the agent runs (extensions may register asynchronously) and cannot be enumerated while the description is built, which is why `tools: "*, ext:mcp/search"` renders `*` and always has. The same absent-vs-empty conflation is corrected in the eject serializer, which wrote `tools: all` for an empty list; that path is not reachable from the menu today, since eject is offered only for built-in defaults and none ships with an empty tool list, so it is hardening rather than a fix. + +## [0.15.1] - 2026-08-13 + +### Added +- **`strictAgentFiles` — fail startup on a broken agent file instead of skipping it**. Off by default. When on, an unreadable or unparseable `agents/*.md` aborts extension load and names the file, so a checked-in `.pi/agents/` can't silently fall through to a different agent. Startup only: mid-session reloads (one per `Agent` call) keep warning, since a bad edit shouldn't kill the session on an unrelated spawn. Settable in `subagents.json` or via `/agents → Settings → Strict agent files`. + +### Fixed +- **A `persist_session` subagent's session now records the session that spawned it** ([#205](https://github.com/tintinweb/pi-subagents/pull/205) — thanks [@0xbentang](https://github.com/0xbentang)). `SessionManager.create(...)` was called without pi's `NewSessionOptions`, so a persisted subagent wrote a session header with no `parentSession` — indistinguishable from one started by hand. pi's session picker builds its tree from exactly that field, so every persisted subagent appeared as its own top-level root: a plan → review → implement orchestration left a flat run of sessions with nothing tying them to the session that produced them, and no way to tell them apart but their first message. The spawning session's file path is now passed through, so those sessions nest under their parent in the `/resume` picker. Grouping still needs both sessions in the same listing — pi lists per session directory, derived from the cwd unless one is named — so a `session_dir` override, or an `isolation: worktree` agent running in the copy's cwd, lands in a different directory and keeps showing as a root; the header link is written either way and travels with the file. A spawning session with no file behind it (any in-memory session — the default for a subagent) leaves the field unset, as before. +- **A subagent that never starts now fails the tool call instead of reporting success** ([#179](https://github.com/tintinweb/pi-subagents/issues/179) — thanks [@xz-dev](https://github.com/xz-dev)). A strict `isolation: "worktree"` spawn in a directory that is not a git repo, has no commits, or where `git worktree add` fails throws before any child session exists — and the `Agent` tool caught that throw and returned the diagnostic as ordinary result text. Pi marks a tool result failed only when `execute` throws; an `isError` flag on a *returned* result is discarded. The parent model therefore saw `isError: false` and read the message as a subagent that had run and reported this, then retried the same doomed call. Both spawn paths now let the throw out. Nothing else changes: a subagent whose *run* fails, or which is aborted or stopped, still settles on its record and keeps its existing result text and status note — the only calls that now fail are the ones where no agent was ever created. The foreground spinner and widget cleanup moved into a `finally` so they cannot be skipped by an early return. +- **An `Agent` call refused before it ran no longer reports a turn limit that never happened** ([#199](https://github.com/tintinweb/pi-subagents/issues/199) — thanks [@Tonours](https://github.com/Tonours), fixed in [#195](https://github.com/tintinweb/pi-subagents/pull/195) by [@xz-dev](https://github.com/xz-dev)). Pi reports every pre-execution failure — an extension `tool_call` hook returning `{ block: true }`, an abort, a schema violation — as `{ content: [reason], details: {} }` with `isError` set. The result renderer's guard tested only for *absent* `details`, and `{}` is truthy, so those results walked past every status branch into the tail and printed `✗ Aborted (max turns exceeded)`. The operator debugged a phantom turn limit while the real reason went only to the model, which alone received the tool result text. A result carrying no status now renders that reason verbatim, and the turn-limit wording is reached only by a genuinely `aborted` run instead of serving as the catch-all for anything unrecognised. +- **`isolation: "worktree"` agents are told to stay in the worktree** ([#187](https://github.com/tintinweb/pi-subagents/issues/187) — thanks [@cad0p](https://github.com/cad0p)). The agent was told its working directory twice — the copy, and the main checkout named by the inherited parent prompt or by the task prompt — and followed the latter, editing and committing in the shared tree. Worktree spawns now carry a `` block naming the copy as theirs and the parent as off-limits. A prompt directive, not enforcement: local trials went from 5/5 escapes to 1/7, so `isolation` is still not a hard sandbox. +- **One malformed agent file no longer takes pi down with it** ([#212](https://github.com/tintinweb/pi-subagents/issues/212) — thanks [@daromaj](https://github.com/daromaj)). A YAML error in any discovered `agents/*.md` aborted extension activation: pi exited 1 before the TUI, naming no file. Unreadable and unparseable files are now skipped, with a warning naming the file and the error. If the skipped file was overriding a same-named agent, a second line names the file that loads in its place. + +## [0.15.0] - 2026-08-10 + +### Added +- **`fallbackSubagent` — choose what happens when `subagent_type` doesn't resolve, including failing closed** ([#183](https://github.com/tintinweb/pi-subagents/issues/183) — thanks [@0xbentang](https://github.com/0xbentang)). Dispatch previously repaired every unresolvable type by substituting `general-purpose`, so a typo silently ran a different agent, model and tool policy — and for a background or scheduled call, execution began before the caller saw any indication. The new project setting names the substitute instead of assuming one: omitted keeps today's `general-purpose` behavior, any enabled agent name routes unresolvable calls there, and `none` (or the boolean `false`, which would otherwise be dropped as the wrong type and silently leave the fallback in place) disables it entirely so dispatch fails closed with an error listing the available types. Resolution now happens at one point shared by every caller-supplied spawn — the `Agent` tool, the scheduler at fire time, cross-extension RPC, and nested delegation — so a refused type never reaches `runAgent`, where the old three-tier fallback in `getConfig` would have substituted an agent anyway. Case-ambiguous names (two enabled agents differing only in case, reachable because agents are keyed by filename across three directories) are treated as unresolvable rather than resolved to whichever was registered first. The fallback note is emitted at resolution rather than after the run, so the background and schedule paths carry it too, and a scheduled job now stores the type that was actually requested instead of the substitute. Nested delegation keeps rejecting unconditionally whatever this is set to, so a project-level fallback cannot hand a nested caller an agent outside its allowlist. An explicitly configured fallback that is itself unknown or disabled is reported as the misconfiguration it is. +- **Opt-in nested subagent delegation — a subagent can delegate to child agents of its own** ([#164](https://github.com/tintinweb/pi-subagents/pull/164) — thanks [@codesoda](https://github.com/codesoda)). Default-off and per-agent: a custom agent receives scoped `Agent`, `get_subagent_result`, and `steer_subagent` tools only when its frontmatter sets `allowed_subagents` (a comma-separated type list, or `all` — `true` and `"*"` are accepted the way `extensions:`/`skills:` take booleans), and never when it is `isolated`. The allowlist is a privilege boundary rather than a routing hint: a child runs with its own `tools:`, `extensions:`, and `isolated:`, so delegating grants the parent the union of what the listed agents can do — `all` reaches `general-purpose`. Nesting is depth-capped from the main session — main is 0, its subagents 1, their children 2 — through `maxSubagentDepth` in `subagents.json` (default `2`; `0` or `1` disables nesting project-wide) or `/agents → Settings → Nested depth`. A per-agent cap is deliberately not exposed yet: at the default project cap it could only restate what omitting `allowed_subagents` already says, and a tighten-only field is safe to add once someone raises that cap. An agent already at the cap is given no nested tools at all, rather than tools whose every call would fail. Everything a parent can do is ownership-scoped — it can fetch, resume, and steer only its own children — and unknown, disabled, or out-of-list types are refused rather than falling back to general-purpose. Nested spawns resolve agents from a registry built for their own config root (never the process-global registry the main session uses) and revalidate `model:` against Model Scope exactly like a top-level spawn. Nested records stay internal to their parent, absent from top-level tools, lifecycle events, and the agent UI, and are stopped when that parent finishes, is stopped, or ends a resumed turn — a hidden child must not outlive the only agent able to reach it. They still write their own `.output` transcript under the root session's directory (same `output_transcript` gate), so a nested run remains inspectable, and their token usage is folded into every ancestor's totals so nested spend stays attributable at any depth, and a nested result that ends `stopped`, `aborted`, or `steered` is labelled partial, the same guarantee top-level results carry. Nested children do not occupy `maxConcurrent` slots: their parent already holds one, and queueing them behind it would deadlock a parent waiting on its own child; the depth cap bounds how deep nesting goes, not how wide, so a `max_turns` on the delegating agent is what ceilings its fan-out. A subagent session no longer activates this extension at all — that is what keeps a child from building a second agent manager, and it is why the nested tools are injected directly instead; a subagent consequently also gets no `/agents` command, no cross-extension RPC handlers, and no `subagents:ready` event. + +### Fixed +- **A disabled agent is no longer dispatchable, and no longer runs with a mixed identity** (found while implementing [#183](https://github.com/tintinweb/pi-subagents/issues/183)). The `Agent` tool resolved types with `resolveType()`, which reports a canonical name without checking `enabled` — so an agent disabled via `enabled: false` or `/agents → Disable` resolved successfully, produced no fallback note, and then split downstream: `getAgentConfig` returned the disabled agent's config to build the system prompt while `getConfig` returned general-purpose for tools, extensions, skills and prompt mode. The run therefore carried the disabled agent's instructions with a different agent's tool policy. Dispatch now requires the type to resolve to exactly one *enabled* agent, so a disabled type takes the fallback path — or is refused outright under `fallbackSubagent: none`. +- **A foreground agent result now says its output is complete, not partial — it is the *task* whose completion is in question** ([#174](https://github.com/tintinweb/pi-subagents/issues/174) — thanks [@killianMei](https://github.com/killianMei)). A foreground run that ended early was annotated `output may be partial`, which reads as *truncated, go retrieve the rest*. It isn't: a foreground result already carries the agent's entire output inline, and its parent has no agent id to retrieve anything with — the id travels in the tool result's renderer `details`, which is never serialized to the model, and only the background path prints `Agent ID:` in text. A parent acting on that reading therefore passed an id it had invented and got back `Agent not found: "". It may have been cleaned up.`, which looks exactly like the record was evicted. It wasn't: foreground completion mutates the record in place, and nothing removes it outside the 10-minute cleanup timer (which keys on `completedAt`, so a long run doesn't age it), `clearCompleted()` on a session start/switch, or shutdown — none of which the report hit. Foreground results now carry their own outcome note stating that everything the agent produced is above and that it is the *task* whose completion is in question — hedged for a `steered` run, which was told to wrap up and did, so it may well have finished at the limit, and stated outright for `aborted` and `stopped`, neither of which ever delivered a final answer; the background notification and `get_subagent_result` keep the existing note, because their caller holds a 500-char preview and a real id, where the same sentence would be false. Every clause is a statement about state rather than an instruction to act: nothing here can measure whether a wording change improves parent behavior, so removing a false cue (which cannot induce new behavior) and adding an instruction (which can) are not equally safe — and an instruction like "ask before restarting" would also presume a user is present to ask, which is false under `pi -p`, in scheduled jobs, and in background-driven runs. Regression tests pin the record-retention contract, the absence of an id in foreground result text, and that a subagent session's own activation lifecycle can't evict the parent's records. + +## [0.14.3] - 2026-07-23 + +### Fixed +- **Subagents can use extension tools that register asynchronously, e.g. over MCP** ([#141](https://github.com/tintinweb/pi-subagents/pull/141) — thanks [@philipmw](https://github.com/philipmw)). MCP-backed extensions usually can't enumerate their tools until their servers connect, so they call `registerTool` from `session_start` (pi-mcp) or `before_agent_start` (context-mode) rather than at load — eagerly connecting during extension discovery would orphan child processes on pi's non-agent code paths (`--help`, config, trust probing). The agent runner, however, snapshotted `extension.tools` into a static `tools:` allowlist immediately after `loader.reload()` and *before* `bindExtensions()` fired `session_start`. Because pi's `allowedToolNames` gates tool **registration** — not merely the initial active set — a name missing from that snapshot was dropped permanently, so those tools never reached a subagent even though the main agent had them. The runner now leaves the allowlist unset so pi's live gate admits tools whenever they register, and expresses the name-stable part of the scope (this extension's own orchestration tools, built-ins the agent didn't ask for, `disallowed_tools:`) as a denylist that pi re-applies on every registry refresh. `extensions: false` / `isolated: true` keep the static allowlist unchanged — nothing can register asynchronously there, and a hard registry gate is the right boundary. +- **`ext:` selectors keep narrowing correctly when tools register late** (fixes [#125](https://github.com/tintinweb/pi-subagents/issues/125)). Admitting late-registered tools is only half the problem: an `ext:` selector is an *allowlist*, and a not-yet-registered tool cannot be listed in one — which is why `tools: "*, ext:context-mode/ctx_execute"`, the exact configuration reported in #125, still saw nothing. `ext:` narrowing is now enforced on the **active** tool set (what the model actually sees) rather than the registry, re-derived from the loader's live extension maps — the same maps `registerTool` writes into — so a tool is judged against the selectors whenever it appears. Scope is re-applied on every `turn_end`, and the hooks live on the session rather than the spawning call, so steered and resumed turns stay scoped too. Turn 1 is guarded at call time instead: `before_agent_start` fires *inside* `prompt()` and can widen the tool set after that turn's tools are already snapshotted, leaving no window to narrow in, so an out-of-scope call there is refused rather than executed. Selecting a lazy extension (`ext:mcp`) now surfaces its tools; leaving one out still mutes it, no matter when it registers. Vetoing a call means wrapping the `beforeToolCall` hook pi installs on the session (pi exposes the veto to *extensions* as `pi.on("tool_call")`, but there is no equivalent for an SDK caller constructing a child session); the wrapper chains to pi's own hook so extension `tool_call` handlers still fire, and a `pi@latest` CI job guards that the hook stays reachable, so a future pi that moves it surfaces as a test failure rather than a silently missing veto. +- **FleetView selection markers now match the rest of the UI** ([#155](https://github.com/tintinweb/pi-subagents/pull/155) — thanks [@xz-dev](https://github.com/xz-dev), closes [#122](https://github.com/tintinweb/pi-subagents/issues/122)). FleetView was the only view using `⏺` (U+23FA) / `◯` (U+25EF) for its selected/unselected rows; everywhere else — the conversation viewer and agent widget — uses `●` / `○` (U+25CF/U+25CB), the base geometric circles with the broadest terminal-font coverage (`◯` renders visibly oversized in many fonts). Selection is already carried by the accent-vs-dim color, so the exotic glyphs added nothing. FleetView now uses `●` selected / `○` unselected, converging on the codebase's existing pair. +- **`get_subagent_result(wait: true)` is now cancellable** ([#159](https://github.com/tintinweb/pi-subagents/pull/159) — thanks [@exoulster](https://github.com/exoulster), fixes [#158](https://github.com/tintinweb/pi-subagents/issues/158)). The tool received the tool-call `AbortSignal` but ignored it, so pressing `Esc` during a wait couldn't cancel it — the call stayed blocked until the child finished, and pi could log a spurious `No running process to background.`. Cancelling now stops **only** the wait: the background agent keeps running, its result stays unconsumed, and its normal completion notification still arrives. Queued waits are abortable the same way. Because the wait no longer pre-marks the result consumed (a cancelled wait never consumes it), a successful wait still suppresses its own redundant notification by cancelling the held nudge on completion — the queued-wait poll interval was tightened so that cancellation reliably lands inside the notification hold window. +- **User-scope agent memory honors `PI_CODING_AGENT_DIR`** ([#166](https://github.com/tintinweb/pi-subagents/pull/166) — thanks [@biowaffeln](https://github.com/biowaffeln)). The `user` memory scope hardcoded `~/.pi/agent-memory/`, ignoring `PI_CODING_AGENT_DIR` even though every other config consumer (custom agents, enabled models, settings, skills) already routes through `getAgentDir()` — so anyone who relocated their config dir got a stray `~/.pi` resurrected the first time a `memory: user` agent ran. User-scope memory now resolves to `/agent-memory/` (default `~/.pi/agent/agent-memory/`). Existing memories aren't orphaned: when the legacy `~/.pi/agent-memory/` directory exists and the new location doesn't, it keeps being used — symlinked legacy dirs are ignored, consistent with the file's other symlink defenses — and once the new location exists it wins. Lazy fallback rather than auto-migration, so no files are moved. + +## [0.14.2] - 2026-07-17 + +### Added +- **`output_transcript` frontmatter + `outputTranscript` project setting — opt out of a subagent's `.output` transcript** ([#146](https://github.com/tintinweb/pi-subagents/pull/146) — thanks [@Thoughts-One](https://github.com/Thoughts-One)). Every subagent streams its full conversation to a per-subagent JSON-lines transcript under the OS temp dir (`/pi-subagents-/…/.output`, owner-only `0700`, cleared on reboot); until now that write was unconditional. Set `output_transcript: false` on a custom agent to write no transcript file or path for it, or `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (a custom agent's frontmatter overrides the project default). Useful when run transcripts shouldn't sit on disk for backup or DLP tooling to ingest. Scope is deliberately narrow — it governs only the `.output` transcript, not the persisted pi session (`persist_session`), worktree commits (`isolation: worktree`), or memory files — so keeping a run fully off disk means setting those too. Default is unchanged: with neither flag set, transcripts are written exactly as before. The write decision is centralized on `record.outputFile`, so every downstream consumer (streaming, notifications, the transcript footer) keys off a single gate. + +### Fixed +- **Bordered conversation-viewer rows stay exact-width at double-width truncation boundaries** ([#153](https://github.com/tintinweb/pi-subagents/pull/153) — thanks [@xz-dev](https://github.com/xz-dev)). The conversation viewer pre-pads each row to the inner width and truncates it to fit between the `│` borders, but `truncateToWidth` was called without its `pad` flag — so when a truncation boundary fell mid-way through a double-width character (CJK, wide emoji), the result came back one column short and the right border shifted left by a column on that row. The row builder now truncates with padding on, restoring the trailing column, so every bordered row renders at exactly the box width regardless of where a wide glyph lands. A regression test sweeps a double-width character across every truncation boundary and asserts each rendered line is exactly the requested width. +- **Isolated subagents keep extension-registered custom providers on pi 0.80.8+** (fixes [#151](https://github.com/tintinweb/pi-subagents/issues/151) via [#152](https://github.com/tintinweb/pi-subagents/pull/152) — thanks [@0xbentang](https://github.com/0xbentang)). pi 0.80.8 replaced `createAgentSession`'s `modelRegistry` option with `modelRuntime`, and the agent runner still passed the now-ignored `modelRegistry` — so pi built a *fresh* model runtime from disk for the child session. Because isolation also disables extension loading, that fresh runtime had neither the extension-registered custom provider nor its auth: an `isolated: true` agent pinned to such a provider failed preflight with `No API key found for `, while the same agent with `isolated: false` — or on any pi <0.80.8 — worked. The runner now forwards the parent session's `ModelRuntime` (read off the `ModelRegistry` facade on `ctx.modelRegistry`) as `modelRuntime` when the running pi exposes one, and still passes `modelRegistry` for the pre-0.80.8 range — so the fix spans the whole `>=0.80.0` peer range without changing it: older pi omits the new field and takes the legacy path, 0.80.8+ inherits the parent runtime. Extensions and tools stay isolated exactly as before — only the model providers and their auth are inherited. A `pi@latest` CI job guards the reachability of that runtime accessor (a `private` field the fix reaches through), so a future pi that renames or hides it surfaces as a test failure instead of a silent return of this bug. + +## [0.14.1] - 2026-07-14 + +### Added +- **`max` thinking level is now advertised in the frontmatter/tool/wizard choices** ([#147](https://github.com/tintinweb/pi-subagents/issues/147) — thanks [@justin-ramirez-gametime](https://github.com/justin-ramirez-gametime)). pi 0.80 added `max` to its `ThinkingLevel`, and the extension already forwarded the value unchanged, but the Agent tool description, generated-agent template, `/agents` creation wizard, and README all stopped at `xhigh` — hiding a valid capability. Those four surfaces now come from one shared list so they can't drift behind pi again. Actual availability still depends on the host pi version and the selected model; pi clamps unsupported levels down. + +### Fixed +- **Runs whose final assistant turn failed are now reported as `error`, not `completed` with an empty result** (fixes [#144](https://github.com/tintinweb/pi-subagents/issues/144) — thanks [@possibilities](https://github.com/possibilities) for the diagnosis). pi resolves an exhausted-retries provider failure normally — the final assistant message carries `stopReason: "error"` plus an `errorMessage`, no rejection — so the manager mapped such runs to `completed` and every consumer (foreground tool result, `get_subagent_result`, background notifications, resume, scheduler, RPC events) saw a clean success reading `No output.` — or worse, an *earlier* turn's text presented as the fresh answer, since the history fallback walks past empty messages. The runner now inspects the final assistant message and reports a failure in two cases: the turn stopped with `stopReason "error"`, or it hit the output-token ceiling (`stopReason "length"`) having produced no text at all — a silent max-token death that reproduced the same empty-`No output.` symptom. Status still derives from how the final turn stopped, never from whether earlier turns produced text: empty-but-clean finals (tool-call-only or thinking-only endings) stay `completed`, a `length` stop that *did* produce text is a legitimate truncated answer and stays `completed`, partial-text provider errors are still failures, and the walk-back fallback keeps preserving partial output for aborted/steered runs. (An `aborted` final turn needs no special-casing here — the manager's hard-abort flag and `stopped` guard already surface it as `aborted`/`stopped`.) The response-text collector also no longer resets on user/tool-result `message_start` events (it tracked the last *message*, not the last *assistant* message). A failed run still surfaces any output it *did* produce: the tool result shows `Agent failed: ` followed by that text under a `Partial output before the failure:` label — and the history fallback is now bounded to the current invocation, so a failed **resume** no longer returns the *previous* turn's answer as this run's result (it returns empty). **Behavior change:** runs that previously ended `completed` with an empty result now end `error` carrying the provider message, and `subagents:failed` fires where `subagents:completed` did; scheduled jobs record `lastStatus "error"` for them. +- **A subagent can allowlist a package-installed extension by its package name, not just its source directory** (fixes [#143](https://github.com/tintinweb/pi-subagents/issues/143) — thanks [@possibilities](https://github.com/possibilities)). Our extension-scoping (`extensions:` / `exclude_extensions:` / `tools: ext:…`) named an extension from its file path, and for an `index.ts` entry it used the parent directory — so a package installed via `pi.extensions: ["./src/index.ts"]` (like this one) only ever matched as `src`, an unstable, collision-prone name that no user would guess; `extensions: [pi-subagents]` silently matched nothing. An extension now also answers to its package's unscoped short name (`@tintinweb/pi-subagents` → `pi-subagents`), read from the nearest `package.json` whose `pi.extensions` manifest actually declares that entry. This is an **added alias** — the path-derived name keeps working, so nothing that already matched `src` breaks — and it fires only for genuinely manifest-declared package entries, so a loose extension is never misattributed to a co-located project's name. +- **A pi-subagents activation that a child session filtered out no longer advertises or answers cross-extension RPC** (fixes [#142](https://github.com/tintinweb/pi-subagents/issues/142) — thanks [@possibilities](https://github.com/possibilities)). pi runs every extension factory *before* applying an agent's `extensions:` filter and only delivers lifecycle events (`session_start`, …) to the survivors, but the `pi.events` bus is shared with the filtered-out activations too. Because we registered the RPC handlers and emitted `subagents:ready` at factory time, a child agent whose `extensions:` omitted pi-subagents still saw a `subagents:ready` broadcast and a successful `subagents:rpc:ping`, yet every `subagents:rpc:spawn` failed with `No active session` — its `session_start` never fired, so the spawn handler had no context. The RPC handler registration and the `subagents:ready` broadcast now happen on the first bound `session_start` instead of at factory time, so a session that excludes pi-subagents stays completely silent on the RPC channels — behaving like a session where it was never installed, rather than advertising a spawn service it can't provide. Emitting readiness after all factories have loaded also closes a latent race where a consumer whose factory ran after ours could miss the event. No change for sessions that do load pi-subagents: `subagents:ready` still fires and RPC still works, just at `session_start`. + +## [0.14.0] - 2026-07-13 + +### Added +- **Project custom agents are also discovered from `.agents/agents/.md`** ([#133](https://github.com/tintinweb/pi-subagents/pull/133) — thanks [@wenerme](https://github.com/wenerme); closes [#132](https://github.com/tintinweb/pi-subagents/issues/132)). Projects that keep their agent assets in the shared cross-tool `.agents` workspace (the same convention this extension already reads for `.agents/skills/`) can now define subagents there instead of duplicating files into `.pi/agents/`. Discovery precedence is `global < .agents/agents < .pi/agents`: on a name clash between the two project locations, **`.pi/agents/` wins** — `.pi` remains the project authority, and the `/agents` create/eject/disable flows keep writing there; `.agents/agents/` is a read location only. + +### Changed +- **BREAKING: Dev/test toolchain now tracks pi 0.80.x; pi peer floor raised to `>=0.80.0`** (as diagnosed by [@philipmw](https://github.com/philipmw) in [#129](https://github.com/tintinweb/pi-subagents/pull/129)). The committed lockfile had pinned the `@earendil-works/pi-*` peers to 0.75.5 (the latest published when it was generated), so tests and typecheck exercised an older API surface than the pi that actually runs the extension — including 22 tests that silently never ran on 0.75.5 and now do (suite skip count 26 → 4 between the two lockfiles). The lockfile now resolves the peers to 0.80.6, and the test suite imports pi-ai's relocated faux/model helpers (`registerFauxProvider`, `getModel` — `/compat`-only since pi-ai 0.80) through a single re-export module, `test/helpers/pi-ai.ts`, so the next relocation touches one file. Because the test surface no longer resolves on ≤0.75.x, `peerDependencies` move from `>=0.74.0` to `>=0.80.0` to match what is actually tested. Migration: installs under pi <0.80 may emit unmet-peer warnings (or fail under strict peer resolution such as `npm --strict-peer-deps` / pnpm defaults) — upgrade pi to ≥0.80; runtime behavior itself is unchanged on any pi version, since pi substitutes its own bundled modules for extension imports at load time. Live-mode e2e (`PI_E2E_LIVE=1`) now fails fast with a clear error when the pinned `PI_PROVIDER`/`PI_MODEL` isn't in pi-ai's builtin catalog, instead of silently letting the session resolve a different model. + +### Fixed +- **Output-file streaming survives session compaction** (fixes [#145](https://github.com/tintinweb/pi-subagents/issues/145) — thanks [@possibilities](https://github.com/possibilities) for the diagnosis). Compaction replaces `session.messages` with a shorter, summarized array, which stranded the streamer's write index past the new end — the flush loop never matched again and the agent's output file silently froze for the rest of the run, exactly on the long background runs that auto-compact. The streamer now flushes any not-yet-written tail when compaction starts and re-anchors its index to the rebuilt array after a successful compaction — deferred one microtask, because on the overflow-retry path pi trims the trailing error assistant message *after* emitting `compaction_end`, and a synchronous anchor would skip the first post-compaction message. Aborted and failed compactions leave the session untouched and change nothing. Verified against a real pi `AgentSession` driving a real `session.compact()` in the new e2e regression. +- **FleetView no longer steals arrow/Enter/Esc keys from other interactive components** (fixes [#123](https://github.com/tintinweb/pi-subagents/issues/123) — thanks [@TommyC81](https://github.com/TommyC81) for the report). pi delivers terminal input to extension listeners *before* the focused component, and selector/input dialogs (`ctx.ui.select` & co.) swap the prompt editor out while `getEditorText()` still reads the detached — empty — editor. So while subagents were running, FleetView's empty-prompt gate passed and it consumed the navigation keys that belonged to whatever dialog was actually focused: other extensions' pickers (e.g. rpiv-ask-user-question), pi's own menus, and even `/agents → Settings` itself. FleetView now checks that pi's prompt editor is the focused component before touching any key (pi's editor is an `Editor` subclass; every dialog is not), and an in-progress list navigation is dropped the moment something else takes focus. Unknowable focus errs toward the editor, so list activation keeps working; non-`Editor` custom editor components simply flow keys through untouched. +- **Widget and viewer lines keep their dim styling after nested color annotations** ([#136](https://github.com/tintinweb/pi-subagents/pull/136) — thanks [@xz-dev](https://github.com/xz-dev)). pi themes close a foreground color with a bare `\x1b[39m`, so the threshold-colored context-fill percent inside `formatSessionTokens` (warning at ≥70%, error at ≥85%) also terminated the surrounding dim style — the closing `)` and any following text on the widget's running-agent line and the conversation-viewer header bled to the terminal's default color. A small `fgPreservingNestedStyles` helper re-opens the outer color after each nested reset (derived from the theme itself, so it stays theme-agnostic and is a no-op under `NO_COLOR`). +- **`get_subagent_result` with `wait: true` now waits for queued agents** ([#127](https://github.com/tintinweb/pi-subagents/pull/127) — thanks [@benrhodeland](https://github.com/benrhodeland)). A background agent past the concurrency ceiling sits in the queue with no run promise yet, so the wait path (gated on `status === "running" && record.promise`) skipped it and returned `No output.` immediately — the orchestrator read a queued agent as "finished with nothing". The wait now also covers `queued`: it polls until the queue starts (or stops) the agent, then awaits the run like any running agent. Most visible with parallel background spawns in one message, where spawns beyond `maxConcurrent` (default 4) queue. +- **Subagent activations no longer clobber the `Symbol.for("pi-subagents:manager")` registry** ([#128](https://github.com/tintinweb/pi-subagents/pull/128) — thanks [@benrhodeland](https://github.com/benrhodeland)). Child sessions re-activate the extension in the same process (`session.bindExtensions` in the agent runner), and every activation overwrote the global registry slot — pointing cross-package consumers (RPC extensions, headless hosts) at a short-lived child manager whose shutdown could then delete the root session's entry entirely. The first activation now claims the slot, child activations leave it alone, and only the owning activation releases it (identity-checked) on shutdown. + +## [0.13.0] - 2026-06-30 + +### Added +- **Steer a running agent from the conversation viewer.** The live conversation overlay (FleetView's `Enter`, or `/agents → Running agents`) now lets you redirect an agent without leaving the view: press `Enter` to open an inline composer, type a message, `Enter` to send — `Esc` or an empty submit just returns. The message is delivered through the same path as the `steer_subagent` tool (`AgentManager.steer()` → `session.steer`, or queued onto `pendingSteers` if the session isn't ready yet), so it appears as a user message and redirects the agent after its current tool execution; feedback is the message showing up in the live transcript you're already watching. The affordance is offered only while the agent is still running/queued (mirrors the `x`/stop affordance), and the viewer stays modal — every existing shortcut (`x`/`x` stop, arrows/`j`/`k` scroll, `q` close) is untouched, and `Enter` was previously inert here so nothing is overridden. The idle footer was reorganized to actions-left / navigation-right so the full scroll-key hint (`↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close`) stays fully visible down to 80-column terminals; the `N lines · %` readout returns on the left whenever there's spare width. +- **Forgiving model resolution for agent `model:` pins.** `resolveModel` now tolerates cosmetic id variations and falls back across providers, so a qualified or date-pinned config resolves widely instead of silently dropping to the parent model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`); a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id); and a `provider/modelId` that isn't available under the named provider retries the bare id against every provider (the named provider is still preferred when present). An exact match still wins over a tolerant one, so dated snapshots aren't conflated — the precedence is exact → fuzzy-under-named-provider → same model under any provider → unavailable. +- **`/agents → Agent types` shows each agent's full description and what its model resolves to.** The list now renders with `SettingsList` (like the Settings menu) instead of a flat selector: the highlighted agent's full description shows on its own line below the list, so long descriptions no longer wrap and push the agent names out of alignment. The model column shows the configured model, flags it `(unavailable, fallback: inherit)` when it can't be resolved against the registry (it would silently inherit the parent model at runtime), and surfaces the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured. +- **`widgetMode` setting — control what the above-editor widget shows: `all` / `background` / `off`** ([#117](https://github.com/tintinweb/pi-subagents/pull/117) — thanks [@Alan-TheGentleman](https://github.com/Alan-TheGentleman); fixes [#118](https://github.com/tintinweb/pi-subagents/issues/118)). Foreground agents already render inline as the `Agent` tool result, so also listing them in the persistent widget double-rendered the same run (most visible in tmux/zellij). `widgetMode` (via `/agents → Settings → Widget`, or `subagents.json`) selects the widget's contents: `all` shows every agent (the previous behavior), `background` shows background/queued/scheduled/RPC runs but hides foreground, and `off` hides the widget entirely (agents still appear inline and in FleetView). Applied live — toggling refreshes immediately. Filtering keys off a new tri-state `AgentRecord.isBackground` captured at spawn (`true` = background, `false` = foreground, `undefined` = undeclared, e.g. a cross-extension RPC spawn), independent of the UI-only `invocation` snapshot — so scheduler- and RPC-spawned background agents stay visible instead of vanishing; only runs *known* to be foreground are dropped. The running-status line was also refactored from one multiline `Text` into two component rows, so rapid partial updates replace cleanly instead of leaving stale rows behind in terminal multiplexers — identical on-screen output, no more ghost lines. + +### Changed +- **The built-in `Explore` agent's model pin is now `anthropic/claude-haiku-4-5`** (was `…-4-5-20251001`). The stale date stamp is dropped to match the `anthropic/claude-sonnet-4-6` / `anthropic/claude-opus-4-6` convention used by the create-agent wizard; combined with the forgiving resolution above, `Explore` now picks up its fast/cheap model across registry variations (dated, undated, dotted, or under a non-`anthropic` provider) instead of silently falling back to the parent model. +- **The above-editor widget now hides foreground agents by default** (`widgetMode` defaults to `background`). Foreground runs still render inline as the `Agent` tool result (and in FleetView); set `/agents → Settings → Widget` to `all` to restore the previous show-everything view, or `off` to hide the widget. Existing `subagents.json` files load unchanged (absent → `background`). + +## [0.12.0] - 2026-06-24 + +### Added +- **FleetView — a Claude Code-style subagent navigator below the editor.** A persistent, navigable list of `main` + every active subagent renders beneath the editor whenever agents are running — **auto-shown, no keypress needed** — mirroring Claude Code's bottom fleet bar: `⏺`/`◯` selection markers, agent type + description, right-aligned `elapsed · ↓ tokens`, and a `↓ N more` overflow once past five rows. Press `↓` (or `←`) at an **empty prompt** to move focus into the list, `↑`/`↓` to select, `Enter` to open the selected agent's live, auto-updating conversation overlay, and `Esc` (or `↑` above `main`) to return to the prompt. Implemented as a `belowEditor` widget with all key handling routed through `onTerminalInput` (which fires before the editor); it only captures arrow keys at an empty prompt — and acts on key-**press** only (kitty-protocol release events are ignored, otherwise each tap moved twice) — so typing, history, and cursor movement are untouched. Rows are ordered **earliest-launched first**; only openable agents (those with a session) are shown, so pending/queued agents appear once they start and `Enter` never dead-ends; **finished agents linger ~4s** before dropping out (their elapsed freezes at completion); and a viewer **stays open through its agent's completion** so the final output remains readable. Selection follows the viewed agent by id, so closing a viewer returns you to the same agent even if the list reordered while it was open. Every rendered line is width-clamped — the narrow-terminal crash/flicker class previously fixed in v0.2.7 and [#7](https://github.com/tintinweb/pi-subagents/issues/7). Toggle via `/agents → Settings → Fleet view` (default on; pure-UI, so no LLM-context cost). + +## [0.11.0] - 2026-06-23 + +### Added +- **`persist_session` / `session_dir` agent frontmatter — persist a subagent as a real pi session** ([#111](https://github.com/tintinweb/pi-subagents/pull/111) — thanks [@codesoda](https://github.com/codesoda)). `persist_session: true` runs the subagent through `SessionManager.create(...)` instead of `SessionManager.inMemory(...)`, so its full transcript is written to pi's normal session location (`~/.pi/agent/sessions`) — inspectable and resumable after the fact, like a top-level session — rather than living in memory only. Useful for long-running, multi-round orchestrations (plan → review → implement → verify) where each subagent's conversation is worth keeping. `session_dir` optionally overrides where the persisted session is written (absolute, `~`, or agent-cwd-relative path); omitted, persistence follows pi's own precedence — `PI_CODING_AGENT_SESSION_DIR`, then the settings manager's `getSessionDir()`, then pi's default location. Both default off/unset, so existing agents are unchanged: the in-memory path is byte-identical to before, and the sidechain `.output` transcript is still written either way. + +## [0.10.4] - 2026-06-23 + +### Fixed +- **Background agent records lost before result is read** ([#108](https://github.com/tintinweb/pi-subagents/issues/108) — thanks [@philipmw](https://github.com/philipmw)). On session switch or `/new`/`/resume`, `clearCompleted()` removed completed agent records regardless of whether the LLM had retrieved the result, causing `get_subagent_result` to return "Agent not found" for agents that had finished but hadn't been checked yet. `clearCompleted()` now accepts a `skipUnconsumed` flag; session event handlers pass `true`, so records with `resultConsumed=false` are preserved across session transitions. The 10-minute cleanup timer handles eventual eviction. Note: a full session shutdown (`session_shutdown`) calls `dispose()` which clears all records unconditionally — that path is not affected by this fix. + +### Added +- **Foreground agent lifecycle completion and conversation logging** ([#105](https://github.com/tintinweb/pi-subagents/pull/105) — thanks [@benrhodeland](https://github.com/benrhodeland)). Two gaps closed: (1) **`onComplete` now fires for foreground agents**, emitting `subagents:completed` / `subagents:failed` lifecycle events and writing a `subagents:record` entry to the parent JSONL — previously only background agents emitted these, leaving cross-extension observers with an orphaned `subagents:started` event and no matching completion. `resultConsumed` is pre-set so the callback skips notifications (the result is returned inline); no change to the tool's return value. (2) **Foreground agent conversations are now streamed to `.output` files** (same `.pi/output/agent-.jsonl` path as background agents) — inline subagent transcripts were previously permanently lost after `spawnAndWait` returned. + +## [0.10.3] - 2026-06-12 + +### Added +- **`SpawnOptions.cwd` — spawn a subagent in a different working directory** ([#96](https://github.com/tintinweb/pi-subagents/issues/96) — thanks [@madeleineostoja](https://github.com/madeleineostoja)). For RPC/programmatic callers (not exposed on the `Agent` tool — the LLM-visible surface is unchanged). The agent's tools operate in the target directory and the prompt's environment block describes it, but **`.pi` config keeps loading from the parent session's project** (new `RunOptions.configCwd` split): the target's `.pi` extensions never execute, and its agents/skills/settings/memory are not picked up — spawning into an untrusted directory sends a worker there with the parent's toolbox, rather than "opening pi there." Composes with `isolation: "worktree"`: the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd keeps its scoping instead of silently widening to the repo root — new `WorktreeInfo.workPath`), and the resulting `pi-agent-*` branch lands in that repo, with the completion message naming it so the orchestrator merges in the right place. Validation is strict, typed, and early — non-strings, relative paths, nonexistent paths, and files all throw curated errors at `spawn()` (before queueing) and are re-checked at queue drain, surfacing as RPC error envelopes (`null` is treated as unset). On dispose, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). + +## [0.10.2] - 2026-06-10 + +### Added +- **`exclude_extensions:` agent frontmatter — extension denylist for subagents** ([#94](https://github.com/tintinweb/pi-subagents/issues/94) — thanks [@ramhaidar](https://github.com/ramhaidar)). Applied after the `extensions:` include set; exclude wins, including over `tools: ext:` selectors (an excluded extension never loads, so its `ext:` reference becomes the usual orphan warning). The key use case: `extensions: true` + `exclude_extensions: pi-notify` — all extensions except a noisy one, without hand-maintaining an allowlist. Plain canonical names only (case-insensitive); paths, `*`, and unmatched names fire `extension-error:…` warnings (warn-not-abort, as with `extensions:` mismatches); `extensions: false` + an exclude warns that the exclude has no effect. **Not a sandbox:** excluded extensions' factory code still executes once during loading — exclusion suppresses handler binding and tool registration, not load-time side effects. The negation syntax `extensions: ["*", "!name"]` was deliberately rejected: an unquoted `!name` is a YAML tag and silently mis-parses. +- **`toolDescriptionMode` setting — opt-in compact Agent tool description** ([#91](https://github.com/tintinweb/pi-subagents/issues/91) — thanks [@tiberiuichim](https://github.com/tiberiuichim)). The full Claude Code-style description costs ~1,400 tokens with the default agents and grows with each custom agent (the type list embeds full agent descriptions) — significant for small/local models. `toolDescriptionMode: "compact"` (via `/agents → Settings → Tool description` or `subagents.json`) swaps in a ~75% smaller description: one-line type list (first sentence of each agent description), terse usage notes, per-option details left to the parameter descriptions. Default `"full"` is byte-identical to before — the rich description's guardrails are deliberately load-bearing and stay the default. A third mode, `"custom"`, registers a user-authored description from `/.pi/agent-tool-description.md` (project) or `/agent-tool-description.md` (global; project wins), with `{{placeholder}}` substitution keeping the dynamic parts live — `{{typeList}}`, `{{compactTypeList}}`, `{{agentDir}}`, `{{scheduleGuideline}}` — so a hand-written description can't drift out of sync with the registered agents (the advertised-vs-spawnable staleness [#92](https://github.com/tintinweb/pi-subagents/issues/92) just fixed). Unknown placeholders are left verbatim with a stderr warning; a missing/empty file falls back to `"full"`. Only the prose is customizable — the parameter schema stays code-owned. A ready-made starting point ships at `examples/agent-tool-description.md`, reproducing the full description exactly (CI-enforced byte-identical, so the example can't go stale). Like `schedulingEnabled`, the mode is read at tool registration — changing it applies on the next pi session. The issue's original ask (move the description to a skill) isn't possible in pi: tools must register their description in the tool schema for the model to call them; skills are lazily-loaded instructions, not tool registrations. + +### Fixed +- **Conversation viewer honors custom `tui.select.*` keybindings** ([#99](https://github.com/tintinweb/pi-subagents/issues/99) — thanks [@owenniles](https://github.com/owenniles)). The viewer hardcoded its scroll keys and discarded the `KeybindingsManager` pi injects into `ctx.ui.custom()`, so user bindings (e.g. emacs-style `ctrl+p`/`ctrl+n` on `tui.select.up`/`down`) worked in pi core selectors but not here. Scrolling now resolves through `tui.select.up`/`down`/`pageUp`/`pageDown`; the viewer-specific `k`/`j` and `shift+arrow` aliases still work alongside, and behavior without custom bindings is unchanged (the `tui.select.*` defaults are the previously hardcoded keys). + +## [0.10.1] - 2026-06-10 + +### Added +- **`disableDefaultAgents` setting** ([#92](https://github.com/tintinweb/pi-subagents/issues/92) — thanks [@TommyC81](https://github.com/TommyC81)). When on, the three built-in default agents (general-purpose, Explore, Plan) are skipped at registration — only user-defined `.pi/agents/*.md` agents are advertised and spawnable. User agents are unaffected, including ones overriding a default by name; with no user agents defined, spawning falls back to the hardcoded generic config. Off by default; toggle via `/agents → Settings → Disable defaults` or `disableDefaultAgents` in `subagents.json`. Like `schedulingEnabled`, the Agent tool's type list reflects the change on the next pi session (tool schema is registered at startup). + +### Fixed +- **Agents with `enabled: false` are no longer advertised in the Agent tool description** ([#92](https://github.com/tintinweb/pi-subagents/issues/92)). `buildTypeListText` listed every registered agent, including disabled ones that `isValidType` then refused to spawn — the LLM was offered types it could never use. The type list now filters through `getAvailableTypes()`, matching the `subagent_type` parameter description. +- **Agent tool type list no longer built from pre-settings state.** The description text was captured into a variable before persisted settings were applied; it's now built at tool-registration time, after `subagents:settings_loaded`. +- **Committed work from `isolation: "worktree"` subagents is now preserved** ([#68](https://github.com/tintinweb/pi-subagents/pull/68) — thanks [@rylwin](https://github.com/rylwin)). If an isolated subagent creates its own commit, cleanup previously saw a clean `git status`, treated it as "no changes", and removed the detached worktree — silently discarding the commits. The worktree now records its base SHA at creation, and cleanup creates the expected `pi-agent-*` branch whenever HEAD moved past it, even with a clean tree. +- **Automatic commits in isolated worktrees skip local Git hooks** ([#68](https://github.com/tintinweb/pi-subagents/pull/68)). The preservation commit at worktree cleanup now uses `--no-verify`, so a failing local pre-commit hook can't abort it (which previously surfaced as `hasChanges: false` — the agent's work lost). + +## [0.10.0] - 2026-06-01 + +> **⚠️ Breaking: `extensions:` and `tools:` in agent frontmatter semantics changed.** The `extensions: [...]` array now selects which extensions *load*, not which tool names surface. Agents that previously used the array form will behave differently — see migration below. The `tools:` field also grew new `ext:` and `*` selector forms; existing `tools:` values without these selectors are unchanged. +> - `extensions: [...]` is now an **extension allowlist applied at load time**, not a tool-name substring filter. Each entry is an extension *name*, a *path* (absolute, `~/`-prefixed, or relative-to-cwd), or `"*"`. **Migration:** `extensions: ["mcp"]` previously loaded *every* extension and then surfaced only tools whose names contained `mcp`. To keep all extensions, use `extensions: true` or `extensions: "*"`. To narrow, name the extensions or point at their files. `"*"` composes: `extensions: "*, /abs/path/extra-ext.ts"` is all defaults plus one path-loaded. +> - `tools:` now accepts `ext:` selectors and `*`. **Gotcha:** a `tools:` value containing **only** `ext:` entries yields **zero built-in tools** — add `*` (e.g. `tools: "*, ext:foo"`) to keep the built-ins. And **any** `ext:` entry flips extension tools to an explicit allowlist (non-listed extensions stay loaded but expose no tools). A `tools:` with no `ext:` entries is unchanged. +> - **`extensions:` is the sole loading authority.** `ext:foo` only narrows tool *exposure* within the already-loaded set; it cannot pull an extension in. `extensions: false` + `tools: "ext:foo"` loads nothing and warns that `ext:foo` is orphaned. To expose one extension's tool from an otherwise-narrow agent, name the extension explicitly: `extensions: [foo]` + `tools: "ext:foo/bar"`. + +> **⚠️ Heads-up — widget glyphs changed (visual only):** turn count now renders as `↻N` (was `⟳N`) and compaction count as `⇊N` (was `↻N`). Fix for [#84](https://github.com/tintinweb/pi-subagents/issues/84) — `⟳` overflowed its cell in common monospace fonts. **No API, behavior, or output-format changes — only the glyphs.** If you grep agent stats lines or pipe widget output through scripts, update your patterns: `⟳` → `↻` (turns), `↻` → `⇊` (compactions). + +### Added +- **`tools:` accepts `ext:` extension-tool selectors and a `*` built-in wildcard.** Entries in the `tools:` CSV are now partitioned: plain names are the built-in allowlist (unchanged); `*` expands to all built-ins (symmetric with `extensions: "*"`); `ext:foo` / `ext:foo/bar` select extension tools. **Any `ext:` entry flips extension tools to an explicit allowlist** — only tools named by an `ext:` selector reach the LLM, and extensions not named stay loaded (their `session_start` etc. handlers still fire) but expose no tools. `ext:foo` exposes all of `foo`'s tools; `ext:foo/bar` narrows `foo` to just `bar` (multiple `ext:foo/x` entries union; a bare `ext:foo` alongside `ext:foo/bar` lets narrowing win). `ext:` is **narrowing-only** — it does not load extensions. `extensions:` remains the sole loading authority; an `ext:foo` against an extension that `extensions:` excluded (including `extensions: false`) is orphaned and warns via `onToolActivity` (`extension-error:ext:foo …`). With no `ext:` entry present, extension-tool behaviour is unchanged. `ext:` is name-only (matched by canonical name, so it composes with path-loaded extensions); paths still go in `extensions:`. `isolated: true` ignores `ext:` selectors. +- **Stop a running agent from the conversation viewer.** In `/agents → Running agents`, select an agent and press `x` (then `x` again to confirm) to abort it. The two-press guard prevents an accidental kill; the footer shows `x stop` → `x again to STOP`. This works for **background** agents — which a global `Esc` can't unambiguously target — while `Esc` still stops a blocking foreground `Agent` call. Wires the existing `AgentManager.abort(id)` to the viewer (`onStop` callback); the affordance only appears while the agent is `running`/`queued`. Addresses the common "how do I stop a background subagent?" question ([#88](https://github.com/tintinweb/pi-subagents/issues/88)). + +### Changed +- **BREAKING: `extensions: [...]` in agent frontmatter is now a loader-level extension allowlist, not a tool-name filter.** Previously a `string[]` value filtered exposed *tool names* by substring (`t.startsWith(e) || t.includes(e)`) while every discovered extension still loaded and ran its handlers. Now each entry selects an *extension*: a bare name keeps the matching default-discovered extension, a path (absolute, `~/`-prefixed, or relative-to-cwd) loads that extension fresh via `additionalExtensionPaths`, and `"*"` keeps all default-discovered extensions. Entries compose — `["*", "/abs/foo.ts"]` is all defaults plus foo, `["mcp", "/abs/foo.ts"]` is just those two. Excluded extensions no longer bind handlers or register tools (their factory still runs once during `reload()`). Directory extensions (`foo/index.ts`) match by the parent directory name. **Extension names match case-insensitively** (`extensions: [Mcp]` resolves the same as `[mcp]`); tool names within `ext:foo/bar` selectors remain case-sensitive (they're matched against pi-mono's registered identifiers). Unmatched names and failed paths warn via `onToolActivity` but do not abort the subagent (see the heads-up above for migration). +- **Non-normal subagent outcomes are now stated explicitly in the text delivered to the parent**, so the orchestrator can't mistake a stopped/incomplete agent for a completed one. The foreground `Agent` result, `get_subagent_result`, and the `` summary all append a clear note for `stopped` (user abort) → `(STOPPED BY THE USER before completion — output is partial; the task was NOT finished)`, `aborted` (turn limit) → `(aborted — hit the turn limit before completion; output may be incomplete)`, and `steered` → `(wrapped up at the turn limit — output may be partial)`. `stopped` (human intervention) is kept distinct from `aborted` (turn-budget cutoff); a clean `completed` adds no note. Extracted as `getStatusNote` in `src/status-note.ts`. +- **`BUILTIN_TOOL_NAMES` is derived from pi's tool factories** (`createCodingTools` + `createReadOnlyTools`) rather than a hardcoded list, so the built-in set tracks pi-mono automatically. Internal; no behavior change (the resolved set is the same seven names). + +### Fixed +- **Turn-count glyph in the agent widget no longer overflows its monospace cell** ([#84](https://github.com/tintinweb/pi-subagents/issues/84) — thanks [@linozen](https://github.com/linozen)). `formatTurns` used `⟳` (U+27F3 CLOCKWISE GAPPED CIRCLE ARROW) from the Miscellaneous Mathematical Symbols-A block, where common monospace fonts (Iosevka Nerd Font Mono, Menlo, SF Mono, JetBrains Mono) draw the glyph visually wider than one cell despite its Neutral East Asian Width — making the next character (the digit) overlap the glyph. Replaced with `↻` (U+21BB CLOCKWISE OPEN CIRCLE ARROW) from the standard Arrows block, which renders cleanly at one cell in those fonts. To avoid colliding with the existing compaction indicator (which previously also used `↻`), the compaction glyph moves to `⇊` (U+21CA DOWNWARDS PAIRED ARROWS) — same Arrows block, also single-cell, visually distinct. Widget vocabulary now reads: `↻5≤30` for turns, `⇊2` for compactions. Pi UI consumers / scripts grepping for the glyph in stats lines must update. +- **`tools: none` now actually yields zero built-in tools.** `getToolNamesForType` treated an explicit empty `builtinToolNames` (`[]`, produced by `tools: none`) as "unspecified" and fell back to all 7 built-ins. It now distinguishes an omitted field (`undefined` → all built-ins, for default agents) from an explicit empty list (`[]` → zero), consistent with `getConfig`. Same fix makes `tools:` values containing only `ext:` selectors yield zero built-ins as documented. +- **`tools:` typos no longer silently break tool-calling** ([#75](https://github.com/tintinweb/pi-subagents/issues/75)). Two parts: (a) `all` was previously parsed as a literal tool name, producing a one-element allowlist of the non-existent tool `"all"` — the model then returned an empty response or emitted raw XML tool calls, all with `status: completed` and no error. `parseToolsField` now treats `all` (case-insensitive) as an alias for the `*` wildcard, both standalone and inside a CSV. (b) Plain entries in `tools:` are expected to be built-in names (extension tools route through `ext:`), so an unknown name there is unambiguously a typo. `runAgent` now emits a `tools-error:tool "X" requested by agent "Y" is not a known built-in` event via `onToolActivity` for each unrecognized plain entry — same surfacing channel as the existing `extension-error:` warnings. +- **Subagents with `extensions: true` now actually expose extension-registered tools (MCP, etc.)** ([#47](https://github.com/tintinweb/pi-subagents/issues/47)). `runAgent` previously passed only the built-in tool names as the `tools:` allowlist to `createAgentSession`, so pi-mono's `allowedToolNames` gate rejected every extension-registered tool at registration — `extensions: true` agents silently got only the 7 built-ins. `runAgent` now enumerates extension tool names from the resource loader after `reload()` and builds the full master allowlist (built-ins + permitted extension tools), so pi-mono's gate admits them from the first instant of the session. `disallowedTools` and the internal `Agent`/`get_subagent_result`/`steer_subagent` exclusions are applied uniformly to built-in and extension tools at construction — no post-construction `setActiveToolsByName` narrowing. +- **Append-mode subagents no longer defeat the LLM's KV cache** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — reported by [@jeffutter](https://github.com/jeffutter)). The assembled child prompt placed the per-spawn-varying `` tag and `# Environment` block *before* the ~8k-token inherited parent prompt, and wrapped the parent prompt in `` tags. Because KV caches key on a byte-identical prefix, every subagent spawn reprocessed all ~8k shared tokens from scratch (~40s on slower hardware). The parent prompt is now emitted **verbatim at the start** of the prompt (wrapper dropped), so it forms an identical, cacheable prefix with the parent session and across every spawn; the static `` bridge follows, then the varying `` tag and env block. `replace` mode is unchanged (it inherits no parent prefix). The `` tag stays present and is parsed position-independently, so downstream permission resolution is unaffected. Mirrors the fix in [gotgenes/pi-packages#180](https://github.com/gotgenes/pi-packages/issues/180). + +## [0.9.1] - 2026-05-30 + +### Added +- **`Agent`, `get_subagent_result`, and `steer_subagent` now surface in pi's default system prompt** ([#87](https://github.com/tintinweb/pi-subagents/pull/87) — thanks [@that-yolanda](https://github.com/that-yolanda)). Adds `promptSnippet` to all three (a line in the prompt's `Available tools:` section) and `promptGuidelines` to `Agent` (bullets in `Guidelines:`). The tools were always callable via the tool-call API; this only adds system-prompt reinforcement for prompt-following models. No schema or tool-call changes. + +## [0.9.0] - 2026-05-30 + +> **Heads-up — orchestrator behavior may shift.** This release substantially rewrites the `Agent` tool description and the three default-agent descriptions (`general-purpose`, `Explore`, `Plan`) to mirror Claude Code's upstream wording. No API, schema, or tool-call shape changes — purely a prompt-engineering shift, but a load-bearing one: +> - **Agent selection may drift.** The new agent descriptions carry richer positive ("Use it to …") and negative ("Do NOT use it for …") guidance plus search-breadth hints for `Explore` (`"quick"` / `"medium"` / `"very thorough"`). For ambiguous tasks where the orchestrator previously picked one default agent, it may now pick another — typically more correctly, but the choice may differ from prior releases. +> - **Subagent briefings will skew longer and more contextual.** The restored upstream guardrails and the new `## Writing the prompt` section actively coach "smart colleague who just walked into the room"-style prompts. Expect more context, more constraint, more upfront framing in the `prompt:` field the orchestrator passes to subagents. +> - **Parallel/background patterns more strongly enforced.** The merged bullet on parallel execution now explicitly says `run_in_background: true` is required on each tool call for actual concurrency, and that the orchestrator MUST send a single message with multiple tool uses when the user says "in parallel." Workflows relying on sequential-foreground default behavior are unaffected. +> - If you have tests or workflows that depend on the prior agent-selection or briefing behavior, pin to a v0.7.x release. + +### Added +- **`scopeModels` setting — opt-in subagent model-scope enforcement** (off by default). New setting toggleable via `/agents → Settings → Scope models`. When enabled, the *effective* model of each subagent spawn is validated against `enabledModels` from pi's settings (which pi manages via its own `/scoped-models` UI; pi-subagents only reads it). **Both pi settings files are honored**: global `/settings.json` plus project-local `/.pi/settings.json`, with project overriding global — mirrors pi's `SettingsManager` deep-merge and our own `subagents.json` precedence. Out-of-scope handling depends on source: caller-supplied via `Agent({ model: "..." })` → hard error to the orchestrator with the allowed list; frontmatter-pinned or parent-inherited → warning toast + the agent runs anyway (preserves "frontmatter is authoritative" guarantee from v0.5.1; `scopeModels` is a guardrail against runtime LLM choices, not user-level config). Limitation: only exact `provider/modelId` entries in `enabledModels` are honored — globs (`*sonnet*`), bare model IDs, and `:thinking` suffixes that pi itself supports are silently dropped here. Matches pi's `/scoped-models` picker output, so the limitation is invisible to UI users. + +### Changed +- **`Agent` tool prompt restructured to mirror Claude Code's upstream Agent tool description format.** Section headings now match upstream (`## When not to use`, `## Usage notes`, `## Writing the prompt`); the auto-generated agent list renders as a flat list (no `Default agents:` / `Custom agents:` sub-headers) with a per-agent `(Tools: …)` suffix derived from each agent's `builtinToolNames` (or `*` when the agent has the full built-in set). Restored upstream's load-bearing guardrails that were missing or compressed in the old prompt: "result is not visible to the user → summarize", "trust but verify", "fresh agent / self-contained prompt" on resume, "tell the agent whether to write code or do research", "use proactively when the description says so", "MUST send a single message for parallel", and the worktree auto-cleanup behavior detail. The three redundant "Use Explore / Plan / general-purpose for …" shorthand bullets were dropped — the agent descriptions themselves now carry the canonical (and richer) selection guidance. Upstream's two `` blocks at the end of "Writing the prompt" are also intentionally omitted: the per-orchestrator-turn token cost is recurring, the abstract guidance + the now-rich agent descriptions cover the same pedagogical ground, and the examples embed Anthropic-specific `` framing that doesn't generalize across pi-ai's provider surface (OpenAI, Bedrock, Gemini, Mistral, …). All pi-specific bullets (`resume`, `steer_subagent`, `model`, `thinking`, `inherit_context`, `isolation: "worktree"`, `${scheduleGuideline}`) preserved. +- **Default agent descriptions (`general-purpose`, `Explore`, `Plan`) replaced with upstream Claude Code's verbatim wording.** Previously one-line labels (e.g. `"Fast codebase exploration agent (read-only)"`); now multi-sentence descriptions that include positive ("Use it to …") and negative ("Do NOT use it for …") guidance plus, for Explore, search-breadth hints (`"quick"` / `"medium"` / `"very thorough"`). The LLM-facing selection signal is now substantially stronger. +- **`/agents → Eject` now emits YAML-safe `description:` frontmatter.** The new Explore description contains a `: ` colon-space pattern (the search-breadth hint) and embedded quote characters — emitting it raw would have produced malformed frontmatter that the `yaml` parser would mis-parse. `ejectAgent` now wraps the description with `JSON.stringify` (a valid YAML 1.2 double-quoted scalar), so any description string round-trips cleanly through eject → re-load. Latent bug: previously unreachable because old descriptions were YAML-plain-safe. +- **`/agents → Settings` UI rewritten to inline-editable `SettingsList`.** Replaces the previous modal `ctx.ui.select` chain. All settings visible at once; `↑`/`↓` to navigate, `Space` to cycle preset values on numerics (`Max concurrency`, `Default max turns`, `Grace turns`), `Enter` to type a custom value, `Esc` to exit. Functionally equivalent — same fields, same valid ranges, same persistence behavior — but the interaction model is different. Users scripting against the old screen flow may notice. +- **`.gitignore` additions.** Added `.pi/subagents.json` (project-local subagents settings — written by `/agents → Settings`, shouldn't be committed) plus pi-runtime working files (`progress.md`, `AGENTS.md`, `CLAUDE.md`). **Migration:** if you previously committed `.pi/subagents.json` to your repo, run `git rm --cached .pi/subagents.json` to untrack — gitignore only blocks new additions. + +## [0.8.0] - 2026-05-26 + +> **⚠️ Breaking: peer dependencies moved from `@mariozechner/pi-*` to `@earendil-works/pi-*`.** The upstream Pi runtime relocated npm scopes on 2026-05-07; the `@mariozechner/pi-*` packages are deprecated. This release pins `@earendil-works/pi-{ai,coding-agent,tui}` at `>=0.74.0`. Hosts on the old scope must update their pi installation first (`pi update --self` handles the rename automatically) before installing this version. +> +> **Note on Node:** this release is tested against `@earendil-works/pi-coding-agent@latest` (currently `0.75.x`), which requires Node `>=22.19.0` because its bundled `undici` calls Node 22+ APIs. CI runs on Node 22. The peer range (`>=0.74.0`) technically also matches the upstream `legacy-node20` line (`0.74.x`, Node 20 compatible) and this extension contains no Node 22+ API calls of its own, but the legacy line is not exercised in CI — consumers pinning it do so at their own risk. + +### Changed +- **Peer deps migrated from `@mariozechner/pi-*` to `@earendil-works/pi-*`** ([#76](https://github.com/tintinweb/pi-subagents/issues/76) — thanks [@SEHANTA](https://github.com/SEHANTA) for the report). On **2026-05-07** the upstream Pi runtime moved npm scopes — `@mariozechner/pi-coding-agent@0.73.1` was the final publish (now deprecated on npm), and `@earendil-works/pi-coding-agent@0.74.0` shipped 30 minutes later from the same monorepo (same author, same code). `peerDependencies` now target `@earendil-works/pi-{ai,coding-agent,tui}` at `>=0.74.0`, and all `src/**` and `test/**` imports are renamed to the new scope — pure rename, no API changes. Consumers pinning the new scope no longer hit the peer-dep conflict warnings reported in [#76](https://github.com/tintinweb/pi-subagents/issues/76). +- **`ThinkingLevel` now imported from `@earendil-works/pi-ai` instead of `…/pi-agent-core`.** `src/types.ts` previously reached past the public API into `pi-agent-core` (an internal package), which only resolved because npm flat-hoisted it as a transitive of `pi-coding-agent` — under pnpm or strict-resolver setups the import failed (`TS2307: Cannot find module '@mariozechner/pi-agent-core'`). `pi-ai` re-exports `ThinkingLevel` from its public surface (`export * from "./types.ts"`), so the import goes through the documented entry point and no extra peer dep is needed. + +### Fixed +- **`.pi/subagent-schedules/` is no longer created in every working directory.** `ScheduleStore`'s constructor previously ran `mkdirSync` unconditionally, so any session with scheduling enabled left an empty `.pi/subagent-schedules/` dir behind even when nothing was ever scheduled. Directory creation is now lazy — deferred to a new private `ensureDir()` invoked at the top of `withLock`, so the dir (and its `.json`) appear only when a job is actually persisted. Additionally, `update`/`remove` now short-circuit on an unknown id (in-memory `jobs.has(id)` check) before taking the lock, so no-op mutations never touch disk. Read-only use (`list`/`get`/`hasName`) and constructing the store never create the dir. Pre-existing leftover dirs are not cleaned up — remove them manually. + +## [0.7.3] - 2026-05-14 + +### Added +- **`` tag prepended to every child system prompt** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — thanks [@chris-lasher](https://github.com/chris-lasher)). `buildAgentPrompt` now emits `` as the first line of the assembled prompt in both `replace` and `append` modes, before the env block. Downstream extensions (e.g. permission/policy systems) can parse it from inside the child session to resolve per-agent policy. The tag uses the agent's `config.name` verbatim — no escaping or normalization — and does not couple this extension to any specific downstream consumer; ignoring it is harmless. + +### Changed +- **Subagent sessions now get a stable, type-derived name with an id suffix for parallel spawns** ([#51](https://github.com/tintinweb/pi-subagents/pull/51) — thanks [@forcepushdev](https://github.com/forcepushdev)). `runAgent` calls `session.setSessionName(agentConfig?.name ?? type)`, and when the manager assigns an `agentId` (always, in production), the name is suffixed with an 8-char slice — e.g. `Explore#a1b2c3d4` — so concurrent spawns of the same agent type are distinguishable in the overlay instead of all collapsing onto the same bare name. Direct `runAgent` callers without an `agentId` (e.g. tests) get the bare name. + +### Fixed +- **Cross-extension spawn RPC now accepts a string `options.model`** ([#59](https://github.com/tintinweb/pi-subagents/pull/59), fixes [#60](https://github.com/tintinweb/pi-subagents/issues/60)). Cross-extension callers (e.g. `@tintinweb/pi-tasks@>=0.4.3`'s `TaskExecute`) naturally forward `model` as a serializable `"provider/modelId"` string. Previously the spawn handler passed strings straight through to `runAgent()`, which expects a `Model` object — the spawned agent then crashed with `No API key found for undefined`. The handler now resolves strings via the same `resolveModel(ctx.modelRegistry)` path the scheduler uses; `Model` objects pass through unchanged. Unresolved strings surface the human-readable `Model not found: "…"` error instead of the auth-lookup crash. Thanks @any-victor. + +## [0.7.2] - 2026-05-12 + +> **Heads-up — behavior changes in skill preloading:** +> - **`.txt` and extensionless flat skill files are no longer loaded.** Only `.md` flat files and `/SKILL.md` directory skills resolve now. Rename any `.txt` or extensionless skill files to `.md`. + +### Added +- **Pi-standard `/SKILL.md` directory layout** is now discovered alongside flat `.md` files. Top-level and nested matches both resolve via BFS — for skill `foo`, the loader checks `/foo/SKILL.md`, then recursively descends looking for `*/.../foo/SKILL.md`. Recursion skips dotfile directories and `node_modules`; a directory that itself contains `SKILL.md` is treated as a single skill (Pi's "skills don't nest" rule). +- **Five discovery roots**, checked in precedence order: + - `/.pi/skills/` (project, Pi) + - `/.agents/skills/` (project, [Agent Skills spec](https://agentskills.io/integrate-skills)) + - `$PI_CODING_AGENT_DIR/skills/` — default `~/.pi/agent/skills/` (user, Pi) + - `~/.agents/skills/` (user, Agent Skills spec) + - `~/.pi/skills/` (legacy global, kept for backward compatibility) +- **Symlink rejection broadened** to the new layouts: symlinked skill roots, nested skill directories, and `SKILL.md` files inside otherwise-real directories are all rejected (intentional deviation from Pi, which follows symlinks). +- **Deterministic traversal order** — entries are sorted byte-order so collisions resolve identically across filesystems. Pi's iteration order is `readdirSync`-dependent. +- **Resolved spawn args are now shown in the dedicated conversation viewer** ([#62](https://github.com/tintinweb/pi-subagents/issues/62)). Open `/subagent` → Running Agents → select an agent: a second header row displays the effective invocation — model override (when different from parent), `thinking: `, `isolated`, `worktree`, `inherit context`, `background`, and `max turns: N`. Tags appear when the resolved value is notable (e.g. `isolated: true`), not just when the caller explicitly set it; `max turns` is the one exception and shows only when explicitly configured. Lets you verify the parent agent honored your spawn instructions without scrolling back through the chat. Snapshot stored on the new `AgentRecord.invocation` field. The same tag set is also surfaced on the `Agent` tool-call result render (which previously showed a narrower subset). +- **`Shift+↑` / `Shift+↓` scroll a full page in the conversation viewer** — same behavior as `PgUp` / `PgDn`. Note: some terminal emulators intercept Shift+arrows for text selection or tab switching, in which case `PgUp`/`PgDn` remain available. + +### Changed +- **`.txt` and extensionless flat skill files are no longer loaded.** Pi only supports `.md`; we now match. **Migration:** rename any `.txt` / `` skill files to `.md`. +- **Conversation viewer no longer fills the full screen.** The overlay is now capped at 70% of terminal height (90% width unchanged), and the viewer's internal viewport mirrors that cap so the footer/scroll indicator can't be clipped. + +## [0.7.1] - 2026-05-07 + +> **Heads-up — behavior change:** +> - `isolation: "worktree"` now fails loud (returns an error) instead of silently falling back to the main tree. Affects users running pi in a non-git directory or a fresh repo with no commits. + +### Changed +- **`isolation: "worktree"` now fails loud instead of silently falling back.** Previously when `createWorktree` returned undefined (not a git repo, no commits yet, or `git worktree add` failed), the agent ran in the main `cwd` with a `[WARNING: ...]` block prepended to its prompt — visible only to the LLM, never surfaced to the caller. Now the failure throws a structured error that propagates back to the `Agent` tool response; no agent record is created. Failed scheduled fires are recorded as `lastStatus: "error"` with the reason in the `subagents:scheduled` error event. Queued background spawns whose worktree creation fails when they dequeue are marked terminal-error and don't block the rest of the queue. + +### Fixed + +- **Headless `pi --print` runs no longer hang or crash after background +subagents complete.** Cleanup timers no longer keep the process alive, and +stale completion notifications are treated as best-effort shutdown side +effects. + +## [0.7.0] - 2026-05-04 + +> **Heads-up — behavior changes:** +> - `subagents:completed`/`failed` event `tokens.total` now excludes `cacheRead` (previously double-counted across turns) — see Fixed [#38]. +> - Cron `?` is now a wildcard (same as `*`), not "current time value" — affects Quartz-style expressions only. + +### Changed +- **`@mariozechner/pi-{ai,coding-agent,tui}` moved to `peerDependencies` (`>=0.70.5`).** Avoids duplicate framework instances when the host loads this extension. +- **`@sinclair/typebox` pinned from `latest` to `^0.34.49`** so installs are reproducible. +- **`croner` bumped 8 → 10.** Heads-up: in cron strings, `?` now means wildcard (same as `*`) instead of "current time value" — affects Quartz-style expressions only. + +### Added +- **Master switch for scheduling** — new `schedulingEnabled` setting (default `true`) under `/agents → Settings → Scheduling`. When set to `false`: the `schedule` parameter and its guideline are stripped from the `Agent` tool spec at registration (zero LLM-context cost), the scheduler does not bind to the session, the `/agents → Scheduled jobs` menu entry is hidden, and any in-flight scheduler is stopped immediately. The schema-level removal applies on next pi session; the runtime kill (menu, fire path) takes effect immediately. Persisted at `/.pi/subagents.json`. +- **Schedule subagent spawns** — the `Agent` tool now accepts an optional `schedule` parameter. When set, the spawn registers a job that fires later instead of running immediately. Three formats: 6-field cron (`"0 0 9 * * 1"` — 9am every Monday), interval (`"5m"`, `"1h"`), or one-shot (`"+10m"` or ISO timestamp). Returns the job ID. Schedules are session-scoped — they reset on `/new`, restore on `/resume` (mirrors the persistence model of pi-chonky-tasks). Storage at `/.pi/subagent-schedules/.json`, with PID-based file locking + atomic temp+rename for concurrent-instance safety. **Result delivery is identical to today's background-spawn completions**: when the scheduled agent finishes, the existing `subagent-notification` followUp path emits the result to the conversation — no new delivery code, no new message types. **Concurrency**: scheduled fires bypass `maxConcurrent` so a 5-minute interval can't be deferred behind 4 long-running manual agents. **Management**: `/agents` → "Scheduled jobs" lists active jobs and lets you cancel any one of them. Creation is via the `Agent` tool only — no parallel manual-create wizard in this iteration. **Events**: `subagents:scheduled` ({ type: "added" | "removed" | "updated" | "fired" | "error", … }) and `subagents:scheduler_ready` for cross-extension consumers. **Restrictions**: `schedule` is incompatible with `inherit_context` (no parent at fire time) and `resume` (schedules create fresh agents); forces `run_in_background: true`. Scheduler engine mirrors `pi-cron-schedule` (`croner` for cron, `setInterval`/`setTimeout` for interval/once); past one-shot timestamps and invalid cron expressions are caught at create time. +- **Context-window utilization indicator in the subagent overlay** — token count is now followed by a colored `(NN%)` showing how full the subagent's context is right now (`estimateContextTokens(messages) / model.contextWindow * 100`, sourced from upstream `contextUsage.percent`). Threshold colors: <70% dim, 70–85% warning, ≥85% error. Gracefully omitted when the model has no `contextWindow` declared, or right after compaction before the next assistant turn (`tokens` is `null` in that window). The same annotation slot also surfaces a compaction count `↻N` when the agent has compacted at least once — e.g. `12.3k token (84% · ↻3)` (percent + compactions joined with `·`), `12.3k token (↻1)` (compactions only, immediately post-compaction while percent is still null). The compaction glyph stays dim regardless; the percent's threshold color carries the urgency signal. Two live overlays get the annotations (running stats line; inspect-overlay header); post-completion notifications and result/event payloads only get the count (the indicator is no longer actionable once the agent is done). +- **Token usage and context% exposed to the parent agent** at every interaction surface — `get_subagent_result` adds `Context: NN%` to its stats line; `steer_subagent` returns a `Current state: 12.3k token · 5 tool uses · context 72% full` line so the steering agent knows whether it has room before sending more context; `task-notification` XML adds `NN` (omitted when null). All plain-text, no ANSI codes — designed for LLM consumption, not human display. +- **New `subagents:compacted` lifecycle event** fires when a subagent's session successfully compacts. Payload: `{ id, type, description, reason: "manual" | "threshold" | "overflow", tokensBefore, compactionCount }` — `tokensBefore` is upstream's pre-compaction context size estimate; `compactionCount` is the running total for this agent (also persisted on `AgentRecord.compactionCount` and surfaced in `get_subagent_result` / `steer_subagent` / `task-notification` when > 0). Aborted compactions don't fire. Routed through a new manager-level `onCompact` constructor callback, matching the existing `onStart` / `onComplete` pattern. + +### Fixed +- **Subagent token count was inflated 5–15× and reset mid-run** ([#38](https://github.com/tintinweb/pi-subagents/issues/38)). Two distinct bugs in the same field. (1) Upstream `getSessionStats().tokens.total` sums per-turn `cacheRead` across every assistant message — but each turn's `cacheRead` is the *cumulative* cached prefix re-read on that one API call, so summing N turns counts the prefix N times (quadratic inflation, very visible on long sessions). (2) Even with that fixed, anything derived from `session.state.messages` resets at compaction because upstream replaces the array via `this.agent.state.messages = sessionContext.messages`. Fix replaces all six display readers with a lifetime accumulator (`AgentRecord.lifetimeUsage` and `AgentActivity.lifetimeUsage` — `{ input, output, cacheWrite }`) fed by a new `onAssistantUsage` callback dispatched from `message_end` events in both `runAgent` and `resumeAgent`. The accumulator is independent of `state.messages` mutation, so it survives compaction; total = input + output + cacheWrite by construction (cacheRead deliberately excluded — same prefix-double-counting reason). The `subagents:completed`/`failed` event payload's `tokens` field is now also lifetime-accumulated for `input`, `output`, and `total` together (was: `total` lifetime, `input`/`output` session-derived → inconsistent after compaction). +- **ESC during a foreground `Agent` call now actually stops the subagent** ([#44](https://github.com/tintinweb/pi-subagents/pull/44) — thanks [@Zeng-Zer](https://github.com/Zeng-Zer)). Pi's interrupt path is `esc → agent.abort()` on the parent → `AbortSignal` delivered to every tool's `execute(toolCallId, params, signal, …)`, but the `Agent` tool dropped that signal on the floor: subagents ran on their own independent `AbortController` inside `AgentManager`, so the parent abort was invisible and the subagent kept running until natural completion or `max_turns`. Fix threads `signal` through `Agent.execute` → `manager.spawnAndWait()` → `SpawnOptions.signal`, and `AgentManager.startAgent()` now attaches an `{ once: true }` `"abort"` listener that calls `this.abort(id)` (which sets `status: "stopped"` and aborts the child controller). The listener is detached in both `.then` and `.catch` to avoid leaking on natural settle. **Scope:** foreground only — background agents intentionally outlive the parent tool call, so their spawn deliberately does not forward `signal`. Resume path (`AgentManager.resume()`) has the same blind spot and is tracked as a follow-up. + +## [0.6.3] - 2026-04-28 + +### Fixed +- **`run_in_background: true` (and `inherit_context`, `isolated`) silently ignored on default agents** ([#37](https://github.com/tintinweb/pi-subagents/issues/37) — thanks [@kylesnowschwartz](https://github.com/kylesnowschwartz) for the diagnosis). The three built-in defaults (`general-purpose`, `Explore`, `Plan`) baked `runInBackground: false`, `inheritContext: false`, and `isolated: false` into their configs. `resolveAgentInvocationConfig` uses `agentConfig?.field ?? params.field ?? false`, and `??` only falls through on `null`/`undefined` — so an explicit `false` from the agent config silently won over the caller's `true`. Calling `Agent({ subagent_type: "general-purpose", run_in_background: true })` returned the result inline instead of backgrounding, blocking the parent UI for the agent's full runtime. Fix drops the three lines from each default (and from the unreachable defensive fallback in `agent-runner.ts`) — the type already declared each as `field?: boolean` with JSDoc *"undefined = caller decides"*, so the runtime now matches the documented contract. **Behavior:** custom agents that explicitly set these fields in frontmatter still lock as before (the v0.5.1 "frontmatter is authoritative" guarantee is preserved); the fix only stops *defaults* from spuriously claiming an opinion on callsite-strategy fields they don't actually have. The unreachable fallback now spreads `DEFAULT_AGENTS.get("general-purpose")` instead of duplicating the config inline, so future drift is impossible. + +## [0.6.2] - 2026-04-28 + +### Fixed +- **`Agent` tool fails on Windows with `ENOENT` creating output directory** ([#27](https://github.com/tintinweb/pi-subagents/issues/27) — thanks [@sixnathan](https://github.com/sixnathan) for the diagnosis). The cwd-encoding regex in `output-file.ts` only handled POSIX `/` separators, so on Windows `cwd = "C:\\Users\\foo\\project"` survived unchanged and `path.join(tmpRoot, encoded, …)` produced an invalid nested-absolute path. Now extracts a small `encodeCwd()` helper that handles both `/` and `\\` separators, strips the Windows drive-letter prefix, and preserves UNC server/share segments. The `chmodSync(root, 0o700)` call is also wrapped in a try/catch that swallows errors only on Windows (where chmod is a no-op and can throw on some filesystems); on Unix the error still propagates so umask-defeating `0o700` enforcement is preserved. + +## [0.6.1] - 2026-04-25 + +### Added +- **Persistent `/agents` → Settings** ([#24](https://github.com/tintinweb/pi-subagents/issues/24)) — the four runtime tuning values (`maxConcurrent`, `defaultMaxTurns`, `graceTurns`, `defaultJoinMode`) now survive pi restarts via a two-file dual-scope model mirroring pi's own `SettingsManager`. Global `~/.pi/agent/subagents.json` provides machine-wide defaults (edit by hand; the menu never writes here); project `/.pi/subagents.json` holds per-project overrides (written by `/agents` → Settings). Load merges both with project winning on conflicts. Invalid fields are silently dropped per field; malformed JSON emits a warning to stderr and falls back to defaults so startup always proceeds; write failures downgrade the settings toast to a warning with `(session only; failed to persist)` so changes aren't silently reverted on next restart. +- **New lifecycle events** — `subagents:settings_loaded` (emitted once at extension init with the merged settings) and `subagents:settings_changed` (emitted on each `/agents` → Settings mutation with the new snapshot and a `persisted: boolean` flag so listeners can react to write failures). + +### Fixed +- **`AGENTS.md` / `CLAUDE.md` / `APPEND_SYSTEM.md` no longer leak into sub-agent prompts** ([#26](https://github.com/tintinweb/pi-subagents/pull/26) — thanks [@mikeyobrien](https://github.com/mikeyobrien) for the diagnosis). Upstream `buildSystemPrompt()` re-appends `contextFiles` and `appendSystemPrompt` *after* our `systemPromptOverride` runs, which silently defeated `prompt_mode: replace` and `isolated: true` — parent project context (e.g. autoresearch-mode blocks) was bleeding into fresh `Explore` / custom sub-agents regardless of frontmatter. Fix uses upstream's `noContextFiles: true` flag (skips the load entirely, introduced in pi 0.68) plus `appendSystemPromptOverride: () => []` (no flag equivalent for append sources). **Behavior change:** subagents no longer implicitly inherit parent `AGENTS.md`/`CLAUDE.md`/`APPEND_SYSTEM.md`. To get parent project context into a subagent, use `prompt_mode: append` (parent's already-built system prompt flows in via `systemPromptOverride`), or `inherit_context: true` (parent conversation), or inline the content into the agent's own frontmatter. +- **Custom agent discovery respects `PI_CODING_AGENT_DIR`** ([#35](https://github.com/tintinweb/pi-subagents/pull/35), closes [#23](https://github.com/tintinweb/pi-subagents/issues/23) — thanks [@Amolith](https://github.com/Amolith) for the diagnosis). Two remaining hardcoded `~/.pi/agent/agents/` paths in `custom-agents.ts` and `index.ts` bypassed the env var, so users who relocated their agent directory (e.g. via `PI_CODING_AGENT_DIR`) still had global agents loaded from the default location and help text referencing the wrong path. Both now use upstream `getAgentDir()`, consistent with `agent-runner.ts` and `settings.ts`; tilde expansion is handled by upstream. + +## [0.6.0] - 2026-04-24 + +> **⚠️ Breaking: drops support for `pi` < 0.68.** The upstream `pi-coding-agent` package shipped breaking API changes in v0.68 (and further ones in v0.70). This release migrates to `^0.70.2` and is **not** backward-compatible with hosts on `pi` 0.62–0.67. Users on those versions must upgrade their `pi` installation (`npm install -g @mariozechner/pi-coding-agent@latest`) before updating this extension. + +### Changed +- **Bumped peer `@mariozechner/pi-coding-agent` to `^0.70.2`** ([#28](https://github.com/tintinweb/pi-subagents/pull/28)) — crosses the v0.68 breaking-change line upstream. Specifically: tools are now passed as `string[]` (was `Tool[]`); `cwd`/`agentDir` are mandatory on `SettingsManager.create()` and `DefaultResourceLoader`; `session_switch` event renamed to `session_before_switch`; `ToolDefinition.params` widens to `unknown` under contextual typing, requiring `defineTool(...)`. +- **Tool registrations wrapped with `defineTool(...)`** — preserves `TParams` inference so `execute` handlers get properly-typed `params` instead of `unknown`. Applies to the `Agent`, `get_subagent_result`, and `steer_subagent` tools. + +### Removed +- **Cwd-bound tool factory registry** — the internal `TOOL_FACTORIES` closure table and `create{Bash,Edit,Read,Write,Grep,Find,Ls}Tool` imports are gone. Exported helpers renamed: `getToolsForType(type, cwd)` → `getToolNamesForType(type)`, `getMemoryTools(cwd, set)` → `getMemoryToolNames(set)`, `getReadOnlyMemoryTools(cwd, set)` → `getReadOnlyMemoryToolNames(set)` — all returning `string[]` instead of `Tool[]`. The host binds cwd when resolving tool names, so the extension no longer instantiates tools directly. + +### Fixed +- **Subagent `SettingsManager` read wrong project settings in worktree mode** ([#30](https://github.com/tintinweb/pi-subagents/pull/30)) — `SettingsManager.create()` was called without arguments, defaulting `cwd` to `process.cwd()`. When the subagent's effective cwd differed (worktree isolation or explicit `cwd` override), its settings manager read `.pi/settings.json` from the parent's cwd rather than its own, diverging from the loader and session manager. Now passes `effectiveCwd` and `agentDir` explicitly, keeping all three managers consistent. + +## [0.5.2] - 2026-03-26 + +### Fixed +- **Extension `session_start` handlers now fire in subagent sessions** ([#20](https://github.com/tintinweb/pi-subagents/issues/20)) — `bindExtensions()` was never called on subagent sessions, so extensions that initialize state in `session_start` (e.g. loading credentials, setting up connections) silently failed at runtime. Tools appeared registered but were non-functional. Now calls `session.bindExtensions()` after tool filtering and before prompting, matching the lifecycle used by pi's interactive, print, and RPC modes. Also triggers `extendResourcesFromExtensions("startup")` so extension-provided skills and prompts are discovered. + +## [0.5.1] - 2026-03-24 + +### Changed +- **Agent config is authoritative** — frontmatter values for `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, and `isolation` now take precedence over `Agent` tool-call parameters. Tool-call params only fill fields the agent config leaves unspecified. +- **`join_mode` is now a global setting only** — removed the per-call `join_mode` parameter from the `Agent` tool. Join behavior is configured via `/agents` → Settings → Join mode. +- **`max_turns: 0` means unlimited** — agent files can now explicitly set `max_turns: 0` to lock unlimited turns. Previously `0` was silently clamped to `1`. + +### Fixed +- **Final subagent text preserved from non-streaming providers** — agents using providers that return the final message without streaming `text_delta` events no longer return empty results. Falls back to extracting text from the completed session history. +- **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely. + +## [0.5.0] - 2026-03-22 + +### Added +- **RPC stop handler** — new `subagents:rpc:stop` event bus RPC allows other extensions to stop running subagents by agent ID. Returns structured error ("Agent not found") on failure. +- **`abort` in `SpawnCapable` interface** — cross-extension RPC consumers can now stop agents, not just spawn them. +- **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress via `onTurnEnd` callback. +- **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix. +- **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs. +- **Auto-trigger parent turn on background completion** — background agent completion notifications now use `triggerTurn: true`, automatically prompting the parent agent to process results instead of waiting for user input. + +### Changed +- **Standardized RPC envelope** — cross-extension RPC handlers (`ping`, `spawn`, `stop`) now use a `handleRpc` wrapper that emits structured envelopes (`{ success: true, data }` / `{ success: false, error }`), matching pi-mono's `RpcResponse` convention. +- **Protocol versioning via ping** — ping reply now includes `{ version: PROTOCOL_VERSION }` (currently v2). Callers can detect version mismatches and warn users to update. +- **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited). +- **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`. + +### Fixed +- **Tool name display** — `getAgentConversation` now reads `ToolCall.name` (the correct property) instead of `toolName`, resolving `[Tool: unknown]` in conversation viewer and verbose output. +- **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo. + +## [0.4.9] - 2026-03-18 + +### Fixed +- **Conversation viewer crash in narrow terminals** ([#7](https://github.com/tintinweb/pi-subagents/issues/7)) — `buildContentLines()` in the live conversation viewer could return lines wider than the terminal when `wrapTextWithAnsi()` misjudged visible width on ANSI-heavy input (e.g. tool output with embedded escape codes, long URLs, wide tables). All content lines are now clamped with `truncateToWidth()` before returning. Same class of bug as the widget fix in v0.2.7, different component. + +### Added +- **Conversation viewer width-safety tests** — 17 tests covering `render()` and `buildContentLines()` across varied content (plain text, ANSI codes, unicode, tables, long URLs, narrow terminals). Includes mock-based regression tests that simulate upstream `wrapTextWithAnsi` returning overwidth lines, ensuring the safety net catches them. + +## [0.4.8] - 2026-03-18 + +### Added +- **Cross-extension RPC** — other pi extensions can spawn subagents via `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load. +- **Session persistence for agent records** — completed agent records are persisted via `pi.appendEntry("subagents:record", ...)` for cross-extension history reconstruction. + +### Fixed +- **Background agent notification race condition** — `pi.sendMessage()` is fire-and-forget, so completion notifications sent eagerly from `onComplete` could not be retracted when `get_subagent_result` was called in the same turn. Notifications are now held behind a 200ms cancellable timer; `get_subagent_result` cancels the pending timer before it fires, eliminating duplicate notifications. Group notifications also re-check `resultConsumed` at send time so consumed agents are filtered out. + +## [0.4.7] - 2026-03-17 + +### Added +- **Custom notification renderer** — background agent completion notifications now render as styled, themed boxes instead of raw XML. Uses `pi.registerMessageRenderer()` with the `"subagent-notification"` custom message type. The LLM continues to receive `` XML via `content`; only the user-facing display changes. +- **Group notification rendering** — group completions render each agent as its own styled block (icon, description, stats, result preview) instead of showing only the first agent. +- **Output file streaming for background agents** — background agents now get the same output file transcript as foreground agents, with `onSessionCreated` wiring and proper cleanup on completion/error. +- `NotificationDetails` type in `types.ts` — structured details for the notification renderer, with optional `others` array for group notifications. +- `buildNotificationDetails()` helper — extracts renderer-facing details from an `AgentRecord`. + +### Changed +- **Notification delivery** — `sendIndividualNudge` and group notification now use `pi.sendMessage()` (custom message) instead of `pi.sendUserMessage()` (plain text), enabling renderer-controlled display. +- **Steered status rendering** — steered agents show "completed (steered)" in the notification box instead of plain "completed". + +### Fixed +- **Output file cleanup on completion** — `agent-manager.ts` now calls `record.outputCleanup()` in both the success and error paths of agent completion, ensuring the streaming subscription is flushed and released. + +## [0.4.6] - 2026-03-16 + +### Fixed +- **Graceful shutdown aborts agents instead of blocking** — `session_shutdown` now calls `abortAll()` instead of `waitForAll()`, so the process exits immediately instead of hanging until all background agents complete. Agent results are undeliverable after shutdown anyway. + +### Added +- `abortAll()` method on `AgentManager` — stops all queued and running agents at once, returning the count of affected agents. + +## [0.4.5] - 2026-03-16 + +### Changed +- **Widget render-once pattern** — the widget callback is now registered once via `setWidget()` and subsequent updates use `requestRender()` instead of re-registering the entire widget on every `update()` call. Eliminates layout thrashing from repeated widget teardown/setup cycles. +- **Status bar dedup** — `setStatus()` is now only called when the status text actually changes, avoiding redundant TUI updates. +- **UICtx change detection** — `setUICtx()` detects context changes and forces widget re-registration, correctly handling session switches. + +### Refactored +- Extracted `renderWidget()` private method — moves all widget content rendering out of the `update()` closure into a standalone method that reads live state on each call. +- `update()` is now a lightweight coordinator: counts agents, manages registration lifecycle, and triggers re-renders. + +## [0.4.4] - 2026-03-16 + +### Fixed +- **Race condition in `get_subagent_result` with `wait: true`** — `resultConsumed` is now set before `await record.promise`, preventing a redundant follow-up notification. Previously the `onComplete` callback (attached at spawn time via `.then()`) always fired before the await resumed, seeing `resultConsumed` as false. +- **Stale agent records across sessions** — new `clearCompleted()` method removes all completed/stopped/errored agent records on `session_start` and `session_switch` events, so tasks from a prior session don't persist into a new one. +- **`steer_subagent` race on freshly launched agents** — steering an agent before its session initialized silently dropped the message. Now steers are queued on the record and flushed once `onSessionCreated` fires. + +### Changed +- Extracted `removeRecord()` private helper in `AgentManager` — deduplicates dispose+delete logic between `cleanup()` and `clearCompleted()`. + +### Added +- 8 new tests covering `resultConsumed` race condition and `clearCompleted` behavior (185 total). + +## [0.4.3] - 2026-03-13 + +### Added +- **Persistent agent memory** — new `memory` frontmatter field with three scopes: `"user"` (global `~/.pi/`), `"project"` (per-project `.pi/`), `"local"` (gitignored `.pi/`). Agents with write/edit tools get full read-write memory; read-only agents get a read-only fallback that injects existing MEMORY.md content without granting write access or creating directories. +- **Git worktree isolation** — new `isolation: "worktree"` frontmatter field and Agent tool parameter. Creates a temporary `git worktree` so agents work on an isolated copy of the repo. On completion, changes are auto-committed to a `pi-agent-` branch; clean worktrees are removed. Includes crash recovery via `pruneWorktrees()`. +- **Skill preloading** — `skills` frontmatter now accepts a comma-separated list of skill names (e.g. `skills: planning, review`). Reads from `.pi/skills/` (project) then `~/.pi/skills/` (global), tries `.md`/`.txt`/bare extensions. Content injected into the system prompt as `# Preloaded Skill: {name}`. +- **Tool denylist** — new `disallowed_tools` frontmatter field (e.g. `disallowed_tools: bash, write`). Blocks specified tools even if `builtinToolNames` or extensions would provide them. Enforced for both extension-enabled and extension-disabled agents. +- **Prompt extras system** — new `PromptExtras` interface in `prompts.ts`; `buildAgentPrompt()` accepts optional memory and skill blocks appended in both `replace` and `append` modes. +- `getMemoryTools()`, `getReadOnlyMemoryTools()` in `agent-types.ts`. +- `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`, `isSymlink()`, `safeReadFile()` in `memory.ts`. +- `preloadSkills()` in `skill-loader.ts`. +- `createWorktree()`, `cleanupWorktree()`, `pruneWorktrees()` in `worktree.ts`. +- `MemoryScope`, `IsolationMode` types; `memory`, `isolation`, `disallowedTools` fields on `AgentConfig`; `worktree`, `worktreeResult` fields on `AgentRecord`. +- 177 total tests across 8 test files (41 new tests). + +### Fixed +- **Read-only agents no longer escalated to read-write** — enabling `memory` on a read-only agent (e.g. Explore) previously auto-added `write`/`edit` tools. Now the runner detects write capability and branches: read-write agents get full memory tools, read-only agents get read-only memory prompt with only the `read` tool added. +- **Denylist-aware memory detection** — write capability check now accounts for `disallowedTools`. An agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory instead of broken read-write instructions. +- **Worktree requires commits** — repos with no commits (empty HEAD) are now rejected early with a warning instead of failing silently at `git worktree add`. +- **Worktree failure warning** — when worktree creation fails, a warning is prepended to the agent's prompt instead of silently falling through to the main cwd. +- **No force-branch overwrite** — worktree cleanup appends a timestamp suffix on branch name conflict instead of using `git branch -f`. + +### Security +- **Whitelist name validation** — agent/skill names must match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, max 128 chars. Rejects path traversal, leading dots, spaces, and special characters. +- **Symlink protection** — `safeReadFile()` and `isSymlink()` reject symlinks in memory directories, MEMORY.md files, and skill files, preventing arbitrary file reads. +- **Symlink-safe directory creation** — `ensureMemoryDir()` throws on symlinked directories. + +### Changed +- `agent-runner.ts`: tool/extension/skill resolution moved before memory detection; `ctx.cwd` → `effectiveCwd` throughout. +- `custom-agents.ts`: extracted `parseCsvField()` helper; added `csvListOptional()` and `parseMemory()`. +- `skill-loader.ts`: uses `safeReadFile()` from `memory.ts` instead of raw `readFileSync`. +- Agent tool schema updated with `isolation` parameter and help text for `memory`, `isolation`, `disallowed_tools`, and skill list. + +## [0.4.2] - 2026-03-12 + +### Added +- **Event bus** — agent lifecycle events emitted via `pi.events.emit()`, enabling other extensions to react to sub-agent activity: + - `subagents:created` — background agent registered (includes `id`, `type`, `description`, `isBackground`) + - `subagents:started` — agent transitions to running (includes queued→running) + - `subagents:completed` — agent finished successfully (includes `durationMs`, `tokens`, `toolUses`, `result`) + - `subagents:failed` — agent errored, stopped, or aborted (same payload as completed) + - `subagents:steered` — steering message sent to a running agent +- `OnAgentStart` callback and `onStart` constructor parameter on `AgentManager`. +- **Cross-package manager** now also exposes `spawn()` and `getRecord()` via the `Symbol.for("pi-subagents:manager")` global. + +## [0.4.1] - 2026-03-11 + +### Fixed +- **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive. + +### Added +- `hasRunning()` / `waitForAll()` methods on `AgentManager`. +- **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion. + +## [0.4.0] - 2026-03-11 + +### Added +- **XML-delimited prompt sections** — append-mode agents now wrap inherited content in ``, ``, and `` XML tags, giving the model explicit structure to distinguish inherited rules from sub-agent-specific instructions. Replace mode is unchanged. +- **Token count in agent results** — foreground agent results, background completion notifications, and `get_subagent_result` now include the token count alongside tool uses and duration (e.g. `Agent completed in 4.2s (12 tool uses, 33.8k token)`). +- **Widget overflow cap** — the running agents widget now caps at 12 lines. When exceeded, running agents are prioritized over finished ones and an overflow summary line shows hidden counts (e.g. `+3 more (1 running, 2 finished)`). + +### Changed - **changing behavior** +- **General-purpose agent inherits parent prompt** — the default `general-purpose` agent now uses `promptMode: "append"` with an empty system prompt, making it a "parent twin" that inherits the full parent system prompt (including CLAUDE.md rules, project conventions, and safety guardrails). Previously it used a standalone prompt that duplicated a subset of the parent's rules. Explore and Plan are unchanged (standalone prompts). To customize: eject via `/agents` → select `general-purpose` → Eject, then edit the resulting `.md` file. Set `prompt_mode: replace` to go back to a standalone prompt, or keep `prompt_mode: append` and add extra instructions in the body. +- **Append-mode agents receive parent system prompt** — `buildAgentPrompt` now accepts the parent's system prompt and threads it into append-mode agents (env header + parent prompt + sub-agent context bridge + optional custom instructions). Replace-mode agents are unchanged. +- **Prompt pipeline simplified** — removed `systemPromptOverride`/`systemPromptAppend` from `SpawnOptions` and `RunOptions`. These were a separate code path where `index.ts` pre-resolved the prompt mode and passed raw strings into the runner, bypassing `buildAgentPrompt`. Now all prompt assembly flows through `buildAgentPrompt` using the agent's `promptMode` config — one code path, no special cases. + +### Removed +- Deprecated backwards-compat aliases: `registerCustomAgents`, `getCustomAgentConfig`, `getCustomAgentNames` (use `registerAgents`, `getAgentConfig`, `getUserAgentNames`). +- `resolveCustomPrompt()` helper in index.ts — no longer needed now that prompt routing is config-driven. + +## [0.3.1] - 2026-03-09 + +### Added +- **Live conversation viewer** — selecting a running (or completed) agent in `/agents` → "Running agents" now opens a scrollable overlay showing the agent's full conversation in real time. Auto-scrolls to follow new content; scroll up to pause, End to resume. Press Esc to close. + +## [0.3.0] - 2026-03-08 + +### Added +- **Case-insensitive agent type lookup** — `"explore"`, `"EXPLORE"`, and `"Explore"` all resolve to the same agent. LLMs frequently lowercase type names; this prevents validation failures. +- **Unknown type fallback** — unrecognized agent types fall back to `general-purpose` with a note, instead of hard-rejecting. Matches Claude Code behavior. +- **Dynamic tool list for general-purpose** — `builtinToolNames` is now optional in `AgentConfig`. When omitted, the agent gets all tools from `TOOL_FACTORIES` at lookup time, so new tools added upstream are automatically available. +- **Agent source indicators in `/agents` menu** — `•` (project), `◦` (global), `✕` (disabled) with legend. Defaults are unmarked. +- **Disabled agents visible in UI** — disabled agents now show in the "Agent types" list (marked `✕`) with an Enable action, instead of being invisible. +- **Enable action** — re-enable a disabled agent from the `/agents` menu. Stub files are auto-cleaned. +- **Disable action for all agent types** — custom and ejected default agents can now be disabled from the UI, not just built-in defaults. +- `resolveType()` export — case-insensitive type name resolution for external use. +- `getAllTypes()` export — returns all agent names including disabled (for UI listing). +- `source` field on `AgentConfig` — tracks where an agent was loaded from (`"default"`, `"project"`, `"global"`). + +### Fixed +- **Model resolver checks auth for exact matches** — `resolveModel("anthropic/claude-haiku-4-5-20251001")` now fails gracefully when no Anthropic API key is configured, instead of returning a model that errors at the API call. Explore silently falls back to the parent model on non-Anthropic setups. + +### Changed +- **Unified agent registry** — built-in and custom agents now use the same `AgentConfig` type and a single registry. No more separate code paths for built-in vs custom agents. +- **Default agents are overridable** — creating a `.md` file with the same name as a default agent (e.g. `.pi/agents/Explore.md`) overrides it. +- **`/agents` menu** — "Agent types" list shows defaults and custom agents together with source indicators. Default agents get Eject/Disable actions; overridden defaults get Reset to default. +- **Eject action** — export a default agent's embedded config as a `.md` file to project or personal location for customization. +- **Model labels** — provider-agnostic: strips `provider/` prefix and `-YYYYMMDD` date suffix (e.g. `anthropic/claude-haiku-4-5-20251001` → `claude-haiku-4-5`). Works for any provider. +- **New frontmatter fields** — `display_name` (UI display name) and `enabled` (default: true; set to false to disable). +- **Menu navigation** — Esc in agent detail returns to agent list (not main menu). + +### Removed +- **`statusline-setup` and `claude-code-guide` agents** — removed as built-in types (never spawned programmatically). Users can recreate them as custom agents if needed. +- `BuiltinSubagentType` union type, `SUBAGENT_TYPES` array, `DISPLAY_NAMES` map, `SubagentTypeConfig` interface — replaced by unified `AgentConfig`. +- `buildSystemPrompt()` switch statement — replaced by config-driven `buildAgentPrompt()`. +- `HAIKU_MODEL_IDS` fallback array — Explore's haiku default is now just the `model` field in its config. +- `BUILTIN_MODEL_LABELS` — model labels now derived from config. +- `ALL_TOOLS` hardcoded constant — general-purpose now derives tools dynamically. + +### Added +- `src/default-agents.ts` — embedded default configs for general-purpose, Explore, and Plan. + +## [0.2.7] - 2026-03-08 + +### Fixed +- **Widget crash in narrow terminals** — agent widget lines were not truncated to terminal width, causing `doRender` to throw when the tmux pane was narrower than the rendered content. All widget lines are now truncated using `truncateToWidth()` with the actual terminal column count. + +## [0.2.6] - 2026-03-07 + +### Added +- **Background task join strategies** — smart grouping of background agent completion notifications + - `smart` (default): 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification instead of individual nudges + - `async`: each agent notifies individually on completion (previous behavior) + - `group`: force grouping even for solo agents + - 30s timeout after first completion delivers partial results; 15s straggler re-batch window for remaining agents +- **`join_mode` parameter** on the `Agent` tool — override join strategy per agent (`"async"` or `"group"`) +- **Join mode setting** in `/agents` → Settings — configure the default join mode at runtime +- New `src/group-join.ts` — `GroupJoinManager` class for batched completion notifications + +### Changed +- `AgentRecord` now includes optional `groupId`, `joinMode`, and `resultConsumed` fields +- Background agent completion routing refactored: individual nudge logic extracted to `sendIndividualNudge()`, group delivery via `GroupJoinManager` + +### Fixed +- **Debounce window race** — agents that complete during the 100ms batch debounce window are now deferred and retroactively fed into the group once it's registered, preventing split notifications (one individual + one partial group) and zombie groups +- **Solo agent swallowed notification** — if only one agent was spawned (no group formed) but it completed during the debounce window, its deferred notification is now sent when the batch finalizes +- **Duplicate notifications after polling** — calling `get_subagent_result` on a completed agent now marks its result as consumed, suppressing the subsequent completion notification (both individual and group) + +## [0.2.5] - 2026-03-06 + +### Added +- **Interactive `/agents` menu** — single command replaces `/agent` and `/agents` with a full management wizard + - Browse and manage running agents + - Custom agents submenu — edit or delete existing agents + - Create new custom agents via manual wizard or AI-generated (with comprehensive frontmatter documentation for the generator) + - Settings: configure max concurrency, default max turns, and grace turns at runtime + - Built-in agent types shown with model info (e.g. `Explore · haiku`) + - Aligned formatting for agent lists +- **Configurable turn limits** — `defaultMaxTurns` and `graceTurns` are now runtime-adjustable via `/agents` → Settings +- Sub-menus return to main menu instead of exiting + +### Removed +- `/agent ` command (use `Agent` tool directly, or create custom agents via `/agents`) + +## [0.2.4] - 2026-03-06 + +### Added +- **Global custom agents** — agents in `~/.pi/agent/agents/*.md` are now discovered automatically and available across all projects +- Two-tier discovery hierarchy: project-level (`.pi/agents/`) overrides global (`~/.pi/agent/agents/`) + +## [0.2.3] - 2026-03-05 + +### Added +- Screenshot in README + +## [0.2.2] - 2026-03-05 + +### Changed +- Renamed package to `@tintinweb/pi-subagents` +- Fuzzy model resolver now only matches models with auth configured (prevents selecting unconfigured providers) +- Custom agents hot-reload on each `Agent` tool call (no restart needed for new `.pi/agents/*.md` files) +- Updated pi dependencies to 0.56.1 + +### Refactored +- Extracted `createActivityTracker()` — eliminates duplicated tool activity wiring between foreground and background paths +- Extracted `safeFormatTokens()` — replaces 4 repeated try-catch blocks +- Extracted `buildDetails()` — consolidates AgentDetails construction +- Extracted `getStatusLabel()` / `getStatusNote()` — consolidates 3 duplicated status formatting chains +- Shared `extractText()` — consolidated duplicate from context.ts and agent-runner.ts +- Added `ERROR_STATUSES` constant in widget for consistent status checks +- `getDisplayName()` now delegates to `getConfig()` instead of separate lookups +- Removed unused `Tool` type export from agent-types + +## [0.2.1] - 2026-03-05 + +### Added +- **Persistent above-editor widget** — tree view of all running/queued/finished agents with animated spinners and live stats +- **Concurrency queue** — configurable max concurrent background agents (default: 4), auto-drain +- **Queued agents** collapsed to single summary line in widget +- **Turn-based widget linger** — completed agents clear after 1 turn, errors/aborted linger for 2 extra turns +- **Colored status icons** — themed rendering via `setWidget` callback form (`✓` green, `✓` yellow, `✗` red, `■` dim) +- **Live response streaming** — `onTextDelta` shows truncated agent response text instead of static "thinking..." + +### Changed +- Tool names match Claude Code: `Agent`, `get_subagent_result`, `steer_subagent` +- Labels use "Agent" / "Agents" (not "Subagent") +- Widget heading: `●` when active, `○` when only lingering finished agents +- Extracted all UI code to `src/ui/agent-widget.ts` + +## [0.2.0] - 2026-03-05 + +### Added +- **Claude Code-style UI rendering** — `renderCall`/`renderResult`/`onUpdate` for live streaming progress + - Live activity descriptions: "searching, reading 3 files…" + - Token count display: "33.8k token" + - Per-agent tool use counter + - Expandable completed results (ctrl+o) + - Distinct states: running, background, completed, error, aborted +- **Async environment detection** — replaced `execSync` with `pi.exec()` for non-blocking git/platform detection +- **Status bar integration** — running background agent count shown in pi's status bar +- **Fuzzy model selection** — `"haiku"`, `"sonnet"` resolve to best matching available model + +### Changed +- Tool label changed from "Spawn Agent" to "Agent" (matches Claude Code style) +- `onToolUse` callback replaced with richer `onToolActivity` (includes tool name + start/end) +- `onSessionCreated` callback for accessing session stats (token counts) +- `env.ts` now requires `ExtensionAPI` parameter (async `pi.exec()` instead of `execSync`) + +## [0.1.0] - 2026-03-05 + +Initial release. + +### Added +- **Autonomous sub-agents** — spawn specialized agents via tool call, each running in an isolated pi session +- **Built-in agent types** — general-purpose, Explore (defaults to haiku), Plan, statusline-setup, claude-code-guide +- **Custom user-defined agents** — define agents in `.pi/agents/.md` with YAML frontmatter + system prompt body +- **Frontmatter configuration** — tools, extensions, skills, model, thinking, max_turns, prompt_mode, inherit_context, run_in_background, isolated +- **Graceful max_turns** — steer message at limit, 5 grace turns, then hard abort +- **Background execution** — `run_in_background` with completion notifications +- **`get_subagent_result` tool** — check status, wait for completion, verbose conversation output +- **`steer_subagent` tool** — inject steering messages into running agents mid-execution +- **Agent resume** — continue a previous agent's session with a new prompt +- **Context inheritance** — fork the parent conversation into the sub-agent +- **Model override** — per-agent model selection +- **Thinking level** — per-agent extended thinking control +- **`/agent` and `/agents` commands** + +[0.6.3]: https://github.com/tintinweb/pi-subagents/compare/v0.6.2...v0.6.3 +[0.6.2]: https://github.com/tintinweb/pi-subagents/compare/v0.6.1...v0.6.2 +[0.6.1]: https://github.com/tintinweb/pi-subagents/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/tintinweb/pi-subagents/compare/v0.5.2...v0.6.0 +[0.5.2]: https://github.com/tintinweb/pi-subagents/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/tintinweb/pi-subagents/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0 +[0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9 +[0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8 +[0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7 +[0.4.6]: https://github.com/tintinweb/pi-subagents/compare/v0.4.5...v0.4.6 +[0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5 +[0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4 +[0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3 +[0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0 +[0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0 +[0.2.7]: https://github.com/tintinweb/pi-subagents/compare/v0.2.6...v0.2.7 +[0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6 +[0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/tintinweb/pi-subagents/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/tintinweb/pi-subagents/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/tintinweb/pi-subagents/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/tintinweb/pi-subagents/releases/tag/v0.1.0 diff --git a/pi-subagents/CONTRIBUTING.md b/pi-subagents/CONTRIBUTING.md new file mode 100644 index 0000000..0fe1692 --- /dev/null +++ b/pi-subagents/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to @tintinweb/pi-subagents + +This guide exists to save both sides time. + +## Philosophy + +`pi-subagents` is a [pi](https://pi.dev) extension, and it tries to stay focused: +spawn and orchestrate autonomous sub-agents that feel native to pi, and do that +well. Features that don't serve that goal, or that bolt on unrelated complexity, +are likely to be declined. When in doubt, open an issue and discuss the idea +before writing the code. + +The extension deliberately mirrors Claude Code's tool names, calling +conventions, and UI patterns. Changes should respect that compatibility unless +there's a good reason to diverge. + +## The One Rule + +**You must understand your code.** If you cannot explain what your changes do and +how they interact with the rest of the system, the PR will be closed. + +Using AI to write code is fine. Submitting AI-generated slop without +understanding it is not. + +## Filing Issues + +Keep issues short, concrete, and worth reading. + +- Keep it concise. If it does not fit on one screen, it is too long. +- Write in your own voice. If you used an LLM to draft it, review and shape it + yourself before posting. +- State the bug or request clearly, and explain why it matters. +- For bugs, include a minimal repro: pi version, this extension's version, your + agent/config, the steps, and the actual vs. expected behavior. +- If you want to implement the change yourself, say so. + +For security issues, do **not** open a public issue — see [SECURITY.md](SECURITY.md). + +## Before Submitting a PR + +For anything beyond a trivial fix, open an issue first so we can agree on the +approach before you invest the time. + +Make sure the full check suite passes locally: + +```bash +npm run lint # biome +npm run typecheck # tsc --noEmit +npm run test # vitest +npm run build # tsc +``` + +All four must pass. `npm run lint:fix` will auto-fix most style issues, and +`npm run test:e2e` runs the end-to-end suite if your change touches that surface. + +Other guidelines: + +- Keep PRs focused — one logical change per PR. Unrelated refactors make review + harder and are likely to be split out or declined. +- Add or update tests for behavior you change. +- Match the surrounding code style (enforced by biome). +- Do not edit `CHANGELOG.md`. Changelog entries are added by the maintainer. +- Update the README when you add or change user-facing behavior. + +## Questions? + +Open an [issue](https://github.com/tintinweb/pi-subagents/issues) — questions and +discussion are welcome. diff --git a/pi-subagents/LICENSE b/pi-subagents/LICENSE new file mode 100644 index 0000000..2921fa5 --- /dev/null +++ b/pi-subagents/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 tintinweb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pi-subagents/README.md b/pi-subagents/README.md new file mode 100644 index 0000000..de946e0 --- /dev/null +++ b/pi-subagents/README.md @@ -0,0 +1,866 @@ +# @tintinweb/pi-subagents + +A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types. + +> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. The root bundle installs it through `file:./pi-subagents`, default-loads the host-owned `extensions/subagents.ts` wrapper, and exposes its orchestration tools through Tool Search's checked-in `subagents` group. The local source implements the `pi-permission-system` child lifecycle bridge described below. See [`UPSTREAM.md`](UPSTREAM.md). + +## my-pi permission integration + +Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed. + +The bundle loads this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`. These exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list. + +Each child system prompt carries one sanitized `` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator. + +The off-screen conversation clone used by `@agent-…` mention handling disables extension, skill, prompt-template, and theme discovery and registers only its synthetic `Agent` tool. This prevents the root bundle's Tool Search lifecycle from replacing the clone's one-tool allowlist while keeping delegated agents themselves on the normal mandatory permission lifecycle. + +Permission decisions remain owned by `pi-permission-system`: + +- hard `deny` and deterministic `allow` stay local to the child's policy gate; +- an `ask` is forwarded to the root session rather than reviewed inside the child; +- root whole-session rules are matched against the child-fixed access facts before another review; +- root auto-review uses the root transcript, where direct human messages remain distinguishable from assistant-generated delegation prompts; +- reviewer `defer`, reviewer failure, or an approval outside the delegation envelope falls through to the root human authority; +- a one-shot approval is not copied into a reusable descendant grant, so executing the same side effect twice still requires two decisions. + +The lifecycle channel names are an event contract only. This package does not import permission-system internals or make policy decisions itself. + +pi-subagents screenshot + + +https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543 + +pi-color-badges-white + + +## Features + +- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native +- **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 10) and smart group join (consolidated notifications) +- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off` +- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view` +- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too +- **Custom agent types** — define agents in `.pi/agents/.md` or `.agents/agents/.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions, and Claude Code-compatible colored name badges +- **Nested subagents** — opt-in, default-off delegation: a custom agent that sets `allowed_subagents` gets its own ownership-scoped `Agent`, `get_subagent_result`, and `steer_subagent` tools, depth-capped from the main session (default 2). It can control only its own children, they are stopped when it finishes, and their transcripts and token spend roll up to it. The allowlist is a privilege boundary — a child runs with its own tools, so pick it as carefully as `tools:` itself +- **Agent mentions** — subagents are first-class: type `@explore also check the RPC path` at the prompt and it goes to that agent instead of the main model, without a word of it entering the chat. One syntax covers the whole lifecycle — message it while it runs, resume it once it has finished, reopen its session from disk long after that, or start it if it never ran. Mentioning an agent that isn't running spawns it through an off-screen clone of the conversation, so it gets Claude Code's context-written prompt and a real `Agent` tool call without a word of it reaching the chat; `direct` mode starts it here from your text instead, with no model call at all. The orchestrator can `name` an agent so you address it as `@auth-audit`, and handles work in `steer_subagent`/`get_subagent_result` too. `@` completes live agents, resumable ones, and startable types alongside pi's file completion; `@main` forces text back to the main model. Toggle via `/agents → Settings → Agent mentions` +- **Mid-run steering** — inject messages into running agents to redirect their work without restarting +- **Session resume** — pick up where an agent left off, preserving full conversation context. Resumes detached by default and notifies you on completion, just like a fresh spawn; pass `run_in_background: false` to block and get the result inline +- **Graceful turn limits** — agents get a "wrap up" warning before hard abort, producing clean partial results instead of cut-off output +- **Case-insensitive agent types** — `"explore"`, `"Explore"`, `"EXPLORE"` all work. A type that doesn't resolve to exactly one *enabled* agent — unknown, disabled, or ambiguous between two agents differing only by case — falls back to general-purpose with a note, or is refused outright under [`fallbackSubagent: none`](#persistent-settings) +- **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models +- **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed +- **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools +- **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion +- **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `/SKILL.md` directory layout supported) +- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter +- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually +- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity +- **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on session start +- **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs` +- **Model scope enforcement** — opt-in validation that subagent model choices stay within your pi `enabledModels` allowlist (sourced from `/scoped-models`, with both global and project-local pi settings honored). Caller-supplied out-of-scope → hard error to orchestrator; frontmatter-pinned out-of-scope → warning + runs anyway (frontmatter authoritative). Toggle via `/agents → Settings → Scope models` + +## Install + +```bash +pi install npm:@tintinweb/pi-subagents +``` + +Or load directly for development: + +```bash +pi -e ./src/index.ts +``` + +## Quick Start + +The parent agent spawns sub-agents using the `Agent` tool: + +``` +Agent({ + subagent_type: "Explore", + prompt: "Find all files that handle authentication", + description: "Find auth files", + run_in_background: true, +}) +``` + +Agents run in the background by default: the call returns an ID immediately and notifies you on completion, carrying a preview of the result (use `get_subagent_result` for the full text). Pass `run_in_background: false` to block until the agent finishes and get its full output inline. + +### Scheduling + +Add a `schedule` field to register the agent to fire later instead of running now: + +``` +Agent({ + subagent_type: "Explore", + prompt: "Look at recent commits and summarize what changed since last week", + description: "Weekly commit review", + schedule: "0 0 9 * * 1", // 9am every Monday (6-field cron) +}) +``` + +Schedule formats: + +- **Cron** — 6-field (`second minute hour day-of-month month day-of-week`), e.g. `"0 0 9 * * 1"` for 9am every Monday, `"0 */15 * * * *"` for every 15 minutes. +- **Interval** — `"5m"`, `"1h"`, `"30s"`, `"2d"`. Fires repeatedly at that interval. +- **One-shot relative** — `"+10m"`, `"+2h"`, `"+1d"`. Fires once at that future time. +- **One-shot absolute** — full ISO timestamp, e.g. `"2026-12-25T09:00:00.000Z"`. + +When a schedule fires, the spawn runs in background and its completion notification arrives in the conversation through the same `subagent-notification` followUp path as a manually-spawned background agent — your parent agent reasons about the result the same way. + +Schedules are **session-scoped**: they reset on `/new` and restore on `/resume`. List and cancel via `/agents → Scheduled jobs` (creation is the `Agent` tool's job — there is no parallel manual-create wizard). Storage at `/.pi/subagent-schedules/.json` with PID-based file locking for cross-instance safety. + +**Disable the feature entirely**: `/agents → Settings → Scheduling → disabled` removes `schedule` from the `Agent` tool spec (no LLM-context cost), hides the menu entry, and stops any active scheduler. The schema-level removal takes effect on the next pi session; the runtime kill is immediate. Re-enable from the same menu. + +Restrictions: +- `schedule` cannot be combined with `inherit_context` (no parent conversation exists at fire time) or `resume` (schedules create fresh agents). +- `run_in_background: false` is refused — scheduled jobs always run in the background. Omitting it, or passing `true`, is fine. +- Scheduled fires bypass the `maxConcurrent` queue so a 5-minute interval cannot be deferred behind long-running manual agents. +- **Headless `pi -p` doesn't wait for scheduled subagents.** + +## UI + +The extension renders a persistent widget above the editor showing active agents. By default it shows background runs only (`widgetMode: background`) — foreground agents already render inline as the `Agent` tool result, so the widget would otherwise double-render them. Switch to `all` (every agent) or `off` (hide the widget) via `/agents → Settings → Widget`: + +``` +● Agents +├─ ⠹ Agent Refactor auth module · ↻5≤30 · 5 tool uses · 33.8k token (62%) · 12.3s +│ ⎿ editing 2 files… +├─ ⠹ Explore Find auth files · ↻3 · 3 tool uses · 12.4k token (8%) · 4.1s +│ ⎿ searching… +├─ ⠹ Agent Long-running task · ↻42 · 38 tool uses · 91.0k token (84% · ⇊2) · 2m17s +│ ⎿ reading… +└─ 2 queued +``` + +The token field is annotated with two optional signals inside parens: +- **`NN%`** — context-window utilization (color-coded: <70% dim, 70–85% warning, ≥85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction. +- **`⇊N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency. + +### FleetView + +While subagents are running, a Claude Code-style navigable list renders **below** the editor: + +``` + esc to interrupt · ← for agents · ↓ to manage + + ● main + ○ general-purpose Sleep then report 1 11s · ↓ 13.1k tokens + ○ general-purpose Sleep then report 2 11s · ↓ 13.1k tokens + ↓ 3 more +``` + +The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `●`, the rest `○`. The selected row renders in the theme's primary text color rather than the muted/dim treatment of the others; an agent with a configured `color` shows its badge there too, bolded. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. Inside the overlay, press `Enter` to steer the running agent — type a message and `Enter` to send it (`Esc` or an empty submit returns), and it redirects the agent the same way the `steer_subagent` tool does. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`. + +### Agent mentions + +Subagents are addressable. Every agent has a typeable handle — the agent type, lowercased, numbered when instances collide (`explore`, `explore-2`) — and `@handle ` at the prompt talks to that agent, whatever state it happens to be in. Type `@` to pick one: + +``` +❯ @ + @auth-audit send message · Explore · running · audit the auth flow + @explore-2 send message · running · find flaky tests + @code-review resume · code-review · check the diff + @plan start agent · Software architect agent for designing implementation plans. + index.ts src/index.ts ← pi's own file rows, still there + index.d.ts dist/index.d.ts +``` + +The handle names the **agent**, not one process, so a single syntax covers its whole lifecycle: + +| State | `@explore fix the flaky test` does | +|-------|-----------------------------------| +| running or queued | sends the message into its conversation, exactly as `steer_subagent` would | +| finished | **resumes** it in the background from its existing session, continuing where it left off | +| finished long ago, record gone | **reopens** its session from disk and continues there | +| never started | **starts** it — by default, through a clone of this conversation ([below](#starting-a-new-agent)) | + +No turn is ever spent in the main conversation, and nothing about the mention enters the chat. The answer comes back as the ordinary background-completion notification, so the main model can relay it. + +#### Starting a new agent + +Claude Code does not start a mentioned agent itself. `@agent-` becomes an *attachment* appending a `` to your prompt — "the user has expressed a desire to invoke the agent X; please invoke the agent appropriately, passing in the required context to it" — and the main model makes the tool call. There is no tool forcing and no allowed-tools narrowing: the mention constrains *which* agent, not what it is told. So the model writes the agent's prompt, giving it the conversation context a cold spawn lacks. + +The cost is a visible turn — the model's reasoning and its tool block, narrating a decision you already made by typing the handle. This extension keeps the mechanism and moves it off-screen. The conversation is copied into a throwaway in-memory session, that clone takes the turn holding only the `Agent` tool, and what it starts is an ordinary top-level agent: + +``` +@cyan whats your favorite color → (nothing in the chat) + └─ clone of this conversation, off-screen + └─ Agent(subagent_type: "cyan", prompt: …) + ▸ Cyan Agent favorite color ← widget, fleet row, handle +``` + +It is a literal clone — the session's own entries and the same system prompt, not [`inherit_context`](#agent-frontmatter)'s text rendering of them — taken from memory and compaction-aware, so what the copy reads is what the main model is working from. The clone gets one tool and one job; it cannot read, write or run anything, because an invisible turn with the full toolset could do invisible work. The agent it starts is attributed to the *real* session, so its transcript and `rootSessionId` land where they would have anyway, and it carries no `tool-use-id` — the main conversation never issued one. + +| Mode | `@plan sketch the migration`, with no Plan agent running | +|------|----------------------------------------------------------| +| `model` (default) | a clone of this conversation takes the turn off-screen and calls `Agent`, so the agent starts with a prompt **written from the conversation**. Nothing reaches the chat but a `Prompting @plan…` toast — the wording marks the wait for that turn, where `direct`'s `Started @plan` means it is already running | +| `direct` | the agent starts here, immediately, with your message verbatim as its prompt. No model call at all, so no latency before it begins | +| `off` | `@` means only "attach a file" again | + +Either way the started agent honours its own frontmatter — `model:`, `thinking:`, `max_turns:` all apply, since neither path passes them and the agent's config wins. Mentioning something as the very first thing in a session works: there is simply no history to carry, and the clone still runs on your model and system prompt. If it cannot deliver at all — a model can always answer in prose instead of calling the tool — the agent is started directly with your text and the toast says so, rather than leaving you with nothing running. + +`model` is also the only mode that works outside the TUI: `pi -p '@plan the migration'` clones, spawns, and reports through the normal completion path, where a direct start would have detached the agent and printed nothing. Messaging and resuming stay TUI-only for that reason, in both modes. + +Two things to weigh against `direct`: the clone re-sends the whole conversation, and the agent does not start until that turn finishes. + +**Named agents.** The `Agent` tool takes an optional `name`, so the orchestrator can call one `auth-audit` instead of leaving you to tell `@explore-2` from `@explore-3`. A name is *additive*: the type-derived handle is still assigned, so `@explore` keeps reaching that agent rather than starting a second one beside it. Both names share one namespace — an alias can never shadow a live handle or the reverse — and the popup shows one row per agent, under its alias, with the type moved into the description. `steer_subagent` and `get_subagent_result` accept a handle too, so you and the model address agents the same way. + +**Resuming much later.** Because subagent sessions are persisted by default ([`rememberAgents`](#persistent-settings)), a handle keeps working after the agent's in-memory record is evicted: `@explore anything else?` reopens the conversation from disk. Only the *definition* is re-resolved, so a continuation runs under the agent type's current frontmatter, not the one the first run used. If the type has since been deleted or disabled, the resume is refused rather than falling back to another agent — re-enable it and the handle works again. Names from an evicted agent stay reserved, so a later Explore becomes `explore-2` rather than shadowing something you can still reach; the 100 most recent are kept, and all of them are forgotten on `/new` and session switch. A resumed agent takes those names back, so `@explore` keeps meaning the same conversation. An agent whose session was only ever in memory leaves nothing to reopen, and the mention starts a fresh one instead; if the session file has since been deleted, the mention says so and frees the handle rather than silently sending your message to a new agent. + +The grammar mirrors Claude Code's, and is deliberately narrow so nothing gets swallowed by accident: + +| Input | Goes to | +|-------|---------| +| `@explore fix the flaky test` | the `explore` agent | +| `@agent-explore fix the flaky test` | the same agent — Claude Code's manual spelling, accepted as a synonym | +| `@main @explore is not a mention` | the main model, with `@main ` stripped — the escape hatch | +| `@explore` (no message) | the main model — a bare handle is never a send | +| `hey @explore look at this` | the main model — only a **leading** mention is routed | +| `@src/index.ts summarize this` | the main model, with pi's normal file attachment | +| `@nosuchagent hello` | the main model, verbatim — no agent, no type, no interception | + +While an agent is live its handle addresses *it*, so `@explore` never starts a second Explore alongside a running one — use the `Agent` tool for deliberate parallelism. `@` works too. `main` is reserved and can never be an agent's handle (a type slugging to it gets `main-2`); handles are capped at 64 characters. A handle written as typed always wins over the `@agent-` form, so an agent genuinely called `agent-explore` stays reachable. [Nested subagents](#nested-subagents) are not addressable — they are hidden from every top-level surface and only their owner may steer them, so a handle that would name one starts a fresh top-level agent instead of reaching through that boundary. Suggestions list live agents first, then resumable ones, then startable types — and then pi's own file rows, in the same popup: `@` stays the file picker it always was, and the handles are added to it rather than replacing it. Disable the whole thing via `/agents → Settings → Agent mentions`. + +A `direct`-mode start takes the non-tool spawn path shared with the scheduler and cross-extension RPC, so — like those — it writes no `.output` transcript. That is the trade for skipping the model call: a `model`-mode start goes through the real `Agent` tool and keeps everything. Live tool activity and the turn counter are *not* part of that trade — a direct start renders them like any other agent. A mention-*resumed* agent goes through the full resume wiring and keeps both in either mode. + +Individual agent results render Claude Code-style in the conversation: + +| State | Example | +|-------|---------| +| **Running** | `⠹ ↻3≤30 · 3 tool uses · 12.4k token (8%)` / `⎿ searching, reading 3 files…` | +| **Completed** | `✓ ↻8 · 5 tool uses · 33.8k token (62%) · 12.3s` / `⎿ Done` | +| **Wrapped up** | `✓ ↻50≤50 · 50 tool uses · 89.1k token (84% · ⇊2) · 45.2s` / `⎿ Wrapped up (turn limit)` | +| **Stopped** | `■ ↻3 · 3 tool uses · 12.4k token (8%)` / `⎿ Stopped` | +| **Error** | `✗ ↻3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` | +| **Aborted** | `✗ ↻55≤50 · 55 tool uses · 102.3k token (95% · ⇊3)` / `⎿ Aborted (max turns exceeded)` | + +Completed results can be expanded (ctrl+o in pi) to show the full agent output inline. + +By default, foreground and background agents each stream their full conversation to a per-subagent transcript — a JSON-lines file at `/pi-subagents-///tasks/.output` (owner-only `0700`, cleared on reboot). Set `output_transcript: false` on a custom agent to write no transcript path or file for it, or set `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (frontmatter overrides the project default). This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` (which commits the agent's work to a git branch) or `memory:` (durable files) — set those accordingly if the goal is to keep a run off disk entirely. Background agent completion notifications render as styled boxes: + +``` +✓ Find auth files completed + ↻3 · 3 tool uses · 12.4k token · 4.1s + ⎿ Found 5 files related to authentication... + transcript: /tmp/pi-subagents-501/home-user-project/sess-1/tasks/agent-abc123.output +``` + +Group completions render each agent as a separate block. The LLM receives structured `` XML for parsing, while the user sees the themed visual. + +## Default Agent Types + +| Type | Tools | Model | Prompt Mode | Description | +|------|-------|-------|-------------|-------------| +| `general-purpose` | all 7 | inherit | `append` (parent twin) | Inherits the parent's full system prompt — same rules, CLAUDE.md, project conventions | +| `Explore` | read, bash, grep, find, ls | haiku (falls back to inherit) | `replace` (standalone) | Fast codebase exploration (read-only) | +| `Plan` | read, bash, grep, find, ls | inherit | `replace` (standalone) | Software architect for implementation planning (read-only) | + +The `general-purpose` agent is a **parent twin** — it receives the parent's entire system prompt plus a sub-agent context bridge, so it follows the same rules the parent does. Explore and Plan use standalone prompts tailored to their read-only roles. + +Default agents can be **ejected** (`/agents` → select agent → Eject) to export them as `.md` files for customization, **overridden** by creating a `.md` file with the same name (e.g. `.pi/agents/general-purpose.md`), or **disabled** per-project with `enabled: false` frontmatter. + +## Custom Agents + +Define custom agent types by creating `.md` files. The frontmatter `name:` is the `subagent_type` and dispatch identity, falling back to the filename when absent; `display_name` only changes the UI label. Claiming a default agent's name overrides it. + +Agents are discovered from three locations (higher priority wins): + +| Priority | Location | Scope | +|----------|----------|-------| +| 1 (highest) | `.pi/agents/.md` | Project — pi's config dir; authoritative, and where `/agents` writes | +| 2 | `.agents/agents/.md` | Project — the shared cross-tool `.agents` workspace (same convention as `.agents/skills/`) | +| 3 | `$PI_CODING_AGENT_DIR/agents/.md` (default `~/.pi/agent/agents/.md`) | Global — available everywhere | + +Project-level agents override global ones with the same name, so you can customize a global agent for a specific project. If both project locations define the same name, **`.pi/agents/` wins** — `.pi` stays the project authority; `.agents/agents/` is an additional read location for projects that keep their agent assets in the `.agents` workspace. The global location follows the upstream `PI_CODING_AGENT_DIR` env var — set it to relocate all pi-coding-agent state (agents, skills, settings) to a custom directory. An agent's name is its frontmatter `name:`, falling back to the filename, so two files can now claim the same one — the later load wins, and the warning below names the file that took over. + +An unreadable or unparseable agent file is skipped, not fatal — a warning names the file and the error. If it was overriding a same-named agent, a second line names the file that loads instead. Set `strictAgentFiles: true` in `subagents.json` (or `/agents → Settings → Strict agent files`) to fail startup on a broken file instead; mid-session reloads still only warn. + +### Example: `.pi/agents/auditor.md` + +```markdown +--- +color: red +description: Security Code Reviewer +tools: read, grep, find, bash +model: anthropic/claude-opus-4-6 +thinking: high +max_turns: 30 +--- + +You are a security auditor. Review code for vulnerabilities including: +- Injection flaws (SQL, command, XSS) +- Authentication and authorization issues +- Sensitive data exposure +- Insecure configurations + +Report findings with file paths, line numbers, severity, and remediation advice. +``` + +Then spawn it like any built-in type: + +``` +Agent({ subagent_type: "auditor", prompt: "Review the auth module", description: "Security audit" }) +``` + +### Frontmatter Fields + +All fields are optional — sensible defaults for everything. + +| Field | Default | Description | +|-------|---------|-------------| +| `description` | filename | Agent description shown in tool listings | +| `name` | filename | **The agent's type** — what `subagent_type` and `@handle` address. Claude Code's rule: the filename doesn't have to match, so `blubb.md` with `name: code-review` dispatches as `code-review`. Omit it and the filename is used. Any value works except one containing `:`, which Claude Code reserves for plugin-scoped identifiers — such a file is skipped with a warning. Two files may declare the same name; the later load wins, as a filename clash always did | +| `display_name` | the type | Label shown in the UI (widget, agent list, badges) — cosmetic only, and independent of `name`. Claude Code has no equivalent; a file that sets only `name` badges as its type, unchanged | +| `color` | — | Background color for the agent name badge in the Agent tool header, widget, FleetView, and conversation viewer. Supports Claude Code's `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan` (the values its own default theme uses); quoted six-digit hex such as `"#8B5CF6"`; and Agency Agents aliases (`amber`, `teal`, `indigo`, `gold`, `neon-green`, `neon-cyan`, `metallic-blue`, `violet`, `rose`, `lime`, `gray`/`grey`, `fuchsia`, `slate`, `navy`). Badge text is black or white, whichever clears 4.5:1 against the rendered background — Claude Code uses one inverse color for every badge. Invalid values render no badge and preserve each surface's existing theme foreground | +| `tools` | all 7 | Which tools the agent can call. Built-in names (`read, grep, …`), `*` / `all` (all built-ins), `none`, and `ext:` / `ext:/` selectors for extension tools. See [Tool & extension scoping](#tool--extension-scoping) below | +| `extensions` | `true` | Which extensions to load for the agent. `true` (all defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. See [Tool & extension scoping](#tool--extension-scoping) below | +| `exclude_extensions` | — | Extension denylist applied after `extensions:` — exclude wins. Plain names only (case-insensitive), no paths or `*`. Useful with `extensions: true` to drop one extension (e.g. `pi-notify`) | +| `skills` | `true` | `true` inherits the parent's skills; `false` inherits none. A comma-separated list preloads **only** those skills into the system prompt and does not inherit the rest (see [Skill Preloading](#skill-preloading) for discovery locations) | +| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents | +| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them | +| `isolation` | — | Set to `worktree` to run in an isolated git worktree, or `off` to refuse one even when the caller passes `isolation: "worktree"` (frontmatter is authoritative). `none`, `no`, and `false` are accepted spellings of `off` | +| `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it | +| `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down | +| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited | +| `persist_session` | `subagents.json` `rememberAgents` (default `true`) | Persist this subagent as a normal pi session instead of keeping the session in memory only; overrides the `rememberAgents` project default in both directions. It records its spawning session as parent, so it nests under it in `/resume`. The subagent's `.output` transcript is still written either way unless `output_transcript: false` | +| `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` | +| `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd. A session outside the parent's session directory (this override, or `isolation: worktree`) is listed separately, so it shows as a root instead of nesting | +| `allowed_subagents` | none | Opt in to scoped nested `Agent`, `get_subagent_result`, and `steer_subagent` tools. Omitted / empty / `none` / `false` = no nesting; `all` (or `"*"` / `true`) = any enabled agent; comma-separated list = only those agent types | +| `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) | +| `inherit_context` | `false` | Fork parent conversation into agent | +| `run_in_background` | — | Pin this agent to background (`true`) or foreground (`false`). Omit to follow `backgroundByDefault` | +| `isolated` | `false` | Hermetic specialist mode: forces optional `extensions: false` + `skills: false` + drops `ext:` selectors. Only built-in tools surface; host-mandatory infrastructure handlers may still bind. Distinct from `isolation: worktree` (filesystem) | +| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) | + +Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified. + +**Forgiving `model:` resolution.** A `model:` pin is matched against pi's model registry tolerantly, so cosmetic id variations don't silently drop the agent back to the parent's model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`), a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id and vice-versa), and a `provider/modelId` whose named provider doesn't carry that model retries the bare id against every provider. Precedence is **exact → fuzzy under the named provider → same model under any provider → unavailable**, so an exact match always wins and dated snapshots aren't conflated. If nothing resolves, the pin can't run and the agent inherits the parent model — `/agents → Agent types` flags this case as `(unavailable, fallback: inherit)` and shows the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured. (This is distinct from [Model Scope](#model-scope) enforcement, which matches the `enabledModels` allowlist by *exact* entry.) + +### Nested subagents + +Nested delegation is default-off. Set `allowed_subagents` only on a non-isolated custom agent that owns a real fan-out responsibility: + +```yaml +--- +tools: read, grep, find +extensions: false +allowed_subagents: support-file-finder, support-callsite-tracer # or `all` +--- +``` + +**The allowlist is a privilege boundary, not just a routing hint.** A child runs with *its own* `tools:`, `extensions:`, and `isolated:` — the parent's restrictions are not inherited — so delegation grants the parent the union of what the listed agents can do. The read-only agent above can write and run commands through any listed agent that can, and `all` reaches every enabled agent including `general-purpose`. Choose the list as carefully as you would choose `tools:` itself; that is the main reason this is default-off. + +`allowed_subagents` is runtime-enforced. A comma-separated list restricts nesting to those types; `all` (or `"*"` / `true`, matching how `extensions:` and `skills:` take booleans) allows any enabled agent; omitted, empty, `none`, or `false` means no nested tools are injected at all. Unknown, disabled, and out-of-list types are rejected rather than falling back — regardless of the project's [fallback agent](#persistent-settings) setting, so a configured fallback can never hand a nested caller an agent outside its allowlist — and a nested `model:` is validated against [Model Scope](#model-scope) exactly like a top-level spawn. Result, resume, and steering operations are ownership-scoped, so a parent can control only its own children. Nested records remain internal to that parent and do not appear in top-level tools, lifecycle events, or agent UI — so when a parent finishes, is stopped, or ends a resumed turn, its nested children are stopped with it. They do write their own `.output` transcript (subject to the same `output_transcript` gate), filed under the root session's directory alongside their ancestors', so a nested run can still be inspected after the fact. Their token usage is folded into every ancestor's totals up to the top-level agent (lifecycle events, completion notifications, `/agents`), so nested spend stays attributable at any depth even though the children themselves stay hidden. A nested result that ends `stopped`, `aborted`, or `steered` is labelled as partial, the same guarantee top-level results carry. + +The hard cap is depth 2 by default: main session (0) → subagent (1) → nested child (2). Change it project-wide with `maxSubagentDepth` in `subagents.json` (or `/agents → Settings → Nested depth`); `0` or `1` turns nesting off everywhere. An agent already at the cap gets no nested tools at all — not even `get_subagent_result`, since it can never own a child. A child must independently set `allowed_subagents` to delegate again; isolated agents never receive nested tools. + +Nested children don't occupy `maxConcurrent` slots — their parent already holds one, and queueing them behind it would deadlock a parent waiting on its own child. The depth cap bounds how *deep* nesting goes, not how *wide*: a parent's only limit on concurrent children is that each spawn costs it a turn. Pair `allowed_subagents` with a `max_turns` on that agent if you want a hard ceiling on its fan-out. + +Because a subagent session never activates this extension (that is what keeps a child from building a second agent manager, and it is why nested tools are injected directly instead), a subagent also gets none of the extension's other surfaces: no `/agents` command, no cross-extension RPC handlers, no `subagents:ready` event. + +### Tool & extension scoping + +`extensions:` decides **which optional extensions load**, while host-mandatory lifecycle entries remain bound; `tools:` decides **which tools surface to the LLM**. They compose: + +```yaml +# Default (both omitted): all extensions load, all 7 built-ins surface + +tools: read, grep, find # narrow to listed built-ins; extensions still load +tools: "*" # all 7 built-ins (alias: `all`) +tools: none # zero built-ins (alias: `""`) +tools: "*, ext:mcp/search" # built-ins plus one extension tool + +extensions: false # no optional extensions; host-mandatory handlers still load +extensions: [mcp] # only mcp loads +extensions: ["*", "/abs/foo.ts"] # all defaults plus one path-loaded extension + +exclude_extensions: pi-notify # everything except pi-notify (with extensions: true) + +# Host policy is stronger than this frontmatter: exact mandatory extension +# entries still bind, but their tools follow the normal policy above. + +# Specialist: load one extension, expose only one of its tools, keep built-ins +extensions: [mcp] +tools: "*, ext:mcp/search" + +isolated: true # built-ins only; no optional extension tools/skills/context +``` + +A few rules the examples don't make obvious: + +- `extensions:` is the sole loading authority. `ext:foo` in `tools:` narrows what surfaces; it can't load `foo` on its own. Mismatches fire `extension-error:…` warnings. +- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension. +- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive. +- Extensions that register tools **lazily** work too. MCP-backed extensions typically can't enumerate their tools until their servers connect, so they register from `session_start` or `before_agent_start` rather than at load. Subagent scoping is re-derived as tools appear, so these surface normally — including under `ext:` selectors, which keep narrowing correctly no matter when a tool shows up. +- Extensions bound into a subagent see **both ends** of that session's lifecycle: `session_start` when the agent starts, `session_shutdown` (reason `quit`) when its session is disposed — on quit, and when its record is evicted ~10 minutes after it finishes. Release per-session resources there; anything left armed outlives the session it belongs to. Handlers are given three seconds on quit, after which teardown proceeds regardless. +- An installed **package** extension matches by its package short name (`@scope/pi-subagents` → `[pi-subagents]`), in addition to its path-derived name (a package whose entry is `src/index.ts` also answers to `[src]`). Prefer the package name — the path-derived one is incidental. +- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent. +- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning. +- `exclude_extensions:` is **not a sandbox**: excluded extensions' factory code still executes once during loading. Exclusion suppresses their tools and their bound lifecycle hooks (`pi.on` handlers like `session_start` only fire for extensions bound to the session), but not other load-time side effects — a factory that subscribes directly to the shared `pi.events` bus stays live. Don't rely on it to contain an untrusted extension. +- Array and string forms are equivalent: `[a, b]` == `"a, b"`. + +**How an agent's scope is advertised.** The Agent tool description lists every available agent with a `(Tools: …)` suffix, and that suffix is what the orchestrator reads when deciding where to route work. It describes **built-in scope only** — extension tools are resolved when the agent runs (extensions may register lazily, see above), so they can't be enumerated when the description is built: + +| `tools:` | suffix | +|---|---| +| omitted, `*`, or `all` | `*` | +| a list of built-ins | that list, e.g. `read, grep` | +| `none` with `isolated: true` or `extensions: false` | `none` | +| `none`, or only `ext:` entries, with extensions loading | `no built-ins, extension tools only` | + +The last two rows are separate because zero built-ins is not zero tools: `tools: none` alongside `extensions:` still surfaces every extension tool, so calling it `none` would understate what the agent can do. Note `*` doesn't enumerate extension tools either — an agent with `tools: "*, ext:mcp/search"` advertises `*`. + +## Tools + +### `Agent` + +Launch a sub-agent. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `prompt` | string | yes | The task for the agent | +| `description` | string | yes | Short 3-5 word summary (shown in UI) | +| `name` | string | no | Memorable name for this agent (`auth-audit`), addressable as `@name` and accepted by `steer_subagent`/`get_subagent_result`. Additive — the type-derived handle is still assigned | +| `subagent_type` | string | yes | Agent type (built-in or custom) | +| `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp interchangeable) with provider fallback | +| `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh, max (availability depends on pi version and model) | +| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) | +| `run_in_background` | boolean | no | Defaults to `true`; `false` blocks and returns the result inline | +| `resume` | string | no | Agent ID to resume a previous session | +| `isolated` | boolean | no | No extension/MCP tools | +| `isolation` | `"off"` \| `"worktree"` | no | `worktree` runs in an isolated git worktree; `off` (the default) does not. Absent from the schema entirely when `worktreeIsolation: false` | +| `inherit_context` | boolean | no | Fork parent conversation into agent | + +### `get_subagent_result` + +Check status and retrieve results from a background agent. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `agent_id` | string | yes | Agent ID to check | +| `wait` | boolean | no | Wait for completion | +| `verbose` | boolean | no | Include full conversation log | + +Cancelling a `wait: true` call (for example, with `Esc`) stops only the wait. The background agent keeps running, and its completion notification still arrives normally. + +### `steer_subagent` + +Send a steering message to a running agent. The message interrupts after the current tool execution. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `agent_id` | string | yes | Agent ID to steer | +| `message` | string | yes | Message to inject into agent conversation | + +## Commands + +| Command | Description | +|---------|-------------| +| `/agents` | Interactive agent management menu | + +The `/agents` command opens an interactive menu: + +``` +Running agents (2) — 1 running, 1 done ← only shown when agents exist +Agent types (6) ← unified list: defaults + custom +Create new agent ← manual wizard or AI-generated +Settings ← max concurrency, max turns, grace turns, join mode +``` + +- **Running agents** — select one to open its live conversation viewer. While it's still running, press `Enter` to open the steering composer, then `Enter` again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; `Esc` or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion. +- **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Each row shows the agent's model, and the highlighted agent's full description appears below the list. The model column flags `(unavailable, fallback: inherit)` when a configured model can't be resolved (it would silently inherit the parent model), and shows `(→ provider/id)` when it resolves to a different provider or version than configured. Select an agent to manage it: + - **Default agents** (no override): Eject (export as `.md`), Disable + - **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete + - **Custom agents**: Edit, Disable, Delete + - **Disabled agents**: Enable, Edit, Delete +- **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it +- **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list (marked `✕`) and can be re-enabled +- **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file). Any name is allowed, including default agent names (overrides them) +- **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime + +## Graceful Max Turns + +Instead of hard-aborting at the turn limit, agents get a graceful shutdown: + +1. At `max_turns` — steering message: *"Wrap up immediately — provide your final answer now."* +2. Up to 5 grace turns to finish cleanly +3. Hard abort only after the grace period + +| Status | Meaning | Icon | +|--------|---------|------| +| `completed` | Finished naturally | `✓` green | +| `steered` | Hit limit, wrapped up in time | `✓` yellow | +| `aborted` | Grace period exceeded | `✗` red | +| `stopped` | User-initiated abort | `■` dim | + +## Concurrency + +Background agents are subject to a configurable concurrency limit (default: 10). Excess agents are automatically queued and start as running agents complete. The widget shows queued agents as a collapsed count. + +Foreground agents bypass the queue — they block the parent anyway. Since agents run in the background by default, nearly every spawn now takes a slot; the limit was raised from 4 so that ordinary parallel fan-outs don't queue. + +## Join Strategies + +When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents. + +| Mode | Behavior | +|------|----------| +| `smart` (default) | 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification. Solo agents notify individually. | +| `async` | Each agent sends its own notification on completion (original behavior). Best when results need incremental processing. | +| `group` | Force grouping even when spawning a single agent. Useful when you know more agents will follow. | + +**Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers. + +**Configuration:** +- Configure join mode in `/agents` → Settings → Join mode + +## Model Scope + +**Opt-in:** off by default. Enable via `/agents → Settings → Scope models`. + +When on, each subagent spawn's effective model is validated against pi's own `enabledModels` list (configured via pi's `/scoped-models` UI). pi-subagents reads that list; it doesn't manage it. Both of pi's settings files are honored: global `~/.pi/agent/settings.json` and project-local `/.pi/settings.json`. **Project overrides global** — mirrors pi's `SettingsManager` deep-merge, so a tighter per-project scope (hand-edited into the project settings) is respected. + +**Out-of-scope handling depends on source:** + +| Model source | Out-of-scope behavior | +|---|---| +| Caller-supplied via `Agent({ model: "..." })` | Hard error returned to the orchestrator, listing allowed models | +| Pinned in agent frontmatter | Warning toast + the pinned model runs (frontmatter is authoritative) | +| Parent-inherited (neither set) | Warning toast + parent's model runs | + +**Design:** `scopeModels` is a guardrail against the orchestrator picking unexpected models at runtime, not a hard policy against user-level config. The "frontmatter is authoritative" guarantee from v0.5.1 still holds for `model:` — caller params can't override frontmatter, and frontmatter pins run even when out of scope (with a visible warning). + +**Nested spawns** ([nested subagents](#nested-subagents)) apply the same table against the parent's config root. The hard-error case is identical; the warning cases proceed silently, since a subagent session has no UI to toast to. + +**Pattern format:** only exact `provider/modelId` entries are honored (e.g. `anthropic/claude-haiku-4-5-20251001`). Glob patterns (`*sonnet*`), bare model IDs, and `:thinking` suffixes — which pi itself supports — are silently dropped here. pi's `/scoped-models` picker writes exact entries, so the limitation is invisible if you configure scope through the UI. Hand-edited globs produce an empty allowed set (scope check becomes a no-op). + +**No-op safety:** if `enabledModels` is missing or empty in pi's settings, scope check skips entirely — no false positives, no spurious errors. + +## Persistent Settings + +Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default join mode, scheduling on/off, scope models on/off, disable defaults on/off, strict agent files on/off, agent mentions on/off, output transcript on/off, tool description full/compact/custom, widget all/background/off, usage reporting on/off, cost display on/off) persist across pi restarts. Two files, merged on load: + +- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here. +- **Project:** `/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings. + +**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `10`, default max turns unlimited, grace turns `5`, nested depth `2`, join mode `smart`, defaults enabled). + +**Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it. + +**Fallback agent** (`fallbackSubagent`, default `general-purpose`): the agent used when a caller-supplied `subagent_type` doesn't resolve to exactly one enabled agent — unknown, disabled, or ambiguous because two agents differ only by case. Name any enabled agent to route those calls there instead, or set `none` for **strict**, fail-closed dispatch: the call is refused with an error listing the available types, and nothing spawns. Strict mode matters most for background and scheduled calls, which would otherwise start executing a substituted agent before the caller learns anything. Also settable from `/agents → Settings → Fallback agent`. The boolean `false` is accepted as a spelling of `none`, because it would otherwise be dropped as the wrong type and silently leave the permissive default in place. Every other value is read as an agent name, so a mistaken `off` fails loudly at dispatch rather than meaning one thing in the settings file and another in the resolver. A fallback agent that is itself unknown or disabled is a misconfiguration and is reported rather than quietly replaced. Note the default is unchanged and stays permissive by design: with `disableDefaultAgents` and no `general-purpose` of your own, an unresolvable type still resolves to a built-in config carrying *all* tools — set `none` (or name one of your own agents) to close that. + +**Strict agent files** (`strictAgentFiles`, default `false`): when on, an unreadable or unparseable [agent file](#custom-agents) aborts extension load at startup and names the file, instead of being skipped with a warning — so a checked-in `.pi/agents/` can't silently fall through to a same-named agent from another location. Startup only: the mid-session reload that runs on each `Agent` call keeps warning either way, since a bad edit shouldn't kill a session on an unrelated spawn. Also settable from `/agents → Settings → Strict agent files`. + +**Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your project/global custom agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup). + +**Agent mentions** (`agentMentions`, default `"model"`): whether [`@handle message`](#agent-mentions) at the prompt addresses that subagent instead of the main model — messaging, resuming or starting it — and whether `@` offers agents alongside pi's file completion. `"model"` and `"direct"` differ only in [who starts an agent that isn't running](#starting-a-new-agent): an off-screen clone of this conversation, via a `` and a real `Agent` call, or this extension, immediately and with no model call. Messaging and resuming are direct in both. `"off"` gates all three actions plus the suggestion list, so `@` means only "attach a file" again and every `@…` prompt reaches the main model verbatim. Toggle via `/agents → Settings → Agent mentions`; applied live. The booleans this setting used to take are still read — `true` as `"model"`, `false` as `"off"`. + +**Background by default** (`backgroundByDefault`, default `true`): what an `Agent` call that doesn't say means. On — following Claude Code — the agent runs detached, the call returns its ID immediately, and a completion notification carries a preview of the result (`get_subagent_result` for the full text). Set `false` to restore the previous behaviour, where an unqualified spawn blocked the turn and returned its output inline. An explicit `run_in_background` on the call, or in an agent file's frontmatter, overrides this in both directions; the setting only decides what "unspecified" means. **Top-level only** — a nested spawn (an agent spawning its own) always defaults to foreground, because a detached child is stopped when its parent settles and has no notification path of its own. Toggle via `/agents → Settings → Background by default`; applied live. + +**Remember agents** (`rememberAgents`, default `true`): whether subagents persist their pi session, which is what lets [`@handle`](#agent-mentions) reopen an agent's conversation after its in-memory record has been evicted. Two visible consequences of the default: top-level subagents write a session file, and they nest under the session that spawned them in pi's `/resume`. Agents spawned by another agent are excluded — they get no handle, so nothing could reopen their transcript. A custom agent's `persist_session` frontmatter overrides this per agent, in both directions. Toggle via `/agents → Settings → Remember agents`; with it off, handles expire with their record (roughly ten minutes past completion) and `@explore` then starts a fresh agent rather than resuming — the behaviour before this setting existed. + +**Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's `.output` transcript. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make transcripts opt-in project-wide — useful when run transcripts shouldn't sit on disk for backup or DLP tooling to pick up. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files. + +**Worktree isolation** (`worktreeIsolation`, default `true`): whether `isolation: "worktree"` may create a worktree at all. Toggle via `/agents → Settings → Worktree isolation`, or set `false` in `subagents.json` on a repo where a copy costs too much time or disk. Off, the `Agent` tool's `isolation` parameter is dropped from the schema entirely and the bullet describing it leaves the tool description with it — nothing to pass, and no context spent describing it — and worktrees are refused on every other path too (agent files, scheduled jobs, cross-extension RPC). The `/agents` agent-file generator stops offering the `isolation:` frontmatter field too, so a generated agent can't bake in a request that would be refused. A requested worktree is downgraded to a normal run rather than failing the call, since declining one is the point; there is deliberately no note on the result, which is exactly why the prose has to go when the parameter does. The refusal applies immediately; the parameter and its prose appear or disappear on the next pi session. See [Turning worktrees off](#turning-worktrees-off). + +**Report usage to session** (`reportUsage`, default `false`): whether subagent spend is added to *this* session's own totals. Subagents run in their own pi sessions, so by default pi's footer, statusline and `/cost` count only what the main model spent — a session that delegated most of its work reads as nearly free. Turn it on and each `Agent` / `get_subagent_result` / `steer_subagent` result carries the spend accumulated since the last one, which pi folds into `getSessionStats()`; `/cost` attributes it to the **Tools/summaries** bucket. Toggle via `/agents → Settings → Report usage to session`; applied live. + +Three things worth knowing about the numbers. Every token component is reported, `cacheRead` included — the cached prefix genuinely is re-read and re-billed on every call, and pi counts it the same way for the session's own messages, so withholding it would make a subagent's rows count differently from every other row in one total. (The extension's *own* token displays still leave it out, which is a different question: there it inflates a reading of how much work was done.) Cost is pi's own per-message figure, priced from the model's listed rates; a model pi has no rates for contributes zero rather than an estimate. And the context-window percentage is untouched: pi derives it from assistant messages alone, so a delegating session's context doesn't appear to fill up faster. Agents that finish in the background have no tool result of their own to ride on, so their spend is carried by the next one you make — the footer catches up on the following call, not the moment they finish. + +**Show cost** (`showCost`, default `false`): whether the subagent surfaces print an estimated cost beside their token counts — the widget (running *and* finished lines), [FleetView](#fleetview), the conversation viewer, foreground results, `get_subagent_result`, and completion notifications: + +```text +├─ ⠹ Explore inspect code · ↻3 · 8.2k token · ~$0.0042 · 4.1s +✓ Explore inspect code · ↻8 · 5 tool uses · ~$0.0181 · 12.3s +``` + +When several background agents finish together, their notification is topped with the batch total (`3 agents · 45.1k token · ~$0.042`) so the figures don't have to be added up by hand. + +The `~` marks it as pi's estimate rather than a billed figure. **A cost is shown only when there is one to show:** a model pi has no pricing data for reports zero, and `$0.00` beside its tokens would say the run was measured and found free rather than never measured — so nothing is printed at all, on every surface. For the same reason a real cost too small to render reads `<$0.0001`. Figures keep cents at minimum and four decimals at most (`~$0.0042`, `~$0.05`, `~$1.24`) — rounding everything to cents would print the same number for runs that differed fourfold. + +Independent of `reportUsage`: this one is what you read, that one is what your session counts. Toggle via `/agents → Settings → Show cost`; applied live. + +**Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session. + +`"custom"` registers your own description from `/.pi/agent-tool-description.md` (project) or `/agent-tool-description.md` (global; project wins). The file is read once at tool registration, so edits also apply on the next pi session. Dynamic parts stay live via placeholders — a static agent list would go stale the moment you add a custom agent: + +```markdown +Launch an autonomous agent. Available types: +{{typeList}} + +Custom agents live in .pi/agents/ or {{agentDir}}/agents/. +``` + +Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{isolationGuideline}}` and `{{scheduleGuideline}}` (each expands with its own leading newline + `- ` bullet when the matching feature is on — place them directly after your last rule line; empty when [worktree isolation](#turning-worktrees-off) / scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do. + +**Starting point:** copy [`examples/agent-tool-description.md`](examples/agent-tool-description.md) — it reproduces the default full description exactly (a CI test keeps it in sync), so you can trim from a known-good baseline instead of writing from scratch. + +**Example — global defaults for a beefy machine:** + +```bash +mkdir -p ~/.pi/agent +cat > ~/.pi/agent/subagents.json <<'EOF' +{ + "maxConcurrent": 16, + "graceTurns": 10 +} +EOF +``` + +Every project now starts with concurrency 16 and grace 10, without ever touching the menu. Individual projects can still override via `/agents` → Settings. + +**Failure behavior:** missing file is silent; malformed JSON logs a `[pi-subagents] Ignoring malformed settings at …` warning to stderr; invalid/out-of-range field values are dropped per-field; write failures downgrade the `/agents` toast to a warning with `(session only; failed to persist)`. + +## Events + +Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react: + +| Event | When | Key fields | +|-------|------|------------| +| `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` | +| `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` | +| `subagents:completed` | Agent finished successfully (background and foreground) | `id`, `type`, `durationMs`, `tokens` (display total, `{ input, output, total }` — see the note below), `usage` (the run's spend as a pi `Usage`: token components including `cacheRead`, plus `cost.total` in USD; absent when nothing was spent), `toolUses`, `result` | +| `subagents:failed` | Agent errored, stopped, or aborted (background and foreground) | same as completed + `error`, `status` | +| `subagents:steered` | Steering message sent | `id`, `message` | +| `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` | +| `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) | +| `subagents:scheduler_ready` | Scheduler bound to session, enabled jobs armed | `sessionId`, `jobCount` | +| `subagents:ready` | RPC handlers registered and armed — fired on session start; not emitted in a session that excludes pi-subagents | — | +| `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) | +| `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean` — `false` on write failure) | + +`tokens.total` = `input + output + cacheWrite`. `cacheRead` is excluded — each turn's `cacheRead` is the cumulative cached prefix re-read on that one API call, so summing per-message would over-count it as a measure of work done. Use `contextUsage.percent` (surfaced as `(NN%)` in the widget) for current context size. + +`usage` answers the other question — what was billed — and so does include `cacheRead`, because the prefix really is re-read and re-charged on every call. It is a pi `Usage`, the same shape pi puts on `ToolResultEvent` and `AssistantMessage`, so `usage.cost.total` is where a listener already expects the money and anything pi adds to `Usage` arrives without a change here. Neither field derives from the other; `tokens` is a view model, `usage` is the data. + +## Cross-Extension RPC + +Other pi extensions can spawn and stop subagents programmatically via the `pi.events` event bus, without importing this package directly. + +All RPC replies use a standardized envelope: `{ success: true, data?: T }` on success, `{ success: false, error: string }` on failure. + +### Discovery + +Listen for `subagents:ready` to know when RPC handlers are available: + +```typescript +pi.events.on("subagents:ready", () => { + // RPC handlers are registered — safe to call ping/spawn/stop +}); +``` + +`subagents:ready` fires only when pi-subagents is actually loaded **and bound** in the current session. A session that excludes it (via an agent's `extensions:`) emits no `subagents:ready` and does not answer the RPC channels — exactly as if pi-subagents were not installed. Treat "no `subagents:ready`" as "not available here" and give discovery a timeout rather than waiting indefinitely. + +### Ping + +Check if the subagents extension is loaded and get the protocol version: + +```typescript +const requestId = crypto.randomUUID(); +const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => { + unsub(); + if (reply.success) console.log("Protocol version:", reply.data.version); +}); +pi.events.emit("subagents:rpc:ping", { requestId }); +``` + +### Spawn + +Spawn a subagent and receive its ID: + +```typescript +const requestId = crypto.randomUUID(); +const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => { + unsub(); + if (!reply.success) { + console.error("Spawn failed:", reply.error); + } else { + console.log("Agent ID:", reply.data.id); + } +}); +pi.events.emit("subagents:rpc:spawn", { + requestId, + type: "general-purpose", + prompt: "Do something useful", + options: { description: "My task", isBackground: true }, +}); +``` + +`options` is the manager's spawn-option object, not the `Agent` tool's parameter schema — the background flag is `isBackground`, and the tool's snake_case `run_in_background` is forwarded verbatim and ignored. Every RPC spawn returns its id immediately and runs detached either way; `isBackground: true` is what makes the agent occupy one of the `maxConcurrent` slots (and queue behind them when they are full) and what `subagents:created` reports. Leaving it unset starts the agent immediately regardless of the limit. A top-level RPC spawn renders in the widget and FleetView while it runs, with the same live tool activity and turn counter an `Agent`-tool spawn gets — only an explicit `isBackground: false` is dropped by the widget's default `background` mode, the way a foreground `Agent` call is. Nested spawns stay hidden from both. + +`options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context. + +`options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory. + +### Stop + +Stop a running agent by ID: + +```typescript +const requestId = crypto.randomUUID(); +const unsub = pi.events.on(`subagents:rpc:stop:reply:${requestId}`, (reply) => { + unsub(); + if (!reply.success) console.error("Stop failed:", reply.error); +}); +pi.events.emit("subagents:rpc:stop", { requestId, agentId: "agent-id-here" }); +``` + +Reply channels are scoped per `requestId`, so concurrent requests don't interfere. + +## Persistent Agent Memory + +Agents can have persistent memory across sessions. Set `memory` in frontmatter to enable: + +```yaml +--- +memory: project # project | local | user +--- +``` + +| Scope | Location | Use case | +|-------|----------|----------| +| `project` | `.pi/agent-memory//` | Shared across the team (committed) | +| `local` | `.pi/agent-memory-local//` | Machine-specific (gitignored) | +| `user` | `/agent-memory//` (default `~/.pi/agent/agent-memory/`, honors `PI_CODING_AGENT_DIR`) | Global personal memory | + +The `user` scope previously hardcoded `~/.pi/agent-memory/`. If that legacy directory exists for an agent and the new location doesn't, it keeps being used — existing memories aren't orphaned. + +Memory uses a `MEMORY.md` index file and individual memory files with frontmatter. Agents with write tools get full read-write access. **Read-only agents** (no `write`/`edit` tools) automatically get read-only memory — they can consume memories written by other agents but cannot modify them. This prevents unintended tool escalation. + +The `disallowed_tools` field is respected when determining write capability — an agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory. + +## Worktree Isolation + +Set `isolation: worktree` to run an agent in a temporary git worktree: + +``` +Agent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" }) +``` + +The agent gets a full, isolated copy of the repository. The worktree directory is removed on completion either way — what differs is whether a branch is left behind: +- **No changes:** worktree is cleaned up automatically, no branch +- **Changes made:** changes are committed to a new branch (`pi-agent-`), and the result names the branch and the `git merge` command for it. The branch is the only artifact — the worktree path is gone, so nothing points into it +- **Agent committed its own work:** the branch is created at the agent's HEAD, preserving its commits (uncommitted leftovers are committed on top first) + +The agent's system prompt names the worktree as an isolated copy and tells it to work only there, even if other instructions name the main checkout — otherwise an inherited parent prompt or a task prompt mentioning the project path walks it straight back out of the copy. This is a directive, not a sandbox: an agent with shell access can still `cd` out, so don't rely on `isolation` alone to protect the main checkout. + +The automatic preservation commit uses `--no-verify`, so local pre-commit hooks can't block it — the commit is local-only and never pushed, and pre-push/server-side hooks still apply. + +If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` call fails with a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. The call is reported as a failed tool call, not as a subagent that ran and returned that message, so the model doesn't retry it as if the agent had merely reported a problem. Initialize git and commit at least once, or omit `isolation`. + +A worktree is a *copy*, so the agent cannot see uncommitted or staged changes in the main checkout. Never use it to review a working-tree or staged diff: the agent finds an empty `git diff` and reports nothing wrong. + +### Turning worktrees off + +Three levers, from narrowest to broadest: + +- **Per call** — omit `isolation`, or pass `isolation: "off"`. The explicit value exists because some models fill every optional parameter they are offered; with `worktree` as the only legal value they had no way to decline one (#231, #184). +- **Per agent** — `isolation: off` in an agent file. Frontmatter is authoritative, so this refuses a worktree even when the caller passes `isolation: "worktree"` — the only way to override a caller. +- **Per project** — `"worktreeIsolation": false` in `subagents.json`. The `Agent` tool's `isolation` parameter disappears from the schema entirely, along with the usage-note bullet that describes it (so it costs the model no context and cannot be passed), and worktree creation is refused on every other path too: agent files, scheduled jobs, and cross-extension RPC. The `/agents` generator also stops offering `isolation:` when writing a new agent file. Use it on a repo large enough that a copy costs real time and disk. The schema and the description are both built at tool registration, so they appear or disappear in the next pi session; the refusal itself takes effect immediately. + + Schema and prose are gated together on purpose. Leaving the bullet in would teach the model to pass a field that is no longer declared — accepted silently, then dropped — and since a refused worktree carries no note on the result, the model would have every reason to go on reporting a `pi-agent-*` branch that was never created. A custom tool description should use the `{{isolationGuideline}}` placeholder rather than hardcoding the bullet, for the same reason. + +## Skill Preloading + +Skills can be preloaded by name and injected into the agent's system prompt: + +```yaml +--- +skills: api-conventions, error-handling +--- +``` + +**Discovery roots** (checked in this order, first match wins): + +| Scope | Path | Source | +|---|---|---| +| Project | `/.pi/skills/` | Pi-standard | +| Project | `/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) | +| User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard | +| User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) | +| User | `~/.pi/skills/` | Legacy (pre-Pi) | + +**Per root, a skill named `foo` resolves to the first of:** + +- `/foo.md` — flat file at the top level +- `/foo/SKILL.md` — directory skill (top-level) +- `/*/.../foo/SKILL.md` — directory skill, found by recursive descent + +Recursion skips dotfile directories and `node_modules`. A directory that itself contains a `SKILL.md` is treated as a single skill — we don't descend into it. Traversal is byte-order sorted for deterministic resolution across filesystems. + +**Security:** symlinks are rejected at every layer (root, flat file, skill directory, `SKILL.md` inside a skill directory) — intentional deviation from Pi, which follows symlinks. Skill names with path-traversal characters (`..`, `/`, `\`, spaces, leading dot, >128 chars) are rejected. + +## Tool Denylist + +Block specific tools from an agent even if extensions provide them: + +```yaml +--- +tools: read, bash, grep, write +disallowed_tools: write, edit +--- +``` + +This is useful for creating agents that inherit extension tools but should not have write access. + +## Architecture + +``` +src/ + index.ts # Extension entry: tool/command registration, /agents menu, rendering + types.ts # Type definitions (AgentConfig, AgentRecord, etc.) + + # Agent registry + default-agents.ts # Embedded default agent configs (general-purpose, Explore, Plan) + custom-agents.ts # Load user-defined agents from .pi/agents/, .agents/agents/, and global agents + agent-types.ts # Unified agent registry (defaults + user), tool name resolution + agent-file-toggle.ts # Locate/edit an agent's .md: enabled: toggle, eject to frontmatter + agent-color.ts # Claude Code/Agency Agents name color parsing and badge rendering + + # Execution + agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume + agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications + nested-tools.ts # Delegation tools handed to subagents (nested spawn/collect/steer) + child-context.ts # AsyncLocalStorage flag marking work done for a child session + abortable.ts # Race a wait against Esc without cancelling the background child + group-join.ts # Group join manager: batched completion notifications with timeout + status-note.ts # Honest status note + salvaged partial output for non-normal outcomes + usage.ts # Token usage shapes, accumulators, session-stats readers + + # Invocation surface + invocation-config.ts # Shared tool-parameter schemas (isolation, join, thinking, ...) + model-resolver.ts # Model resolution: exact provider/modelId with fuzzy fallback + enabled-models.ts # Read pi's enabledModels settings (project over global) + model-scope.ts # scopeModels allowlist policy, shared by top-level and nested tools + mention.ts # `@handle message` grammar: suggestion triggers and send parsing + mention-clone.ts # Run a mention's turn in a cloned conversation, off the main chat + cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events + + # Scheduling + schedule.ts # SubagentScheduler: cron / +10m / interval / ISO dispatch + schedule-store.ts # PID-locked, session-scoped, atomic schedule persistence + + # Context & environment + memory.ts # Persistent agent memory (resolve, read, build prompt blocks) + skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts) + output-file.ts # Streaming output file transcripts for agent sessions + worktree.ts # Git worktree isolation (create, cleanup, prune) + prompts.ts # Config-driven system prompt builder + context.ts # Parent conversation context for inherit_context + settings.ts # Persistent settings (~/.pi/agent/subagents.json + .pi/subagents.json) + env.ts # Environment detection (git, platform) + + ui/ + agent-widget.ts # Persistent widget: spinners, activity, status icons, theming + fleet-list.ts # FleetView: navigable agent list below the editor + conversation-viewer.ts # Live conversation overlay for viewing agent sessions + viewer-keys.ts # Viewer scroll keys resolved through user keybindings + agent-mention.ts # `@` roster (running, resumable, and startable agents) + popup rows + schedule-menu.ts # /agents → Scheduled jobs submenu + select-item.ts # Collision-safe ctx.ui.select wrapper (numbered rows) +``` + +## License + +MIT — [tintinweb](https://github.com/tintinweb) diff --git a/pi-subagents/SECURITY.md b/pi-subagents/SECURITY.md new file mode 100644 index 0000000..885def4 --- /dev/null +++ b/pi-subagents/SECURITY.md @@ -0,0 +1,95 @@ +# Security Policy + +This document explains the security model behind `@tintinweb/pi-subagents` and +where the boundaries are. + +`pi-subagents` is a [pi](https://pi.dev) extension. It spawns and orchestrates +autonomous sub-agents that run locally within the same security boundary as the +user running pi, and inherit pi's trust model. It is the responsibility of the +user to monitor those agents' operations or to contain them within a container, +virtual machine, or other sandbox solution. + +Sub-agents run with the local user account's privileges and can use the tools +they are granted (reading and writing files, running commands, network access, +etc.). They treat the local user account and files writable by that account as +inside the same trust boundary as the pi process itself. If an attacker can +modify files under the user's home directory, workspace, shell startup files, +environment, pi configuration, or this extension's configuration, they can +generally influence pi, its sub-agents, or other local developer tools. Reports +that depend on such prior local write access are not security vulnerabilities +unless they demonstrate how `pi-subagents` grants that write access or crosses an +operating-system privilege boundary. + +`pi-subagents` relies on the user only loading trustworthy agent definitions +(`.pi/agents/*.md`, `.agents/agents/*.md`, and global agents), skills, and +tools, and only using pi within trusted repositories. Files like `AGENTS.md`, +custom agent frontmatter/system prompts, preloaded skills, or instructions +embedded in repository content and comments can be used to prompt-inject the +coding agent and its sub-agents trivially, and this cannot be protected against. + +## Reporting a Vulnerability + +If you believe you found a security vulnerability in `pi-subagents`, please +report it privately by opening a draft advisory through +[GitHub Security Advisories](https://github.com/tintinweb/pi-subagents/security/advisories/new) +for this repository. + +Please include: + +- A description of the issue and its impact +- Steps to reproduce, proof of concept, or relevant logs +- Affected version, commit, or configuration +- Any known mitigations + +Do not open a public issue for security-sensitive reports. Reports will be +reviewed and disclosure coordinated as appropriate. + +## Scope + +Security issues in the published npm package and the code in this repository are +in scope — for example, a flaw in `pi-subagents` that crosses an +operating-system privilege boundary, or that causes the extension to bypass a +tool restriction, denylist, or agent boundary it claims to enforce. + +## Out Of Scope + +- Local code execution or sandboxing behavior (sub-agents intentionally do not + have a sandbox and run with the user's privileges) +- Behavior of pi itself, or of other pi extensions, skills, or tools installed by + the user (report those to their respective projects) +- Risks from working in untrusted repositories +- Risks from installing or loading untrusted agent definitions, skills, + extensions, packages, or tools +- Issues caused by non-trustworthy MITM proxies +- Public internet exposure of a pi installation +- Prompt injection attacks (including via `AGENTS.md`, agent frontmatter, custom + system prompts, preloaded skills, repository content, or context inheritance) +- Exposed secrets that are third-party/user-controlled credentials +- Reports requiring the ability to create, modify, delete, or replace files, + directories, symlinks, environment variables, shell configuration, or other + user-controlled local state on the target machine. This includes `.pi/agents/`, + `.agents/agents/`, agent and extension configuration, persistent agent memory, + workspace files, `AGENTS.md`, skills, dotfiles, and files synchronized through NFS, roaming + profiles, or dotfile managers, unless the report shows how `pi-subagents` + itself grants that access. +- Issues caused by intentionally weakened user configuration +- Resource/DOS claims that require trusted local input/config +- Reports about malicious model output +- User-approved or user-initiated local actions presented as vulnerabilities + +## Notes for Reporters + +The most useful reports show a current, reproducible security boundary bypass +with demonstrated impact. Reports that only show expected local-agent behavior, +prompt injection, or a malicious trusted agent definition/skill are not security +vulnerabilities under this model. + +For example, a report showing that malicious contents written to a trusted agent +definition or extension configuration cause a sub-agent to execute commands, load +attacker-controlled tools, or send credentials to an attacker-controlled endpoint +is out of scope. + +When possible, include the exact affected path, package version or commit SHA, +configuration, and a proof of concept against the latest release or latest +`master`. For dependency reports, include evidence that the shipped dependency is +affected and that the issue is reachable through `pi-subagents`. diff --git a/pi-subagents/UPSTREAM.md b/pi-subagents/UPSTREAM.md new file mode 100644 index 0000000..04a51b0 --- /dev/null +++ b/pi-subagents/UPSTREAM.md @@ -0,0 +1,16 @@ +# Upstream provenance + +- Upstream: +- Imported tag: `v0.18.0` +- Imported commit: `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` +- Snapshot date: 2026-08-20 +- Upstream package version: `0.18.0` +- License: MIT + +## Local maintenance + +This directory is maintained directly by the `my-pi` repository. It is not a submodule and must not contain a nested `.git`, upstream `.pi` state, `node_modules`, coverage output, or generated build output. + +The initial import preserves the upstream TypeScript source, tests, examples, documentation, media, package metadata, and license. The upstream `.github/` automation and local `.pi/` state were intentionally not imported. + +The source is included in the root package and installed through the root `file:./pi-subagents` dependency, while the host-owned `extensions/subagents.ts` wrapper is default-loaded from root `pi.extensions`. The local snapshot adds the documented permission child lifecycle bridge, root-authority forwarding identity, active-agent identity, and host-owned exact-path mandatory extension policy. The bundle wrapper protects `extensions/permission-system.ts` from Agent-controlled extension filtering and fails closed if that entry is unavailable. Root orchestration tools are assigned to Tool Search's checked-in `subagents` group, and Pi host peers are marked optional so the bundle does not install a second Pi runtime. diff --git a/pi-subagents/biome.json b/pi-subagents/biome.json new file mode 100644 index 0000000..d8476c6 --- /dev/null +++ b/pi-subagents/biome.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "recommended": false + }, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "off", + "noEmptyInterface": "off" + } + } + }, + "formatter": { + "enabled": false + }, + "files": { + "includes": [ + "src/**/*.ts", + "test/**/*.ts" + ] + } +} diff --git a/pi-subagents/examples/agent-tool-description.md b/pi-subagents/examples/agent-tool-description.md new file mode 100644 index 0000000..b468a04 --- /dev/null +++ b/pi-subagents/examples/agent-tool-description.md @@ -0,0 +1,42 @@ +Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it. + +Available agent types and the tools they have access to: +{{typeList}} + +Custom agents can be defined in .pi/agents/.md (project) or {{agentDir}}/agents/.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it. + +When using the Agent tool, specify a subagent_type parameter to select which agent type to use. + +## When not to use + +If the target is already known, use a direct tool — `read` for a known path, `grep`/`find` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type. + +## Usage notes + +- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI). +- When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. +- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary. +- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done. +- Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead. +- **Foreground vs background**: Pass `run_in_background: false` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own. +- **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess. +- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained. +- Use steer_subagent to send mid-run messages to a running background agent. +- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent. +- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first. +- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet"). +- Use thinking to control extended thinking level. +- Use inherit_context if the agent needs the parent conversation history.{{isolationGuideline}}{{scheduleGuideline}} + +## Writing the prompt + +Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters. +- Explain what you're trying to accomplish and why. +- Describe what you've already learned or ruled out. +- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction. +- If you need a short response, say so ("report in under 200 words"). +- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong. + +Terse command-style prompts produce shallow, generic work. + +**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change. diff --git a/pi-subagents/media/screenshot.png b/pi-subagents/media/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..c5063a4fb575c6aaa1475ca6c508063698b99656 GIT binary patch literal 168646 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 Y9oX_@pj>Z~Pk}%B+PAdcY1oJT4=&l2pa1{> literal 0 HcmV?d00001 diff --git a/pi-subagents/package-lock.json b/pi-subagents/package-lock.json new file mode 100644 index 0000000..c5d4c64 --- /dev/null +++ b/pi-subagents/package-lock.json @@ -0,0 +1,5380 @@ +{ + "name": "@tintinweb/pi-subagents", + "version": "0.18.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@tintinweb/pi-subagents", + "version": "0.18.0", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "croner": "^10.0.1", + "nanoid": "^5.1.16" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@earendil-works/pi-ai": "0.84.2", + "@earendil-works/pi-coding-agent": "0.84.2", + "@earendil-works/pi-tui": "0.84.2", + "@types/node": "^25.5.0", + "@vitest/coverage-istanbul": "^4.1.10", + "typescript": "^6.0.0", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@earendil-works/pi-ai": ">=0.81.0", + "@earendil-works/pi-coding-agent": ">=0.81.0", + "@earendil-works/pi-tui": ">=0.81.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", + "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.25", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", + "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", + "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", + "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-login": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", + "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", + "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-ini": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", + "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", + "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/token-providers": "3.1052.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1052.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", + "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", + "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", + "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", + "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", + "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", + "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.28", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", + "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", + "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "integrity": "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.2", + "@google/genai": "1.52.0", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", + "integrity": "sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "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-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "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.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "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.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "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.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "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.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "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.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "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.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "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.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "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.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "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/pi-agent-core": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.2", + "@google/genai": "1.52.0", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "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/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "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.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.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.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.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.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "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/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "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": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "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/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-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "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/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "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": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "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.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "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.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "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-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "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/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.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "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/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "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.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "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/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "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/@earendil-works/pi-telemetry": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "integrity": "sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "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/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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/@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/@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/@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/@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/@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/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/@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/@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/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", + "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", + "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", + "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "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/@vitest/coverage-istanbul": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz", + "integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/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/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "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/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/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/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/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "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/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "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/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/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "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/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/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "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/inquire": "^1.1.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/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/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "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/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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/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/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "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": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/pi-subagents/package.json b/pi-subagents/package.json new file mode 100644 index 0000000..c24f1b6 --- /dev/null +++ b/pi-subagents/package.json @@ -0,0 +1,75 @@ +{ + "name": "@tintinweb/pi-subagents", + "version": "0.18.0", + "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.", + "author": "tintinweb", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tintinweb/pi-subagents.git" + }, + "homepage": "https://github.com/tintinweb/pi-subagents#readme", + "bugs": { + "url": "https://github.com/tintinweb/pi-subagents/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "pi-package", + "pi", + "pi-extension", + "subagent", + "agent", + "autonomous" + ], + "peerDependencies": { + "@earendil-works/pi-ai": ">=0.81.0", + "@earendil-works/pi-coding-agent": ">=0.81.0", + "@earendil-works/pi-tui": ">=0.81.0" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-ai": { + "optional": true + }, + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-tui": { + "optional": true + } + }, + "dependencies": { + "@sinclair/typebox": "^0.34.49", + "croner": "^10.0.1", + "nanoid": "^5.1.16" + }, + "scripts": { + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc", + "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "vitest run e2e --reporter=verbose", + "typecheck": "tsc --noEmit", + "lint": "biome check src/ test/", + "lint:fix": "biome check --fix src/ test/", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@earendil-works/pi-ai": "0.84.2", + "@earendil-works/pi-coding-agent": "0.84.2", + "@earendil-works/pi-tui": "0.84.2", + "@types/node": "^25.5.0", + "@vitest/coverage-istanbul": "^4.1.10", + "typescript": "^6.0.0", + "vitest": "^4.0.18" + }, + "pi": { + "extensions": [ + "./src/index.ts" + ], + "video": "https://github.com/tintinweb/pi-subagents/raw/master/media/demo.mp4", + "image": "https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" + } +} diff --git a/pi-subagents/src/abortable.ts b/pi-subagents/src/abortable.ts new file mode 100644 index 0000000..2acca0c --- /dev/null +++ b/pi-subagents/src/abortable.ts @@ -0,0 +1,43 @@ +/** + * abortable.ts — race a promise against an AbortSignal without cancelling the + * underlying work. + * + * Used by the `get_subagent_result` wait paths (top-level and nested): pressing + * Esc cancels only the caller's wait; the background child keeps running and its + * result stays unconsumed. The listener is removed on every settle path so the + * signal accumulates no handlers, and a late settlement of the wrapped promise + * after an abort is absorbed as a no-op (no unhandled rejection). + */ + +/** Await a promise until it settles or the caller cancels, without aborting the underlying work. */ +export function abortable(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(signal.reason); + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + reject(signal.reason); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }, + ); + }); +} diff --git a/pi-subagents/src/agent-color.ts b/pi-subagents/src/agent-color.ts new file mode 100644 index 0000000..748d1f6 --- /dev/null +++ b/pi-subagents/src/agent-color.ts @@ -0,0 +1,161 @@ +/** + * agent-color.ts — Claude Code-compatible agent name badges. + * + * Claude Code renders a subagent's name as a badge: the configured color is the + * background, the text an inverse foreground. Its eight named colors are + * reproduced here, along with six-digit hex and the extra palette names Agency + * Agents uses, so those definitions render as written. + */ + +import { getConfig } from "./agent-types.js"; + +const NAMED_AGENT_COLORS: Readonly> = { + // Claude Code's eight subagent colors, as its default theme renders them. + red: "#DC2626", + blue: "#6A9BCC", + green: "#16A34A", + yellow: "#CA8A04", + purple: "#827DBD", + orange: "#D97757", + pink: "#C46686", + cyan: "#0891B2", + // Agency Agents palette aliases. + amber: "#F59E0B", + teal: "#008080", + indigo: "#6366F1", + gold: "#EAB308", + "neon-green": "#10B981", + "neon-cyan": "#06B6D4", + "metallic-blue": "#3B82F6", + violet: "#8B5CF6", + rose: "#F43F5E", + lime: "#84CC16", + gray: "#6B7280", + grey: "#6B7280", + fuchsia: "#D946EF", + slate: "#64748B", + navy: "#1E3A8A", +}; + +const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; +const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10); +const BLACK = { r: 0, g: 0, b: 0 }; +const WHITE = { r: 255, g: 255, b: 255 }; + +type Rgb = { r: number; g: number; b: number }; +type ColorMode = "truecolor" | "256color"; + +export interface AgentNameTheme { + fg(color: string, text: string): string; + bold(text: string): string; + getColorMode?(): ColorMode; +} + +export interface AgentNameStyle { + /** Existing theme foreground used when no valid agent color is configured. */ + fallbackColor?: string; + /** Reapply an enclosing background after the badge instead of resetting it. */ + restoreBackground?: string; + bold?: boolean; +} + +/** Resolve Claude Code/Agency Agents color syntax to normalized #RRGGBB. */ +export function resolveAgentColor(value: string | undefined): string | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + const resolved = NAMED_AGENT_COLORS[normalized] ?? normalized; + return /^#[0-9a-f]{6}$/i.test(resolved) ? resolved.toUpperCase() : undefined; +} + +function parseHex(hex: string): Rgb { + return { + r: Number.parseInt(hex.slice(1, 3), 16), + g: Number.parseInt(hex.slice(3, 5), 16), + b: Number.parseInt(hex.slice(5, 7), 16), + }; +} + +/** Index of the entry in `values` closest to `value`. */ +function nearest(values: readonly number[], value: number): number { + return values.reduce((best, v, i) => (Math.abs(value - v) < Math.abs(value - values[best]) ? i : best), 0); +} + +/** + * Quantize to the xterm-256 palette the way pi's own theme does, returning both + * the index to emit and the color the terminal will actually show — badge + * contrast is judged against the latter. + */ +function rgbTo256({ r, g, b }: Rgb): { index: number; rgb: Rgb } { + const [rIndex, gIndex, bIndex] = [r, g, b].map((channel) => nearest(CUBE_VALUES, channel)); + const distance = ({ r: cr, g: cg, b: cb }: Rgb) => 0.299 * (r - cr) ** 2 + 0.587 * (g - cg) ** 2 + 0.114 * (b - cb) ** 2; + const grayIndex = nearest(GRAY_VALUES, Math.round(0.299 * r + 0.587 * g + 0.114 * b)); + const gray = { r: GRAY_VALUES[grayIndex], g: GRAY_VALUES[grayIndex], b: GRAY_VALUES[grayIndex] }; + const cube = { r: CUBE_VALUES[rIndex], g: CUBE_VALUES[gIndex], b: CUBE_VALUES[bIndex] }; + // Only near-neutral colors may take the gray ramp; anything else keeps its tint. + if (Math.max(r, g, b) - Math.min(r, g, b) < 10 && distance(gray) < distance(cube)) { + return { index: 232 + grayIndex, rgb: gray }; + } + return { index: 16 + 36 * rIndex + 6 * gIndex + bIndex, rgb: cube }; +} + +function ansiColor(layer: "foreground" | "background", color: Rgb | number): string { + const code = layer === "foreground" ? 38 : 48; + return typeof color === "number" + ? `\u001b[${code};5;${color}m` + : `\u001b[${code};2;${color.r};${color.g};${color.b}m`; +} + +function relativeLuminance({ r, g, b }: Rgb): number { + const linear = (value: number) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +/** + * Render one name as a padded background badge when `color` is valid. Claude + * Code uses one inverse color for every badge's text; black or white is picked + * by WCAG contrast here instead, so each palette entry stays readable. Invalid + * or omitted colors preserve the caller's existing theme styling. + */ +export function renderAgentNameLabel( + name: string, + color: string | undefined, + theme: AgentNameTheme, + style: AgentNameStyle = {}, +): string { + const resolved = resolveAgentColor(color); + if (!resolved) { + const text = style.bold ? theme.bold(name) : name; + return style.fallbackColor ? theme.fg(style.fallbackColor, text) : text; + } + + const rgb = parseHex(resolved); + const quantized = (theme.getColorMode?.() ?? "truecolor") === "256color" ? rgbTo256(rgb) : undefined; + const shown = quantized?.rgb ?? rgb; + const contrasting = relativeLuminance(shown) > 0.179 ? BLACK : WHITE; + const label = style.bold ? theme.bold(` ${name} `) : ` ${name} `; + + return ansiColor("background", quantized?.index ?? rgb) + + ansiColor("foreground", quantized ? rgbTo256(contrasting).index : contrasting) + + label + + "\u001b[39m" + + (style.restoreBackground ?? "\u001b[49m"); +} + +/** Whether an agent renders as a badge — i.e. it has a valid configured color. */ +export function hasAgentBadge(type: string | undefined): boolean { + return type !== undefined && resolveAgentColor(getConfig(type).color) !== undefined; +} + +/** Render a registered agent's display name with its configured color. */ +export function renderAgentName( + type: string | undefined, + theme: AgentNameTheme, + style: AgentNameStyle = {}, +): string { + if (!type) return renderAgentNameLabel("Agent", undefined, theme, style); + const config = getConfig(type); + return renderAgentNameLabel(config.displayName, config.color, theme, style); +} diff --git a/pi-subagents/src/agent-file-toggle.ts b/pi-subagents/src/agent-file-toggle.ts new file mode 100644 index 0000000..2588be9 --- /dev/null +++ b/pi-subagents/src/agent-file-toggle.ts @@ -0,0 +1,261 @@ +/** + * agent-file-toggle.ts — Pure helpers for the `/agents` file-editing operations: + * locating an agent's .md file, toggling its `enabled:` frontmatter flag, and + * serializing an AgentConfig back to frontmatter for eject. + * + * These live outside src/index.ts so they can be tested directly: the `/agents` + * command handler is an ~890-line closure reached only through `registerCommand`, + * which every test mocks. + * + * The read side of this data (src/custom-agents.ts) parses frontmatter with a + * real YAML parser, so it honors `enabled: false` at any position in the block. + * This module must agree with it, and splits the work accordingly: + * + * - Deciding whether a file is disabled is a *read*, so it calls that same parser + * (`isDisabledContent`) instead of mirroring it. A mirror has to be right about + * YAML's boolean spellings and about pi's fence scan, and a regex was wrong + * about both. + * - *Editing* cannot go through the parser, because re-serializing a parsed + * document would reformat a file the README tells users to hand-author — + * discarding their comments, key order, and quoting. So the edits are line-wise + * and preserve everything they don't touch. + * + * That leaves removal best-effort: it recognizes a lowercase bare `false`, and + * reports `changed: false` for the spellings it cannot rewrite, so the caller + * refuses honestly rather than announcing a change it did not make. + */ + +import { existsSync } from "node:fs"; +import { join, sep } from "node:path"; +import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import type { AgentConfig } from "./types.js"; + +export type AgentFileLocation = "project" | "workspace" | "personal"; + +export const projectAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".pi", "agents"); +export const workspaceAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".agents", "agents"); +export const personalAgentsDir = () => join(getAgentDir(), "agents"); + +/** + * Find the file path of a custom agent by name, in discovery-precedence order + * (project, workspace, then global). Mirrors the load-side precedence in + * src/custom-agents.ts — if the two drift, `/agents` edits a file the loader + * isn't reading. + */ +export function findAgentFile( + name: string, + cwd: string = process.cwd(), +): { path: string; location: AgentFileLocation } | undefined { + const projectPath = join(projectAgentsDir(cwd), `${name}.md`); + if (existsSync(projectPath)) return { path: projectPath, location: "project" }; + const workspacePath = join(workspaceAgentsDir(cwd), `${name}.md`); + if (existsSync(workspacePath)) return { path: workspacePath, location: "workspace" }; + const personalPath = join(personalAgentsDir(), `${name}.md`); + if (existsSync(personalPath)) return { path: personalPath, location: "personal" }; + return undefined; +} + +/** + * Find the file behind a *loaded* agent, preferring the path the loader + * actually read (`AgentConfig.sourcePath`) over the `.md` guess. + * + * An agent's type comes from its frontmatter `name:` now, so the two can + * disagree: `reviewer.md` declaring `name: code-reviewer` is loaded as + * `code-reviewer`, and probing for `code-reviewer.md` finds nothing. That is + * not a harmless miss — `/agents → Disable` would then take the no-file branch + * and write a NEW `code-reviewer.md` stub, which loses to `reviewer.md` on + * load, leaving the agent enabled while reporting success. + * + * The probe stays as the fallback: a built-in that was never ejected has no + * `sourcePath`, and a path can go stale between a load and this call. + */ +export function locateAgentFile( + name: string, + sourcePath: string | undefined, + cwd: string = process.cwd(), +): { path: string; location: AgentFileLocation } | undefined { + if (sourcePath && existsSync(sourcePath)) { + return { path: sourcePath, location: classifyAgentDir(sourcePath, cwd) }; + } + return findAgentFile(name, cwd); +} + +/** + * Which discovery location a loaded agent's file came from. Only ever names + * a directory in a confirmation prompt, so an unrecognized parent — which + * loadCustomAgents cannot currently produce — reports as personal rather than + * widening the type for a case that has no better answer. + */ +function classifyAgentDir(path: string, cwd: string): AgentFileLocation { + if (path.startsWith(projectAgentsDir(cwd) + sep)) return "project"; + if (path.startsWith(workspaceAgentsDir(cwd) + sep)) return "workspace"; + return "personal"; +} + +export type DisableOutcome = "disabled" | "already-disabled" | "no-frontmatter"; + +/** A line that sets `enabled: false`, ignoring trailing whitespace / CR. */ +const ENABLED_FALSE = /^enabled:[ \t]*false[ \t]*$/; +/** An opening or closing `---` fence line. */ +const FENCE = /^---[ \t]*$/; + +/** + * Split a file into its frontmatter lines and everything else, agreeing with + * what `parseFrontmatter` (the load side) considers a frontmatter block. + * + * Lines keep their terminators, so an edit preserves the file's existing line + * endings instead of rewriting CRLF to LF. Returns undefined when there is no + * usable block — notably for a BOM-prefixed file, which the parser also reads + * as having none, so writing a key into it would change nothing on load. + */ +function splitFrontmatter(content: string): + | { lines: string[]; openIdx: number; closeIdx: number; eol: string } + | undefined { + const lines = content.split(/(?<=\n)/); + if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, ""))) return undefined; + const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, ""))); + if (closeIdx === -1) return undefined; + return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" }; +} + +/** + * Does the loader consider this file disabled? + * + * Detection is a READ operation, so it asks the same parser the loader uses + * rather than mirroring it with a regex — that mirror has to be right about + * YAML's boolean spellings (`False`, `FALSE`, a trailing `# comment`, a quoted + * key) *and* about pi's fence scan, which closes the block on any line starting + * `---` and so ends it early on `----`. A throw means the file is already + * unparseable, which is what the loader sees too: it skips the agent, so there + * is no "disabled" state to report. + */ +export function isDisabledContent(content: string): boolean { + try { + return parseFrontmatter>(content).frontmatter.enabled === false; + } catch { + return false; + } +} + +/** + * Add `enabled: false` to a file's frontmatter. + * + * `outcome` distinguishes a real edit from a no-op so the caller can report + * honestly instead of unconditionally claiming success. + */ +export function disableInContent(content: string): { content: string; outcome: DisableOutcome } { + const block = splitFrontmatter(content); + if (!block) return { content, outcome: "no-frontmatter" }; + if (isDisabledContent(content)) return { content, outcome: "already-disabled" }; + const lines = [...block.lines]; + lines.splice(1, 0, `enabled: false${block.eol}`); + return { content: lines.join(""), outcome: "disabled" }; +} + +/** + * Remove `enabled: false` from a file's frontmatter, wherever it appears in the + * block — the loader honors the key at any position, so the two must agree or a + * hand-authored agent can be disabled and never re-enabled. + * + * `changed` is false when the key wasn't found, so the caller can avoid + * reporting "Enabled " for a write that did nothing. + */ +export function enableInContent(content: string): { content: string; changed: boolean } { + const block = splitFrontmatter(content); + if (!block) return { content, changed: false }; + const kept = block.lines.filter( + (l, i) => !(i > 0 && i < block.closeIdx && ENABLED_FALSE.test(l.replace(/\r?\n$/, ""))), + ); + if (kept.length === block.lines.length) return { content, changed: false }; + return { content: kept.join(""), changed: true }; +} + +/** Is this the empty stub `/agents` writes when disabling a built-in default? */ +export function isEmptyStub(content: string): boolean { + return content.replace(/\r\n/g, "\n").trim() === "---\n---"; +} + +/** The answers `/agents → Create agent → Manual` collects, before serialization. */ +export interface NewAgentInput { + description: string; + /** Already-resolved `tools:` value ("none", "all", or a CSV of tool names). */ + tools: string; + /** `provider/modelId`, or undefined to inherit the parent's model. */ + model?: string; + /** A pi thinking level, or undefined to inherit. */ + thinking?: string; + systemPrompt: string; +} + +/** + * Build the .md file the create wizard writes. + * + * `description` and `model` come straight from a free-text prompt, so they are + * quoted rather than interpolated — `serializeAgentFile` above quotes the + * description for the same reason. An unquoted YAML scalar mishandles ordinary + * input in two ways, and both are silent: a colon ("Scout: find things") makes + * the file unparseable, and since #212 an unparseable agent file is *skipped*, + * so the wizard reports success for an agent that does not exist; a `#` + * ("audit #security") opens a comment and truncates the value. `model` can + * carry a colon too — pi accepts a `provider/model:thinking` suffix. + * + * `tools` and `thinking` are not quoted: both are chosen from fixed menus, and + * `tools` is a CSV that must stay a bare scalar for the loader's parser. + */ +export function buildNewAgentFile(input: NewAgentInput): string { + const modelLine = input.model ? `\nmodel: ${JSON.stringify(input.model)}` : ""; + const thinkingLine = input.thinking ? `\nthinking: ${input.thinking}` : ""; + return `--- +description: ${JSON.stringify(input.description)} +tools: ${input.tools}${modelLine}${thinkingLine} +prompt_mode: replace +--- + +${input.systemPrompt} +`; +} + +/** Render a built-in tool list as a `tools:` frontmatter value. */ +function formatToolsField(tools: string[] | undefined): string { + if (tools === undefined) return "all"; + if (tools.length === 0) return "none"; + return tools.join(", "); +} + +/** Serialize an AgentConfig to a full .md file (frontmatter + system prompt) for eject. */ +export function serializeAgentFile(cfg: AgentConfig): string { + const fmFields: string[] = []; + fmFields.push(`description: ${JSON.stringify(cfg.description)}`); + if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`); + if (cfg.color) fmFields.push(`color: ${JSON.stringify(cfg.color)}`); + // Absent means "all built-ins"; an EMPTY list means explicitly zero. Writing + // `all` for both would hand a deliberately tool-less agent the whole toolbox + // the first time it is ejected. + fmFields.push(`tools: ${formatToolsField(cfg.builtinToolNames)}`); + if (cfg.model) fmFields.push(`model: ${cfg.model}`); + if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`); + if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`); + if (cfg.allowedSubagents !== undefined) { + fmFields.push(`allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`); + } + fmFields.push(`prompt_mode: ${cfg.promptMode}`); + if (cfg.extensions === false) fmFields.push("extensions: false"); + else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`); + if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`); + if (cfg.skills === false) fmFields.push("skills: false"); + else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`); + if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`); + if (cfg.inheritContext) fmFields.push("inherit_context: true"); + // Both cases, not just `true`: with `backgroundByDefault` on, omitting the + // field means background, so `false` is the only way to pin an agent file to + // foreground and is no longer interchangeable with absence. No caller can + // reach it yet — Eject only handles built-in defaults, which omit the field — + // so this keeps the writer symmetric with the loader, nothing more. + if (cfg.runInBackground !== undefined) fmFields.push(`run_in_background: ${cfg.runInBackground}`); + if (cfg.outputTranscript === false) fmFields.push("output_transcript: false"); + if (cfg.isolated) fmFields.push("isolated: true"); + if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`); + if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`); + + return `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`; +} diff --git a/pi-subagents/src/agent-manager.ts b/pi-subagents/src/agent-manager.ts new file mode 100644 index 0000000..a1c0f5f --- /dev/null +++ b/pi-subagents/src/agent-manager.ts @@ -0,0 +1,1109 @@ +/** + * agent-manager.ts — Tracks agents, background execution, resume support. + * + * Background agents are subject to a configurable concurrency limit (default: 4). + * Excess agents are queued and auto-started as running agents complete. + * Foreground agents bypass the queue (they block the parent anyway), and so do + * nested children — see `occupiesPoolSlot`. + */ + +import { randomUUID } from "node:crypto"; +import { statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import type { Model } from "@earendil-works/pi-ai"; +import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"; +import { + type MandatoryExtensionPolicy, + normalizeMandatoryExtensionPaths, +} from "./mandatory-extensions.js"; +import { assignHandle, handleBase } from "./mention.js"; +import { unregisterPermissionChildSession } from "./permission-system-bridge.js"; +import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, SubagentType, ThinkingLevel } from "./types.js"; +import { addUsage, type LifetimeUsage } from "./usage.js"; +import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js"; + +export type OnAgentComplete = (record: AgentRecord) => void; +export type OnAgentStart = (record: AgentRecord) => void; +export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void; +/** + * Fired once per assistant `message_end`, for EVERY agent this manager owns — + * top-level and nested alike, spawns and resumes. The one place where each + * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately + * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend + * shows up on the record a human can see, which makes those records useless as + * a basis for anything that must not count a message twice — parent-session + * accounting above all. + */ +export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void; +export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }; + +/** + * Default max concurrent background agents. + * + * Raised from 4 when top-level spawns started defaulting to background + * (`backgroundByDefault`): foreground agents bypass this pool entirely, so + * while foreground was the default a fan-out of six ran six. With background + * as the default every top-level agent takes a slot, and a limit of 4 would + * have silently queued the tail of exactly the parallel fan-outs the `Agent` + * tool description tells the model to send. + */ +const DEFAULT_MAX_CONCURRENT = 10; + +/** + * How many evicted agents stay addressable by name. Only a bound on memory — + * a session that spawns hundreds of agents shouldn't retain every one — and + * far above the handful anyone keeps in their head. + */ +const MAX_TOMBSTONES = 100; + +/** + * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset" + * (parent cwd). Anything else must be an absolute path to an existing + * directory — curated errors instead of TypeErrors from path/fs internals + * (RPC callers send arbitrary JSON: null, numbers, file paths). + */ +function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null { + if (cwd == null) return; + if (typeof cwd !== "string" || !isAbsolute(cwd)) { + throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`); + } + let isDirectory = false; + try { + isDirectory = statSync(cwd).isDirectory(); + } catch { + throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`); + } + if (!isDirectory) { + throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`); + } +} + +/** + * Whether a record occupies one of the `maxConcurrent` background slots. + * Nested children don't: their parent already holds a slot, so counting (and + * therefore queueing) them would deadlock a parent that waits on its own child. + * + * Note this bounds nothing horizontally — the depth cap limits how DEEP nesting + * goes, not how WIDE. A parent's only limit on concurrent children is that each + * spawn costs it a turn, which is unbounded when max turns is unlimited. + */ +function occupiesPoolSlot(record: Pick): boolean { + return !!record.isBackground && record.parentAgentId === undefined; +} + +interface SpawnArgs { + pi: ExtensionAPI; + ctx: ExtensionContext; + type: SubagentType; + prompt: string; + options: SpawnOptions; +} + +interface SpawnOptions { + description: string; + /** + * Optional memorable name for this instance, becoming a second handle + * (`@auth-audit`) alongside the type-derived one. Slugged, not validated — + * anything unusable degrades via `handleBase` rather than failing the spawn. + */ + name?: string; + /** + * Reopen this pi session file instead of starting a fresh conversation, so a + * mention of an evicted agent continues where it left off. The agent's + * definition is still resolved from its type, so the continuation runs under + * the type's CURRENT config. + */ + resumeSessionFile?: string; + /** + * Take an evicted agent's names back verbatim instead of allocating fresh + * ones, so a resumed conversation keeps the handle the user just typed — + * `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an + * `assignHandle` pass because tombstoned names are excluded from allocation + * (`takenHandles`), so nothing live can be holding them. + * + * Internal capability, like `resumeSessionFile`: a forged handle would + * duplicate a live agent's name and make `resolveMention` ambiguous, so + * `spawnTopLevel` strips it from anything a caller sends. + */ + reclaim?: { handle: string; alias?: string }; + model?: Model; + maxTurns?: number; + isolated?: boolean; + inheritContext?: boolean; + thinkingLevel?: ThinkingLevel; + isBackground?: boolean; + /** + * Skip the maxConcurrent queue check for this spawn — start immediately even + * if the configured concurrency limit would otherwise queue it. Used by the + * scheduler so a fired job can't be deferred past its trigger window. + */ + bypassQueue?: boolean; + /** Isolation mode — "worktree" creates a temp git worktree for the agent. */ + isolation?: IsolationMode; + /** + * Working directory for the agent (absolute path). Default: parent session + * cwd. The agent's tools operate here, but .pi config (extensions, skills, + * settings, memory) still loads from the parent session's project — the + * target directory's `.pi` extensions never execute. With isolation: + * "worktree", the worktree is created FROM this directory and the result + * branch lands in that repo. + */ + cwd?: string; + /** Resolved invocation snapshot captured for UI display. */ + invocation?: AgentInvocation; + /** Parent abort signal — when aborted, the subagent is also stopped. */ + signal?: AbortSignal; + /** Called on tool start/end with activity info (for streaming progress to UI). */ + onToolActivity?: (activity: ToolActivity) => void; + /** Called on streaming text deltas from the assistant response. */ + onTextDelta?: (delta: string, fullText: string) => void; + /** Called when the agent session is created (for accessing session stats). */ + onSessionCreated?: (session: AgentSession) => void; + /** Called at the end of each agentic turn with the cumulative count. */ + onTurnEnd?: (turnCount: number) => void; + /** Called once per assistant message_end with that message's usage delta. */ + onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; + /** Called when the session successfully compacts. */ + onCompaction?: (info: CompactionInfo) => void; + /** Nesting depth: top-level subagent = 1. */ + depth?: number; + /** Parent agent ID for ownership-scoped nested controls. */ + parentAgentId?: string; + /** Effective inherited nesting cap for this branch. */ + maxSubagentDepth?: number; + /** Config-discovery root inherited by nested launches when it differs from the working directory. */ + configCwd?: string; + /** Root session id, inherited by nested launches so transcripts stay grouped. */ + rootSessionId?: string; +} + +interface ResumeOptions { + /** + * Run the resumed turn detached in the background: return immediately with + * the record still "running" (or "queued" at the concurrency limit) and + * notify on completion via onComplete, exactly like a background spawn. + * Default (false/undefined) runs the resume inline and returns the settled + * record — the historical behavior. + */ + isBackground?: boolean; + /** Called on tool start/end with activity info (for streaming progress to UI). */ + onToolActivity?: (activity: ToolActivity) => void; + /** Called once per assistant message_end with that message's usage delta. */ + onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; + /** Called when the session successfully compacts. */ + onCompaction?: (info: CompactionInfo) => void; + /** + * Background resume only: called synchronously when the run actually starts — + * immediately, or later from drainQueue. Callers wire per-run side effects + * (output-file streaming) here rather than at the call site, so a resume that + * is stopped while still queued never leaves a subscription behind: `abort()` + * drops a queued record without reaching `settle()`, which is what would have + * torn that subscription down. + */ + onStarted?: () => void; +} + +/** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */ +const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000; + +/** + * Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose. + * + * `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event + * itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds + * extensions onto a session without going through that path. Without the emit, everything an + * extension armed in `session_start` leaks once per spawn, and its next tick throws + * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242). + */ +async function shutdownChildSession(session: AgentSession | undefined): Promise { + try { + const runner = session?.extensionRunner; + // Optional all the way down: on a pi without the getter, or a stubbed session from a + // partial `onSessionCreated`, skip the emit — the same degrade as before this fix. + if (runner?.hasHandlers?.("session_shutdown")) { + // Raced, not awaited outright. `emit` runs every handler serially with no timeout of + // its own, and dispose() is reached from pi's own `session_shutdown` with the TUI + // already torn down — one hung handler would leave a dead terminal. + await Promise.race([ + runner.emit({ type: "session_shutdown", reason: "quit" }), + new Promise(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()), + ]); + } + } catch { /* a partial session must degrade, not take the teardown down with it */ } + // Always, even on timeout: disposal is what this function ultimately exists to do. + // Publish child disposal only after the session can no longer issue permission asks. + try { session?.dispose?.(); } catch { /* ignore */ } + finally { unregisterPermissionChildSession(session); } +} + +export interface AgentManagerOptions extends MandatoryExtensionPolicy {} + +export class AgentManager { + private agents = new Map(); + private cleanupInterval: ReturnType; + private onComplete?: OnAgentComplete; + private onStart?: OnAgentStart; + private onCompact?: OnAgentCompact; + private onUsage?: OnAgentUsage; + private maxConcurrent: number; + private readonly mandatoryExtensionPaths: readonly string[]; + /** Base repos worktrees were created from — so dispose() can prune them all, + * not just the parent repo (caller-supplied cwd can target other repos). */ + private worktreeRepos = new Set(); + + /** + * Evicted agents that can still be reached by name, keyed by handle. Outlives + * the 10-minute record cleanup — that timer exists to bound memory, not to + * expire a conversation the user might still want — and is cleared alongside + * completed records on session start/switch. + */ + private tombstones = new Map(); + + /** Queue of background agents waiting to start. */ + private queue: { id: string; start: () => void }[] = []; + /** Number of currently running background agents. */ + private runningBackground = 0; + + constructor( + onComplete?: OnAgentComplete, + maxConcurrent = DEFAULT_MAX_CONCURRENT, + onStart?: OnAgentStart, + onCompact?: OnAgentCompact, + onUsage?: OnAgentUsage, + options: AgentManagerOptions = {}, + ) { + this.onComplete = onComplete; + this.onStart = onStart; + this.onCompact = onCompact; + this.onUsage = onUsage; + this.maxConcurrent = maxConcurrent; + this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths( + options.mandatoryExtensionPaths, + ); + // Cleanup completed agents after 10 minutes (but keep sessions for resume) + this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); + this.cleanupInterval.unref(); + } + + /** Update the max concurrent background agents limit. */ + setMaxConcurrent(n: number) { + this.maxConcurrent = Math.max(1, n); + // Start queued agents if the new limit allows + this.drainQueue(); + } + + getMaxConcurrent(): number { + return this.maxConcurrent; + } + + /** + * Spawn an agent and return its ID immediately (for background use). + * If the concurrency limit is reached, the agent is queued. + */ + spawn( + pi: ExtensionAPI, + ctx: ExtensionContext, + type: SubagentType, + prompt: string, + options: SpawnOptions, + ): string { + // Validate before the queue branch — a queued spawn should fail at the + // call, not minutes later at drain. Throw (not warn): programmatic callers + // can fix and retry; the RPC layer converts throws into error envelopes. + assertValidSpawnCwd(options.cwd); + + const id = randomUUID().slice(0, 17); + const abortController = new AbortController(); + const record: AgentRecord = { + id, + type, + // Nested children are filtered out of every top-level surface, so no + // handle: nothing can address them and they must not consume a name a + // top-level sibling could otherwise take. + handle: options.parentAgentId !== undefined + ? undefined + // A reclaimed handle is used as-is: it belongs to the conversation this + // spawn is reopening, and re-deriving it would lose the numbering. + : options.reclaim?.handle ?? assignHandle(handleBase(type), this.takenHandles()), + description: options.description, + // Reclaimed here, or filled in below from `name` — in which case it must + // see the handle this record just took, since both come out of the same + // namespace. + alias: options.parentAgentId === undefined ? options.reclaim?.alias : undefined, + status: options.isBackground ? "queued" : "running", + toolUses: 0, + startedAt: Date.now(), + abortController, + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 }, + compactionCount: 0, + // Raw tri-state (not coerced to a boolean): true = background, false = + // foreground (has an inline tool-result surface), undefined = caller never + // declared it (e.g. a cross-extension RPC spawn). The widget's background- + // only filter excludes only explicit `false`, so undefined agents — which + // have no inline surface — stay visible instead of vanishing. + isBackground: options.isBackground, + invocation: options.invocation, + depth: options.depth ?? 1, + parentAgentId: options.parentAgentId, + maxSubagentDepth: options.maxSubagentDepth, + rootSessionId: options.rootSessionId, + }; + this.agents.set(id, record); + // After the insert, so `takenHandles()` already counts this record's own + // handle — a spawn named after its own type gets `explore-2`, not a + // duplicate `explore` that would make resolution ambiguous. + if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) { + record.alias = assignHandle(handleBase(options.name), this.takenHandles()); + } + + const args: SpawnArgs = { pi, ctx, type, prompt, options }; + + if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) { + // Queue it — will be started when a running agent completes + this.queue.push({ id, start: () => this.startAgent(id, record, args) }); + return id; + } + + // startAgent can throw (e.g. strict worktree-isolation failure) — clean + // up the record so callers don't see an orphan in `listAgents()`. + try { + this.startAgent(id, record, args); + } catch (err) { + this.agents.delete(id); + throw err; + } + return id; + } + + /** Actually start an agent (called immediately or from queue drain). */ + private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) { + // Re-validate a caller-supplied cwd: queued spawns can start minutes after + // spawn()'s check, and the directory may be gone by then (TOCTOU). Same + // curated errors; drainQueue parks a throw on the record as an error. + assertValidSpawnCwd(options.cwd); + // Single resolution point for the caller-supplied cwd — the worktree base + // repo and both cleanup calls below MUST agree on this value forever. + const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined + const baseCwd = customCwd ?? ctx.cwd; + + // Worktree isolation: try to create a temporary git worktree. Strict — + // fail loud if not possible (no silent fallback to main tree). Done + // BEFORE state mutation so a throw doesn't leave the record half-running. + // The project switch is enforced here as well as at the tool boundary + // because cross-extension RPC forwards its options unvalidated — a schema + // that omits the field can't stop a caller that never saw the schema. + let worktreeCwd: string | undefined; + if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) { + const wt = createWorktree(baseCwd, id); + if (!wt) { + throw new Error( + 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' + + 'Initialize git and commit at least once, or omit `isolation`.', + ); + } + record.worktree = wt; + // workPath preserves subdirectory scoping for caller-supplied cwds: a + // cwd deep in a monorepo maps to the same subdir inside the copy, not + // the copied repo's root. Plain worktree spawns keep the historical + // behavior (agent at the copy's root) — moving them to workPath would + // also move .pi config discovery when the parent session sits in a repo + // subdirectory, silently dropping extensions/skills. + worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path; + this.worktreeRepos.add(baseCwd); + } + + record.status = "running"; + record.startedAt = Date.now(); + if (occupiesPoolSlot(record)) this.runningBackground++; + this.onStart?.(record); + + // Wire parent abort signal to stop the subagent when the parent is interrupted + let detachParentSignal: (() => void) | undefined; + if (options.signal) { + const onParentAbort = () => this.abort(id); + options.signal.addEventListener("abort", onParentAbort, { once: true }); + detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort); + } + const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; }; + + const promise = runAgent(ctx, type, prompt, { + pi, + agentId: id, + model: options.model, + maxTurns: options.maxTurns, + isolated: options.isolated, + inheritContext: options.inheritContext, + thinkingLevel: options.thinkingLevel, + resumeSessionFile: options.resumeSessionFile, + nested: options.parentAgentId !== undefined, + // Every descendant forwards permission asks to the interactive root, not + // to an intermediate headless child. Top-level tool calls already stamp + // rootSessionId; programmatic callers fall back to their current context. + authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(), + mandatoryExtensionPaths: this.mandatoryExtensionPaths, + // Worktree wins for the working dir (the agent must run in the copy — + // which, with a custom cwd, was created from that target). Config stays + // with the parent project when a caller-supplied cwd is in play; it must + // stay undefined otherwise so plain worktree runs keep resolving config + // (incl. relative extension paths and memory) inside the worktree copy. + cwd: worktreeCwd ?? customCwd, + // Set iff a worktree was created (see above) — names the directory the + // copy came from, so the prompt can tell the agent not to work there. + worktreeBase: worktreeCwd ? baseCwd : undefined, + configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined), + signal: record.abortController!.signal, + onToolActivity: (activity) => { + if (activity.type === "end") record.toolUses++; + options.onToolActivity?.(activity); + }, + onTurnEnd: options.onTurnEnd, + onTextDelta: options.onTextDelta, + onAssistantUsage: (usage) => { + addUsage(record.lifetimeUsage, usage); + this.onUsage?.(record, usage); + options.onAssistantUsage?.(usage); + }, + onCompaction: (info) => { + record.compactionCount++; + this.onCompact?.(record, info); + options.onCompaction?.(info); + }, + nestedRuntime: { + manager: this, + parentAgentId: id, + depth: record.depth ?? 1, + maxSubagentDepth: record.maxSubagentDepth, + }, + onSessionCreated: (session) => { + record.session = session; + // Capture now, while the session object exists: after eviction this + // path is the only thing that can reopen the conversation, and an + // in-memory session reports undefined, which correctly means + // "nothing to come back to". + // Optional chaining, not defensiveness for its own sake: this is the + // only field read off the session at creation, so an older pi or a + // stubbed session must degrade to "not resumable" rather than throw + // and take the whole spawn down with it. + record.sessionFile = session.sessionManager?.getSessionFile?.(); + // Flush any steers that arrived before the session was ready + if (record.pendingSteers?.length) { + for (const msg of record.pendingSteers) { + session.steer(msg).catch(() => {}); + } + record.pendingSteers = undefined; + } + options.onSessionCreated?.(session); + }, + }) + .then(({ responseText, session, aborted, steered, failure }) => { + // Don't overwrite status if externally stopped via abort() + if (record.status !== "stopped") { + // Precedence: a hard abort keeps "aborted"; then a failed final turn + // (provider error that pi resolved instead of rejecting, #144) is an + // honest "error" — not a completion with an empty or stale result. + if (aborted) { + record.status = "aborted"; + } else if (failure) { + record.status = "error"; + record.error = failure; + } else { + record.status = steered ? "steered" : "completed"; + } + } + record.result = responseText; + record.session = session; + record.completedAt ??= Date.now(); + + detach(); + + // Final flush of streaming output file + if (record.outputCleanup) { + try { record.outputCleanup(); } catch { /* ignore */ } + record.outputCleanup = undefined; + } + + // Clean up worktree if used + if (record.worktree) { + const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description); + record.worktreeResult = wtResult; + if (wtResult.hasChanges && wtResult.branch) { + // With a caller-supplied cwd the branch lives in THAT repo, not the + // parent session's — say so, or the orchestrator merges in the wrong repo. + const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : ""; + record.result = (record.result ?? "") + + `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`; + } + } + + this.abortOwnedChildren(id); + + // Fire onComplete for foreground agents too — lifecycle symmetry. + // Mark resultConsumed so the callback skips notifications (result returned inline). + if (!options.isBackground) { + record.resultConsumed = true; + try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } + } else { + if (occupiesPoolSlot(record)) this.runningBackground--; + try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } + this.drainQueue(); + } + return responseText; + }) + .catch((err) => { + // Don't overwrite status if externally stopped via abort() + if (record.status !== "stopped") { + record.status = "error"; + } + record.error = err instanceof Error ? err.message : String(err); + record.completedAt ??= Date.now(); + + detach(); + + // Final flush of streaming output file on error + if (record.outputCleanup) { + try { record.outputCleanup(); } catch { /* ignore */ } + record.outputCleanup = undefined; + } + + // Best-effort worktree cleanup on error + if (record.worktree) { + try { + const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description); + record.worktreeResult = wtResult; + } catch { /* ignore cleanup errors */ } + } + + this.abortOwnedChildren(id); + + // Fire onComplete for foreground agents too — lifecycle symmetry. + // Mark resultConsumed so the callback skips notifications (result returned inline). + if (!options.isBackground) { + record.resultConsumed = true; + this.onComplete?.(record); + } else { + if (occupiesPoolSlot(record)) this.runningBackground--; + this.onComplete?.(record); + this.drainQueue(); + } + return ""; + }); + + record.promise = promise; + + // Notify caller that spawn is complete (record is in the map, promise is set). + // Called synchronously — onSessionCreated fires asynchronously inside runAgent. + // Used by spawnAndWait to let the caller set up output files before streaming starts. + this.onSpawned?.(id); + } + + /** + * Stop the nested children a settled parent owns. Nested records are hidden + * from the UI and only their owner can consume them, so a child outliving its + * parent would burn tokens unseen with no way to reach it. Grandchildren are + * covered transitively — each abort lands in that child's own settle path. + */ + private abortOwnedChildren(parentId: string): void { + for (const [id, record] of this.agents) { + if (record.parentAgentId === parentId) this.abort(id); + } + } + + /** Start queued agents up to the concurrency limit. */ + private drainQueue() { + while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) { + const next = this.queue.shift()!; + const record = this.agents.get(next.id); + if (!record || record.status !== "queued") continue; + try { + next.start(); + } catch (err) { + // Late failure (e.g. strict worktree-isolation) — surface on the record + // so the user/agent can see it via /agents, then keep draining. + record.status = "error"; + record.error = err instanceof Error ? err.message : String(err); + record.completedAt = Date.now(); + this.onComplete?.(record); + } + } + } + + /** + * Called synchronously right after spawn, before onSessionCreated fires. + * Lets the caller set up the output file path on the record. + * The record is guaranteed to be in this.agents at this point. + */ + private onSpawned?: (id: string) => void; + + /** + * Spawn an agent and wait for completion (foreground use). + * Foreground agents bypass the concurrency queue. + * Returns { id, record } so callers can access the agent ID. + * + * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires. + * Use this to set record.outputFile so streamToOutputFile can pick it up. + */ + async spawnAndWait( + pi: ExtensionAPI, + ctx: ExtensionContext, + type: SubagentType, + prompt: string, + options: Omit, + onSpawned?: (id: string) => void, + ): Promise<{ id: string; record: AgentRecord }> { + // Temporarily register the onSpawned hook so startAgent can call it. + const prevOnSpawned = this.onSpawned; + this.onSpawned = onSpawned; + let id: string; + try { + // spawn() invokes onSpawned synchronously before returning. Restore the + // shared hook immediately so unrelated concurrent spawns cannot inherit + // this foreground caller's callback while its run is awaited. + id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false }); + } finally { + this.onSpawned = prevOnSpawned; + } + const record = this.agents.get(id)!; + await record.promise; + return { id, record }; + } + + /** + * Resume an existing agent session with a new prompt. + */ + async resume( + id: string, + prompt: string, + signal?: AbortSignal, + options?: ResumeOptions, + ): Promise { + const record = this.agents.get(id); + if (!record?.session) return undefined; + + // Background resume: settle asynchronously and notify on completion exactly + // like a background spawn, returning immediately with the record still + // "running" — or "queued" when at the concurrency limit. Previously + // run_in_background was ignored on resume (the Agent tool's resume branch + // returned before its background branch, and resume() only ever awaited + // inline), so a resumed agent always blocked the caller until it finished. + if (options?.isBackground) { + // Never re-enter a run that is still in flight. Detaching means the caller + // gets control back while the record stays "running", so nothing stops the + // model from resuming the same agent again. Starting a second run would + // overwrite record.abortController — orphaning the live run beyond the + // reach of `/agents` stop and abortAll() — double-count the pool slot, and + // then reject from session.prompt() with "Agent is already processing", + // whose settle path would abort the LIVE run's children and report a + // failure for a run that is still going. Refuse instead, leaving the + // record untouched; the caller decides whether to wait or steer. + if (record.status === "running" || record.status === "queued") return undefined; + + record.isBackground = true; + record.resultConsumed = false; + record.result = undefined; + record.error = undefined; + record.completedAt = undefined; + record.status = "queued"; + + const start = () => this.startResume(id, record, prompt, signal, options); + if (occupiesPoolSlot(record) && this.runningBackground >= this.maxConcurrent) { + // At the concurrency limit — queue it, drains when a slot frees. + this.queue.push({ id, start }); + } else { + start(); + } + return record; + } + + // Foreground resume: run inline and return the settled record. + record.status = "running"; + record.startedAt = Date.now(); + record.completedAt = undefined; + record.result = undefined; + record.error = undefined; + + try { + const { text, failure } = await resumeAgent(record.session, prompt, { + onToolActivity: (activity) => { + if (activity.type === "end") record.toolUses++; + options?.onToolActivity?.(activity); + }, + onAssistantUsage: (usage) => { + addUsage(record.lifetimeUsage, usage); + this.onUsage?.(record, usage); + options?.onAssistantUsage?.(usage); + }, + onCompaction: (info) => { + record.compactionCount++; + this.onCompact?.(record, info); + options?.onCompaction?.(info); + }, + signal, + }); + // Same contract as the spawn path (#144): a failed final turn is an + // error, not a completion — but the resumed text stays available. + record.status = failure ? "error" : "completed"; + if (failure) record.error = failure; + record.result = text; + record.completedAt = Date.now(); + } catch (err) { + record.status = "error"; + record.error = err instanceof Error ? err.message : String(err); + record.completedAt = Date.now(); + } + + // Same contract as the spawn settle paths: children spawned during the + // resumed turn must not outlive it — nothing else can see or reach them. + this.abortOwnedChildren(id); + + return record; + } + + /** + * Start a background resume run: detached, settling and notifying like + * startAgent's background path. Invoked immediately, or from drainQueue when + * a concurrency slot frees. The session already exists (resume reuses it), so + * there is no onSessionCreated to hang per-run wiring off — callers use + * `options.onStarted`, which fires on both the immediate and the drained path. + */ + private startResume( + id: string, + record: AgentRecord, + prompt: string, + parentSignal: AbortSignal | undefined, + options: ResumeOptions, + ) { + if (!record.session) return; + + record.status = "running"; + record.startedAt = Date.now(); + if (occupiesPoolSlot(record)) this.runningBackground++; + this.onStart?.(record); + + // Fresh abort controller so /agents stop and steering target THIS run rather + // than the previous one's settled controller. + const abortController = new AbortController(); + record.abortController = abortController; + // Optional, and NOT what the Agent tool passes for a detached resume: a + // parent signal aborts on the parent's own interrupt (user Esc), which is + // right for a foreground run whose result the caller is awaiting, and wrong + // for a detached one — background spawns omit it for exactly this reason. + let detachParentSignal: (() => void) | undefined; + if (parentSignal) { + const onParentAbort = () => this.abort(id); + parentSignal.addEventListener("abort", onParentAbort, { once: true }); + detachParentSignal = () => parentSignal.removeEventListener("abort", onParentAbort); + } + + // Per-run side effects (output streaming) — see ResumeOptions.onStarted. + // After the record is in its running shape, before the run is kicked off. + try { options.onStarted?.(); } catch { /* ignore caller wiring errors */ } + + const settle = () => { + detachParentSignal?.(); + detachParentSignal = undefined; + // Final flush of streaming output file + if (record.outputCleanup) { + try { record.outputCleanup(); } catch { /* ignore */ } + record.outputCleanup = undefined; + } + // Children spawned during the resumed turn must not outlive it. + this.abortOwnedChildren(id); + if (occupiesPoolSlot(record)) this.runningBackground--; + try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } + this.drainQueue(); + }; + + const promise = resumeAgent(record.session, prompt, { + onToolActivity: (activity) => { + if (activity.type === "end") record.toolUses++; + options.onToolActivity?.(activity); + }, + onAssistantUsage: (usage) => { + addUsage(record.lifetimeUsage, usage); + this.onUsage?.(record, usage); + options.onAssistantUsage?.(usage); + }, + onCompaction: (info) => { + record.compactionCount++; + this.onCompact?.(record, info); + options.onCompaction?.(info); + }, + signal: abortController.signal, + }) + .then(({ text, failure }) => { + // Don't overwrite status if externally stopped via abort(). + if (record.status !== "stopped") { + // Same contract as the spawn path (#144): a failed final turn is an + // error, not a completion — but the resumed text stays available. + record.status = failure ? "error" : "completed"; + if (failure) record.error = failure; + } + record.result = text; + record.completedAt ??= Date.now(); + settle(); + return text; + }) + .catch((err) => { + if (record.status !== "stopped") { + record.status = "error"; + record.error = err instanceof Error ? err.message : String(err); + } + record.completedAt ??= Date.now(); + settle(); + return ""; + }); + + record.promise = promise; + } + + /** + * Send a steering message to an agent from the UI (mirrors the steer_subagent + * tool). A live session delivers it now — it interrupts the agent after its + * current tool execution and appears as a user message. If the session isn't + * ready yet, the message is queued on `pendingSteers` and flushed when the + * session is created. Returns false if the agent can't accept steering + * (unknown id, or no longer running/queued). + */ + steer(id: string, message: string): boolean { + const record = this.agents.get(id); + if (!record) return false; + if (record.status !== "running" && record.status !== "queued") return false; + if (record.session) { + record.session.steer(message).catch(() => {}); + } else { + if (!record.pendingSteers) record.pendingSteers = []; + record.pendingSteers.push(message); + } + return true; + } + + getRecord(id: string): AgentRecord | undefined { + return this.agents.get(id); + } + + /** Handles already in use, so a fresh spawn can pick an unclaimed one. */ + private takenHandles(): Set { + const taken = new Set(); + for (const record of this.agents.values()) { + if (record.handle) taken.add(record.handle); + if (record.alias) taken.add(record.alias); + } + // Tombstones hold their names too: an evicted `@explore` is still + // resurrectable, so a later Explore must become `explore-2` rather than + // shadowing a conversation the user can still reach. + for (const entry of this.tombstones.values()) { + taken.add(entry.handle); + if (entry.alias) taken.add(entry.alias); + } + return taken; + } + + /** + * Resolve an `@name` from the prompt. Matches a top-level agent's handle + * case-insensitively, preferring one that can still be steered and otherwise + * the most recently started (which is the one a resume should continue), then + * falls back to an exact agent id so `@` works too. + */ + resolveMention(name: string): MentionResolution | undefined { + const wanted = name.toLowerCase(); + let fallback: AgentRecord | undefined; + for (const record of this.agents.values()) { + if (record.parentAgentId !== undefined) continue; + // Handle and alias share one namespace, so at most one agent answers a + // name and it makes no difference which of the two matched. + if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted) continue; + if (record.status === "running" || record.status === "queued") return { kind: "live", record }; + if (!fallback || record.startedAt > fallback.startedAt) fallback = record; + } + if (fallback) return { kind: "live", record: fallback }; + const byId = this.agents.get(name); + if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId }; + // Only once nothing live answers: a tombstone is a conversation to reopen, + // and reopening one while its record still exists would fork the session. + for (const entry of this.tombstones.values()) { + if (entry.handle.toLowerCase() === wanted || entry.alias?.toLowerCase() === wanted || entry.id === name) { + return { kind: "tombstone", entry }; + } + } + return undefined; + } + + /** + * Forget an evicted agent, by handle. For the case where its session file has + * gone: the entry can then only ever fail, while still holding the name + * against the type that would otherwise start a fresh agent under it. + * + * A *successful* resume does not drop its tombstone — the live record it + * creates already wins in `resolveMention`, and overwrites the entry in place + * when it is itself evicted. + */ + dropTombstone(handle: string): void { + this.tombstones.delete(handle); + } + + /** Evicted agents whose conversation can still be reopened, newest first. */ + listTombstones(): AgentTombstone[] { + return [...this.tombstones.values()].sort((a, b) => b.completedAt - a.completedAt); + } + + listAgents(): AgentRecord[] { + return [...this.agents.values()].sort( + (a, b) => b.startedAt - a.startedAt, + ); + } + + abort(id: string): boolean { + const record = this.agents.get(id); + if (!record) return false; + + // Remove from queue if queued + if (record.status === "queued") { + this.queue = this.queue.filter(q => q.id !== id); + record.status = "stopped"; + record.completedAt = Date.now(); + return true; + } + + if (record.status !== "running") return false; + record.abortController?.abort(); + record.status = "stopped"; + record.completedAt = Date.now(); + return true; + } + + /** Dispose a record's session and remove it from the map. */ + private removeRecord(id: string, record: AgentRecord): void { + this.tombstone(record); + const session = record.session; + // Detached before the shutdown starts, so the record leaves the map at once and + // nothing can observe a session that is half torn down. + record.session = undefined; + this.agents.delete(id); + // Fire-and-forget is right here and only here: this runs from the 60s cleanup timer + // and from `clearCompleted()` on session boundaries, with the process staying alive, + // so handlers get their full window. The quit path awaits instead — see dispose(). + void shutdownChildSession(session); + } + + /** + * Preserve enough of a departing record for `@handle` to reopen its + * conversation later. Nothing to keep unless it has both a handle to be + * addressed by and a session file to reopen — an in-memory session leaves no + * transcript, so the mention would have nothing to continue from. + */ + private tombstone(record: AgentRecord): void { + if (!record.handle || !record.sessionFile) return; + this.tombstones.set(record.handle, { + handle: record.handle, + alias: record.alias, + id: record.id, + type: record.type, + description: record.description, + sessionFile: record.sessionFile, + completedAt: record.completedAt ?? Date.now(), + }); + // Bound the memory a long session can accumulate. Oldest first, since the + // agent someone still wants to reach is the one they used most recently. + while (this.tombstones.size > MAX_TOMBSTONES) { + const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b)); + this.tombstones.delete(oldest.handle); + } + } + + private cleanup() { + const cutoff = Date.now() - 10 * 60_000; + for (const [id, record] of this.agents) { + if (record.status === "running" || record.status === "queued") continue; + if ((record.completedAt ?? 0) >= cutoff) continue; + this.removeRecord(id, record); + } + } + + /** + * Remove all completed/stopped/errored records immediately. + * Called on session start/switch so tasks from a prior session don't persist. + * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet + * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead. + */ + clearCompleted(skipUnconsumed = false): void { + for (const [id, record] of this.agents) { + if (record.status === "running" || record.status === "queued") continue; + if (skipUnconsumed && !record.resultConsumed) continue; + this.removeRecord(id, record); + } + // Unconditional: both callers are session boundaries (`session_start` and + // `session_before_switch`), and `skipUnconsumed` only spares records whose + // results the LLM has yet to read — it does not make the sweep partial in + // the sense that matters here. A new session means new handles, or + // `@explore` would silently reach an agent the user never started. Claude + // Code resets its registry on `/clear` for the same reason. + this.tombstones.clear(); + } + + /** Whether any agents are still running or queued. */ + hasRunning(): boolean { + return [...this.agents.values()].some( + r => r.status === "running" || r.status === "queued", + ); + } + + /** Abort all running and queued agents immediately. */ + abortAll(): number { + let count = 0; + // Clear queued agents first + for (const queued of this.queue) { + const record = this.agents.get(queued.id); + if (record) { + record.status = "stopped"; + record.completedAt = Date.now(); + count++; + } + } + this.queue = []; + // Abort running agents + for (const record of this.agents.values()) { + if (record.status === "running") { + record.abortController?.abort(); + record.status = "stopped"; + record.completedAt = Date.now(); + count++; + } + } + return count; + } + + /** Wait for all running and queued agents to complete (including queued ones). */ + async waitForAll(): Promise { + // Loop because drainQueue respects the concurrency limit — as running + // agents finish they start queued ones, which need awaiting too. + while (true) { + this.drainQueue(); + const pending = [...this.agents.values()] + .filter(r => r.status === "running" || r.status === "queued") + .map(r => r.promise) + .filter(Boolean); + if (pending.length === 0) break; + await Promise.allSettled(pending); + } + } + + async dispose(): Promise { + clearInterval(this.cleanupInterval); + // Clear queue + this.queue = []; + const sessions = [...this.agents.values()].map(record => record.session); + this.agents.clear(); + // Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown` + // handler and the process exits right after it returns, so anything left unawaited + // here never runs at all. Bounded — each call carries its own ceiling, concurrently. + await Promise.all(sessions.map(session => shutdownChildSession(session))); + // Prune any orphaned git worktrees (crash recovery) + try { pruneWorktrees(process.cwd()); } catch { /* ignore */ } + // Also prune repos that caller-supplied cwds created worktrees in — a clean + // exit with in-flight agents would otherwise leave stale registrations there. + for (const repo of this.worktreeRepos) { + try { pruneWorktrees(repo); } catch { /* ignore */ } + } + } +} diff --git a/pi-subagents/src/agent-runner.ts b/pi-subagents/src/agent-runner.ts new file mode 100644 index 0000000..2c176ad --- /dev/null +++ b/pi-subagents/src/agent-runner.ts @@ -0,0 +1,1225 @@ +/** + * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results. + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { Model } from "@earendil-works/pi-ai"; +import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent"; +import { + type AgentSession, + type AgentSessionEvent, + createAgentSession, + DefaultResourceLoader, + type ExtensionAPI, + getAgentDir, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js"; +import { runInChildSessionContext } from "./child-context.js"; +import { buildParentContext, extractText } from "./context.js"; +import { DEFAULT_AGENTS } from "./default-agents.js"; +import { detectEnv } from "./env.js"; +import { + assertMandatoryExtensionsLoaded, + isMandatoryExtensionPath, + normalizeMandatoryExtensionPaths, +} from "./mandatory-extensions.js"; +import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js"; +import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js"; +import { + registerPermissionChildSession, + unregisterPermissionChildSession, + withActiveAgentIdentity, +} from "./permission-system-bridge.js"; +import { buildAgentPrompt, type PromptExtras } from "./prompts.js"; +import { preloadSkills } from "./skill-loader.js"; +import type { SubagentType, ThinkingLevel } from "./types.js"; +import type { LifetimeUsage } from "./usage.js"; + +/** + * Tool names registered by THIS extension. Single source of truth so the + * registration sites (index.ts) and the subagent exclusion list below can't + * drift apart. These are our own tools, not pi built-ins, so they can't be + * derived from pi — but they only need defining once. + */ +export const SUBAGENT_TOOL_NAMES = { + AGENT: "Agent", + GET_RESULT: "get_subagent_result", + STEER: "steer_subagent", +} as const; + +/** Names of tools registered by this extension that subagents must NOT inherit. */ +const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES); + +/** + * Canonical name of an extension for `extensions: [...]` allowlist matching. + * Lowercased — extension names match case-insensitively so `extensions: [Mcp]` + * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected. + * Directory extensions (`foo/index.ts`) resolve to the parent directory name; + * single-file extensions to the basename minus `.ts`/`.js`. + */ +export function extensionCanonicalName(extPath: string): string { + const base = basename(extPath); + const name = base === "index.ts" || base === "index.js" + ? basename(dirname(extPath)) + : base.replace(/\.(ts|js)$/, ""); + return name.toLowerCase(); +} + +/** + * The unscoped, lowercased npm short name of the pi package that DECLARES + * `extPath` as an extension entry — or undefined if the entry doesn't belong to + * such a package. + * + * Climbs from the entry's directory looking for the package that owns it, and + * stays strictly within that package's tree by stopping at two structural + * boundaries — no hardcoded depth: + * - the FIRST `package.json` found (the package root); the entry's own + * manifest always sits at the root, above the entry, below any node_modules. + * - a `node_modules` directory: a package never spans one (it's where OTHER + * packages live), so reaching it means we've climbed out of the package — + * stop before reading a consumer's or parent package's manifest. + * The name is then taken only when that root's `pi.extensions` manifest actually + * lists this entry. That "declares this entry" check is deliberate: our own test + * fixtures live under this repo, whose root manifest declares `./src/index.ts` + * as `@tintinweb/pi-subagents`, so a looser rule would misattribute every + * co-located file to `pi-subagents`. + */ +function extensionPackageName(extPath: string): string | undefined { + const entry = resolve(extPath); + let dir = dirname(extPath); + for (;;) { + // Climbing into node_modules means we've left the owning package's tree. + if (basename(dir) === "node_modules") return undefined; + let pkg: { name?: unknown; pi?: { extensions?: unknown } }; + try { + pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")); + } catch { + const parent = dirname(dir); + if (parent === dir) return undefined; // walked to the filesystem root + dir = parent; + continue; + } + // First package.json wins — it's the package root; decide here. + const entries = pkg.pi?.extensions; + if ( + typeof pkg.name === "string" && + Array.isArray(entries) && + entries.some((e) => typeof e === "string" && resolve(dir, e) === entry) + ) { + const short = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name; + return short.toLowerCase(); + } + return undefined; + } +} + +/** + * All names an extension answers to for allowlist matching (lowercased): its + * path-derived {@link extensionCanonicalName} plus, when a pi package manifest + * declares this entry, that package's unscoped short name (`@scope/foo` → `foo`). + * #143: an extension installed via `pi.extensions: ["./src/index.ts"]` would + * otherwise only ever match as `src` (the source directory), never by its + * package name. The path-derived name is preserved, so it keeps matching too. + */ +export function extensionCanonicalNames(extPath: string): string[] { + const canonical = extensionCanonicalName(extPath); + const pkg = extensionPackageName(extPath); + return pkg && pkg !== canonical ? [canonical, pkg] : [canonical]; +} + +/** + * Classify `extensions: string[]` frontmatter entries for the loader-level filter. + * + * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise + * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions). + * + * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths` + * — and their canonical name is also added to `names`. The loader override matches + * everything by canonical name, so path-loaded extensions are matched via their name + * rather than their post-staging `Extension.path`. + */ +export function parseExtensionsSpec( + entries: string[], + cwd: string, +): { names: Set; paths: string[]; wildcard: boolean } { + const names = new Set(); + const paths: string[] = []; + let wildcard = false; + for (const entry of entries) { + if (!entry) continue; + if (entry === "*") { + wildcard = true; + continue; + } + const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~"); + if (!isPathEntry) { + names.add(entry.toLowerCase()); + continue; + } + let p = entry; + if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { + p = homedir() + p.slice(1); + } + const abs = isAbsolute(p) ? p : resolve(cwd, p); + paths.push(abs); + names.add(extensionCanonicalName(abs)); + } + return { names, paths, wildcard }; +} + +/** + * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of + * extension names to keep loaded and a per-extension tool-narrowing map. + * + * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools). + * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`). + * A name lands in `narrowing` only when a `/tool` form is seen, so a bare + * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins). + * The split is on the first `/`; extension canonical names never contain `/`. + */ +export function parseExtSelectors(entries: string[]): { + extNames: Set; + narrowing: Map>; +} { + const extNames = new Set(); + const narrowing = new Map>(); + for (const raw of entries) { + if (!raw) continue; + const body = raw.slice("ext:".length); + const slash = body.indexOf("/"); + // Extension name matches case-insensitively (matches the loader-side canonical + // name). Tool names are case-preserved — they're matched against pi-mono's + // registered identifiers, which are case-sensitive. + const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase(); + if (!name) continue; + extNames.add(name); + if (slash === -1) continue; + const tool = body.slice(slash + 1).trim(); + if (!tool) continue; + let set = narrowing.get(name); + if (!set) { + set = new Set(); + narrowing.set(name, set); + } + set.add(tool); + } + return { extNames, narrowing }; +} + +/** + * Keep a subagent's tool scope correct as extensions register tools over time. + * + * Extensions may call `registerTool` long after load — pi-mcp from `session_start`, + * context-mode from `before_agent_start` — so scope has to be re-derived rather than + * snapshotted. `registerTool` writes into the very `extension.tools` maps this reads, + * so `inScope()` sees late arrivals on the next call. + * + * Two enforcement points, because neither covers the whole picture: + * + * - `turn_end` re-narrows the ACTIVE set. pi emits `turn_end` immediately before + * `prepareNextTurn` re-snapshots `agent.state.tools`, and session listeners run + * synchronously, so the narrow lands in time for turns 2..N. + * - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all: + * `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but + * `createContextSnapshot()` freezes that turn's tools immediately after — there + * is no hook in between. A call-time check is the only correct guard there. + * + * Both are installed on the session and deliberately NOT unsubscribed: they must + * outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()` + * clears `_eventListeners`, so they die with the session rather than leaking. + * + * Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the + * static `allowedToolNames` allowlist already gates the registry itself. + */ +export function installExtensionToolScope( + session: AgentSession, + ctx: { + loader: DefaultResourceLoader; + toolNames: string[]; + disallowedSet: Set | undefined; + extNames: Set; + narrowing: Map>; + /** Opt-in nested-delegation tool names to keep active despite the EXCLUDED strip. */ + /** Loaded extensions whose tools Agent policy permits the LLM to use. */ + toolEligibleExtensionPaths: ReadonlySet; + nestedToolNames: Set; + }, +): void { + const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, toolEligibleExtensionPaths } = ctx; + + // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:` + // selector is present, extension tools become an explicit allowlist — a loaded + // extension not named by a selector contributes nothing (its handlers still ran), + // and `ext:foo/bar` narrows `foo` to just `bar`. + const inScope = (): Set => { + const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t))); + const optInActive = extNames.size > 0; + for (const extension of loader.getExtensions().extensions) { + // Mandatory means its handlers must bind; it does not implicitly expose + // tools that `extensions: false` or normal include/exclude policy hid. + if (!toolEligibleExtensionPaths.has(extension.path)) continue; + const canons = extensionCanonicalNames(extension.path); + if (optInActive && !canons.some((c) => extNames.has(c))) continue; + // First alias that carries a narrowing set — a user won't narrow one + // extension under two different names, so first-match is correct. + const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean); + for (const name of extension.tools.keys()) { + if (narrowed && !narrowed.has(name)) continue; + if (disallowedSet?.has(name)) continue; + keep.add(name); + } + } + for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name); + // Opt-in nested delegation tools share EXCLUDED_TOOL_NAMES' names but are + // legitimately active for this agent — re-admit them so the renarrow keeps + // them in the active set and beforeToolCall doesn't block them. + for (const name of nestedToolNames) { + if (!disallowedSet?.has(name)) keep.add(name); + } + return keep; + }; + + const renarrow = () => { + const allowed = inScope(); + const next = session.getAllTools().map((t) => t.name).filter((n) => allowed.has(n)); + const current = session.getActiveToolNames(); + // setActiveToolsByName unconditionally rebuilds the system prompt, so skip + // the no-op that steady-state turns would otherwise pay for every turn. + if (next.length !== current.length || next.some((n, i) => n !== current[i])) { + session.setActiveToolsByName(next); + } + }; + + // Activate what registered during session_start (eager MCP servers); pi would + // otherwise leave only its four default built-ins active at turn 1. + renarrow(); + + session.subscribe((event: AgentSessionEvent) => { + if (event.type === "turn_end") renarrow(); + }); + + const priorBeforeToolCall = session.agent.beforeToolCall; + session.agent.beforeToolCall = async (context, signal) => { + if (!inScope().has(context.toolCall.name)) { + return { + block: true, + reason: `Tool "${context.toolCall.name}" is not available to this subagent.`, + }; + } + return priorBeforeToolCall?.(context, signal); + }; +} + +/** Default max turns. undefined = unlimited (no turn limit). */ +let defaultMaxTurns: number | undefined; + +/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */ +export function normalizeMaxTurns(n: number | undefined): number | undefined { + if (n == null || n === 0) return undefined; + return Math.max(1, n); +} + +/** Get the default max turns value. undefined = unlimited. */ +export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; } +/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */ +export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); } + +/** + * The turn limit a run of `type` will actually enforce: an explicit value if the + * caller supplied one, else the agent's own `max_turns`, else the project + * default. `undefined` = unlimited. + * + * Exported because the widget's turn counter (`↻3≤20`) has to predict this + * before the run starts, and a second copy of the expression would drift from + * the one below that enforces it. + */ +export function resolveEffectiveMaxTurns(type: string, explicit?: number): number | undefined { + return normalizeMaxTurns(explicit ?? getAgentConfig(type)?.maxTurns ?? defaultMaxTurns); +} + +/** + * Project default for `persist_session`, from the `rememberAgents` setting. + * On by default: a persisted session is what lets `@handle` reopen an agent's + * conversation after its record has been evicted, which is the whole point of + * addressing an agent by a name that outlives one run. Per-agent frontmatter + * still overrides it in both directions. + */ +let rememberAgents = true; + +/** Whether subagent sessions are persisted by default. */ +export function getRememberAgents(): boolean { return rememberAgents; } +/** Set whether subagent sessions are persisted by default. */ +export function setRememberAgents(b: boolean): void { rememberAgents = b; } + +/** Additional turns allowed after the soft limit steer message. */ +let graceTurns = 5; + +/** Get the grace turns value. */ +export function getGraceTurns(): number { return graceTurns; } +/** Set the grace turns value (minimum 1). */ +export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); } + +/** + * Try to find the right model for an agent type. + * Priority: explicit option > config.model > parent model. + */ +export function resolveDefaultModel( + parentModel: Model | undefined, + registry: { find(provider: string, modelId: string): Model | undefined; getAvailable?(): Model[] }, + configModel?: string, +): Model | undefined { + if (configModel) { + const slashIdx = configModel.indexOf("/"); + if (slashIdx !== -1) { + const provider = configModel.slice(0, slashIdx); + const modelId = configModel.slice(slashIdx + 1); + + // Build a set of available model keys for fast lookup + const available = registry.getAvailable?.(); + const availableKeys = available + ? new Set(available.map((m: any) => `${m.provider}/${m.id}`)) + : undefined; + const isAvailable = (p: string, id: string) => + !availableKeys || availableKeys.has(`${p}/${id}`); + + const found = registry.find(provider, modelId); + if (found && isAvailable(provider, modelId)) return found; + } + } + + return parentModel; +} + +/** Info about a tool event in the subagent. */ +export interface ToolActivity { + type: "start" | "end"; + toolName: string; +} + +export interface RunOptions { + /** ExtensionAPI instance — used for pi.exec() instead of execSync. */ + pi: ExtensionAPI; + /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */ + agentId?: string; + model?: Model; + maxTurns?: number; + signal?: AbortSignal; + isolated?: boolean; + inheritContext?: boolean; + thinkingLevel?: ThinkingLevel; + /** + * Reopen this pi session file rather than starting an empty conversation. + * `createAgentSession` seeds itself from whatever its SessionManager holds, + * so pointing it at an existing file rehydrates that agent's history and the + * prompt continues it. Everything else — tools, model, system prompt, turn + * caps — is still resolved from the agent type, so the continuation runs + * under the type's *current* definition, not the one the original run used. + */ + resumeSessionFile?: string; + /** + * True when another agent spawned this one. Only top-level agents get a + * handle, so only they can be reopened by name — which is the whole reason + * `rememberAgents` persists a session at all. A nested run's transcript would + * be unreachable by anything, so it stays in memory unless its own + * frontmatter asks otherwise. + */ + nested?: boolean; + /** Interactive root session that serves permission requests for this branch. */ + authoritySessionId?: string; + /** Canonical bundle-owned extension entries that Agent config cannot disable. */ + mandatoryExtensionPaths?: readonly string[]; + /** Override working directory (e.g. for worktree isolation). */ + cwd?: string; + /** + * Directory the worktree copy was created from. Set only when `cwd` points + * into a worktree — the prompt then tells the agent to stay in the copy + * instead of following the inherited parent prompt back to the main tree. + */ + worktreeBase?: string; + /** + * Where .pi config is discovered (project extensions, skills, pi settings, + * agent memory). Default: same as the working directory. The manager sets + * this to the parent session's cwd when `SpawnOptions.cwd` points the + * working directory elsewhere — the agent works *there* but carries the + * parent project's config (the target's `.pi` extensions never execute). + * + * WARNING for future callers: if you pass `cwd` pointing at a directory the + * user didn't open, you almost certainly must pass `configCwd` too — + * omitting it makes the target's `.pi` extensions execute in this process. + * (Worktree isolation is the one intentional exception: its copy IS the + * parent's repo, so config resolving inside it is correct.) + */ + configCwd?: string; + /** Called on tool start/end with activity info. */ + onToolActivity?: (activity: ToolActivity) => void; + /** Called on streaming text deltas from the assistant response. */ + onTextDelta?: (delta: string, fullText: string) => void; + onSessionCreated?: (session: AgentSession) => void; + /** Called at the end of each agentic turn with the cumulative count. */ + onTurnEnd?: (turnCount: number) => void; + /** + * Called once per assistant message_end with that message's usage delta. + * Lets callers maintain a lifetime accumulator that survives compaction + * (which replaces session.state.messages and resets stats-derived sums). + * + * `cost` is pi's own `usage.cost.total` for that message — priced from the + * model's rates, so it is 0 (not missing) for a model pi has no pricing for. + * We never price anything ourselves; every dollar figure this extension shows + * or reports traces back to this field. + */ + onAssistantUsage?: (usage: LifetimeUsage) => void; + /** + * Called when the session successfully compacts. `tokensBefore` is upstream's + * pre-compaction context size estimate. Aborted compactions don't fire. + */ + onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void; + /** Runtime bridge for opt-in child-safe nested delegation. */ + nestedRuntime?: { + manager: NestedAgentManager; + parentAgentId: string; + depth: number; + maxSubagentDepth?: number; + }; +} + +export interface RunResult { + responseText: string; + session: AgentSession; + /** True if the agent was hard-aborted (max_turns + grace exceeded). */ + aborted: boolean; + /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */ + steered: boolean; + /** + * A failure message for the run's FINAL assistant turn, when that turn failed: + * a provider error (stopReason "error"), or a "length" stop that produced no + * text (a silent max-token death). pi resolves an exhausted-retries failure + * normally instead of rejecting, so without this the manager would report such + * a run as completed — with an empty result, or worse, an earlier turn's text + * presented as the answer (#144). Undefined for a clean stop, or a "length" + * stop that produced text (a legitimate truncated answer). + */ + failure?: string; +} + +/** + * Subscribe to a session and collect the last assistant message text. + * Returns an object with a `getText()` getter and an `unsubscribe` function. + */ +function collectResponseText(session: AgentSession) { + let text = ""; + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + // message_start also fires for user and toolResult messages — resetting on + // those would wipe assistant text already collected. Reset only when a new + // ASSISTANT message begins, so getText() is the last assistant message's text. + if (event.type === "message_start" && event.message.role === "assistant") { + text = ""; + } + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + text += event.assistantMessageEvent.delta; + } + }); + return { getText: () => text, unsubscribe }; +} + +/** + * Get the last non-empty assistant text produced during THIS invocation. + * `startIndex` is the message count captured before the prompt, so the walk-back + * never crosses into a previous turn: on a resume whose new turn failed empty, + * this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a + * fresh spawn, where the whole history belongs to this run). + */ +function getLastAssistantText(session: AgentSession, startIndex = 0): string { + for (let i = session.messages.length - 1; i >= startIndex; i--) { + const msg = session.messages[i]; + if (msg.role !== "assistant") continue; + const text = extractText(msg.content).trim(); + if (text) return text; + } + return ""; +} + +/** + * Error message of THIS invocation's final assistant message, when that turn + * failed. Two failure shapes, both keyed off how the final turn STOPPED: + * - stopReason "error": a provider failure pi resolved instead of rejecting + * (any text; partial output is surfaced separately). + * - stopReason "length" with NO text: a silent max-token death — the run hit + * the output-token ceiling before writing anything, which would otherwise + * land as a "completed" run with an empty result (the #144 symptom). + * Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a + * "length" stop that DID produce text (a legitimate truncated-but-useful answer). + * "aborted" is handled by the manager's abort flag / "stopped" guard, not here. + * Bounded by `startIndex` (like the text fallback) so a resume that produced no + * assistant message of its own never inherits a PRIOR turn's stop reason. + */ +function finalTurnError(session: AgentSession, startIndex = 0): string | undefined { + for (let i = session.messages.length - 1; i >= startIndex; i--) { + const msg = session.messages[i]; + if (msg.role !== "assistant") continue; + if (msg.stopReason === "error") { + return (msg as { errorMessage?: string }).errorMessage?.trim() || "provider error with no output"; + } + if (msg.stopReason === "length" && !extractText(msg.content).trim()) { + return "run hit the output token limit before producing any text"; + } + return undefined; + } + return undefined; +} + +/** + * Wire an AbortSignal to abort a session. + * Returns a cleanup function to remove the listener. + */ +function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void { + if (!signal) return () => {}; + const onAbort = () => session.abort(); + signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); +} + +function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined { + if (!sessionDir) return undefined; + if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2)); + if (isAbsolute(sessionDir)) return sessionDir; + return resolve(cwd, sessionDir); +} + +export async function runAgent( + ctx: ExtensionContext, + type: SubagentType, + prompt: string, + options: RunOptions, +): Promise { + const config = getConfig(type); + const agentConfig = getAgentConfig(type); + + // Resolve working directory: worktree override > parent cwd + const effectiveCwd = options.cwd ?? ctx.cwd; + // Filesystem work happens in effectiveCwd; config discovery in configCwd. + // They differ only for SpawnOptions.cwd spawns (config stays with the parent). + const configCwd = options.configCwd ?? effectiveCwd; + + const env = await detectEnv(options.pi, effectiveCwd); + + // Get parent system prompt for append-mode agents + const parentSystemPrompt = ctx.getSystemPrompt(); + + // Build prompt extras (memory, skill preloading) + const extras: PromptExtras = {}; + if (options.worktreeBase) extras.worktreeBase = options.worktreeBase; + + // Resolve extensions/skills: isolated overrides to false + const extensions = options.isolated ? false : config.extensions; + // Nulling excludes under isolated also suppresses the orphaned-exclude warning — + // isolation is an intentional override, not a misconfiguration. + const excludeExtensions = options.isolated ? undefined : config.excludeExtensions; + const skills = options.isolated ? false : config.skills; + + // Skill preloading: when skills is string[], preload their content into prompt + if (Array.isArray(skills)) { + const loaded = preloadSkills(skills, configCwd); + if (loaded.length > 0) { + extras.skillBlocks = loaded; + } + } + + let toolNames = getToolNamesForType(type); + + // Persistent memory: detect write capability and branch accordingly. + // Account for disallowedTools — a tool in the base set but on the denylist is not truly available. + if (agentConfig?.memory) { + const existingNames = new Set(toolNames); + const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined; + const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name); + const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit"); + + if (hasWriteTools) { + // Read-write memory: add any missing memory tool names (read/write/edit) + const extraNames = getMemoryToolNames(existingNames); + if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames]; + extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd); + } else { + // Read-only memory: only add read tool name, use read-only prompt + const extraNames = getReadOnlyMemoryToolNames(existingNames); + if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames]; + extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd); + } + } + + // Build system prompt from agent config + let systemPrompt: string; + if (agentConfig) { + systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras); + } else { + // Unknown type fallback: spread the canonical general-purpose config (defensive — + // unreachable in practice since index.ts resolves unknown types before calling runAgent). + const fallback = DEFAULT_AGENTS.get("general-purpose"); + if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`); + systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras); + } + // Child permission policy is selected by the agent-definition filename, + // which is also where pi-permission-system reads `permission:` frontmatter. + // A declared `name:` may differ from that filename, so sourcePath wins when + // available. Strip inherited parent identity before installing this one. + const permissionAgentName = agentConfig?.sourcePath + ? basename(agentConfig.sourcePath).replace(/\.md$/i, "") + : agentConfig?.name ?? type; + systemPrompt = withActiveAgentIdentity(systemPrompt, permissionAgentName); + + // When skills is string[], we've already preloaded them into the prompt. + // Still pass noSkills: true since we don't need the skill loader to load them again. + const noSkills = skills === false || Array.isArray(skills); + + const agentDir = getAgentDir(); + + // Extension loading: + // - true → all default-discovered extensions + // - false → no optional extensions + // - string[] → loader-level allowlist. Bare names keep the matching + // default-discovered extension; path entries load that extension fresh; + // "*" keeps all default-discovered extensions. + // Bundle-owned mandatory entries are merged after this Agent-controlled policy: + // neither extensions:false, isolated mode, nor exclude_extensions can prevent + // their handlers from binding. Their tools remain governed by normal Agent policy. + // + // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's + // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which + // would defeat prompt_mode: replace and isolated: true. Parent context, if + // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt + // is embedded in systemPromptOverride) or inherit_context (conversation). + // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to + // the LLM. They do NOT control loading — `extensions:` is the sole Agent-level + // authority for optional extensions. Mandatory extension tools are not surfaced + // unless that same Agent-level policy would normally select the extension. + const { extNames, narrowing } = parseExtSelectors( + options.isolated ? [] : (agentConfig?.extSelectors ?? []), + ); + const optionalExtensionsDisabled = extensions === false; + const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths); + const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0; + + const extensionsSpec = Array.isArray(extensions) + ? parseExtensionsSpec(extensions, configCwd) + : undefined; + const keepNames = extensionsSpec?.names ?? new Set(); + // Agent excludes still hide optional extensions and extension tools, but they + // cannot remove a mandatory extension's lifecycle handlers. + const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase())); + const hasExcludes = excludeNames.size > 0; + const loadAll = extensions === true || extensionsSpec?.wildcard === true; + const normalPolicySelects = (extensionPath: string): boolean => { + if (optionalExtensionsDisabled) return false; + const canons = extensionCanonicalNames(extensionPath); + if (canons.some((name) => excludeNames.has(name))) return false; + return loadAll || canons.some((name) => keepNames.has(name)); + }; + const requestedPaths = [ + ...(extensionsSpec?.paths ?? []), + ...mandatoryExtensionPaths, + ]; + const additionalExtensionPaths = requestedPaths.length + ? [...new Set(requestedPaths)] + : undefined; + // Pre-filter discovered set, captured by the override — typo warnings compare + // against discovery, while mandatory identity is checked by exact canonical path. + let discoveredNames: Set | undefined; + const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined = + noExtensions || (loadAll && !hasExcludes) + ? undefined + : (base) => { + discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path))); + return { + ...base, + extensions: base.extensions.filter((extension) => + isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths) + || normalPolicySelects(extension.path) + ), + }; + }; + + const loader = new DefaultResourceLoader({ + cwd: configCwd, + agentDir, + noExtensions, + additionalExtensionPaths, + extensionsOverride, + noSkills, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPromptOverride: () => systemPrompt, + appendSystemPromptOverride: () => [], + }); + await runInChildSessionContext(() => loader.reload()); + const loadedExtensions = loader.getExtensions().extensions; + assertMandatoryExtensionsLoaded( + loadedExtensions.map((extension) => extension.path), + mandatoryExtensionPaths, + ); + const toolEligibleExtensionPaths = new Set( + loadedExtensions + .filter((extension) => normalPolicySelects(extension.path)) + .map((extension) => extension.path), + ); + + // Plain entries in `tools:` are expected to be built-in names (extension tools + // go through `ext:`), so an unknown name there is unambiguously a typo. Previously + // this produced a silently broken agent (#75) — pi-mono accepted the bogus name + // into the allowlist, then dropped it at registration with no signal back. + if (agentConfig?.builtinToolNames?.length) { + const knownBuiltins = new Set(BUILTIN_TOOL_NAMES); + for (const name of agentConfig.builtinToolNames) { + if (!knownBuiltins.has(name)) { + options.onToolActivity?.({ + type: "end", + toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`, + }); + } + } + } + + // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather + // than aborts. Two distinct misconfigurations to catch: + // - `extensions: [foo]` but no extension named foo was discovered (typo or + // path that failed to load — path entries fold their canonical name into + // `keepNames`, so this covers them too). + // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:` + // didn't include it). Since v0.9, `ext:` no longer pulls extensions in; + // loading is `extensions:`-authoritative. + // An exclude alongside extensions:false cannot affect optional loading. A + // mandatory extension may still bind, but its tools remain hidden by that policy. + if (hasExcludes && optionalExtensionsDisabled) { + options.onToolActivity?.({ + type: "end", + toolName: `extension-error:exclude_extensions has no effect on optional extensions for agent "${type}" — mandatory extensions remain loaded`, + }); + } + // Exclude typo check: compares against the PRE-filter discovered set (an excluded + // name absent from the surviving set is the exclude working as intended). Also + // flags path-like and "*" entries — excludes are plain names only. + if (hasExcludes && discoveredNames) { + for (const name of excludeNames) { + if (!discoveredNames.has(name)) { + options.onToolActivity?.({ + type: "end", + toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`, + }); + } + } + for (const extension of loader.getExtensions().extensions) { + if (!isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)) continue; + const attemptedExclusions = extensionCanonicalNames(extension.path) + .filter((name) => excludeNames.has(name)); + for (const name of attemptedExclusions) { + options.onToolActivity?.({ + type: "end", + toolName: `extension-warning:ignored lifecycle exclusion of mandatory extension "${name}" for agent "${type}"`, + }); + } + } + } + if (keepNames.size > 0 || extNames.size > 0) { + const survivingNames = new Set( + loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)), + ); + for (const name of keepNames) { + if (!survivingNames.has(name)) { + options.onToolActivity?.({ + type: "end", + toolName: excludeNames.has(name) + ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins` + : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`, + }); + } + } + for (const name of extNames) { + if (!survivingNames.has(name)) { + options.onToolActivity?.({ + type: "end", + toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`, + }); + } + } + } + + // Resolve model: explicit option > config.model > parent model + const model = options.model ?? resolveDefaultModel( + ctx.model, ctx.modelRegistry, agentConfig?.model, + ); + + // Resolve thinking level: explicit option > agent config > undefined (inherit) + const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking; + + const disallowedSet = agentConfig?.disallowedTools + ? new Set(agentConfig.disallowedTools) + : undefined; + + // Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent + // set `allowed_subagents` and a nestedRuntime was provided — and never when + // isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the + // scoping below re-admits them explicitly (registry deny + active-set narrow). + const effectiveMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth(); + // At (or past) the cap this agent can never spawn, so it can never own a child + // to fetch from or steer either — inject nothing rather than three tools whose + // every call is an error. This is also what makes `maxSubagentDepth` 0/1 mean + // "nesting off" instead of "nesting always fails". + const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth + ? options.nestedRuntime + : undefined; + const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !options.isolated + ? createNestedSubagentTools({ + manager: nestedRuntime.manager, + pi: options.pi, + parentAgentId: nestedRuntime.parentAgentId, + depth: nestedRuntime.depth, + maxSubagentDepth: effectiveMaxDepth, + allowedSubagents: agentConfig.allowedSubagents, + configCwd, + }) + : []; + const nestedToolNames = new Set(nestedTools.map(tool => tool.name)); + + // ─── Tool scoping ─────────────────────────────────────────────────────── + // + // Some extensions register their tools ASYNCHRONOUSLY, long after the + // `loader.reload()` above: pi-mcp calls registerTool from `session_start` + // (once its MCP servers connect), context-mode from `before_agent_start`. + // That is deliberate on their part — eagerly spawning an MCP bridge during + // extension discovery orphans child processes on pi's non-agent code paths + // (--help, config, trust probing). + // + // So the tool set cannot be snapshotted here. pi's `allowedToolNames` gates + // tool *registration* (`_refreshToolRegistry`'s `isAllowedTool`), not merely + // the active set, and is frozen at construction — a name absent from the + // snapshot is dropped forever, even once the tool actually registers (#125). + // + // Whenever extensions are in play we therefore: + // - leave `allowedToolNames` unset, so pi's live gate admits tools whenever + // they register; + // - express the name-stable, permanent part of the scope (our own + // orchestration tools, built-ins the agent didn't ask for, and + // `disallowedTools`) as `excludeTools`, which pi re-applies on every + // registry refresh; + // - enforce `ext:` narrowing on the ACTIVE set via the live `inScope()` + // predicate installed after bind — the active set is what the LLM sees, + // so a registry tool that is never activated is invisible and uncallable. + // + // `noExtensions`/`isolated` keeps the historical static allowlist: nothing + // async can appear there, and a hard registry gate is the correct boundary. + const builtinToolNameSet = new Set(toolNames); + + let sessionTools: string[] | undefined; + let sessionExcludeTools: string[] | undefined; + if (noExtensions) { + // Strict allowlist: built-ins the agent asked for, plus any opt-in nested + // tools (whose names would otherwise be dropped as EXCLUDED_TOOL_NAMES). + sessionTools = [ + ...toolNames.filter( + (t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t), + ), + ...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)), + ]; + } else { + // Deny the orchestration tools EXCEPT the nested ones this agent opted into — + // those are injected as customTools and must survive the registry gate. + const denyTools = new Set( + EXCLUDED_TOOL_NAMES.filter((t) => !nestedToolNames.has(t)), + ); + // Keep only the built-ins the agent asked for — deny the rest. + for (const name of BUILTIN_TOOL_NAMES) { + if (!builtinToolNameSet.has(name)) denyTools.add(name); + } + if (disallowedSet) { + // disallowed_tools wins even over an opt-in nested tool of the same name. + for (const name of disallowedSet) denyTools.add(name); + } + sessionExcludeTools = [...denyTools]; + } + + const settingsManager = SettingsManager.create(configCwd, agentDir); + const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd); + const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.(); + // Frontmatter wins when it says anything; otherwise the project default, + // which `rememberAgents` supplies for top-level agents only. Same precedence + // as `outputTranscript`. + const persistSession = agentConfig?.persistSession ?? (options.nested ? false : rememberAgents); + const sessionManager = options.resumeSessionFile + // Reopening an existing conversation: the file already carries its own + // header (cwd, parent) and history, so none of the create-time options + // apply. `sessionDir` still matters for a later /new or /branch off it. + ? SessionManager.open(options.resumeSessionFile, configuredSessionDir ?? defaultSessionDir) + : persistSession + ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, { + // Optional metadata — it only nests the subagent under its spawner in + // `/resume`. Until `rememberAgents` this ran solely for the rare + // `persist_session: true` agent; now it runs for every spawn, so a + // context without a session manager (a bare programmatic ctx) must + // still persist rather than take the whole spawn down. + parentSession: ctx.sessionManager?.getSessionFile?.(), + }) + : SessionManager.inMemory(effectiveCwd); + + // Pi 0.80.8 replaced createAgentSession's modelRegistry option with + // modelRuntime, but ExtensionContext still exposes only the registry facade. + // Pass both so the full supported Pi range retains the parent's providers. + const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime; + const sessionOpts: Parameters[0] & { + modelRegistry: ExtensionContext["modelRegistry"]; + modelRuntime?: unknown; + } = { + cwd: effectiveCwd, + agentDir, + sessionManager, + settingsManager, + modelRegistry: ctx.modelRegistry, + // `as never` is what keeps this assignable across the supported Pi range: + // pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim + // above, while newer Pi types it as `ModelRuntime` — a shape an opaque + // `unknown` read off the private facade field can never satisfy. + ...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }), + model, + tools: sessionTools, + customTools: nestedTools, + resourceLoader: loader, + }; + if (sessionExcludeTools) { + sessionOpts.excludeTools = sessionExcludeTools; + } + if (thinkingLevel) { + sessionOpts.thinkingLevel = thinkingLevel; + } + + const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts)); + + const baseSessionName = agentConfig?.name ?? type; + session.setSessionName( + options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName, + ); + + // pi-permission-system must know this is a headless child BEFORE its + // session_start handlers run in bindExtensions(). All nested descendants + // retain the interactive root as authority rather than forwarding to an + // intermediate child that has no UI. + registerPermissionChildSession( + options.pi, + session, + options.authoritySessionId ?? ctx.sessionManager?.getSessionId?.(), + ); + + // Bind extensions so that session_start fires and extensions can initialize + // (e.g. loading credentials, setting up state). Tool gating already happened + // at session construction via the `tools:` allowlist above — no separate + // post-bind filter is needed. All ExtensionBindings fields are optional. + try { + await session.bindExtensions({ + onError: (err) => { + options.onToolActivity?.({ + type: "end", + toolName: `extension-error:${err.extensionPath}`, + }); + }, + }); + } catch (error) { + // The manager never receives a session whose bind failed, so close both + // halves of the lifecycle here. Disposal precedes the registry event: once + // observers see "disposed", the child cannot issue another permission ask. + try { session.dispose(); } finally { unregisterPermissionChildSession(session); } + throw error; + } + + // With `allowedToolNames` unset, the registry is scoped by `excludeTools` but + // the ACTIVE set still needs managing: pi activates only its four default + // built-ins at turn 1, and `ext:` narrowing has no registry-level expression + // (we can't deny the name of a tool that hasn't registered yet). Both are + // handled below by re-deriving scope from the loader's live extension maps — + // `registerTool` writes into those same maps, so late arrivals are judged too. + if (!noExtensions) { + installExtensionToolScope(session, { + loader, + toolNames, + disallowedSet, + extNames, + narrowing, + nestedToolNames, + toolEligibleExtensionPaths, + }); + } + + options.onSessionCreated?.(session); + + // Track turns for graceful max_turns enforcement + let turnCount = 0; + const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns); + let softLimitReached = false; + let aborted = false; + + let currentMessageText = ""; + const unsubTurns = session.subscribe((event: AgentSessionEvent) => { + if (event.type === "turn_end") { + turnCount++; + options.onTurnEnd?.(turnCount); + if (maxTurns != null) { + if (!softLimitReached && turnCount >= maxTurns) { + softLimitReached = true; + session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now."); + } else if (softLimitReached && turnCount >= maxTurns + graceTurns) { + aborted = true; + session.abort(); + } + } + } + if (event.type === "message_start") { + currentMessageText = ""; + } + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + currentMessageText += event.assistantMessageEvent.delta; + options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText); + } + if (event.type === "tool_execution_start") { + options.onToolActivity?.({ type: "start", toolName: event.toolName }); + } + if (event.type === "tool_execution_end") { + options.onToolActivity?.({ type: "end", toolName: event.toolName }); + } + if (event.type === "message_end" && event.message.role === "assistant") { + const u = (event.message as any).usage; + if (u) options.onAssistantUsage?.({ + input: u.input ?? 0, + output: u.output ?? 0, + cacheWrite: u.cacheWrite ?? 0, + cacheRead: u.cacheRead ?? 0, + cost: u.cost?.total ?? 0, + }); + } + if (event.type === "compaction_end" && !event.aborted && event.result) { + options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore }); + } + }); + + const collector = collectResponseText(session); + const cleanupAbort = forwardAbortSignal(session, options.signal); + + // Build the effective prompt: optionally prepend parent context + let effectivePrompt = prompt; + if (options.inheritContext) { + const parentContext = buildParentContext(ctx); + if (parentContext) { + effectivePrompt = parentContext + prompt; + } + } + + // Boundary for the history fallback: only assistant text produced from here + // on counts as this run's output (a fresh session, so usually 0). + const startLen = session.messages.length; + try { + await session.prompt(effectivePrompt); + } finally { + unsubTurns(); + collector.unsubscribe(); + cleanupAbort(); + } + + const responseText = collector.getText().trim() || getLastAssistantText(session, startLen); + return { responseText, session, aborted, steered: softLimitReached, failure: finalTurnError(session, startLen) }; +} + +/** + * Send a new prompt to an existing session (resume). + */ +export async function resumeAgent( + session: AgentSession, + prompt: string, + options: { + onToolActivity?: (activity: ToolActivity) => void; + onAssistantUsage?: (usage: LifetimeUsage) => void; + onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void; + signal?: AbortSignal; + } = {}, +): Promise<{ text: string; failure?: string }> { + // Boundary for the history fallback: the session already holds prior turns, + // so only assistant text produced by THIS resume prompt counts as its output + // — a failed resume must not surface the previous turn's answer (#144). + const startLen = session.messages.length; + const collector = collectResponseText(session); + const cleanupAbort = forwardAbortSignal(session, options.signal); + + const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction) + ? session.subscribe((event: AgentSessionEvent) => { + if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName }); + if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName }); + if (event.type === "message_end" && event.message.role === "assistant") { + const u = (event.message as any).usage; + if (u) options.onAssistantUsage?.({ + input: u.input ?? 0, + output: u.output ?? 0, + cacheWrite: u.cacheWrite ?? 0, + cacheRead: u.cacheRead ?? 0, + cost: u.cost?.total ?? 0, + }); + } + if (event.type === "compaction_end" && !event.aborted && event.result) { + options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore }); + } + }) + : () => {}; + + try { + await session.prompt(prompt); + } finally { + collector.unsubscribe(); + unsubEvents(); + cleanupAbort(); + } + + return { + text: collector.getText().trim() || getLastAssistantText(session, startLen), + failure: finalTurnError(session, startLen), + }; +} + +/** + * Send a steering message to a running subagent. + * The message will interrupt the agent after its current tool execution. + */ +export async function steerAgent( + session: AgentSession, + message: string, +): Promise { + await session.steer(message); +} + +/** + * Get the subagent's conversation messages as formatted text. + */ +export function getAgentConversation(session: AgentSession): string { + const parts: string[] = []; + + for (const msg of session.messages) { + if (msg.role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : extractText(msg.content); + if (text.trim()) parts.push(`[User]: ${text.trim()}`); + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const toolCalls: string[] = []; + for (const c of msg.content) { + if (c.type === "text" && c.text) textParts.push(c.text); + else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`); + } + if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`); + if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`); + } else if (msg.role === "toolResult") { + const text = extractText(msg.content); + const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text; + parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`); + } + } + + return parts.join("\n\n"); +} diff --git a/pi-subagents/src/agent-types.ts b/pi-subagents/src/agent-types.ts new file mode 100644 index 0000000..b6c544a --- /dev/null +++ b/pi-subagents/src/agent-types.ts @@ -0,0 +1,346 @@ +/** + * agent-types.ts — Unified agent type registry. + * + * Merges embedded default agents with user-defined agents from .pi/agents/*.md, .agents/agents/*.md, and global agents. + * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning. + */ + +import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent"; +import { DEFAULT_AGENTS } from "./default-agents.js"; +import type { AgentConfig } from "./types.js"; + +/** + * All known built-in tool names, derived from pi's own tool factories rather + * than hardcoded so the set tracks pi-mono if it adds/renames a built-in. + * `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` → + * read/grep/find/ls; their de-duplicated union is the 7 built-ins + * (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool + * operations we never invoke here — we read each tool's `.name` and discard it. + */ +export const BUILTIN_TOOL_NAMES: string[] = [ + ...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)), +]; + +/** Unified runtime registry of all agents (defaults + user-defined). */ +const agents = new Map(); + +/** When true, DEFAULT_AGENTS are skipped during registration. */ +let disableDefaults = false; + +/** Check whether default agents are disabled. */ +export function isDefaultsDisabled(): boolean { return disableDefaults; } + +/** Set whether default agents are disabled. */ +export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; } + +/** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */ +export const NO_FALLBACK = "none"; + +/** + * Agent type substituted when a caller-supplied `subagent_type` doesn't resolve + * to exactly one enabled agent. `undefined` keeps the historical behavior + * (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from + * `subagents.json` (`fallbackSubagent`). + * + * Module state rather than an index.ts closure because every caller-supplied + * spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC. + */ +let fallbackSubagent: string | undefined; + +/** Get the configured fallback agent type. undefined = general-purpose. */ +export function getFallbackSubagent(): string | undefined { return fallbackSubagent; } + +/** Set the configured fallback agent type. undefined = general-purpose. */ +export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; } + +/** + * Build a registry map: DEFAULT_AGENTS first (unless disabled via settings), + * then user agents overlaid on top (same name overrides the default). + * Pure — callers that must not disturb the process-wide registry (nested + * delegation resolving agents from its own config root) build their own map. + */ +export function buildAgentRegistry(userAgents: Map): Map { + const registry = new Map(); + if (!disableDefaults) { + for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config); + } + for (const [name, config] of userAgents) registry.set(name, config); + return registry; +} + +/** + * Register agents into the unified registry. + * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name). + * Disabled agents (enabled === false) are kept in the registry but excluded from spawning. + */ +export function registerAgents(userAgents: Map): void { + agents.clear(); + for (const [name, config] of buildAgentRegistry(userAgents)) { + agents.set(name, config); + } +} + +/** Case-insensitive key resolution within a registry. */ +function resolveKeyIn(registry: Map, name: string): string | undefined { + if (registry.has(name)) return name; + const lower = name.toLowerCase(); + for (const key of registry.keys()) { + if (key.toLowerCase() === lower) return key; + } + return undefined; +} + +/** Case-insensitive key resolution. */ +function resolveKey(name: string): string | undefined { + return resolveKeyIn(agents, name); +} + +/** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */ +export function resolveTypeIn(registry: Map, name: string): string | undefined { + return resolveKeyIn(registry, name); +} + +/** Get the agent config for a type (case-insensitive) from a registry. */ +export function getAgentConfigIn(registry: Map, name: string): AgentConfig | undefined { + const key = resolveKeyIn(registry, name); + return key ? registry.get(key) : undefined; +} + +/** Check if a type is valid and enabled (case-insensitive) in a registry. */ +export function isValidTypeIn(registry: Map, type: string): boolean { + const key = resolveKeyIn(registry, type); + if (!key) return false; + return registry.get(key)?.enabled !== false; +} + +/** Get all enabled type names in a registry (for spawning and tool descriptions). */ +export function getAvailableTypesIn(registry: Map): string[] { + return [...registry.entries()] + .filter(([_, config]) => config.enabled !== false) + .map(([name]) => name); +} + +/** + * Case-insensitive resolution that refuses to guess. An exact match always wins; + * otherwise the name must match exactly one key. Two agents differing only in + * case are reachable (`loadCustomAgents` keys by filename across three + * directories), and picking whichever came first would silently dispatch a + * different agent, model and tool policy than the caller meant. + */ +function resolveUnambiguousKeyIn(registry: Map, name: string): string | undefined { + if (registry.has(name)) return name; + const lower = name.toLowerCase(); + const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower); + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * The canonical key for a caller-supplied name that identifies exactly one + * ENABLED agent, or undefined. Strict by construction: no fallback, no guessing + * between case-variants. Nested delegation resolves with this directly, since + * "unknown types are rejected rather than falling back" is its own contract. + */ +export function resolveEnabledTypeIn( + registry: Map, + requested: unknown, +): string | undefined { + const raw = typeof requested === "string" ? requested.trim() : ""; + if (!raw) return undefined; + const key = resolveUnambiguousKeyIn(registry, raw); + return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined; +} + +/** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */ +export type SpawnTypeResolution = + /** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */ + | { ok: true; type: string; fellBackFrom?: string } + /** Refuse the spawn and return this message to the caller. */ + | { ok: false; message: string }; + +/** + * Resolve a caller-supplied agent type against a registry, applying the + * `fallbackSubagent` policy. The single decision point for every caller-supplied + * spawn — the Agent tool, the scheduler, cross-extension RPC, and the nested + * tools — so a type that fails here never reaches `runAgent`, where `getConfig` + * would silently substitute general-purpose. + * + * Unknown, disabled, and case-ambiguous names are all treated the same way: + * the caller named something that doesn't identify exactly one enabled agent. + * + * Pure over `registry` — callers that need fresh agent files reload before + * calling (the Agent tool already does, per spawn). Reloading here would mean + * importing custom-agents.ts, which imports this module. + */ +export function resolveSpawnTypeIn( + registry: Map, + requested: unknown, +): SpawnTypeResolution { + const raw = typeof requested === "string" ? requested.trim() : ""; + const available = () => getAvailableTypesIn(registry).join(", ") || "(none)"; + + const key = resolveEnabledTypeIn(registry, raw); + if (key !== undefined) return { ok: true, type: key }; + + // A missing type follows the same policy as a wrong one rather than always + // erroring: before this setting existed an empty type fell back like any + // other unresolvable name, and only opting in should change that. + const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given."; + + // Trimmed like `requested`: a padded value set programmatically would + // otherwise be reported as a missing agent. + const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined; + + if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) { + return { ok: false, message: `${reason} Available: ${available()}.` }; + } + + if (configured !== undefined) { + // An explicitly configured fallback that is itself unusable is a + // misconfiguration, not a second chance to guess — say so rather than + // quietly dropping to general-purpose. + const fallbackKey = resolveUnambiguousKeyIn(registry, configured); + if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) { + return { + ok: false, + message: + `${reason} The configured fallbackSubagent "${configured}" is itself ` + + `unknown or disabled. Available: ${available()}.`, + }; + } + return { ok: true, type: fallbackKey, fellBackFrom: raw }; + } + + // Unset: historical behavior, deliberately unchanged. #183 asks for the + // fallback to remain the default, so the pre-existing hole it leaves — an + // unregistered general-purpose resolving to `getConfig`'s all-tools hardcoded + // tier — is what `fallbackSubagent: none` is for, not something to close + // under everyone silently. + return { ok: true, type: "general-purpose", fellBackFrom: raw }; +} + +/** Resolve a caller-supplied agent type against the process-wide registry. */ +export function resolveSpawnType(requested: unknown): SpawnTypeResolution { + return resolveSpawnTypeIn(agents, requested); +} + +/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */ +export function resolveType(name: string): string | undefined { + return resolveKey(name); +} + +/** Get the agent config for a type (case-insensitive). */ +export function getAgentConfig(name: string): AgentConfig | undefined { + return getAgentConfigIn(agents, name); +} + +/** Get all enabled type names (for spawning and tool descriptions). */ +export function getAvailableTypes(): string[] { + return getAvailableTypesIn(agents); +} + +/** Get all type names including disabled (for UI listing). */ +export function getAllTypes(): string[] { + return [...agents.keys()]; +} + +/** Get names of default agents currently in the registry. */ +export function getDefaultAgentNames(): string[] { + return [...agents.entries()] + .filter(([_, config]) => config.isDefault === true) + .map(([name]) => name); +} + +/** Get names of user-defined agents (non-defaults) currently in the registry. */ +export function getUserAgentNames(): string[] { + return [...agents.entries()] + .filter(([_, config]) => config.isDefault !== true) + .map(([name]) => name); +} + +/** Check if a type is valid and enabled (case-insensitive). */ +export function isValidType(type: string): boolean { + return isValidTypeIn(agents, type); +} + +/** Tool names required for memory management. */ +const MEMORY_TOOL_NAMES = ["read", "write", "edit"]; + +/** + * Get memory tool names (read/write/edit) not already in the provided set. + */ +export function getMemoryToolNames(existingToolNames: Set): string[] { + return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n)); +} + +/** Tool names needed for read-only memory access. */ +const READONLY_MEMORY_TOOL_NAMES = ["read"]; + +/** + * Get read-only memory tool names not already in the provided set. + */ +export function getReadOnlyMemoryToolNames(existingToolNames: Set): string[] { + return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n)); +} + +/** Get built-in tool names for a type (case-insensitive). */ +export function getToolNamesForType(type: string): string[] { + const key = resolveKey(type); + const raw = key ? agents.get(key) : undefined; + const config = raw?.enabled !== false ? raw : undefined; + // `undefined` (definition omitted the field) → all built-ins; an explicit `[]` + // (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins. + return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES]; +} + +/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */ +export function getConfig(type: string): { + displayName: string; + color?: string; + description: string; + builtinToolNames: string[]; + extensions: true | string[] | false; + excludeExtensions?: string[]; + skills: true | string[] | false; + promptMode: "replace" | "append"; +} { + const key = resolveKey(type); + const config = key ? agents.get(key) : undefined; + if (config && config.enabled !== false) { + return { + displayName: config.displayName ?? config.name, + color: config.color, + description: config.description, + builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES, + extensions: config.extensions, + excludeExtensions: config.excludeExtensions, + skills: config.skills, + promptMode: config.promptMode, + }; + } + + // Fallback for unknown/disabled types — general-purpose config + const gp = agents.get("general-purpose"); + if (gp && gp.enabled !== false) { + return { + displayName: gp.displayName ?? gp.name, + color: gp.color, + description: gp.description, + builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES, + extensions: gp.extensions, + excludeExtensions: gp.excludeExtensions, + skills: gp.skills, + promptMode: gp.promptMode, + }; + } + + // Absolute fallback (should never happen) + return { + displayName: "Agent", + description: "General-purpose agent for complex, multi-step tasks", + builtinToolNames: BUILTIN_TOOL_NAMES, + extensions: true, + skills: true, + promptMode: "append", + }; +} + diff --git a/pi-subagents/src/child-context.ts b/pi-subagents/src/child-context.ts new file mode 100644 index 0000000..e330080 --- /dev/null +++ b/pi-subagents/src/child-context.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * Marks resource loading/session construction performed for a subagent. This is + * async-context-local so concurrent top-level extension work is unaffected. + */ +const childSessionContext = new AsyncLocalStorage(); + +export function inChildSessionContext(): boolean { + return childSessionContext.getStore() === true; +} + +export function runInChildSessionContext(fn: () => Promise): Promise { + return childSessionContext.run(true, fn); +} diff --git a/pi-subagents/src/context.ts b/pi-subagents/src/context.ts new file mode 100644 index 0000000..c4dcbf8 --- /dev/null +++ b/pi-subagents/src/context.ts @@ -0,0 +1,58 @@ +/** + * context.ts — Extract parent conversation context for subagent inheritance. + */ + +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +/** Extract text from a message content block array. */ +export function extractText(content: unknown[]): string { + return content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text ?? "") + .join("\n"); +} + +/** + * Build a text representation of the parent conversation context. + * Used when inherit_context is true to give the subagent visibility + * into what has been discussed/done so far. + */ +export function buildParentContext(ctx: ExtensionContext): string { + const entries = ctx.sessionManager.getBranch(); + if (!entries || entries.length === 0) return ""; + + const parts: string[] = []; + + for (const entry of entries) { + if (entry.type === "message") { + const msg = entry.message; + if (msg.role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : extractText(msg.content); + if (text.trim()) parts.push(`[User]: ${text.trim()}`); + } else if (msg.role === "assistant") { + const text = extractText(msg.content); + if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`); + } + // Skip toolResult messages — too verbose for context + } else if (entry.type === "compaction") { + // Include compaction summaries — they're already condensed + if (entry.summary) { + parts.push(`[Summary]: ${entry.summary}`); + } + } + } + + if (parts.length === 0) return ""; + + return `# Parent Conversation Context +The following is the conversation history from the parent session that spawned you. +Use this context to understand what has been discussed and decided so far. + +${parts.join("\n\n")} + +--- +# Your Task (below) +`; +} diff --git a/pi-subagents/src/cross-extension-rpc.ts b/pi-subagents/src/cross-extension-rpc.ts new file mode 100644 index 0000000..2f8a622 --- /dev/null +++ b/pi-subagents/src/cross-extension-rpc.ts @@ -0,0 +1,122 @@ +/** + * Cross-extension RPC handlers for the subagents extension. + * + * Exposes ping, spawn, and stop RPCs over the pi.events event bus, + * using per-request scoped reply channels. + * + * Reply envelope follows pi-mono convention: + * success → { success: true, data?: T } + * error → { success: false, error: string } + */ + +import { type ModelRegistry, resolveModel } from "./model-resolver.js"; + +/** Minimal event bus interface needed by the RPC handlers. */ +export interface EventBus { + on(event: string, handler: (data: unknown) => void): () => void; + emit(event: string, data: unknown): void; +} + +/** RPC reply envelope — matches pi-mono's RpcResponse shape. */ +export type RpcReply = + | { success: true; data?: T } + | { success: false; error: string }; + +/** RPC protocol version — bumped when the envelope or method contracts change. */ +export const PROTOCOL_VERSION = 2; + +/** Minimal AgentManager interface needed by the spawn/stop RPCs. */ +export interface SpawnCapable { + spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string; + abort(id: string): boolean; +} + +export interface RpcDeps { + events: EventBus; + pi: unknown; // passed through to manager.spawn + getCtx: () => unknown | undefined; // returns current ExtensionContext + manager: SpawnCapable; +} + +export interface RpcHandle { + unsubPing: () => void; + unsubSpawn: () => void; + unsubStop: () => void; +} + +/** + * Wire a single RPC handler: listen on `channel`, run `fn(params)`, + * emit the reply envelope on `channel:reply:${requestId}`. + */ +function handleRpc

( + events: EventBus, + channel: string, + fn: (params: P) => unknown | Promise, +): () => void { + return events.on(channel, async (raw: unknown) => { + const params = raw as P; + try { + const data = await fn(params); + const reply: { success: true; data?: unknown } = { success: true }; + if (data !== undefined) reply.data = data; + events.emit(`${channel}:reply:${params.requestId}`, reply); + } catch (err: any) { + events.emit(`${channel}:reply:${params.requestId}`, { + success: false, error: err?.message ?? String(err), + }); + } + }); +} + +/** + * Register ping, spawn, and stop RPC handlers on the event bus. + * Returns unsub functions for cleanup. + */ +export function registerRpcHandlers(deps: RpcDeps): RpcHandle { + const { events, pi, getCtx, manager } = deps; + + const unsubPing = handleRpc(events, "subagents:rpc:ping", () => { + return { version: PROTOCOL_VERSION }; + }); + + const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>( + events, "subagents:rpc:spawn", ({ type, prompt, options }) => { + const ctx = getCtx(); + if (!ctx) throw new Error("No active session"); + + // Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally + // forward serializable values, so options.model can be a string like + // "openai-codex/gpt-5.5". Resolve it to a real Model instance here + // — same pattern the scheduler path already uses — so the spawned + // agent's auth lookup doesn't crash with "No API key found for + // undefined". + let normalizedOptions = options ?? {}; + if (typeof normalizedOptions.model === "string") { + const registry = (ctx as { modelRegistry?: ModelRegistry }).modelRegistry; + if (!registry) { + throw new Error( + `Model override "${normalizedOptions.model}" provided but ctx.modelRegistry is unavailable`, + ); + } + const resolved = resolveModel(normalizedOptions.model, registry); + if (typeof resolved === "string") { + // resolveModel returns a human-readable error string when the + // input doesn't match any available model. Surface it instead of + // silently falling back so the caller sees the auth/typo issue. + throw new Error(resolved); + } + normalizedOptions = { ...normalizedOptions, model: resolved }; + } + + return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) }; + }, + ); + + const unsubStop = handleRpc<{ requestId: string; agentId: string }>( + events, "subagents:rpc:stop", ({ agentId }) => { + if (!manager.abort(agentId)) throw new Error("Agent not found"); + }, + ); + + return { unsubPing, unsubSpawn, unsubStop }; +} diff --git a/pi-subagents/src/custom-agents.ts b/pi-subagents/src/custom-agents.ts new file mode 100644 index 0000000..d7978ea --- /dev/null +++ b/pi-subagents/src/custom-agents.ts @@ -0,0 +1,305 @@ +/** + * custom-agents.ts — Load user-defined agents from project (.pi/agents/, plus the shared .agents/agents/ workspace) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { BUILTIN_TOOL_NAMES } from "./agent-types.js"; +import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./types.js"; + +/** + * The one thing a declared `name:` may not contain, matching Claude Code + * exactly: it reserves `:` for plugin-scoped identifiers (`my-plugin:reviewer`) + * and refuses to load a file whose name uses one. + * + * Nothing else is rejected. Claude Code's docs describe names as "lowercase + * letters and hyphens", but that is guidance — the only stated load failure is + * the colon, so `name: Code Reviewer` must work here too. (The stricter + * letters/digits/underscore/hyphen regex in Claude Code applies to the Agent + * tool's spawn-time `name` parameter, which is a different field.) Mixed case + * has to be allowed regardless: the built-in types `Explore` and `Plan` use it, + * and a file must be able to override one. + */ +const RESERVED_IN_TYPE = ":"; + +/** + * Scan for custom agent .md files from multiple locations. + * Discovery hierarchy (higher priority wins): + * 1. Project: /.pi/agents/*.md (authoritative — also where /agents writes) + * 2. Workspace: /.agents/agents/*.md (shared cross-tool .agents workspace, read-only) + * 3. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md) + * + * Project-level agents override global ones with the same name. On a name clash + * between the two project locations, .pi/agents wins — .pi stays the project + * authority; .agents/agents is an additional read location. + * Any name is allowed — names matching defaults (e.g. "Explore") override them. + * + * An agent's type comes from its frontmatter `name:`, falling back to the + * filename — Claude Code's rule, where "the filename doesn't have to match". + * Because the type is now declared rather than derived from a unique path, two + * files can claim the same one; the later load wins, as it always has for a + * filename clash, and `warnSkippedOverride` reports the substitution. + */ +export function loadCustomAgents(cwd: string, strict = false): Map { + const globalDir = join(getAgentDir(), "agents"); + const workspaceProjectDir = join(cwd, ".agents", "agents"); + const projectDir = join(cwd, ".pi", "agents"); + + const agents = new Map(); + loadFromDir(globalDir, agents, "global", strict); // lowest priority + loadFromDir(workspaceProjectDir, agents, "project", strict); // shared workspace + loadFromDir(projectDir, agents, "project", strict); // highest priority (overwrites) + + warnedLastLoad = warnedThisLoad; + warnedThisLoad = new Set(); + return agents; +} + +/** Load agent configs from a directory into the map. */ +function loadFromDir(dir: string, agents: Map, source: "project" | "global", strict: boolean): void { + if (!existsSync(dir)) return; + + let files: string[]; + try { + files = readdirSync(dir).filter(f => f.endsWith(".md")); + } catch { + return; + } + + for (const file of files) { + const filenameType = basename(file, ".md"); + + const path = join(dir, file); + + const parsed = readAgentFile(path, strict); + if (!parsed) { + warnSkippedOverride(filenameType, agents); + continue; + } + const { frontmatter: fm, body } = parsed; + + // Claude Code's rule: `name:` IS the agent type, and the filename need not + // match. Absent, the filename stands in — Claude Code requires the field, + // but most files here predate it and must keep loading. + const declared = str(fm.name)?.trim(); + if (declared?.includes(RESERVED_IN_TYPE)) { + // Refusing beats silently substituting: the file would otherwise load + // under its filename, so `Agent({subagent_type})` would succeed against + // an agent whose declared identity nothing honoured. + warnIfNew( + `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for ` + + "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.", + ); + // No `warnSkippedOverride`: this file would have registered under its + // *declared* name, which nothing else can hold (a colon keeps it out of + // the registry), so it shadowed nothing. Passing the filename instead + // would report a substitution of an unrelated agent that never happened. + continue; + } + // `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise + // register the agent under the empty type — unspawnable, and it takes the + // filename-derived one down with it. + const name = declared || filenameType; + + const { builtinToolNames, extSelectors } = parseToolsField(fm.tools); + + agents.set(name, { + name, + // Only `display_name` now: `name` is the type, and `getConfig` already + // falls back to the type when no label is set — so a Claude Code file + // with `name: code-reviewer` still badges as "code-reviewer". + displayName: str(fm.display_name), + color: str(fm.color), + description: str(fm.description) ?? name, + builtinToolNames, + extSelectors, + disallowedTools: csvListOptional(fm.disallowed_tools), + extensions: inheritField(fm.extensions ?? fm.inherit_extensions), + excludeExtensions: csvListOptional(fm.exclude_extensions), + skills: inheritField(fm.skills ?? fm.inherit_skills), + model: str(fm.model), + thinking: str(fm.thinking) as ThinkingLevel | undefined, + maxTurns: nonNegativeInt(fm.max_turns), + persistSession: fm.persist_session != null ? fm.persist_session === true : undefined, + outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined, + sessionDir: str(fm.session_dir), + allowedSubagents: parseAllowedSubagents(fm.allowed_subagents), + systemPrompt: body.trim(), + promptMode: fm.prompt_mode === "append" ? "append" : "replace", + inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined, + runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined, + isolated: fm.isolated != null ? fm.isolated === true : undefined, + memory: parseMemory(fm.memory), + isolation: parseIsolation(fm.isolation), + enabled: fm.enabled !== false, // default true; explicitly false disables + source, + sourcePath: path, + }); + } +} + +/** + * Read and parse one agent file, or warn and return undefined for the caller to + * skip. One bad file must not take the whole extension down with it — an + * unparseable `.md` used to abort activation, so pi exited before the TUI. + * + * The path is as much of the fix as the recovery: a bare YAML error ("line 2, + * column 14") is unactionable when agents come from three directories at once, + * and the only other symptom is `Unknown agent type`, which reads like a typo. + * + * Under `strict` the same failure rethrows, still naming the path, so callers + * that opted into failing closed stop rather than run a substituted agent. + */ +function readAgentFile(path: string, strict: boolean): { frontmatter: Record; body: string } | undefined { + try { + return parseFrontmatter>(readFileSync(path, "utf-8")); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (strict) throw new Error(`${path}: ${reason}`); + warnIfNew(`Skipping agent file ${path}: ${reason}`); + return undefined; + } +} + +/** + * A skipped file that was overriding an already-loaded agent leaves the name + * pointing at a *different* file — its own prompt, model and tools. Nothing + * downstream can flag that: unlike an unknown type, the `Agent` call succeeds. + */ +function warnSkippedOverride(name: string, agents: Map): void { + const surviving = agents.get(name); + // Nothing shadowed, or what it shadowed is disabled: dispatch refuses the type + // either way (see resolveEnabledTypeIn), so there is no substitution to report. + if (!surviving?.sourcePath || surviving.enabled === false) return; + warnIfNew(`Agent "${name}" now loads from ${surviving.sourcePath} instead`); +} + +let warnedLastLoad = new Set(); +let warnedThisLoad = new Set(); + +/** + * Agents reload on activation and again on every `Agent` call, so an unchanged + * problem would re-warn all session — over a painted TUI, since pi does not + * redirect console output. Compare against the previous load rather than every + * load ever, so a file that is fixed and then broken again still reports. + */ +function warnIfNew(message: string): void { + warnedThisLoad.add(message); + if (warnedLastLoad.has(message)) return; + console.warn(`[pi-subagents] ${message}`); +} + +// ---- Field parsers ---- +// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact. + +/** Extract a string or undefined. */ +function str(val: unknown): string | undefined { + return typeof val === "string" ? val : undefined; +} + +/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */ +function nonNegativeInt(val: unknown): number | undefined { + return typeof val === "number" && val >= 0 ? val : undefined; +} + +/** + * Parse a raw CSV field value into items, or undefined if absent/empty/"none". + */ +function parseCsvField(val: unknown): string[] | undefined { + if (val === undefined || val === null) return undefined; + const s = String(val).trim(); + if (!s || s === "none") return undefined; + const items = s.split(",").map(t => t.trim()).filter(Boolean); + return items.length > 0 ? items : undefined; +} + +/** + * Parse the nested-delegation allowlist. Single field, default-off: + * omitted/empty/"none"/`false` → undefined (no nested tools); "all"/"*"/`true` + * → "all" (any enabled agent); csv → only the listed types. + * + * Booleans are accepted because `extensions:`/`skills:` take them and users + * generalize: without this, YAML's `true` stringifies into an agent type + * literally named "true", so the tools appear and every spawn is refused. + */ +function parseAllowedSubagents(val: unknown): "all" | string[] | undefined { + if (typeof val === "boolean") return val ? "all" : undefined; + const items = parseCsvField(val); + if (!items) return undefined; + return items.some(i => i === "*" || i.toLowerCase() === "all") ? "all" : items; +} + +/** + * Parse a comma-separated list field with defaults. + * omitted → defaults; "none"/empty → []; csv → listed items. + */ +function csvList(val: unknown, defaults: string[]): string[] { + if (val === undefined || val === null) return defaults; + return parseCsvField(val) ?? []; +} + +/** + * Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors. + * `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all + * built-ins; plain entries are built-in names; `ext:` entries are extension-tool + * selectors parsed later by the runner. omitted → all built-ins, no selectors. + * `tools:` present with only `ext:` entries → zero built-ins (use `*`). + */ +function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } { + const entries = csvList(val, BUILTIN_TOOL_NAMES); + const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all"; + const hasWildcard = entries.some(isWildcard); + const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:")); + const extEntries = entries.filter(e => e.startsWith("ext:")); + return { + builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain, + extSelectors: extEntries.length > 0 ? extEntries : undefined, + }; +} + +/** + * Parse an optional comma-separated list field. + * omitted → undefined; "none"/empty → undefined; csv → listed items. + */ +function csvListOptional(val: unknown): string[] | undefined { + return parseCsvField(val); +} + +/** + * Parse a memory scope field. + * omitted → undefined; "user"/"project"/"local" → MemoryScope. + */ +function parseMemory(val: unknown): MemoryScope | undefined { + if (val === "user" || val === "project" || val === "local") return val; + return undefined; +} + +/** + * Parse the `isolation` frontmatter field. + * + * `off` is kept as a value rather than folded into `undefined` because the two + * do not mean the same thing here: agent config outranks tool-call params, so + * `off` vetoes a caller's `worktree` while an absent field lets it through. + * + * pi's frontmatter parser is not YAML 1.1 — bare `off` and `no` arrive as + * strings and only `false` becomes a boolean — so all three spellings are + * accepted rather than leaving an author's intent silently dropped. Anything + * else stays `undefined`, as before. + */ +function parseIsolation(val: unknown): IsolationMode | undefined { + if (val === "worktree") return "worktree"; + if (val === "off" || val === "none" || val === "no" || val === false) return "off"; + return undefined; +} + +/** + * Parse an inherit field (extensions, skills). + * omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names. + */ +function inheritField(val: unknown): true | string[] | false { + if (val === undefined || val === null || val === true) return true; + if (val === false || val === "none") return false; + const items = csvList(val, []); + return items.length > 0 ? items : false; +} diff --git a/pi-subagents/src/default-agents.ts b/pi-subagents/src/default-agents.ts new file mode 100644 index 0000000..2e62cfe --- /dev/null +++ b/pi-subagents/src/default-agents.ts @@ -0,0 +1,126 @@ +/** + * default-agents.ts — Embedded default agent configurations. + * + * These are always available but can be overridden by user .md files with the same name. + */ + +import type { AgentConfig } from "./types.js"; + +const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"]; + +export const DEFAULT_AGENTS: Map = new Map([ + [ + "general-purpose", + { + name: "general-purpose", + displayName: "Agent", + description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", + // builtinToolNames omitted — means "all available tools" (resolved at lookup time) + // inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call. + // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts). + extensions: true, + skills: true, + systemPrompt: "", + promptMode: "append", + isDefault: true, + }, + ], + [ + "Explore", + { + name: "Explore", + displayName: "Explore", + description: "Fast read-only search agent for locating code. Use it to find files by pattern (eg. \"src/components/**/*.tsx\"), grep for symbols or keywords (eg. \"API endpoints\"), or answer \"where is X defined / which files reference Y.\" Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: \"quick\" for a single targeted lookup, \"medium\" for moderate exploration, or \"very thorough\" to search across multiple locations and naming conventions.", + builtinToolNames: READ_ONLY_TOOLS, + extensions: true, + skills: true, + // Fast/cheap model for read-only search. Provider-preferred but resilient: + // resolveModel matches this fuzzily (date-stamp optional) and falls back to + // the same model under another provider if anthropic doesn't expose it. + model: "anthropic/claude-haiku-4-5", + systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS +You are a file search specialist. You excel at thoroughly navigating and exploring codebases. +Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools. + +You are STRICTLY PROHIBITED from: +- Creating new files +- Modifying existing files +- Deleting files +- Moving or copying files +- Creating temporary files anywhere, including /tmp +- Using redirect operators (>, >>, |) or heredocs to write to files +- Running ANY commands that change system state + +Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail. + +# Tool Usage +- Use the find tool for file pattern matching (NOT the bash find command) +- Use the grep tool for content search (NOT bash grep/rg command) +- Use the read tool for reading files (NOT bash cat/head/tail) +- Use Bash ONLY for read-only operations +- Make independent tool calls in parallel for efficiency +- Adapt search approach based on thoroughness level specified + +# Output +- Use absolute file paths in all references +- Report findings as regular messages +- Do not use emojis +- Be thorough and precise`, + promptMode: "replace", + isDefault: true, + }, + ], + [ + "Plan", + { + name: "Plan", + displayName: "Plan", + description: "Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.", + builtinToolNames: READ_ONLY_TOOLS, + extensions: true, + skills: true, + systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS +You are a software architect and planning specialist. +Your role is EXCLUSIVELY to explore the codebase and design implementation plans. +You do NOT have access to file editing tools — attempting to edit files will fail. + +You are STRICTLY PROHIBITED from: +- Creating new files +- Modifying existing files +- Deleting files +- Moving or copying files +- Creating temporary files anywhere, including /tmp +- Using redirect operators (>, >>, |) or heredocs to write to files +- Running ANY commands that change system state + +# Planning Process +1. Understand requirements +2. Explore thoroughly (read files, find patterns, understand architecture) +3. Design solution based on your assigned perspective +4. Detail the plan with step-by-step implementation strategy + +# Requirements +- Consider trade-offs and architectural decisions +- Identify dependencies and sequencing +- Anticipate potential challenges +- Follow existing patterns where appropriate + +# Tool Usage +- Use the find tool for file pattern matching (NOT the bash find command) +- Use the grep tool for content search (NOT bash grep/rg command) +- Use the read tool for reading files (NOT bash cat/head/tail) +- Use Bash ONLY for read-only operations + +# Output Format +- Use absolute file paths +- Do not use emojis +- End your response with: + +### Critical Files for Implementation +List 3-5 files most critical for implementing this plan: +- /absolute/path/to/file.ts - [Brief reason]`, + promptMode: "replace", + isDefault: true, + }, + ], +]); diff --git a/pi-subagents/src/enabled-models.ts b/pi-subagents/src/enabled-models.ts new file mode 100644 index 0000000..e8bcda9 --- /dev/null +++ b/pi-subagents/src/enabled-models.ts @@ -0,0 +1,180 @@ +/** + * Reads `enabledModels` from pi's settings (global `/settings.json` + * + project-local `/.pi/settings.json`, project wins) and resolves + * entries to concrete `provider/modelId` keys for scope validation. + * + * **Project overrides global**, mirroring pi's own `SettingsManager` + * deep-merge behavior and matching the precedence we use for our own + * `subagents.json` settings (see `src/settings.ts:loadSettings`). If + * project file has `enabledModels` set, it wholly replaces global's + * (array fields are replaced, not concatenated). + * + * **Limited subset of upstream's resolveModelScope.** We support exact + * `provider/modelId` matching only. Upstream (pi-coding-agent's + * `core/model-resolver.ts`) additionally supports glob patterns + * (`*sonnet*`, `anthropic/*`), bare model IDs without provider, and + * thinking-level suffixes (`provider/*:high`). Those forms are silently + * ignored here. + * + * In practice, pi's `/scoped-models` picker writes exact `provider/modelId` + * entries, so the limitation is invisible for users who configure scope + * through pi's UI. Hand-edited settings using globs or bare IDs will + * produce an empty allowed set (scope check becomes a no-op). + * + * Example: + * enabledModels = ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"] + * → resolves to { "anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6" } + */ + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { ModelEntry } from "./model-resolver.js"; + +/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */ +export interface ModelRegistryRef { + getAll(): unknown[]; + getAvailable?(): unknown[]; +} + +/** Paths to pi's settings.json files: [project, global] (project takes precedence). */ +function settingsPaths(cwd: string): [project: string, global: string] { + return [ + join(cwd, ".pi", "settings.json"), + join(getAgentDir(), "settings.json"), + ]; +} + +/** Read `enabledModels` from a single settings.json file. Undefined when missing or absent. */ +function readField(path: string): string[] | undefined { + if (!existsSync(path)) return undefined; + try { + const raw = JSON.parse(readFileSync(path, "utf-8")); + if (Array.isArray(raw?.enabledModels)) return raw.enabledModels as string[]; + } catch { + /* corrupt file — silent */ + } + return undefined; +} + +/** + * Read enabledModels from pi's settings — project-local overrides global. + * Mirrors pi's SettingsManager deep-merge for the `enabledModels` field + * (and matches our own loadSettings precedence in src/settings.ts). + * Returns undefined when neither file has the field. + */ +export function readEnabledModels(cwd: string): string[] | undefined { + const [project, global] = settingsPaths(cwd); + return readField(project) ?? readField(global); +} + +/** + * Resolve enabledModels patterns → Set<"provider/modelId"> (lowercase keys). + * + * Only exact `provider/modelId` patterns are matched (case-insensitive). + * Patterns without a slash, with glob characters, or with a `:thinking` + * suffix are silently dropped. See module-level docstring for rationale. + * + * Cache: keyed on JSON.stringify(patterns) + mtime/size of *both* + * project and global settings.json files. Re-resolves when either file + * changes or the patterns argument differs. + * + * Returns undefined when no patterns are provided or no patterns match + * (scope check becomes a no-op at the call site). + */ + +// Module-level cache — invalidated when either settings.json changes or patterns differ. +let cachedAllowed: Set | undefined; +let cachedHash = ""; +let cachedPatternsKey = ""; + +/** mtime+size hash of one file, or "missing" if absent. */ +function hashOf(path: string): string { + try { + const s = statSync(path); + return `${s.mtimeMs}-${s.size}`; + } catch { + return "missing"; + } +} + +export function resolveEnabledModels( + patterns: string[] | undefined, + registry: ModelRegistryRef, + cwd: string = process.cwd(), +): Set | undefined { + // Fast path: check cache (stat both project and global settings.json files) + const patternsKey = JSON.stringify(patterns); + const [project, global] = settingsPaths(cwd); + const fileHash = `${hashOf(project)};${hashOf(global)}`; + + if (fileHash === cachedHash && patternsKey === cachedPatternsKey) { + return cachedAllowed; + } + + // Cache miss — resolve + if (!patterns || patterns.length === 0) { + cachedHash = fileHash; + cachedPatternsKey = patternsKey; + cachedAllowed = undefined; + return undefined; + } + + const available = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[]; + const allowed = new Set(); + + for (const pattern of patterns) { + const trimmed = pattern.trim(); + if (!trimmed) continue; // skip empty/whitespace + resolveExact(trimmed, available, allowed); + } + + const result = allowed.size > 0 ? allowed : undefined; + cachedHash = fileHash; + cachedPatternsKey = patternsKey; + cachedAllowed = result; + return result; +} + + + +/** + * True when `model` is in the allowed set. Centralizes the key format + * (`provider/id` lowercase) so callers don't have to reproduce it — + * both set-building (resolveExact) and lookup go through `modelKey`. + */ +export function isModelInScope( + model: { provider: string; id: string }, + allowed: Set, +): boolean { + return allowed.has(modelKey(model)); +} + +/** Canonical lowercase `provider/id` key for the allowed set. */ +function modelKey(model: { provider: string; id: string }): string { + return `${model.provider}/${model.id}`.toLowerCase(); +} + +/** + * Resolve exact model pattern. Example: "google/gemma-4-31b-it". + */ +function resolveExact( + pattern: string, + available: ModelEntry[], + allowed: Set, +): void { + // "provider/modelId" — exact (colon is part of id, not split) + const slashIdx = pattern.indexOf("/"); + if (slashIdx === -1) return; // bare modelId not supported + + const provider = pattern.slice(0, slashIdx).toLowerCase(); + const modelId = pattern.slice(slashIdx + 1).toLowerCase(); + const exact = available.find( + m => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId, + ); + if (exact) { + allowed.add(modelKey(exact)); + } +} + + diff --git a/pi-subagents/src/env.ts b/pi-subagents/src/env.ts new file mode 100644 index 0000000..ac3b705 --- /dev/null +++ b/pi-subagents/src/env.ts @@ -0,0 +1,33 @@ +/** + * env.ts — Detect environment info (git, platform) for subagent system prompts. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { EnvInfo } from "./types.js"; + +export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise { + let isGitRepo = false; + let branch = ""; + + try { + const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 }); + isGitRepo = result.code === 0 && result.stdout.trim() === "true"; + } catch { + // Not a git repo or git not installed + } + + if (isGitRepo) { + try { + const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 }); + branch = result.code === 0 ? result.stdout.trim() : "unknown"; + } catch { + branch = "unknown"; + } + } + + return { + isGitRepo, + branch, + platform: process.platform, + }; +} diff --git a/pi-subagents/src/group-join.ts b/pi-subagents/src/group-join.ts new file mode 100644 index 0000000..07e0578 --- /dev/null +++ b/pi-subagents/src/group-join.ts @@ -0,0 +1,141 @@ +/** + * group-join.ts — Manages grouped background agent completion notifications. + * + * Instead of each agent individually nudging the main agent on completion, + * agents in a group are held until all complete (or a timeout fires), + * then a single consolidated notification is sent. + */ + +import type { AgentRecord } from "./types.js"; + +export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void; + +interface AgentGroup { + groupId: string; + agentIds: Set; + completedRecords: Map; + timeoutHandle?: ReturnType; + delivered: boolean; + /** Shorter timeout for stragglers after a partial delivery. */ + isStraggler: boolean; +} + +/** Default timeout: 30s after first completion in a group. */ +const DEFAULT_TIMEOUT = 30_000; +/** Straggler re-batch timeout: 15s. */ +const STRAGGLER_TIMEOUT = 15_000; + +export class GroupJoinManager { + private groups = new Map(); + private agentToGroup = new Map(); + + constructor( + private deliverCb: DeliveryCallback, + private groupTimeout = DEFAULT_TIMEOUT, + ) {} + + /** Register a group of agent IDs that should be joined. */ + registerGroup(groupId: string, agentIds: string[]): void { + const group: AgentGroup = { + groupId, + agentIds: new Set(agentIds), + completedRecords: new Map(), + delivered: false, + isStraggler: false, + }; + this.groups.set(groupId, group); + for (const id of agentIds) { + this.agentToGroup.set(id, groupId); + } + } + + /** + * Called when an agent completes. + * Returns: + * - 'pass' — agent is not grouped, caller should send individual nudge + * - 'held' — result held, waiting for group completion + * - 'delivered' — this completion triggered the group notification + */ + onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' { + const groupId = this.agentToGroup.get(record.id); + if (!groupId) return 'pass'; + + const group = this.groups.get(groupId); + if (!group || group.delivered) return 'pass'; + + group.completedRecords.set(record.id, record); + + // All done — deliver immediately + if (group.completedRecords.size >= group.agentIds.size) { + this.deliver(group, false); + return 'delivered'; + } + + // First completion in this batch — start timeout + if (!group.timeoutHandle) { + const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout; + group.timeoutHandle = setTimeout(() => { + this.onTimeout(group); + }, timeout); + } + + return 'held'; + } + + private onTimeout(group: AgentGroup): void { + if (group.delivered) return; + group.timeoutHandle = undefined; + + // Partial delivery — some agents still running + const remaining = new Set(); + for (const id of group.agentIds) { + if (!group.completedRecords.has(id)) remaining.add(id); + } + + // Clean up agentToGroup for delivered agents (they won't complete again) + for (const id of group.completedRecords.keys()) { + this.agentToGroup.delete(id); + } + + // Deliver what we have + this.deliverCb([...group.completedRecords.values()], true); + + // Set up straggler group for remaining agents + group.completedRecords.clear(); + group.agentIds = remaining; + group.isStraggler = true; + // Timeout will be started when the next straggler completes + } + + private deliver(group: AgentGroup, partial: boolean): void { + if (group.timeoutHandle) { + clearTimeout(group.timeoutHandle); + group.timeoutHandle = undefined; + } + group.delivered = true; + this.deliverCb([...group.completedRecords.values()], partial); + this.cleanupGroup(group.groupId); + } + + private cleanupGroup(groupId: string): void { + const group = this.groups.get(groupId); + if (!group) return; + for (const id of group.agentIds) { + this.agentToGroup.delete(id); + } + this.groups.delete(groupId); + } + + /** Check if an agent is in a group. */ + isGrouped(agentId: string): boolean { + return this.agentToGroup.has(agentId); + } + + dispose(): void { + for (const group of this.groups.values()) { + if (group.timeoutHandle) clearTimeout(group.timeoutHandle); + } + this.groups.clear(); + this.agentToGroup.clear(); + } +} diff --git a/pi-subagents/src/index.ts b/pi-subagents/src/index.ts new file mode 100644 index 0000000..2d0f67a --- /dev/null +++ b/pi-subagents/src/index.ts @@ -0,0 +1,3243 @@ +/** + * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents. + * + * Tools: + * Agent — LLM-callable: spawn a sub-agent + * get_subagent_result — LLM-callable: check background agent status/result + * steer_subagent — LLM-callable: send a steering message to a running agent + * + * Commands: + * /agents — Interactive agent management menu + */ + +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent"; +import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { abortable } from "./abortable.js"; +import { hasAgentBadge, renderAgentName } from "./agent-color.js"; +import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js"; +import { AgentManager } from "./agent-manager.js"; +import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js"; +import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js"; +import { inChildSessionContext } from "./child-context.js"; +import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js"; +import { loadCustomAgents } from "./custom-agents.js"; +import { GroupJoinManager } from "./group-join.js"; +import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js"; +import type { MandatoryExtensionPolicy } from "./mandatory-extensions.js"; +import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js"; +import { runMentionClone } from "./mention-clone.js"; +import { type ModelRegistry, resolveModel } from "./model-resolver.js"; +import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js"; +import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js"; +import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js"; +import { SubagentScheduler } from "./schedule.js"; +import { resolveStorePath, ScheduleStore } from "./schedule-store.js"; +import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js"; +import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js"; +import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js"; +import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js"; +import { + type AgentActivity, + type AgentDetails, + AgentWidget, + buildInvocationTags, + describeActivity, + fgPreservingNestedStyles, + formatCost, + formatDuration, + formatMs, + formatTokens, + formatTurns, + getDisplayName, + getPromptModeLabel, + SPINNER, + type Theme, + type UICtx, +} from "./ui/agent-widget.js"; +import { FleetList, type FleetUICtx } from "./ui/fleet-list.js"; +import { showSchedulesMenu } from "./ui/schedule-menu.js"; +import { selectItem } from "./ui/select-item.js"; +import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js"; +import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js"; + +// ---- Shared helpers ---- + +/** Tool execute return value for a text response. */ +function textResult(msg: string, details?: AgentDetails) { + return { content: [{ type: "text" as const, text: msg }], details: details as any }; +} + +export function renderRunningAgentStatus( + frame: string, + statsText: string, + activity: string, + theme: Pick, +): Container { + const container = new Container(); + container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0)); + container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0)); + return container; +} + +/** Format an agent's lifetime token total, or "" when zero. */ +function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string { + const t = getLifetimeTotal(o.lifetimeUsage); + return t > 0 ? formatTokens(t) : ""; +} + +/** + * Create an AgentActivity state and spawn callbacks for tracking tool usage. + * Used by both foreground and background paths to avoid duplication. + */ +function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) { + const state: AgentActivity = { + activeTools: new Map(), + toolUses: 0, + turnCount: 1, + maxTurns, + responseText: "", + session: undefined, + }; + + const callbacks = { + onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => { + if (activity.type === "start") { + state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName); + } else { + for (const [key, name] of state.activeTools) { + if (name === activity.toolName) { state.activeTools.delete(key); break; } + } + state.toolUses++; + } + onStreamUpdate?.(); + }, + onTextDelta: (_delta: string, fullText: string) => { + state.responseText = fullText; + onStreamUpdate?.(); + }, + onTurnEnd: (turnCount: number) => { + state.turnCount = turnCount; + onStreamUpdate?.(); + }, + onSessionCreated: (session: any) => { + state.session = session; + }, + // Spend is accumulated on the AgentRecord (agent-manager), which is what + // every surface reads; this callback exists here only to repaint on it. + onAssistantUsage: (_usage: LifetimeUsage) => { + onStreamUpdate?.(); + }, + }; + + return { state, callbacks }; +} + +/** + * Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS + * (`off` + every `ThinkingLevel`). Single source for the Agent tool description, + * the generated-agent template, and the `/agents` wizard so these lists can't + * drift behind pi again (#147). Availability of any level still depends on the + * host pi version and the selected model — pi clamps unsupported levels down. + */ +const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +/** Human-readable status label for agent completion. */ +function getStatusLabel(status: string, error?: string): string { + switch (status) { + case "error": return `Error: ${error ?? "unknown"}`; + case "aborted": return "Aborted (max turns exceeded)"; + case "steered": return "Wrapped up (turn limit)"; + case "stopped": return "Stopped"; + default: return "Done"; + } +} + +/** Escape XML special characters to prevent injection in structured notifications. */ +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** Format a structured task notification matching Claude Code's XML. */ +function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string { + const status = getStatusLabel(record.status, record.error); + const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0; + const totalTokens = getLifetimeTotal(record.lifetimeUsage); + const contextPercent = getSessionContextPercent(record.session); + const ctxXml = contextPercent !== null ? `${Math.round(contextPercent)}` : ""; + const compactXml = record.compactionCount ? `${record.compactionCount}` : ""; + // Only under `showCost`: this is LLM context, and a figure the orchestrator + // did not ask for is a figure it may start reporting unprompted. + const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0; + const costXml = cost > 0 ? `${cost.toFixed(4)}` : ""; + + const resultPreview = record.result + ? record.result.length > resultMaxLen + ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)" + : record.result + : "No output."; + + return [ + ``, + `${record.id}`, + record.toolCallId ? `${escapeXml(record.toolCallId)}` : null, + record.outputFile ? `${escapeXml(record.outputFile)}` : null, + `${escapeXml(status)}`, + `

Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}`, + `${escapeXml(resultPreview)}`, + `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${costXml}${durationMs}`, + `
`, + ].filter(Boolean).join('\n'); +} + +/** Build AgentDetails from a base + record-specific fields. */ +function buildDetails( + base: Pick, + record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage }, + activity?: AgentActivity, + overrides?: Partial, +): AgentDetails { + return { + ...base, + toolUses: record.toolUses, + tokens: formatLifetimeTokens(record), + // Raw, and unconditional: `tokens` is preformatted because it is one stat, + // but a cost is joined by "·" in one surface, "," in another and "|" in a + // third — so it travels as a number and each renderer punctuates its own. + cost: getLifetimeCost(record.lifetimeUsage), + turnCount: activity?.turnCount, + maxTurns: activity?.maxTurns, + durationMs: (record.completedAt ?? Date.now()) - record.startedAt, + status: record.status as AgentDetails["status"], + agentId: record.id, + error: record.error, + ...overrides, + }; +} + +/** Build notification details for the custom message renderer. */ +function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails { + const totalTokens = getLifetimeTotal(record.lifetimeUsage); + + return { + id: record.id, + description: record.description, + status: record.status, + toolUses: record.toolUses, + turnCount: activity?.turnCount ?? 0, + maxTurns: activity?.maxTurns, + totalTokens, + // Carried unconditionally; the renderer gates on the setting. Details are + // data, and a notification rendered before a mid-session toggle should not + // be stuck with the old answer. + totalCost: getLifetimeCost(record.lifetimeUsage), + durationMs: record.completedAt ? record.completedAt - record.startedAt : 0, + outputFile: record.outputFile, + error: record.error, + resultPreview: record.result + ? record.result.length > resultMaxLen + ? record.result.slice(0, resultMaxLen) + "…" + : record.result + : "No output.", + }; +} + +/** + * Format an agent's tool scope for the Agent tool description. + * + * This suffix describes BUILT-IN scope only — extension tools are resolved when + * the agent runs (extensions can register asynchronously), so they cannot be + * enumerated while the description is being built. That is why an agent with + * `tools: "*, ext:mcp/search"` renders "*" and always has. + * + * Two distinctions matter, both of them capability claims the orchestrator acts on: + * + * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed + * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only + * `tools:` parse to, and the runtime really does hand those agents no built-ins. + * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`. + * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero + * tools: `tools: none` alongside `extensions:` still surfaces every extension + * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling + * that "none" understates the agent instead of overstating it — better, but still + * wrong, and it would route work away from the only agent able to do it. "none" + * is therefore reserved for agents that genuinely can call nothing: `isolated` + * agents and those with `extensions: false`. + */ +export function formatToolsSuffix(cfg: AgentConfig | undefined): string { + const tools = cfg?.builtinToolNames; + if (!tools) return "*"; + if (tools.length === 0) { + // `isolated` overrides extensions to false in the runner, so both mean the + // agent has no extension tools either — and then it truly has nothing. + const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false; + return noExtensionTools ? "none" : "no built-ins, extension tools only"; + } + const isFullSet = + tools.length === BUILTIN_TOOL_NAMES.length + && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)); + return isFullSet ? "*" : tools.join(", "); +} + +export interface PiSubagentsExtensionOptions extends MandatoryExtensionPolicy {} + +/** Create a host-configured extension; Agent frontmatter cannot mutate these options. */ +export function createPiSubagentsExtension( + options: PiSubagentsExtensionOptions = {}, +): (pi: ExtensionAPI) => void { + return (pi) => installPiSubagentsExtension(pi, options); +} + +const defaultExtension = createPiSubagentsExtension(); +export default defaultExtension; + +function installPiSubagentsExtension( + pi: ExtensionAPI, + options: PiSubagentsExtensionOptions, + ): void { + // Child AgentSessions load normal extensions. Re-entering this extension there + // would create another manager and leak handlers. Nested orchestration is + // injected as scoped custom tools by the existing manager instead. + if (inChildSessionContext()) return; + + // ---- Register custom notification renderer ---- + pi.registerMessageRenderer( + "subagent-notification", + (message, { expanded }, theme) => { + const d = message.details; + if (!d) return undefined; + + function renderOne(d: NotificationDetails): string { + const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted"; + const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const statusText = isError ? d.status + : d.status === "steered" ? "completed (steered)" + : "completed"; + + // Line 1: icon + agent description + status + let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`; + + // Line 2: stats + const parts: string[] = []; + if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns)); + if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); + if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens)); + if (showCost) { + const costText = formatCost(d.totalCost ?? 0); + if (costText) parts.push(costText); + } + if (d.durationMs > 0) parts.push(formatMs(d.durationMs)); + if (parts.length) { + line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " "); + } + + // Line 3: result preview (collapsed) or full (expanded) + if (expanded) { + const lines = d.resultPreview.split("\n").slice(0, 30); + for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`); + } else { + const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? ""; + line += "\n " + theme.fg("dim", `⎿ ${preview}`); + } + + // Line 4: output file link (if present) + if (d.outputFile) { + line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`); + } + + return line; + } + + const all = [d, ...(d.others ?? [])]; + const rendered = all.map(renderOne); + // A group of agents lands as one notification, and the number a user wants + // from it is what the batch cost — not four figures to add up by hand. + // Derived from the per-agent details rather than carried alongside them: + // one source, so the total can never disagree with the rows above it. + if (showCost && all.length > 1) { + const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0)); + if (total) { + const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0); + rendered.unshift(theme.fg("dim", `${all.length} agents · ${formatTokens(tokens)} · ${total}`)); + } + } + return new Text(rendered.join("\n"), 0, 0); + } + ); + + // Read directly rather than waiting for applyAndEmitLoaded below: this decides + // the initial load, which happens hundreds of lines before settings are applied. + let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true; + + /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */ + const reloadCustomAgents = (strict = false) => { + const userAgents = loadCustomAgents(process.cwd(), strict); + registerAgents(userAgents); + }; + + // Initial load — the only strict one. A bad edit mid-session must not kill the + // session on the next unrelated spawn, so every later reload keeps warning. + reloadCustomAgents(strictAgentFiles); + + // ---- Agent activity tracking + widget ---- + const agentActivity = new Map(); + + // ---- Usage reporting (both off by default; see SubagentsSettings) ---- + /** Attach subagent spend to tool results, so the parent session counts it. */ + let reportUsage = false; + function isReportUsageEnabled(): boolean { return reportUsage; } + function setReportUsage(b: boolean): void { + reportUsage = b; + // Whatever accumulated while it was on is stale the moment it goes off: + // draining it later would bill the parent for a window the user opted out + // of, in one lump, on some unrelated later tool call. + if (!b) pendingUsage.drain(); + } + /** Show `~$X` next to token counts in the subagent surfaces. */ + let showCost = false; + function isShowCostEnabled(): boolean { return showCost; } + function setShowCost(b: boolean): void { showCost = b; widget.update(); fleet.update(); } + const pendingUsage = new PendingUsagePool(); + + // ---- Cancellable pending notifications ---- + // Holds notifications briefly so get_subagent_result can cancel them + // before they reach pi.sendMessage (fire-and-forget). + const pendingNudges = new Map>(); + const NUDGE_HOLD_MS = 200; + // A queued result wait must observe completion before its held notification + // can fire, so successful waits can still suppress that redundant nudge. + const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4); + + function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) { + cancelNudge(key); + pendingNudges.set(key, setTimeout(() => { + pendingNudges.delete(key); + try { send(); } catch { /* ignore stale completion side-effect errors */ } + }, delay)); + } + + function cancelNudge(key: string) { + const timer = pendingNudges.get(key); + if (timer != null) { + clearTimeout(timer); + pendingNudges.delete(key); + } + } + + // ---- Individual nudge helper (async join mode) ---- + function emitIndividualNudge(record: AgentRecord) { + if (record.resultConsumed) return; // re-check at send time + + const notification = formatTaskNotification(record, 500, showCost); + const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : ''; + + pi.sendMessage({ + customType: "subagent-notification", + content: notification + footer, + display: true, + details: buildNotificationDetails(record, 500, agentActivity.get(record.id)), + }, { deliverAs: "followUp", triggerTurn: true }); + } + + function sendIndividualNudge(record: AgentRecord) { + agentActivity.delete(record.id); + widget.markFinished(record.id); + fleet.onAgentFinished(record.id); + scheduleNudge(record.id, () => emitIndividualNudge(record)); + widget.update(); + } + + // ---- Group join manager ---- + const groupJoin = new GroupJoinManager( + (records, partial) => { + for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); } + + const groupKey = `group:${records.map(r => r.id).join(",")}`; + scheduleNudge(groupKey, () => { + // Re-check at send time + const unconsumed = records.filter(r => !r.resultConsumed); + if (unconsumed.length === 0) { widget.update(); return; } + + const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n'); + const label = partial + ? `${unconsumed.length} agent(s) finished (partial — others still running)` + : `${unconsumed.length} agent(s) finished`; + + const [first, ...rest] = unconsumed; + const details = buildNotificationDetails(first, 300, agentActivity.get(first.id)); + if (rest.length > 0) { + details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id))); + } + + pi.sendMessage({ + customType: "subagent-notification", + content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`, + display: true, + details, + }, { deliverAs: "followUp", triggerTurn: true }); + }); + widget.update(); + }, + 30_000, + ); + + /** Helper: build event data for lifecycle events from an AgentRecord. */ + function buildEventData(record: AgentRecord) { + const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt; + // All three fields are lifetime-accumulated (Σ over every assistant message_end), + // so they survive compaction together — input + output ≤ total always. + // tokens is omitted when nothing was ever produced (e.g. agent errored before + // any message_end fired), preserving prior payload shape. + const u = record.lifetimeUsage; + const total = getLifetimeTotal(u); + const tokens = total > 0 + ? { input: u.input, output: u.output, total } + : undefined; + // The whole run's spend as a pi `Usage` — pi's convention for handing spend + // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a + // listener already expects them and anything pi adds to `Usage` arrives + // without a change here. Omitted when nothing was spent, so "spent nothing" + // and "never ran" stay distinguishable. Ungated by `showCost`: that setting + // governs what a human is shown, not what the event carries. + // + // `tokens` above is the other convention, kept as it shipped: a flat view + // model like pi's own `SessionStats`, carrying the DISPLAY total, which + // excludes cacheRead (#38). The two answer different questions and neither + // derives from the other. + const usage = toReportedUsage(u); + return { + id: record.id, + type: record.type, + description: record.description, + result: record.result, + error: record.error, + status: record.status, + toolUses: record.toolUses, + durationMs, + tokens, + usage, + }; + } + + // Background completion: route through group join or send individual nudge + const manager = new AgentManager((record) => { + // Nested children report only through their owning parent's scoped tools. + // Keep them out of top-level lifecycle, transcript, notification, and UI channels. + if (record.parentAgentId) return; + + // Emit lifecycle event based on terminal status + const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted"; + const eventData = buildEventData(record); + if (isError) { + pi.events.emit("subagents:failed", eventData); + } else { + pi.events.emit("subagents:completed", eventData); + } + + // Persist final record for cross-extension history reconstruction + pi.appendEntry("subagents:record", { + id: record.id, type: record.type, description: record.description, + status: record.status, result: record.result, error: record.error, + startedAt: record.startedAt, completedAt: record.completedAt, + }); + + // Skip notification if result was already consumed via get_subagent_result + if (record.resultConsumed) { + agentActivity.delete(record.id); + widget.markFinished(record.id); + fleet.onAgentFinished(record.id); + widget.update(); + return; + } + + // If this agent is pending batch finalization (debounce window still open), + // don't send an individual nudge — finalizeBatch will pick it up retroactively. + if (currentBatchAgents.some(a => a.id === record.id)) { + widget.update(); + return; + } + + const result = groupJoin.onAgentComplete(record); + if (result === 'pass') { + sendIndividualNudge(record); + } + // 'held' → do nothing, group will fire later + // 'delivered' → group callback already fired + widget.update(); + }, undefined, (record) => { + if (record.parentAgentId) return; + // Agent-tool spawns refresh these surfaces in their tool handler, but RPC + // and scheduler spawns enter through the manager directly. + if (currentCtx?.hasUI) { + widget.ensureTimer(); + widget.update(); + fleet.ensureTimer(); + fleet.update(); + } + // Emit started event when agent transitions to running (including from queue) + pi.events.emit("subagents:started", { + id: record.id, + type: record.type, + description: record.description, + }); + }, (record, info) => { + if (record.parentAgentId) return; + // Emit compacted event when agent's session compacts (preserves count on record). + pi.events.emit("subagents:compacted", { + id: record.id, + type: record.type, + description: record.description, + reason: info.reason, + tokensBefore: info.tokensBefore, + compactionCount: record.compactionCount, + }); + }, (_record, usage) => { + // Every assistant message from every agent — nested included, exactly once. + // Parked here until a tool result can carry it back to the parent session; + // see `PendingUsagePool`. Skipped entirely when the feature is off, so no + // pool grows in a session that will never drain it. + if (reportUsage) pendingUsage.add(usage); + }, { mandatoryExtensionPaths: options.mandatoryExtensionPaths }); + + // Expose manager via Symbol.for() global registry for cross-package access. + // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.). + // + // Claim the slot only if it's free: subagent sessions re-activate this + // extension in the same process (session.bindExtensions in agent-runner.ts), + // and unconditionally overwriting would point the registry at a short-lived + // child manager — and the child's shutdown would then delete the root + // session's entry. The first activation (the root session) wins; child + // activations leave it alone. + const MANAGER_KEY = Symbol.for("pi-subagents:manager"); + // Process-external callers may supply arbitrary options. Nested ownership and + // config-root metadata are internal capabilities issued only by scoped tools. + /** + * Resolve the agent type and spawn. Trusts its options — every caller must + * either be in-process or have gone through `spawnTopLevel` first. + */ + const spawnResolved = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => { + // Cross-extension callers get the same dispatch contract as the LLM (#183). + // The RPC layer already throws for an unresolvable model rather than falling + // back silently; a bad agent type should not be quieter. Throws become error + // envelopes at the RPC boundary. Reload first so an agent file added mid + // session is spawnable here too, not only through the Agent tool. + reloadCustomAgents(); + const dispatch = resolveSpawnType(type); + if (!dispatch.ok) throw new Error(dispatch.message); + // Every programmatic spawn lands here — cross-extension RPC, both `@handle` + // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and + // none came through the Agent tool, which is where the UI activity tracker is + // otherwise created. Without one the widget and FleetView have no tool name + // and no turn count, so the row reads `thinking…` for the agent's whole life + // while the header's tool-use count climbs beside it (#181). Double-tracking + // is not possible: the Agent tool calls `manager.spawn` directly. The tracker + // callbacks are the funnel's own — a caller's are not honoured, since a + // half-wired tracker renders worse than none. + // + // The turn limit is resolved rather than read off `options`, which a mention + // spawn deliberately omits so the agent's own config can decide: a tracker + // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`. + // Like the tool's own, it is a prediction — editing the agent file mid-run + // leaves the displayed ceiling stale. + const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns)); + // Repaints are left to the manager's `onStart` callback, which already starts + // the widget/fleet timers for agents that enter this way. + const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks }); + agentActivity.set(id, state); + return id; + }; + + const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => { + const safeOptions = { ...(options ?? {}) }; + delete safeOptions.parentAgentId; + delete safeOptions.depth; + delete safeOptions.maxSubagentDepth; + delete safeOptions.configCwd; + // Also internal: it names a transcript directory, so a forged value would + // be a path-traversal primitive. + delete safeOptions.rootSessionId; + // Worse than rootSessionId: this one names a file to OPEN and replay as a + // conversation. Only the mention dispatcher may set it, and only from a + // path this extension itself recorded — never from anything a caller sent. + delete safeOptions.resumeSessionFile; + // Bypasses handle allocation, so a forged value would duplicate a live + // agent's name and make `@handle` ambiguous. Same rule: dispatcher only. + delete safeOptions.reclaim; + return spawnResolved(piRef, ctxRef, type, prompt, safeOptions); + }; + + /** + * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses + * agents by the same names the user types. Ids are tried first, keeping the + * existing behaviour exact — a handle is only consulted when the string is + * not an id at all. Only live records: a tombstone has nothing to steer and + * no result to read. Callers still enforce the nested-ownership rejection. + */ + const resolveAgentRef = (ref: string): AgentRecord | undefined => { + const byId = manager.getRecord(ref); + if (byId) return byId; + const resolved = manager.resolveMention(ref); + return resolved?.kind === "live" ? resolved.record : undefined; + }; + + const registryEntry = { + waitForAll: () => manager.waitForAll(), + hasRunning: () => manager.hasRunning(), + spawn: spawnTopLevel, + getRecord: (id: string) => { + const record = manager.getRecord(id); + return record?.parentAgentId ? undefined : record; + }, + }; + const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined; + if (ownsManagerRegistry) { + (globalThis as any)[MANAGER_KEY] = registryEntry; + } + + // --- Cross-extension RPC via pi.events --- + let currentCtx: ExtensionContext | undefined; + // RPC handlers + the `subagents:ready` broadcast are wired on `session_start` + // (a bound lifecycle event), not at factory time. pi runs every extension + // factory before the `extensions:` filter and only fires lifecycle events for + // survivors, so a child session that filtered pi-subagents out never reaches + // session_start — and must not advertise or answer RPC it can't service + // (currentCtx would stay undefined → spawn always "No active session"). Gating + // here makes a filtered session behave like an absent one (#142). + let rpcHandle: RpcHandle | undefined; + /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */ + let mentionProviderRegistered = false; + + // ---- Subagent scheduler ---- + // Session-scoped: store is constructed inside session_start once sessionId + // is available. Mirrors pi-chonky-tasks's session-scoped task store — + // schedules reset on /new, restore on /resume. + const scheduler = new SubagentScheduler(); + + function startScheduler(ctx: ExtensionContext) { + try { + const sessionId = ctx.sessionManager?.getSessionId?.(); + if (!sessionId) return; // sessionId not yet available — try again on next event + const path = resolveStorePath(ctx.cwd, sessionId); + const store = new ScheduleStore(path); + scheduler.start(pi, ctx, manager, store); + pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length }); + } catch (err) { + // Scheduling is non-essential — log and move on so the rest of the + // extension keeps working if e.g. .pi/ is unwritable. + console.warn("[pi-subagents] Failed to start scheduler:", err); + } + } + + // Capture ctx from session_start for RPC spawn handler + start the scheduler. + // This also wires the RPC handlers and broadcasts readiness — on the first + // bound session_start, so a filtered-out activation never advertises (#142). + pi.on("session_start", async (_event, ctx) => { + currentCtx = ctx; + if (ctx.hasUI) { + widget.setUICtx(ctx.ui); + fleet.setUICtx(ctx.ui as any); + } + manager.clearCompleted(true); + // Guard mirrors the `!scheduler.isActive()` pattern below: session_start + // fires once per activation, but a double-bind must not leak listeners. + if (!rpcHandle) { + rpcHandle = registerRpcHandlers({ + events: pi.events, + pi, + getCtx: () => currentCtx, + manager: { + spawn: spawnTopLevel, + abort: (id) => { + const record = manager.getRecord(id); + return !record?.parentAgentId && manager.abort(id); + }, + }, + }); + // Broadcast readiness so extensions loaded alongside us can discover us. + // Emitting after all factories have run (rather than at factory time) + // also avoids the race where a consumer loaded after us misses the event. + pi.events.emit("subagents:ready", {}); + } + if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx); + // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at + // most once per activation: pi appends wrappers to a list it never prunes, + // so a second call would layer a duplicate provider on the first. TUI only + // — print mode has no such method, and RPC mode's is a no-op. + if (ctx.mode === "tui" && !mentionProviderRegistered) { + mentionProviderRegistered = true; + ctx.ui.addAutocompleteProvider(current => + createMentionProvider( + current, + // Plain text, not renderAgentName: the same label FleetView and the + // widget show, but the autocomplete description cannot carry ANSI. + () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName), + isAgentMentionsEnabled, + ), + ); + } + }); + + /** Agent types `@` can start, in the shape the roster wants. */ + const mentionTypes = (): TypeInfo[] => + getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name })); + + /** + * `@handle message` typed at the prompt addresses that agent instead of the + * main model — Claude Code's prompt mention, same grammar (see mention.ts). + * + * The handle names the *agent*, not one process, so one syntax covers its + * whole lifecycle: message it while it runs, resume it once it has finished, + * start it if it never ran. Everything that isn't an agent mention falls + * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare + * `@handle`, and ordinary prose working. A delivered mention costs no + * main-model turn; the answer arrives through the ordinary completion + * notification either way. + */ + pi.on("input", async (event, ctx) => { + // Never hijack text the extension layer itself submitted (pi.sendMessage, + // scheduled prompts) — only something a person typed can be a mention. + if (event.source === "extension" || !isAgentMentionsEnabled()) return { action: "continue" }; + // Claiming the turn is TUI only, matching the `@` completion that teaches + // the syntax. Pi defaults `session.prompt()` to source "interactive", so a + // headless `pi -p "@explore …"` reaches here too — and claiming it would + // answer with silence, which the background hold cannot fix: `handled` + // returns from prompt() before any turn starts, so the loop that patch wraps + // never runs (it holds subagents spawned by the Agent tool MID-turn, a + // different path). The agent would detach, `ctx.ui.notify` is a no-op + // outside the TUI, and print mode would exit having printed nothing. + // + // `model` mode has none of that problem: it queues a reminder and lets the + // turn run, so the answer is the model's own, printed as usual. It is the + // only branch allowed to act headlessly; everything else falls through to + // the main model exactly as it did before mentions existed. + const canDispatchDirectly = ctx.mode === "tui"; + if (!canDispatchDirectly && getAgentMentionMode() !== "model") return { action: "continue" }; + + const mention = parseMention(event.text); + if (!mention) return { action: "continue" }; + + // `@main` addresses the main conversation, never a subagent — the one name + // `assignHandle` refuses to allocate. An explicit escape hatch for text + // that would otherwise read as a mention, so the prefix is dropped and the + // rest goes to the model with its attachments intact. + if (isReservedHandle(mention.handle)) { + return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) }; + } + + // As typed first, so an agent actually called `agent-foo` wins over Claude + // Code's `@agent-` + `foo` spelling rather than being shadowed by it. + const alias = stripAgentPrefix(mention.handle); + const resolved = manager.resolveMention(mention.handle) + ?? (alias ? manager.resolveMention(alias) : undefined); + + // Steering and resuming are direct in every mode, so headless they are not + // available at all. Falling through here rather than dropping to the start + // path below matters: the handle names an agent that already exists, and + // asking the model to start another one is not what was typed. + if (resolved && !canDispatchDirectly) return { action: "continue" }; + + if (resolved?.kind === "live") { + const record = resolved.record; + const target = `@${record.alias ?? record.handle ?? mention.handle}`; + + if (record.status === "running" || record.status === "queued") { + // Steering interrupts after the current tool call, exactly like the + // steer_subagent tool. Un-consume the result so the agent's reply to + // this message is still relayed even if the LLM read its last answer. + record.resultConsumed = false; + manager.steer(record.id, mention.message); + pi.events.emit("subagents:steered", { id: record.id, message: mention.message }); + ctx.ui.notify(`Sent to ${target}`, "info"); + return { action: "handled" }; + } + + if (record.session) { + // Both derived from the record's OWN type: a mention names an existing + // agent, so its frontmatter is what governs — `output_transcript: false` + // must keep holding, since record.outputFile is the sole gate every + // downstream consumer keys off and a resume must not re-open it. + const config = getAgentConfig(record.type); + const resumedRecord = await startBackgroundResume(ctx, record, mention.message, { + outputTranscript: config?.outputTranscript ?? getOutputTranscriptDefault(), + maxTurns: normalizeMaxTurns(config?.maxTurns ?? getDefaultMaxTurns()), + }); + ctx.ui.notify( + resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`, + resumedRecord ? "info" : "warning", + ); + return { action: "handled" }; + } + // A live record with no session never got far enough to continue, so it + // falls through to the start-fresh path below, like Claude's + // `no_transcript`. + } + + // Evicted, but its conversation is still on disk: reopen it. This is an + // ordinary spawn carrying a session file, so the new record picks up the + // widget, fleet row, transcript and completion notification unchanged — + // and `reclaim` hands it back the names the tombstone was holding. + if (resolved?.kind === "tombstone") { + const entry = resolved.entry; + const target = `@${entry.alias ?? entry.handle}`; + + // Checked here rather than left to SessionManager.open: that runs inside + // runAgent, whose rejection lands on the record as an agent error, not in + // the catch below. A `/new` in another pi window or a manual delete makes + // the conversation unrecoverable (Claude Code's `not_reachable`), so drop + // the entry — a row that can only ever fail is worse than none — and say + // so rather than quietly sending this message to an unrelated agent. + if (!existsSync(entry.sessionFile)) { + manager.dropTombstone(entry.handle); + ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning"); + return { action: "handled" }; + } + + // The Agent tool deliberately falls back to general-purpose for a type it + // cannot resolve (#183), which covers a deleted file AND a merely + // disabled one. A resume must not inherit that: reopening this + // conversation under a different agent's prompt and tools is not + // continuing it, and the new record would re-tombstone under the + // substitute, so the handle would never find its way back. + reloadCustomAgents(); + const dispatch = resolveSpawnType(entry.type); + if (!dispatch.ok || dispatch.fellBackFrom !== undefined) { + // The tombstone stays: re-enabling the agent makes the handle work + // again, which a drop would foreclose. + ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning"); + return { action: "handled" }; + } + + try { + // spawnResolved, not spawnTopLevel: the latter strips + // `resumeSessionFile` and `reclaim` as untrusted. This path is the + // exception — both come from a tombstone this extension wrote. + spawnResolved(pi, ctx, dispatch.type, mention.message, { + description: entry.description, + reclaim: { handle: entry.handle, alias: entry.alias }, + resumeSessionFile: entry.sessionFile, + isBackground: true, + }); + // The tombstone deliberately stays. `resolveMention` prefers the live + // record holding these same names, so it cannot shadow the resume — and + // if this run dies before establishing its own session, the original + // transcript is still the right thing for the next mention to reopen. + // Once the resumed record is evicted it overwrites this entry in place, + // keyed by the same handle, so nothing accumulates. + ctx.ui.notify(`Resuming ${target}`, "info"); + } catch (err) { + // The type is already settled above, so what is left is a spawn-time + // failure: a strict worktree-isolation error, an unusable cwd. + ctx.ui.notify( + `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + return { action: "handled" }; + } + + // No agent under that handle — but the name may still be an agent type, in + // which case the mention starts one. + const typeHandle = mention.handle; + const type = resolveHandleToType(typeHandle, getAvailableTypes()) + ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined); + if (!type) return { action: "continue" }; + + // Claude Code never starts the agent itself: `@agent-` becomes an + // attachment asking the main model to do it, and the model writes the + // agent's prompt from the conversation rather than forwarding the typed + // text. That buys a real `Agent` tool call — transcript, per-tool widget + // detail, tool-use-id correlation, join grouping — and a prompt with the + // context a cold spawn lacks. + // + // It also costs a visible turn, spent narrating a decision the user already + // made by typing the handle. So the turn is taken by a clone of this + // conversation instead (mention-clone.ts): same messages, same system + // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the + // chat, and what it starts is an ordinary top-level agent. + if (getAgentMentionMode() === "model") { + const label = `@${handleBase(type)}`; + // "Prompting", not "Starting": in this mode nothing starts until the + // off-screen clone has taken a whole model turn writing the agent's + // prompt, and that wait is the one thing the chat cannot show. `direct` + // says "Started" because by then it has. The distinction tells the user + // which of the two they are waiting on. + ctx.ui.notify(`Prompting ${label}…`, "info"); + // Not awaited: the clone runs a full model turn, and prompt() is blocked + // until this hook returns. The user gets their prompt back immediately + // and the agent appears in the widget when it starts. + void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool }) + .then((result) => { + if (result.spawned) return; + // A clone that could not run must not swallow the mention: start the + // agent the direct way rather than leaving the user with a toast and + // nothing running. + try { + spawnTopLevel(pi, ctx, type, mention.message, { + description: describeMention(mention.message), + isBackground: true, + }); + ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning"); + } catch (err) { + ctx.ui.notify( + `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`, + "error", + ); + } + }); + return { action: "handled" }; + } + + try { + // Nothing else to pass: runAgent resolves model, thinking and max turns + // from the agent's own config when the spawn omits them, and the + // manager's onStart/onComplete callbacks own the widget, the fleet list + // and the completion notification — the same contract the scheduler and + // cross-extension RPC spawns run under. + spawnTopLevel(pi, ctx, type, mention.message, { + description: describeMention(mention.message), + isBackground: true, + }); + ctx.ui.notify(`Started @${handleBase(type)}`, "info"); + } catch (err) { + ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error"); + } + return { action: "handled" }; + }); + + pi.on("session_before_switch", () => { + manager.clearCompleted(true); + scheduler.stop(); + }); + + // On shutdown, abort all agents immediately and clean up. + // If the session is going down, there's nothing left to consume agent results. + pi.on("session_shutdown", async () => { + rpcHandle?.unsubSpawn(); + rpcHandle?.unsubStop(); + rpcHandle?.unsubPing(); + rpcHandle = undefined; + currentCtx = undefined; + // Only release the global slot if this activation claimed it — a child + // session's shutdown must not delete the root session's registry entry. + if (ownsManagerRegistry && (globalThis as any)[MANAGER_KEY] === registryEntry) { + delete (globalThis as any)[MANAGER_KEY]; + } + scheduler.stop(); + manager.abortAll(); + for (const timer of pendingNudges.values()) clearTimeout(timer); + pendingNudges.clear(); + fleet.dispose(); + // Awaited: it emits `session_shutdown` into every retained child session so + // extensions bound there can release what they armed in `session_start` (#242). + // pi awaits this handler, and the process exits right after — unawaited, those + // handlers would never run. Internally bounded, so a hung one can't strand quit. + await manager.dispose(); + }); + + // Live widget: show running agents above editor. + // widgetMode (default "background") selects what the widget shows: "all" = + // every agent; "background" = hide foreground (they already render inline as + // the Agent tool result, so showing them here too is a duplicate, #118), keep + // everything else; "off" = hide the widget entirely. Read live at render time. + let widgetMode: WidgetMode = "background"; + function getWidgetMode(): WidgetMode { return widgetMode; } + const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled); + function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); } + + // Claude Code-style FleetView: navigable list of main + subagents below the editor. + const fleet = new FleetList(manager, agentActivity, isShowCostEnabled); + let fleetViewEnabled = true; + function isFleetViewEnabled(): boolean { return fleetViewEnabled; } + function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); } + + // Claude Code-style `@handle message` prompt mentions. Read live by both the + // `input` hook and the stacked autocomplete provider, so the toggle applies + // immediately — the provider itself can never be unregistered (pi's wrapper + // list is append-only), it just delegates everything when this is off. + let agentMentionMode: AgentMentionMode = "model"; + function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; } + function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; } + // `model` and `direct` differ only in who starts a not-yet-running agent, so + // everything that just asks "are mentions live at all" — the suggestion list, + // the steer and resume branches — reads this instead of the mode. + function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; } + + // Project/global default for writing the subagent .output transcript lives in + // output-file.ts (both spawn paths read it). A custom agent's + // `output_transcript` frontmatter overrides it per spawn; when the frontmatter + // is silent, this default applies. Read live at spawn time. + + // ---- Join mode configuration ---- + let defaultJoinMode: JoinMode = 'smart'; + function getDefaultJoinMode(): JoinMode { return defaultJoinMode; } + function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; } + + // What an unqualified top-level spawn means. Defaults to background, + // following Claude Code; `backgroundByDefault: false` restores the previous + // foreground default. Nested spawns ignore this — see nested-tools.ts. + let backgroundByDefault = true; + function getBackgroundByDefault(): boolean { return backgroundByDefault; } + function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; } + + // Master switch for the schedule subagent feature. Defaults to enabled. + // Read once at extension init (before tool registration) so the Agent tool's + // param schema reflects the persisted setting. Runtime toggles via /agents + // → Settings short-circuit the menu entry + the execute-time addJob path + // immediately, but the schema-level removal only takes effect on next + // extension load (next pi session). Documented in CHANGELOG/README. + let schedulingEnabled = true; + function isSchedulingEnabled(): boolean { return schedulingEnabled; } + function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; } + + // ---- Disable default agents configuration ---- + // When enabled, the three hardcoded default agents (general-purpose, Explore, + // Plan) are not registered. User-defined agents from project/global custom + // agent dirs are completely unaffected — only DEFAULT_AGENTS are suppressed. + // Defaults to false; opt-in via `/agents → Settings` or subagents.json. + // State lives in agent-types.ts (isDefaultsDisabled) because registerAgents + // needs it; this wrapper just re-registers after flipping it. + function setDisableDefaultAgents(b: boolean): void { + setDefaultsDisabled(b); + reloadCustomAgents(); // re-register with new setting + } + + // ---- Agent tool description mode ---- + // "full" (default) keeps the rich Claude Code-style description; "compact" + // swaps in a ~75% smaller one for small/local models (#91). Read once at + // tool registration — flipping it applies on the next pi session. + let toolDescriptionMode: ToolDescriptionMode = "full"; + function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; } + function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; } + + // ---- Batch tracking for smart join mode ---- + // Collects background agent IDs spawned in the current turn for smart grouping. + // Uses a debounced timer: each new agent resets the 100ms window so that all + // parallel tool calls (which may be dispatched across multiple microtasks by the + // framework) are captured in the same batch. + let currentBatchAgents: { id: string; joinMode: JoinMode }[] = []; + let batchFinalizeTimer: ReturnType | undefined; + let batchCounter = 0; + + /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */ + function finalizeBatch() { + batchFinalizeTimer = undefined; + const batchAgents = [...currentBatchAgents]; + currentBatchAgents = []; + + const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group'); + if (smartAgents.length >= 2) { + const groupId = `batch-${++batchCounter}`; + const ids = smartAgents.map(a => a.id); + groupJoin.registerGroup(groupId, ids); + // Retroactively process agents that already completed during the debounce window. + // Their onComplete fired but was deferred (agent was in currentBatchAgents), + // so we feed them into the group now. + for (const id of ids) { + const record = manager.getRecord(id); + if (!record) continue; + record.groupId = groupId; + if (record.completedAt != null && !record.resultConsumed) { + groupJoin.onAgentComplete(record); + } + } + } else { + // No group formed — send individual nudges for any agents that completed + // during the debounce window and had their notification deferred. + for (const { id } of batchAgents) { + const record = manager.getRecord(id); + if (record?.completedAt != null && !record.resultConsumed) { + sendIndividualNudge(record); + } + } + } + } + + /** + * Launch a detached resume of an existing agent and wire everything a + * re-running agent needs: transcript anchoring, activity tracking, join-mode + * batching, the widget/fleet refresh, and the `subagents:created` event. + * + * Shared by the Agent tool's `resume` + `run_in_background` branch and the + * `@handle message` prompt mention — they differ only in how they report the + * outcome. Returns the record, or undefined when the manager refused because + * the agent is still running (see AgentManager.resume). + * + * Callers must have already established that the record has a session. + */ + async function startBackgroundResume( + ctx: ExtensionContext, + existing: AgentRecord, + prompt: string, + opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string }, + ): Promise { + const id = existing.id; + const joinMode = resolveJoinMode(defaultJoinMode, true); + // Assigned unconditionally: the completion notification carries this as + // ``, so a mention-resume (which passes none) has to CLEAR the + // id left by the spawn that created the record. Keeping it would point the + // orchestrator's new result at a tool call that was answered runs ago. + existing.toolCallId = opts.toolCallId; + if (joinMode) existing.joinMode = joinMode; + // Reuse the agent's transcript rather than starting a fresh one: the + // path is deterministic per agent+session, so writing an initial entry + // would truncate the previous run's turns (see ensureOutputFile). + if (opts.outputTranscript) { + existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId()); + ensureOutputFile(existing.outputFile); + } + // Anchor streaming past the turns already on disk, captured BEFORE the + // run starts. The resumed prompt lands as an ordinary user message at + // this index, so it is written exactly once. + const transcriptAnchor = existing.session?.messages.length ?? 0; + + const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns); + // resumeAgent has no onSessionCreated — the session predates this run — + // so seed it directly, or the widget shows no context % for the agent. + bgState.session = existing.session; + + // No `signal`: a background spawn deliberately omits it, and a detached + // resume must behave the same. Passing it would abort this agent when + // the parent turn is interrupted (user Esc), while agents started with + // run_in_background in that same turn keep going. + const record = await manager.resume(id, prompt, undefined, { + isBackground: true, + onToolActivity: bgCallbacks.onToolActivity, + onAssistantUsage: bgCallbacks.onAssistantUsage, + // Fires when the run actually starts — immediately, or on queue + // drain. Wiring it here (rather than after resume() returns) means a + // resume stopped while still queued never started streaming, so + // there is no subscription left behind for a later run to trip over. + onStarted: () => { + const rec = manager.getRecord(id); + if (rec?.session && rec.outputFile) { + rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor); + } + }, + }); + if (!record) return undefined; + + if (joinMode != null && joinMode !== 'async') { + currentBatchAgents.push({ id, joinMode }); + if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer); + batchFinalizeTimer = setTimeout(finalizeBatch, 100); + } + + agentActivity.set(id, bgState); + // This agent already finished once, so the widget holds a finished-age + // for it that is past the linger limit — without clearing it, the + // resumed run's ✓/✗ line never renders and the agent just vanishes. + widget.markRunning(id); + widget.ensureTimer(); + widget.update(); + fleet.ensureTimer(); + fleet.update(); + + // Resume ignores subagent_type (the record keeps the type it was + // spawned with), so report the record's own identity — a "created" + // event carrying the caller's type would re-register the agent under + // the wrong one in cross-extension mirrors keyed by id. + pi.events.emit("subagents:created", { + id, + type: existing.type, + description: existing.description, + isBackground: true, + }); + + return record; + } + + // Grab UI context from first tool execution + clear lingering widget on new turn + pi.on("tool_execution_start", async (_event, ctx) => { + widget.setUICtx(ctx.ui as UICtx); + fleet.setUICtx(ctx.ui as unknown as FleetUICtx); + widget.onTurnStart(); + }); + + /** Build the full type list text dynamically from available agents only. */ + const buildTypeListText = () => { + const available = getAvailableTypes(); + + return available.map((name) => { + const cfg = getAgentConfig(name); + const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : ""; + const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`; + return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`; + }).join("\n"); + }; + + /** First sentence of an agent description — for the compact type list. */ + const firstSentence = (text: string): string => { + const match = text.match(/^.*?[.!?](?=\s|$)/s); + return (match ? match[0] : text).replace(/\s+/g, " ").trim(); + }; + + /** Compact type list: one line per agent, first sentence only. */ + const buildCompactTypeListText = () => + getAvailableTypes().map((name) => { + const cfg = getAgentConfig(name); + return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`; + }).join("\n"); + + /** Derive a short model label from a model string. */ + function getModelLabelFromConfig(model: string): string { + // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6") + const name = model.includes("/") ? model.split("/").pop()! : model; + // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5") + return name.replace(/-\d{8}$/, ""); + } + + // Apply persisted settings on startup and emit `subagents:settings_loaded`. + // Global + project merged; missing → defaults; corrupt file emits a warning + // to stderr and falls back to defaults. + applyAndEmitLoaded( + { + setMaxConcurrent: (n) => manager.setMaxConcurrent(n), + setDefaultMaxTurns, + setGraceTurns, + setDefaultJoinMode, + setBackgroundByDefault, + setSchedulingEnabled, + setScopeModels: setScopeModelsEnabled, + setStrictAgentFiles: (b) => { strictAgentFiles = b; }, + setDisableDefaultAgents: setDisableDefaultAgents, + setToolDescriptionMode: setToolDescriptionMode, + setFleetView: setFleetViewEnabled, + setAgentMentions: setAgentMentionMode, + setRememberAgents, + setWidgetMode: setWidgetMode, + setOutputTranscript: setOutputTranscriptDefault, + setWorktreeIsolation: setWorktreeIsolationEnabled, + setMaxSubagentDepth: setMaxSubagentDepth, + setFallbackSubagent: setFallbackSubagent, + setReportUsage, + setShowCost, + }, + (event, payload) => pi.events.emit(event, payload), + ); + + // ---- Agent tool ---- + + // Schedule param + its guideline are gated on `schedulingEnabled` (read once + // at registration; flipping the setting later requires next pi session for + // the schema to update). Defining the shape once and spreading it via Partial + // preserves Type.Object's inference when present and produces a + // `schedule`-free schema when absent — zero LLM-context cost in disabled mode. + const scheduleParamShape = { + schedule: Type.Optional( + Type.String({ + description: + 'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' + + 'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' + + 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.', + }), + ), + }; + const scheduleParam: Partial = + isSchedulingEnabled() ? scheduleParamShape : {}; + + const scheduleGuideline = isSchedulingEnabled() + ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.` + : ""; + + // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops + // the field from the schema when the project set `worktreeIsolation: false`, + // so the prose has to go with it. Left in, it would teach the model to pass a + // parameter that isn't declared — accepted (TypeBox sets no + // `additionalProperties: false`) and then silently dropped by the resolver. + // With no per-result note by design, the model would have every reason to go + // on reporting a `pi-agent-*` branch that was never created. + const isolationGuideline = isWorktreeIsolationEnabled() + ? `\n- Use isolation: "worktree" to give the agent its own git worktree (safe parallel file modifications); leave it unset, or pass "off", for none. The worktree is removed when the agent finishes; if it made changes, they are committed to a branch and the branch is named in the result.` + : ""; + + const isolationCompactGuideline = isWorktreeIsolationEnabled() + ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.` + : ""; + + // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) — + // the same load-bearing facts as the full version at ~75% fewer tokens, for + // small/local models. Per-option details live in the param descriptions. + const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types: +${buildCompactTypeListText()} + +Custom agents: .pi/agents/.md (project) or ${getAgentDir()}/agents/.md (global). + +Notes: +- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation. +- Parallel work: one message, multiple Agent calls — they run concurrently. +- Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your very next action depends on the result and nothing else could usefully happen while it runs. Never fabricate or predict a pending agent's results — if the user asks before the notification arrives, say it's still running. +- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done. +- resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`; + + const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it. + +Available agent types and the tools they have access to: +${buildTypeListText()} + +Custom agents can be defined in .pi/agents/.md (project) or ${getAgentDir()}/agents/.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it. + +When using the Agent tool, specify a subagent_type parameter to select which agent type to use. + +## When not to use + +If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type. + +## Usage notes + +- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI). +- When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. +- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary. +- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done. +- Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead. +- **Foreground vs background**: Pass \`run_in_background: false\` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own. +- **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess. +- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained. +- Use steer_subagent to send mid-run messages to a running background agent. +- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent. +- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first. +- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet"). +- Use thinking to control extended thinking level. +- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline} + +## Writing the prompt + +Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters. +- Explain what you're trying to accomplish and why. +- Describe what you've already learned or ruled out. +- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction. +- If you need a short response, say so ("report in under 200 words"). +- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong. + +Terse command-style prompts produce shallow, generic work. + +**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`; + + // `toolDescriptionMode: "custom"` — user-authored description with live + // dynamic parts. Project file wins over global; missing/empty falls back to + // "full" (a stale fallback beats a blank tool description). Only the prose + // is customizable — the parameter schema stays code-owned. + const renderToolDescriptionTemplate = (template: string): string => { + const vars: Record string> = { + typeList: buildTypeListText, + compactTypeList: buildCompactTypeListText, + agentDir: getAgentDir, + isolationGuideline: () => isolationGuideline, + scheduleGuideline: () => scheduleGuideline, + }; + // Replacement callback (not a string) — agent descriptions may contain `$&` etc. + return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => { + if (vars[name]) return vars[name](); + console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`); + return raw; + }); + }; + + const loadCustomToolDescription = (): string | undefined => { + for (const path of [ + join(process.cwd(), ".pi", "agent-tool-description.md"), + join(getAgentDir(), "agent-tool-description.md"), + ]) { + try { + if (!existsSync(path)) continue; + const text = readFileSync(path, "utf-8").trim(); + if (text) return renderToolDescriptionTemplate(text); + console.warn(`[pi-subagents] ${path} is empty — ignoring`); + } catch (err) { + console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return undefined; + }; + + const agentToolDescription = (() => { + const mode = getToolDescriptionMode(); + if (mode === "compact") return compactAgentToolDescription; + if (mode === "custom") { + const custom = loadCustomToolDescription(); + if (custom) return custom; + console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"'); + } + return fullAgentToolDescription; + })(); + + // Held rather than registered inline: the mention clone reuses this exact + // definition, so the agent it starts is an ordinary top-level spawn instead + // of a second implementation that has to be kept in step with this one. + const agentTool = defineTool({ + name: SUBAGENT_TOOL_NAMES.AGENT, + label: "Agent", + description: agentToolDescription, + promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks", + promptGuidelines: [ + "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.", + "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.", + "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.", + "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.", + ], + parameters: Type.Object({ + prompt: Type.String({ + description: "The task for the agent to perform.", + }), + description: Type.String({ + description: "A short (3-5 word) description of the task (shown in UI).", + }), + name: Type.Optional( + Type.String({ + description: + 'Optional memorable name for this agent, e.g. "auth-audit", so it can be addressed as `@name` at the prompt and by steer_subagent / get_subagent_result. Letters, digits, `_` and `-`. Worth setting when several agents of the same type run at once; omit for one-off work. The agent stays reachable by its type either way.', + }), + ), + subagent_type: Type.String({ + description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`, + }), + model: Type.Optional( + Type.String({ + description: + 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.', + }), + ), + thinking: Type.Optional( + Type.String({ + description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`, + }), + ), + max_turns: Type.Optional( + Type.Number({ + description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).", + minimum: 1, + }), + ), + run_in_background: Type.Optional( + Type.Boolean({ + description: "Defaults to true — the agent runs detached, returning its ID immediately, and you are notified on completion. Set false only when your very next action depends on the result; the call then blocks and returns the agent's full output inline.", + }), + ), + resume: Type.Optional( + Type.String({ + description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.", + }), + ), + isolated: Type.Optional( + Type.Boolean({ + description: "If true, agent gets no extension/MCP tools — only built-in tools.", + }), + ), + inherit_context: Type.Optional( + Type.Boolean({ + description: "If true, fork parent conversation into the agent. Default: false (fresh context).", + }), + ), + ...isolationParam(isWorktreeIsolationEnabled()), + ...scheduleParam, + }), + + // ---- Custom rendering: Claude Code style ---- + + renderCall(args, theme, context) { + // A badge closes its own background, which would clear the tool block's row tint + // for the rest of the line, so the badge restores it. The tint is opened here too: + // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a + // background the line never opened is what banded the export before. The line is + // deliberately left open — Box.applyBackgroundToLine pads to width and *then* + // wraps, so closing here would leave that padding untinted, and HTML export closes + // any open span per line anyway. No badge means no tint, so an uncolored agent + // renders exactly the line it always did. + const rowBackground = hasAgentBadge(args.subagent_type) + ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg") + : ""; + const desc = args.description ?? ""; + const name = renderAgentName(args.subagent_type, theme, { + fallbackColor: "toolTitle", + restoreBackground: rowBackground, + bold: true, + }); + return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme, renderContext) { + const details = result.details as AgentDetails | undefined; + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + // Pi reports pre-execution failures (extension block, abort, argument + // validation) as `{ content: [reason], details: {} }` with isError set — + // no status to render, so show the reason instead of inventing one (#199). + if (renderContext.isError || !details?.status) { + return new Text(text, 0, 0); + } + + // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string + const stats = (d: AgentDetails) => { + const parts: string[] = []; + if (d.modelName) parts.push(d.modelName); + if (d.tags) parts.push(...d.tags); + if (d.turnCount != null && d.turnCount > 0) { + parts.push(formatTurns(d.turnCount, d.maxTurns)); + } + if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); + if (d.tokens) parts.push(d.tokens); + if (showCost) { + const costText = formatCost(d.cost ?? 0); + if (costText) parts.push(costText); + } + return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " "); + }; + + // ---- While running (streaming) ---- + if (isPartial || details.status === "running") { + const frame = SPINNER[details.spinnerFrame ?? 0]; + const s = stats(details); + return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme); + } + + // ---- Background agent launched ---- + if (details.status === "background") { + return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0); + } + + // ---- Completed / Steered ---- + if (details.status === "completed" || details.status === "steered") { + const duration = formatMs(details.durationMs); + const isSteered = details.status === "steered"; + const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓"); + const s = stats(details); + let line = icon + (s ? " " + s : ""); + line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration); + + if (expanded) { + const resultText = result.content[0]?.type === "text" ? result.content[0].text : ""; + if (resultText) { + const lines = resultText.split("\n").slice(0, 50); + for (const l of lines) { + line += "\n" + theme.fg("dim", ` ${l}`); + } + if (resultText.split("\n").length > 50) { + line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)"); + } + } + } else { + const doneText = isSteered ? "Wrapped up (turn limit)" : "Done"; + line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`); + } + return new Text(line, 0, 0); + } + + // ---- Stopped (user-initiated abort) ---- + if (details.status === "stopped") { + const s = stats(details); + let line = theme.fg("dim", "■") + (s ? " " + s : ""); + line += "\n" + theme.fg("dim", " ⎿ Stopped"); + return new Text(line, 0, 0); + } + + // Anything left ("queued", or a status added later) has no rendering of + // its own — the turn-limit wording below must not be the catch-all. + if (details.status !== "error" && details.status !== "aborted") { + return new Text(text, 0, 0); + } + + // ---- Error / Aborted (hard max_turns) ---- + const s = stats(details); + let line = theme.fg("error", "✗") + (s ? " " + s : ""); + + if (details.status === "error") { + line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`); + } else { + line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)"); + } + + return new Text(line, 0, 0); + }, + + // ---- Execute ---- + + execute: async (toolCallId, params, signal, onUpdate, ctx) => { + // Ensure we have UI context for widget rendering + widget.setUICtx(ctx.ui as UICtx); + + // Reload custom agents so new project/global .md files are picked up without restart + reloadCustomAgents(); + + const rawType = params.subagent_type as SubagentType; + // Single decision point for dispatch (#183): unknown, disabled and + // case-ambiguous types are refused here, BEFORE anything spawns, so a + // background or scheduled call can't start running the wrong agent while + // the caller is still unaware. `fallbackSubagent` decides whether an + // unresolvable type falls back or fails closed. + const dispatch = resolveSpawnType(rawType); + // `resume` replays a stored session and ignores `subagent_type` entirely, + // but the parameter is required by the schema — so gating it here would + // make a live agent unresumable the moment its type is deleted, disabled, + // or gains a case-clashing sibling. Only a real spawn is gated. + if (!dispatch.ok && !params.resume) return textResult(dispatch.message); + const subagentType = dispatch.ok ? dispatch.type : rawType; + // What the caller actually asked for, named once: `fellBackFrom` is "" for + // a blank request, so reading it inline invites the `??`-vs-`||` slip that + // once persisted an empty type into a scheduled job. + const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType; + // Computed at resolution rather than after the run, so the background and + // schedule branches carry it too — previously it existed only on the + // foreground path. Resume deliberately doesn't: it replays the stored + // session and ignores `subagent_type` entirely, so a note about type + // substitution would be describing something that didn't happen. + const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined + ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n` + : ""; + + const displayName = getDisplayName(subagentType); + + // Get agent config (if any) + const customConfig = getAgentConfig(subagentType); + + const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, { + worktreeAllowed: isWorktreeIsolationEnabled(), + defaultRunInBackground: getBackgroundByDefault(), + }); + + // Resolve model from agent config first; tool-call params only fill gaps. + let model = ctx.model; + if (resolvedConfig.modelInput) { + const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry); + if (typeof resolved === "string") { + if (resolvedConfig.modelFromParams) return textResult(resolved); + // config-specified: silent fallback to parent + } else { + model = resolved; + } + } + + // Scope validation: the effective resolved model is checked against the + // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives + // in model-scope.ts so the nested delegation tools apply the same rule. + const scopeVerdict = checkModelScope({ + model, + cwd: ctx.cwd, + modelRegistry: ctx.modelRegistry, + callerSupplied: resolvedConfig.modelFromParams, + agentLabel: customConfig?.displayName ?? subagentType, + modelInput: resolvedConfig.modelInput, + }); + if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message); + if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning"); + + const thinking = resolvedConfig.thinking; + const inheritContext = resolvedConfig.inheritContext; + const runInBackground = resolvedConfig.runInBackground; + const isolated = resolvedConfig.isolated; + const isolation = resolvedConfig.isolation; + // Whether this spawn writes its .output transcript. Per-agent + // frontmatter (`output_transcript`) wins; otherwise the project/global + // default applies. `attachTranscript` below is the SOLE gate — every + // downstream consumer keys off record.outputFile being set, so no spawn + // path can re-enable the transcript by accident. + const outputTranscript = customConfig?.outputTranscript ?? getOutputTranscriptDefault(); + const attachTranscript = (rec: AgentRecord | undefined, agentId: string): void => { + if (!rec || !outputTranscript) return; + rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId()); + writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd); + }; + + const parentModelId = ctx.model?.id; + const effectiveModelId = model?.id; + const modelName = effectiveModelId && effectiveModelId !== parentModelId + ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase() + : undefined; + const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns()); + const agentInvocation: AgentInvocation = { + modelName, + thinking, + // Explicit value only — the default fallback would just add noise. + // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0". + maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns), + isolated, + inheritContext, + runInBackground, + isolation, + }; + // Tool-result render shows the mode label too; viewer's header already does. + const modeLabel = getPromptModeLabel(subagentType); + const { tags: invocationTags } = buildInvocationTags(agentInvocation); + const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags; + const detailBase = { + displayName, + description: params.description, + subagentType, + modelName, + tags: agentTags.length > 0 ? agentTags : undefined, + }; + + // ---- Schedule: register a job, don't spawn now ---- + if (params.schedule) { + if (!isSchedulingEnabled()) { + return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling."); + } + if (params.resume) { + return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents."); + } + if (params.inherit_context) { + return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time."); + } + if (params.run_in_background === false) { + return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background."); + } + if (!scheduler.isActive()) { + return textResult("Scheduler is not active in this session yet. Try again after the session has fully started."); + } + try { + const job = scheduler.addJob({ + name: params.description as string, + description: params.description as string, + schedule: params.schedule as string, + // The caller's own name, not the substitute — the scheduler re-resolves + // at fire time, and the original is what a user edits. + subagent_type: requestedType, + prompt: params.prompt as string, + model: params.model as string | undefined, + thinking: thinking, + max_turns: effectiveMaxTurns, + isolated: isolated, + isolation: isolation, + }); + const next = scheduler.getNextRun(job.id); + return textResult( + `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` + + `Next run: ${next ?? "(unknown)"}. ` + + `Manage via /agents → Scheduled jobs.`, + ); + } catch (err) { + return textResult(err instanceof Error ? err.message : String(err)); + } + } + + // Resume existing agent + if (params.resume) { + const existing = manager.getRecord(params.resume); + if (!existing || existing.parentAgentId) { + return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`); + } + if (!existing.session) { + return textResult(`Agent "${params.resume}" has no active session to resume.`); + } + + // Background resume: detached run that notifies on completion, mirroring + // a background spawn. Previously run_in_background was silently ignored + // on resume (this branch returned before the background branch below), + // so a resumed agent always blocked the main loop until it finished. + if (runInBackground) { + const id = existing.id; + // A detached resume hands control back while the record stays + // "running", so nothing stops the model from resuming the same agent + // again mid-run. manager.resume() refuses that (it would orphan the + // live run's abort controller); say why here, where the model can act + // on it, instead of letting it read as a generic failure. + if (existing.status === "running" || existing.status === "queued") { + return textResult( + `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` + + `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`, + ); + } + + const record = await startBackgroundResume(ctx, existing, params.prompt, { + outputTranscript, + maxTurns: effectiveMaxTurns, + toolCallId, + }); + if (!record) { + return textResult(`Failed to resume agent "${params.resume}".`); + } + + const isQueued = record.status === "queued"; + return textResult( + `Agent ${isQueued ? "queued" : "resumed"} in background.\n` + + `Agent ID: ${id}\n` + + `Type: ${existing.type}\n` + + (record.outputFile ? `Output file: ${record.outputFile}\n` : "") + + (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") + + `\nYou will be notified when this agent completes.\n` + + `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`, + { ...detailBase, subagentType: existing.type, displayName: existing.type, toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id }, + ); + } + + const record = await manager.resume(params.resume, params.prompt, signal); + if (!record) { + return textResult(`Failed to resume agent "${params.resume}".`); + } + // A failed resume surfaces the error, plus any partial output THIS + // resume produced (never the previous turn's answer, #144). + if (record.status === "error") { + return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record)); + } + return textResult( + record.result?.trim() || "No output.", + buildDetails(detailBase, record), + ); + } + + // Background execution + if (runInBackground) { + const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns); + + // Wrap onSessionCreated to wire output file streaming. + // The callback lazily reads record.outputFile (set right after spawn) + // rather than closing over a value that doesn't exist yet. + let id: string; + const origBgOnSession = bgCallbacks.onSessionCreated; + bgCallbacks.onSessionCreated = (session: any) => { + origBgOnSession(session); + const rec = manager.getRecord(id); + if (rec?.outputFile) { + rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd); + } + }; + + // A throw here means the agent never started. Let it out: pi marks a + // tool call failed only when execute throws, and a returned message + // reads to the model as a subagent that ran and reported this (#179). + id = manager.spawn(pi, ctx, subagentType, params.prompt, { + description: params.description, + name: params.name as string | undefined, + model, + maxTurns: effectiveMaxTurns, + isolated, + inheritContext, + thinkingLevel: thinking, + isBackground: true, + isolation, + invocation: agentInvocation, + rootSessionId: ctx.sessionManager.getSessionId(), + ...bgCallbacks, + }); + + // Set output file + join mode synchronously after spawn, before the + // event loop yields — onSessionCreated is async so this is safe. + const joinMode = resolveJoinMode(defaultJoinMode, true); + const record = manager.getRecord(id); + if (record && joinMode) { + record.joinMode = joinMode; + record.toolCallId = toolCallId; + attachTranscript(record, id); + } + + if (joinMode == null || joinMode === 'async') { + // Foreground/no join mode or explicit async — not part of any batch + } else { + // smart or group — add to current batch + currentBatchAgents.push({ id, joinMode }); + // Debounce: reset timer on each new agent so parallel tool calls + // dispatched across multiple event loop ticks are captured together + if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer); + batchFinalizeTimer = setTimeout(finalizeBatch, 100); + } + + agentActivity.set(id, bgState); + widget.ensureTimer(); + widget.update(); + fleet.ensureTimer(); + fleet.update(); + + // Emit created event + pi.events.emit("subagents:created", { + id, + type: subagentType, + description: params.description, + isBackground: true, + }); + + const isQueued = record?.status === "queued"; + return textResult( + `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` + + `Agent ID: ${id}\n` + + `Type: ${displayName}\n` + + `Description: ${params.description}\n` + + (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") + + (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") + + `\nYou will be notified when this agent completes.\n` + + `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` + + `Do not duplicate this agent's work.`, + { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id }, + ); + } + + // Foreground (synchronous) execution — stream progress via onUpdate + let spinnerFrame = 0; + const startedAt = Date.now(); + let fgId: string | undefined; + + const streamUpdate = () => { + // Spend from the record, everything else from the live tracker. `fgId` + // is set in onSessionCreated below, which fires before the first + // assistant message — so nothing is spent while this reads zero. + const fgRecord = fgId ? manager.getRecord(fgId) : undefined; + const details: AgentDetails = { + ...detailBase, + toolUses: fgState.toolUses, + tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "", + cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0, + turnCount: fgState.turnCount, + maxTurns: fgState.maxTurns, + durationMs: Date.now() - startedAt, + status: "running", + activity: describeActivity(fgState.activeTools, fgState.responseText), + spinnerFrame: spinnerFrame % SPINNER.length, + }; + onUpdate?.({ + content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }], + details: details as any, + }); + }; + + const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate); + + // Wire session creation: register in widget + stream to output file. + // The output file path is set synchronously after spawn (below), + // before onSessionCreated fires — same pattern as background agents. + const origOnSession = fgCallbacks.onSessionCreated; + fgCallbacks.onSessionCreated = (session: any) => { + origOnSession(session); + for (const a of manager.listAgents()) { + if (a.session === session) { + fgId = a.id; + agentActivity.set(a.id, fgState); + widget.ensureTimer(); + fleet.ensureTimer(); + fleet.update(); + break; + } + } + // Stream conversation to output file (foreground agent logging) + if (fgId) { + const rec = manager.getRecord(fgId); + if (rec?.outputFile) { + rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd); + } + } + }; + + // Animate spinner at ~80ms (smooth rotation through 10 braille frames) + const spinnerInterval = setInterval(() => { + spinnerFrame++; + streamUpdate(); + }, 80); + + streamUpdate(); + + let record: AgentRecord; + try { + const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, { + description: params.description, + name: params.name as string | undefined, + model, + maxTurns: effectiveMaxTurns, + isolated, + inheritContext, + thinkingLevel: thinking, + isolation, + invocation: agentInvocation, + signal, + rootSessionId: ctx.sessionManager.getSessionId(), + ...fgCallbacks, + }, (fgAgentId) => { + // onSpawned: called synchronously after spawn, before onSessionCreated fires. + // Set up the output file so streamToOutputFile can pick it up. + const fgRec = manager.getRecord(fgAgentId); + attachTranscript(fgRec, fgAgentId); + }); + record = fgResult.record; + } finally { + // Runs on both paths, so a startup throw — which now propagates, see + // the background spawn above (#179) — no longer leaves the spinner + // ticking or a finished agent on the widget. + clearInterval(spinnerInterval); + if (fgId) { + agentActivity.delete(fgId); + widget.markFinished(fgId); + fleet.onAgentFinished(fgId); + } + } + + // Get final token count — from the record, like the cost below it, so the + // two describe the same work when the agent delegated to nested children. + const tokenText = formatLifetimeTokens(record); + + const details = buildDetails(detailBase, record, fgState, { tokens: tokenText }); + + if (record.status === "error") { + // Error headline + any partial output the run produced before failing. + return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details); + } + + const durationMs = (record.completedAt ?? Date.now()) - record.startedAt; + const statsParts = [`${record.toolUses} tool uses`]; + if (tokenText) statsParts.push(tokenText); + if (showCost) { + const costText = formatCost(getLifetimeCost(record.lifetimeUsage)); + if (costText) statsParts.push(costText); + } + return textResult( + `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` + + (record.result?.trim() || "No output."), + details, + ); + }, + }); + /** + * Wrap a tool so its results carry back whatever subagent spend the parent + * session has not been told about yet (see `PendingUsagePool`). + * + * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and + * folds it into `getSessionStats()`, which is what the footer, the statusline + * and `/cost` read — so this is the whole of "report usage to the parent". + * + * Nothing is attached to a call with no tool-call id. That is the `@handle` + * mention path (`mention-clone.ts`), which invokes this tool from a fork of the + * conversation that is discarded moments later: the result never becomes a + * message in the real session, so usage hung on it would be spend the user paid + * for and nobody counted. Skipping leaves it pending for the next real result. + */ + function withUsageReporting any }>(tool: T): T { + return { + ...tool, + execute: async (toolCallId: string | undefined, ...rest: any[]) => { + const result = await tool.execute(toolCallId, ...rest); + if (!reportUsage || !toolCallId) return result; + const usage = pendingUsage.drain(); + return usage ? { ...result, usage } : result; + }, + }; + } + function registerToolReportingUsage(tool: any): void { + pi.registerTool(withUsageReporting(tool)); + } + + // The mention path is handed THIS object, not the bare `agentTool` — see the + // mention-clone header on why the clone must call the registered tool. + const registeredAgentTool = withUsageReporting(agentTool); + pi.registerTool(registeredAgentTool); + + // ---- get_subagent_result tool ---- + + registerToolReportingUsage(defineTool({ + name: SUBAGENT_TOOL_NAMES.GET_RESULT, + label: "Get Agent Result", + description: + "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.", + promptSnippet: "Check status and retrieve results from a background agent", + parameters: Type.Object({ + agent_id: Type.String({ + description: "The agent ID to check. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).", + }), + wait: Type.Optional( + Type.Boolean({ + description: "If true, wait for the agent to complete before returning. Default: false.", + }), + ), + verbose: Type.Optional( + Type.Boolean({ + description: "If true, include the agent's full conversation (messages + tool calls). Default: false.", + }), + ), + }), + execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => { + const record = resolveAgentRef(params.agent_id); + if (!record || record.parentAgentId) { + return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`); + } + + // Wait for completion if requested. Cancellation stops only this tool + // call; the background agent keeps running and remains unconsumed so its + // completion notification can still be delivered. + // Queued agents have no promise yet (it's created when the queue starts + // them), so poll until they leave the queue, then await like a running one. + if (params.wait && (record.status === "running" || record.status === "queued")) { + while (record.status === "queued") { + await abortable( + new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), + signal, + ); + } + if (record.promise) await abortable(record.promise, signal); + } + + const displayName = getDisplayName(record.type); + const duration = formatDuration(record.startedAt, record.completedAt); + const tokens = formatLifetimeTokens(record); + const contextPercent = getSessionContextPercent(record.session); + const statsParts = [`Tool uses: ${record.toolUses}`]; + if (tokens) statsParts.push(tokens); + if (showCost) { + const costText = formatCost(getLifetimeCost(record.lifetimeUsage)); + if (costText) statsParts.push(`Cost: ${costText}`); + } + if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`); + if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`); + statsParts.push(`Duration: ${duration}`); + + let output = + `Agent: ${record.id}\n` + + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` + + `Description: ${record.description}\n\n`; + + if (record.status === "running") { + output += "Agent is still running. Use wait: true or check back later."; + } else if (record.status === "error") { + output += `Error: ${record.error}${partialOutputSuffix(record)}`; + } else { + output += record.result?.trim() || "No output."; + } + + // Mark result as consumed — suppresses the completion notification + if (record.status !== "running" && record.status !== "queued") { + record.resultConsumed = true; + cancelNudge(params.agent_id); + } + + // Verbose: include full conversation + if (params.verbose && record.session) { + const conversation = getAgentConversation(record.session); + if (conversation) { + output += `\n\n--- Agent Conversation ---\n${conversation}`; + } + } + + return textResult(output); + }, + })); + + // ---- steer_subagent tool ---- + + registerToolReportingUsage(defineTool({ + name: SUBAGENT_TOOL_NAMES.STEER, + label: "Steer Agent", + description: + "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " + + "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.", + promptSnippet: "Send a steering message to redirect a running background agent", + parameters: Type.Object({ + agent_id: Type.String({ + description: "The agent ID to steer (must be currently running). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).", + }), + message: Type.String({ + description: "The steering message to send. This will appear as a user message in the agent's conversation.", + }), + }), + execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { + const record = resolveAgentRef(params.agent_id); + if (!record || record.parentAgentId) { + return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`); + } + if (record.status !== "running") { + return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`); + } + if (!record.session) { + // Session not ready yet — queue the steer for delivery once initialized + if (!record.pendingSteers) record.pendingSteers = []; + record.pendingSteers.push(params.message); + pi.events.emit("subagents:steered", { id: record.id, message: params.message }); + return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`); + } + + try { + await steerAgent(record.session, params.message); + pi.events.emit("subagents:steered", { id: record.id, message: params.message }); + const tokens = formatLifetimeTokens(record); + const contextPercent = getSessionContextPercent(record.session); + const stateParts: string[] = []; + if (tokens) stateParts.push(tokens); + if (showCost) { + const costText = formatCost(getLifetimeCost(record.lifetimeUsage)); + if (costText) stateParts.push(costText); + } + stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`); + if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`); + if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`); + return textResult( + `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` + + `Current state: ${stateParts.join(" · ")}`, + ); + } catch (err) { + return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`); + } + }, + })); + + // ---- /agents interactive menu ---- + + // Directory resolution and the frontmatter edits live in agent-file-toggle.ts + // so they are reachable from tests — this command handler is only registered + // through `registerCommand`, which every test mocks. + + function getModelLabel(type: string, registry?: ModelRegistry): string { + const cfg = getAgentConfig(type); + if (!cfg?.model) return "inherit"; // no model configured → really inherits parent + const label = getModelLabelFromConfig(cfg.model); + if (!registry) return label; + const resolved = resolveModel(cfg.model, registry); + // Configured but unresolvable: the runtime silently falls back to the parent + // model, so flag it (and the fallback) rather than hiding the config. + if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`; + // Surface what it actually resolved to when that differs from the config — + // e.g. a provider fallback or a looser version pin. Cosmetic separator/date + // differences are normalized away so an effectively-identical match stays quiet. + const resolvedFull = `${resolved.provider}/${resolved.id}`; + const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, ""); + if (norm(cfg.model) === norm(resolvedFull)) return label; + return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`; + } + + async function showAgentsMenu(ctx: ExtensionCommandContext) { + reloadCustomAgents(); + const allNames = getAllTypes(); + + // Build select options + const options: string[] = []; + + // Running agents entry (only if there are active agents) + const agents = manager.listAgents().filter(a => !a.parentAgentId); + if (agents.length > 0) { + const running = agents.filter(a => a.status === "running" || a.status === "queued").length; + const done = agents.filter(a => a.status === "completed" || a.status === "steered").length; + options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`); + } + + // Agent types list + if (allNames.length > 0) { + options.push(`Agent types (${allNames.length})`); + } + + // Scheduled jobs entry (always present when scheduler is active) + if (scheduler.isActive()) { + const jobCount = scheduler.list().length; + options.push(`Scheduled jobs (${jobCount})`); + } + + // Actions + options.push("Create new agent"); + options.push("Settings"); + + const noAgentsMsg = allNames.length === 0 && agents.length === 0 + ? "No agents found. Create specialized subagents that can be delegated to.\n\n" + + "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" + + "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n" + : ""; + + if (noAgentsMsg) { + ctx.ui.notify(noAgentsMsg, "info"); + } + + const choice = await ctx.ui.select("Agents", options); + if (!choice) return; + + if (choice.startsWith("Running agents (")) { + await showRunningAgents(ctx); + await showAgentsMenu(ctx); + } else if (choice.startsWith("Agent types (")) { + await showAllAgentsList(ctx); + await showAgentsMenu(ctx); + } else if (choice.startsWith("Scheduled jobs (")) { + await showSchedulesMenu(ctx, scheduler); + await showAgentsMenu(ctx); + } else if (choice === "Create new agent") { + await showCreateWizard(ctx); + } else if (choice === "Settings") { + await showSettings(ctx); + await showAgentsMenu(ctx); + } + } + + async function showAllAgentsList(ctx: ExtensionCommandContext) { + const allNames = getAllTypes(); + if (allNames.length === 0) { + ctx.ui.notify("No agents.", "info"); + return; + } + + // Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global) + // Disabled agents get ✕ prefix + const sourceIndicator = (cfg: AgentConfig | undefined) => { + const disabled = cfg?.enabled === false; + if (cfg?.source === "project") return disabled ? "✕• " : "• "; + if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ "; + if (disabled) return "✕ "; + return " "; + }; + + // One row per agent (name in the left column, model on the right); the + // full description renders below the highlighted row via SettingsList, + // exactly like the Settings menu — so long descriptions never wrap the list. + const items: SettingItem[] = allNames.map(name => { + const cfg = getAgentConfig(name); + const disabled = cfg?.enabled === false; + const model = getModelLabel(name, ctx.modelRegistry); + return { + id: name, + label: `${sourceIndicator(cfg)}${name}`, + currentValue: model, + description: disabled ? "(disabled)" : (cfg?.description ?? name), + // Single-value list so Enter "activates" the row (fires onChange with the + // agent's id) without offering anything to actually cycle. + values: [model], + }; + }); + + const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; }); + const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false); + const legendParts: string[] = []; + if (hasCustom) legendParts.push("• = project ◦ = global"); + if (hasDisabled) legendParts.push("✕ = disabled"); + + const selected = await ctx.ui.custom((_tui, _theme, _kb, done) => { + const slTheme = getSettingsListTheme(); + const list = new SettingsList( + items, + Math.min(items.length, 12), + slTheme, + id => done(id), // Enter/Space on a row → return that agent's name + () => done(undefined), // Esc → cancel + ); + const container = new Container(); + container.addChild(new Text("Agent types", 0, 0)); + if (legendParts.length) container.addChild(new Text(slTheme.hint(legendParts.join(" ")), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(list); + return { + render: (w: number) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data: string) => list.handleInput?.(data), + }; + }); + + if (selected && getAgentConfig(selected)) { + await showAgentDetail(ctx, selected); + await showAllAgentsList(ctx); + } + } + + async function showRunningAgents(ctx: ExtensionCommandContext) { + const agents = manager.listAgents().filter(a => !a.parentAgentId); + if (agents.length === 0) { + ctx.ui.notify("No agents.", "info"); + return; + } + + // Numbered + item-paired. Two same-type agents spawned together with the + // same description render identically here, and resolving the choice by + // string match would open whichever came first. + const record = await selectItem(ctx.ui, "Running agents", agents, a => { + const dn = getDisplayName(a.type); + const dur = formatDuration(a.startedAt, a.completedAt); + return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`; + }); + if (!record) return; + + await viewAgentConversation(ctx, record); + // Back-navigation: re-show the list + await showRunningAgents(ctx); + } + + async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) { + if (!record.session) { + ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info"); + return; + } + + const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js"); + const session = record.session; + const activity = agentActivity.get(record.id); + + await ctx.ui.custom( + (tui, theme, keybindings, done) => { + return new ConversationViewer(tui, session, record, activity, theme, done, () => { + if (manager.abort(record.id)) { + ctx.ui.notify(`Stopped "${record.description}".`, "info"); + } + }, keybindings, (message: string) => manager.steer(record.id, message), showCost); + }, + { + overlay: true, + overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` }, + }, + ); + } + + async function showAgentDetail(ctx: ExtensionCommandContext, name: string) { + const cfg = getAgentConfig(name); + if (!cfg) { + ctx.ui.notify(`Agent config not found for "${name}".`, "warning"); + return; + } + + const file = locateAgentFile(name, cfg.sourcePath); + const isDefault = cfg.isDefault === true; + const disabled = cfg.enabled === false; + + let menuOptions: string[]; + if (disabled && file) { + // Disabled agent with a file — offer Enable + menuOptions = isDefault + ? ["Enable", "Edit", "Reset to default", "Delete", "Back"] + : ["Enable", "Edit", "Delete", "Back"]; + } else if (isDefault && !file) { + // Default agent with no .md override + menuOptions = ["Eject (export as .md)", "Disable", "Back"]; + } else if (isDefault && file) { + // Default agent with .md override (ejected) + menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"]; + } else { + // User-defined agent + menuOptions = ["Edit", "Disable", "Delete", "Back"]; + } + + const choice = await ctx.ui.select(name, menuOptions); + if (!choice || choice === "Back") return; + + if (choice === "Edit" && file) { + const content = readFileSync(file.path, "utf-8"); + const edited = await ctx.ui.editor(`Edit ${name}`, content); + if (edited !== undefined && edited !== content) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(file.path, edited, "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Updated ${file.path}`, "info"); + } + } else if (choice === "Delete") { + if (file) { + const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`); + if (confirmed) { + unlinkSync(file.path); + reloadCustomAgents(); + ctx.ui.notify(`Deleted ${file.path}`, "info"); + } + } + } else if (choice === "Reset to default" && file) { + const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`); + if (confirmed) { + unlinkSync(file.path); + reloadCustomAgents(); + ctx.ui.notify(`Restored default ${name}`, "info"); + } + } else if (choice.startsWith("Eject")) { + await ejectAgent(ctx, name, cfg); + } else if (choice === "Disable") { + await disableAgent(ctx, name); + } else if (choice === "Enable") { + await enableAgent(ctx, name); + } + } + + /** Eject a default agent: write its embedded config as a .md file. */ + async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) { + const location = await ctx.ui.select("Choose location", [ + "Project (.pi/agents/)", + `Personal (${personalAgentsDir()})`, + ]); + if (!location) return; + + const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); + mkdirSync(targetDir, { recursive: true }); + + const targetPath = join(targetDir, `${name}.md`); + if (existsSync(targetPath)) { + const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); + if (!overwrite) return; + } + + const content = serializeAgentFile(cfg); + + const { writeFileSync } = await import("node:fs"); + writeFileSync(targetPath, content, "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info"); + } + + /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */ + async function disableAgent(ctx: ExtensionCommandContext, name: string) { + const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath); + if (file) { + // Existing file — set enabled: false in frontmatter (idempotent) + const content = readFileSync(file.path, "utf-8"); + const { content: updated, outcome } = disableInContent(content); + if (outcome === "already-disabled") { + ctx.ui.notify(`${name} is already disabled.`, "info"); + return; + } + if (outcome === "no-frontmatter") { + // Nothing to edit — say so rather than rewriting the file unchanged and + // reporting success for a change that never happened. + ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error"); + return; + } + const { writeFileSync } = await import("node:fs"); + writeFileSync(file.path, updated, "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Disabled ${name} (${file.path})`, "info"); + return; + } + + // No file (built-in default) — create a stub + const location = await ctx.ui.select("Choose location", [ + "Project (.pi/agents/)", + `Personal (${personalAgentsDir()})`, + ]); + if (!location) return; + + const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); + mkdirSync(targetDir, { recursive: true }); + + const targetPath = join(targetDir, `${name}.md`); + const { writeFileSync } = await import("node:fs"); + writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info"); + } + + /** Enable a disabled agent by removing enabled: false from its frontmatter. */ + async function enableAgent(ctx: ExtensionCommandContext, name: string) { + const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath); + if (!file) return; + + const content = readFileSync(file.path, "utf-8"); + const { content: updated, changed } = enableInContent(content); + if (!changed && !isEmptyStub(updated)) { + // The file carries no `enabled: false` to remove, so it was never disabled + // by us — reporting success here would hide a no-op. + ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info"); + return; + } + const { writeFileSync } = await import("node:fs"); + + // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default + if (isEmptyStub(updated)) { + unlinkSync(file.path); + reloadCustomAgents(); + ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info"); + } else { + writeFileSync(file.path, updated, "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Enabled ${name} (${file.path})`, "info"); + } + } + + async function showCreateWizard(ctx: ExtensionCommandContext) { + const location = await ctx.ui.select("Choose location", [ + "Project (.pi/agents/)", + `Personal (${personalAgentsDir()})`, + ]); + if (!location) return; + + const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); + + const method = await ctx.ui.select("Creation method", [ + "Generate with Claude (recommended)", + "Manual configuration", + ]); + if (!method) return; + + if (method.startsWith("Generate")) { + await showGenerateWizard(ctx, targetDir); + } else { + await showManualWizard(ctx, targetDir); + } + } + + async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) { + const description = await ctx.ui.input("Describe what this agent should do"); + if (!description) return; + + const name = await ctx.ui.input("Agent name (filename, no spaces)"); + if (!name) return; + + mkdirSync(targetDir, { recursive: true }); + + const targetPath = join(targetDir, `${name}.md`); + if (existsSync(targetPath)) { + const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); + if (!overwrite) return; + } + + ctx.ui.notify("Generating agent definition...", "info"); + + const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}" + +Write a markdown file to: ${targetPath} + +The file format is a markdown file with YAML frontmatter and a system prompt body: + +\`\`\`markdown +--- +description: +color: +tools: +model: +thinking: +max_turns: +prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace> +extensions: +skills: +disallowed_tools: +inherit_context: +run_in_background: +output_transcript: +isolated: +memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${ + // Offering the field on a project that turned worktrees off would bake a + // request that is refused at spawn time into a file that outlives the + // session — the #231 pathology (models fill the fields they are shown) + // one layer up. Built per invocation, so this read is live. + isWorktreeIsolationEnabled() + ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>` + : "" + } +--- + + +\`\`\` + +Guidelines for choosing settings: +- For read-only tasks (review, analysis): tools: read, bash, grep, find, ls +- For code modification tasks: include edit, write +- Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top +- Use prompt_mode: replace for fully custom agents with their own personality/instructions +- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation +- Set isolated: true if the agent should NOT have access to MCP servers or other extensions +- Set output_transcript: false to skip writing this agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal +- Only include frontmatter fields that differ from defaults — omit fields where the default is fine + +Write the file using the write tool. Only write the file, nothing else.`; + + const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, { + description: `Generate ${name} agent`, + maxTurns: 5, + }); + + if (record.status === "error") { + ctx.ui.notify(`Generation failed: ${record.error}`, "warning"); + return; + } + + reloadCustomAgents(); + + if (existsSync(targetPath)) { + ctx.ui.notify(`Created ${targetPath}`, "info"); + } else { + ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning"); + } + } + + async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) { + // 1. Name + const name = await ctx.ui.input("Agent name (filename, no spaces)"); + if (!name) return; + + // 2. Description + const description = await ctx.ui.input("Description (one line)"); + if (!description) return; + + // 3. Tools + const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]); + if (!toolChoice) return; + + let tools: string; + if (toolChoice === "all") { + tools = BUILTIN_TOOL_NAMES.join(", "); + } else if (toolChoice === "none") { + tools = "none"; + } else if (toolChoice.startsWith("read-only")) { + tools = "read, bash, grep, find, ls"; + } else { + const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", ")); + if (!customTools) return; + tools = customTools; + } + + // 4. Model + const modelChoice = await ctx.ui.select("Model", [ + "inherit (parent model)", + "haiku", + "sonnet", + "opus", + "custom...", + ]); + if (!modelChoice) return; + + let model: string | undefined; + if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5"; + else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6"; + else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6"; + else if (modelChoice === "custom...") { + model = (await ctx.ui.input("Model (provider/modelId)")) || undefined; + } + + // 5. Thinking + // "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi. + const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]); + if (!thinkingChoice) return; + + // 6. System prompt + const systemPrompt = await ctx.ui.editor("System prompt", ""); + if (systemPrompt === undefined) return; + + const content = buildNewAgentFile({ + description, + tools, + model, + thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice, + systemPrompt, + }); + + mkdirSync(targetDir, { recursive: true }); + const targetPath = join(targetDir, `${name}.md`); + + if (existsSync(targetPath)) { + const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); + if (!overwrite) return; + } + + const { writeFileSync } = await import("node:fs"); + writeFileSync(targetPath, content, "utf-8"); + reloadCustomAgents(); + ctx.ui.notify(`Created ${targetPath}`, "info"); + } + + /** + * Every settings mutation writes this WHOLE object back to disk, so a field + * missing here is erased from the user's subagents.json the next time they + * toggle something unrelated. `SubagentsSettings` has every field optional, + * so a `: SubagentsSettings` return annotation would let a newly-added setting + * be forgotten here and still type-check. `satisfies` instead: it still checks + * each value's type and rejects a mistyped key, but leaves the return type + * inferred so `_NoMissingSettingsKeys` below can check completeness. + */ + function snapshotSettings() { + return { + maxConcurrent: manager.getMaxConcurrent(), + // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and + // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined). + defaultMaxTurns: getDefaultMaxTurns() ?? 0, + graceTurns: getGraceTurns(), + defaultJoinMode: getDefaultJoinMode(), + backgroundByDefault: getBackgroundByDefault(), + schedulingEnabled: isSchedulingEnabled(), + scopeModels: isScopeModelsEnabled(), + strictAgentFiles, + disableDefaultAgents: isDefaultsDisabled(), + toolDescriptionMode: getToolDescriptionMode(), + fleetView: isFleetViewEnabled(), + agentMentions: getAgentMentionMode(), + rememberAgents: getRememberAgents(), + widgetMode: getWidgetMode(), + outputTranscript: getOutputTranscriptDefault(), + worktreeIsolation: isWorktreeIsolationEnabled(), + maxSubagentDepth: getMaxSubagentDepth(), + // Deliberately NOT `?? "general-purpose"`: every settings change writes the + // whole snapshot, and materializing the implicit default would turn it into + // explicit configuration — which then fails loudly if general-purpose later + // goes away. undefined is dropped by JSON.stringify. + fallbackSubagent: getFallbackSubagent(), + reportUsage: isReportUsageEnabled(), + showCost: isShowCostEnabled(), + } satisfies SubagentsSettings; + } + + // Compile-time completeness guard for snapshotSettings(). If a field is added + // to SubagentsSettings and not mirrored above, this Exclude is non-empty and + // fails to satisfy `never` — turning a silent settings-erasure bug into a + // typecheck error. `npm run typecheck` runs in CI. + type _NoMissingSettingsKeys = + Exclude> extends never + ? true + : ["snapshotSettings() is missing a SubagentsSettings key"]; + const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true; + void _settingsSnapshotIsComplete; + + const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "maxSubagentDepth"]); + + async function showSettings(ctx: ExtensionCommandContext) { + function buildItems(): SettingItem[] { + const mc = manager.getMaxConcurrent(); + const dmt = getDefaultMaxTurns() ?? 0; + const gt = getGraceTurns(); + const msd = getMaxSubagentDepth(); + // Label what unset actually does — it targets general-purpose even when + // that is unregistered (the permissive hardcoded tier), so showing "none" + // there would advertise strict dispatch for the most permissive state. + // `values` still offers only resolvable targets, so the user cannot + // persist a fallback that would hard-error on every dispatch. + const fallbackValue = getFallbackSubagent() ?? "general-purpose"; + const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])]; + + return [ + { + id: "maxConcurrent", + label: "Max concurrency", + description: "Max concurrent background agents (Enter to type)", + currentValue: String(mc), + values: [String(mc)], + }, + { + id: "defaultMaxTurns", + label: "Default max turns", + description: "Default max turns before wrap-up (0 = unlimited, Enter to type)", + currentValue: String(dmt), + values: [String(dmt)], + }, + { + id: "graceTurns", + label: "Grace turns", + description: "Grace turns after wrap-up steer (Enter to type)", + currentValue: String(gt), + values: [String(gt)], + }, + { + id: "maxSubagentDepth", + label: "Nested depth", + description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)", + currentValue: String(msd), + values: [String(msd)], + }, + { + id: "joinMode", + label: "Join mode", + description: "Default join mode for background agents", + currentValue: getDefaultJoinMode(), + values: ["smart", "async", "group"], + }, + { + id: "backgroundByDefault", + label: "Background by default", + description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)", + currentValue: getBackgroundByDefault() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "schedulingEnabled", + label: "Scheduling", + description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)", + currentValue: isSchedulingEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "scopeModels", + label: "Scope models", + description: "Validate subagent models against scoped models (/scoped-models)", + currentValue: isScopeModelsEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "strictAgentFiles", + label: "Strict agent files", + description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning", + currentValue: strictAgentFiles ? "on" : "off", + values: ["on", "off"], + }, + { + id: "disableDefaultAgents", + label: "Disable defaults", + description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected", + currentValue: isDefaultsDisabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "fallbackSubagent", + label: "Fallback agent", + description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`, + currentValue: fallbackValue, + values: fallbackValues, + }, + { + id: "outputTranscript", + label: "Output transcript", + description: "Write each subagent's .output transcript by default. A custom agent's output_transcript frontmatter overrides this.", + currentValue: getOutputTranscriptDefault() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "worktreeIsolation", + label: "Worktree isolation", + description: + "Allow isolation: worktree to copy the repo. Off refuses worktrees on every path immediately — for repos where a copy costs too much time or disk — and drops the `isolation` param from the Agent tool spec on next pi session.", + currentValue: isWorktreeIsolationEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "reportUsage", + label: "Report usage to session", + description: + "Add subagent tokens and cost to this session's own totals, so pi's footer and /cost stop reading a delegating session as nearly free. Reported on the next tool result (agents that finish in the background are counted on the one after). Context-window % is unaffected.", + currentValue: isReportUsageEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "showCost", + label: "Show cost", + description: + "Show an estimated `~$0.0042` beside subagent token counts in the widget, fleet view, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.", + currentValue: isShowCostEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "fleetView", + label: "Fleet view", + description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)", + currentValue: isFleetViewEnabled() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "agentMentions", + label: "Agent mentions", + description: "Route `@handle message` at the prompt to that agent. model = an off-screen clone of this conversation calls the Agent tool, so the agent gets a context-written prompt, a transcript and per-tool detail, and the chat stays clean; direct = started here from your text, no model call. Messaging and resuming are direct either way.", + currentValue: getAgentMentionMode(), + values: ["model", "direct", "off"], + }, + { + id: "rememberAgents", + label: "Remember agents", + description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)", + currentValue: getRememberAgents() ? "on" : "off", + values: ["on", "off"], + }, + { + id: "widgetMode", + label: "Widget", + description: "Above-editor agent widget: all = every agent; background = hide foreground (they already render inline); off = hide the widget.", + currentValue: getWidgetMode(), + values: ["all", "background", "off"], + }, + { + id: "toolDescriptionMode", + label: "Tool description", + description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})", + currentValue: getToolDescriptionMode(), + values: ["full", "compact", "custom"], + }, + ]; + } + + function applyValue(id: string, value: string) { + if (id === "maxConcurrent") { + const n = parseInt(value, 10); + if (n >= 1) { + manager.setMaxConcurrent(n); + notifyApplied(ctx, `Max concurrency set to ${n}`); + } + } else if (id === "defaultMaxTurns") { + const n = parseInt(value, 10); + if (n === 0) { + setDefaultMaxTurns(undefined); + notifyApplied(ctx, "Default max turns set to unlimited"); + } else if (n >= 1) { + setDefaultMaxTurns(n); + notifyApplied(ctx, `Default max turns set to ${n}`); + } + } else if (id === "graceTurns") { + const n = parseInt(value, 10); + if (n >= 1) { + setGraceTurns(n); + notifyApplied(ctx, `Grace turns set to ${n}`); + } + } else if (id === "maxSubagentDepth") { + const n = parseInt(value, 10); + if (n >= 0) { + setMaxSubagentDepth(n); + notifyApplied( + ctx, + n <= 1 + ? "Nested delegation disabled" + : `Nested depth set to ${n}. Applies to agents started from now on.`, + ); + } + } else if (id === "joinMode") { + setDefaultJoinMode(value as JoinMode); + notifyApplied(ctx, `Default join mode set to ${value}`); + } else if (id === "backgroundByDefault") { + const enabled = value === "on"; + setBackgroundByDefault(enabled); + notifyApplied( + ctx, + enabled + ? "Agent calls run in the background unless they pass run_in_background: false" + : "Agent calls block and return inline unless they pass run_in_background: true", + ); + } else if (id === "schedulingEnabled") { + const enabled = value === "on"; + if (enabled === isSchedulingEnabled()) { + ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info"); + } else { + setSchedulingEnabled(enabled); + if (!enabled) scheduler.stop(); // immediate kill — outstanding fires stop ticking + notifyApplied( + ctx, + `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`, + ); + } + } else if (id === "scopeModels") { + const enabled = value === "on"; + setScopeModelsEnabled(enabled); + notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`); + } else if (id === "strictAgentFiles") { + const enabled = value === "on"; + strictAgentFiles = enabled; + notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`); + } else if (id === "disableDefaultAgents") { + const enabled = value === "on"; + setDisableDefaultAgents(enabled); + notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`); + } else if (id === "fallbackSubagent") { + setFallbackSubagent(value); + notifyApplied( + ctx, + value === NO_FALLBACK + ? "Unknown or disabled agent types will now be rejected" + : `Unknown agent types will fall back to ${value}`, + ); + } else if (id === "outputTranscript") { + const enabled = value === "on"; + setOutputTranscriptDefault(enabled); + notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`); + } else if (id === "worktreeIsolation") { + const enabled = value === "on"; + setWorktreeIsolationEnabled(enabled); + // The refusal is live, but the tool schema is built at registration, so + // the isolation parameter only appears/disappears next session. + notifyApplied( + ctx, + `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`, + ); + } else if (id === "toolDescriptionMode") { + setToolDescriptionMode(value as ToolDescriptionMode); + notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`); + } else if (id === "reportUsage") { + const enabled = value === "on"; + setReportUsage(enabled); + notifyApplied( + ctx, + enabled + ? "Subagent usage now counted in this session's totals" + : "Subagent usage no longer counted in this session's totals", + ); + } else if (id === "showCost") { + const enabled = value === "on"; + setShowCost(enabled); + notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`); + } else if (id === "fleetView") { + const enabled = value === "on"; + setFleetViewEnabled(enabled); + notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`); + } else if (id === "agentMentions") { + const mode = value as AgentMentionMode; + setAgentMentionMode(mode); + notifyApplied( + ctx, + mode === "off" + ? "Agent mentions disabled" + : mode === "model" + ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen" + : "Agent mentions on — a mentioned agent starts here, with no model call", + ); + } else if (id === "rememberAgents") { + const enabled = value === "on"; + setRememberAgents(enabled); + notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`); + } else if (id === "widgetMode") { + setWidgetMode(value as WidgetMode); + notifyApplied(ctx, `Widget set to ${value}`); + } + } + + let list: SettingsList; + // Track current selection index directly (SettingsList doesn't expose it). + // Updated on arrow keys so Enter knows which field is selected immediately. + let currentIndex = 0; + + const result = await ctx.ui.custom((_tui, _theme, _kb, done) => { + const items = buildItems(); + + list = new SettingsList( + items, + items.length + 2, + getSettingsListTheme(), + (id, newValue) => { + applyValue(id, newValue); + }, + () => done(undefined as undefined), + ); + + const container = new Container(); + container.addChild(new Text("⚙ Subagent Settings", 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(list); + + return { + render: (w: number) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data: string) => { + // Track navigation so Enter knows the current field + if (matchesKey(data, "up")) { + currentIndex = Math.max(0, currentIndex - 1); + } else if (matchesKey(data, "down")) { + currentIndex = Math.min(items.length - 1, currentIndex + 1); + } + + // Enter on numeric field → close and prompt for typed input + if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) { + done(items[currentIndex].id); + return; + } + list.handleInput?.(data); + }, + }; + }); + + // If a numeric field ID was returned, prompt for typed input + if (result && NUMERIC_IDS.has(result)) { + const current = result === "maxConcurrent" + ? String(manager.getMaxConcurrent()) + : result === "defaultMaxTurns" + ? String(getDefaultMaxTurns() ?? 0) + : result === "maxSubagentDepth" + ? String(getMaxSubagentDepth()) + : String(getGraceTurns()); + + const label = result === "maxConcurrent" + ? "Max concurrency (1+)" + : result === "defaultMaxTurns" + ? "Default max turns (0 = unlimited)" + : result === "maxSubagentDepth" + ? "Nested depth (0/1 = nesting off)" + : "Grace turns (1+)"; + + // Loop until user enters a valid integer or cancels (Esc / null). + // Silently trims whitespace; rejects non-numeric input by re-prompting. + let input: string | undefined = await ctx.ui.input(label, current); + while (input != null) { + const trimmed = input.trim(); + const n = Number(trimmed); + if (trimmed !== "" && Number.isInteger(n)) { + applyValue(result, String(n)); + await showSettings(ctx); + return; + } + // Invalid — re-prompt with the user's last entry so they can edit it + input = await ctx.ui.input(label, trimmed); + } + } + } + + // Persist the current snapshot, emit `subagents:settings_changed`, and surface + // the right toast. Successful saves show info; persistence failures downgrade + // to warning so users aren't silently reverted on restart. Event fires regardless + // of outcome so listeners see the in-memory change. + function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) { + const { message, level } = saveAndEmitChanged( + snapshotSettings(), + successMsg, + (event, payload) => pi.events.emit(event, payload), + ); + ctx.ui.notify(message, level); + } + + pi.registerCommand("agents", { + description: "Manage agents", + handler: async (_args, ctx) => { await showAgentsMenu(ctx); }, + }); +} diff --git a/pi-subagents/src/invocation-config.ts b/pi-subagents/src/invocation-config.ts new file mode 100644 index 0000000..7b9d84d --- /dev/null +++ b/pi-subagents/src/invocation-config.ts @@ -0,0 +1,130 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js"; + +/** + * The model-facing `isolation` parameter, shared by the `Agent` tool and the + * nested delegation tool so the two cannot drift. + * + * Shape matters more than wording here. As a single-value optional literal, + * models that fill every optional parameter — the transcript on #231 shows one + * emitting `resume: ""`, `schedule: ""` and `model: "default"` alongside it — + * had only `"worktree"` available to fill it with, and kept spawning worktrees + * across three turns while their own reasoning said to omit the field. Every + * other optional parameter has an inert filler; this one did not. `"off"` is + * listed first and described as the default so the harmless value is the + * obvious one to reach for. + * + * The wording tracks Claude Code's own `isolation` parameter, whose phrasing + * models have the most exposure to: one description on the union rather than + * per-value ones, opening "Isolation mode.", then a sentence per value in + * schema order, each with its caveats in a trailing parenthetical. Two clauses + * are ours, because our shape is not theirs — `"off"` has no counterpart there + * (their enum is `worktree | remote`, so both of their values do something), + * and neither does the uncommitted-work warning, which is the specific trap + * #231 fell into. Deliberately absent is any "only use a worktree when…" + * restriction: Claude Code's `Agent` tool states the capability and stops, and + * a second legal value is what lets a model decline one, not being told to. + */ +const isolationParamShape = { + isolation: Type.Optional( + Type.Union([Type.Literal("off"), Type.Literal("worktree")], { + description: + 'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).', + }), + ), +}; + +/** + * Build the `isolation` parameter for a tool schema, or nothing when the + * project disabled worktrees (`worktreeIsolation: false`). + * + * Dropping the field beats accepting it and quietly downgrading. The setting is + * for a project whose model passes `"worktree"` on *every* call, so a + * per-result "isolation was disabled" note would be noise on every result and + * would keep raising the salience of a capability that isn't there. With no + * field there is nothing to pass, nothing to drop, and nothing to explain — the + * same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context + * cost. The resolver gate and the `agent-manager` check still cover the paths a + * schema can't reach: agent files, the scheduler, and cross-extension RPC. + * + * Like `scheduleParam`, this is read once at tool registration — flipping the + * setting needs a new pi session for the schema to change. + */ +export function isolationParam(enabled: boolean): Partial { + return enabled ? isolationParamShape : {}; +} + +interface AgentInvocationParams { + model?: string; + thinking?: string; + max_turns?: number; + run_in_background?: boolean; + inherit_context?: boolean; + isolated?: boolean; + /** + * Untyped on purpose. Both tool schemas now build this field conditionally + * and spread it, which erases TypeBox's literal inference to `unknown` (the + * `schedule` param has the same shape). The resolver below narrows by + * comparison rather than trusting the declaration, which also makes it safe + * for the cross-extension RPC path, where options arrive unvalidated. + */ + isolation?: unknown; +} + +interface ResolveOptions { + /** + * Whether worktree isolation is permitted at all. False when the project set + * `worktreeIsolation: false`, which drops a requested worktree rather than + * failing the call: the fail-loud precedent covers spawns that *cannot* work, + * while this one is the user opting out, and throwing would break exactly the + * calls the `"off"` value exists to tolerate. Defaults to allowed. + */ + worktreeAllowed?: boolean; + /** + * What an unqualified spawn means — neither the call nor the agent file said. + * + * Top-level callers pass the `backgroundByDefault` setting (default `true`, + * following Claude Code). Nested callers pass `false` unconditionally: a + * detached child is killed by `abortOwnedChildren` when its parent settles + * and has no notification path of its own, so backgrounding one loses its + * work. Both call sites pass it explicitly; the `false` fallback only covers + * a caller that supplies no options at all, which in-tree means tests. + */ + defaultRunInBackground?: boolean; +} + +export function resolveAgentInvocationConfig( + agentConfig: AgentConfig | undefined, + params: AgentInvocationParams, + opts?: ResolveOptions, +): { + modelInput?: string; + modelFromParams: boolean; + thinking?: ThinkingLevel; + maxTurns?: number; + inheritContext: boolean; + runInBackground: boolean; + isolated: boolean; + isolation?: IsolationMode; +} { + // Precedence first, collapse second — reversing these loses the veto, since + // an agent file's "off" only outranks a caller's "worktree" while it is still + // a value. Everything downstream then sees "worktree" or nothing at all. + const requested = agentConfig?.isolation ?? params.isolation; + const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined; + + return { + modelInput: agentConfig?.model ?? params.model, + modelFromParams: agentConfig?.model == null && params.model != null, + thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined, + maxTurns: agentConfig?.maxTurns ?? params.max_turns, + inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false, + runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? opts?.defaultRunInBackground ?? false, + isolated: agentConfig?.isolated ?? params.isolated ?? false, + isolation, + }; +} + +export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined { + return runInBackground ? defaultJoinMode : undefined; +} diff --git a/pi-subagents/src/mandatory-extensions.ts b/pi-subagents/src/mandatory-extensions.ts new file mode 100644 index 0000000..d54cfd1 --- /dev/null +++ b/pi-subagents/src/mandatory-extensions.ts @@ -0,0 +1,65 @@ +import { realpathSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +export interface MandatoryExtensionPolicy { + /** Exact, bundle-owned extension entry paths that an agent cannot disable. */ + mandatoryExtensionPaths?: readonly string[]; +} + +function canonicalExistingPath(path: string): string { + if (!isAbsolute(path)) { + throw new Error(`Mandatory extension path must be absolute: ${path}`); + } + let canonical: string; + try { + canonical = realpathSync.native(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Mandatory extension path is unavailable: ${path} (${message})`); + } + const stat = statSync(canonical); + if (!stat.isFile() && !stat.isDirectory()) { + throw new Error(`Mandatory extension path is not a file or directory: ${path}`); + } + return canonical; +} + +/** Validate, canonicalize, and deduplicate bundle-owned extension entry paths. */ +export function normalizeMandatoryExtensionPaths(paths: readonly string[] | undefined): readonly string[] { + if (!paths?.length) return []; + return Object.freeze([...new Set(paths.map(canonicalExistingPath))]); +} + +/** + * Compare loader-reported paths to a canonical mandatory entry. Loader paths + * normally exist, but resolve-only fallback keeps diagnostics deterministic if + * an extension disappears between reload and verification. + */ +export function extensionPathMatches(actualPath: string, canonicalExpectedPath: string): boolean { + let actual: string; + try { + actual = realpathSync.native(actualPath); + } catch { + actual = resolve(actualPath); + } + return actual === canonicalExpectedPath; +} + +export function isMandatoryExtensionPath( + extensionPath: string, + mandatoryExtensionPaths: readonly string[], +): boolean { + return mandatoryExtensionPaths.some((path) => extensionPathMatches(extensionPath, path)); +} + +/** Mandatory infrastructure is useful only if the exact trusted entry survived reload. */ +export function assertMandatoryExtensionsLoaded( + loadedExtensionPaths: readonly string[], + mandatoryExtensionPaths: readonly string[], +): void { + for (const mandatoryPath of mandatoryExtensionPaths) { + if (!loadedExtensionPaths.some((loadedPath) => extensionPathMatches(loadedPath, mandatoryPath))) { + throw new Error(`Mandatory extension failed to load: ${mandatoryPath}`); + } + } +} diff --git a/pi-subagents/src/memory.ts b/pi-subagents/src/memory.ts new file mode 100644 index 0000000..833c8a2 --- /dev/null +++ b/pi-subagents/src/memory.ts @@ -0,0 +1,179 @@ +/** + * memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions. + * + * Memory scopes: + * - "user" → getAgentDir()/agent-memory/{agent-name}/ (default ~/.pi/agent/agent-memory/, honors $PI_CODING_AGENT_DIR) + * - "project" → .pi/agent-memory/{agent-name}/ + * - "local" → .pi/agent-memory-local/{agent-name}/ + * + * The user scope previously hardcoded ~/.pi/agent-memory/. That legacy location + * is still honored (read + write) when it exists and the new location doesn't, + * so existing memories aren't orphaned. + */ + +import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { MemoryScope } from "./types.js"; + +/** Maximum lines to read from MEMORY.md */ +const MAX_MEMORY_LINES = 200; + +/** + * Returns true if a name contains characters not allowed in agent/skill names. + * Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot). + */ +export function isUnsafeName(name: string): boolean { + if (!name || name.length > 128) return true; + return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); +} + +/** + * Returns true if the given path is a symlink (defense against symlink attacks). + */ +export function isSymlink(filePath: string): boolean { + try { + return lstatSync(filePath).isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Safely read a file, rejecting symlinks. + * Returns undefined if the file doesn't exist, is a symlink, or can't be read. + */ +export function safeReadFile(filePath: string): string | undefined { + if (!existsSync(filePath)) return undefined; + if (isSymlink(filePath)) return undefined; + try { + return readFileSync(filePath, "utf-8"); + } catch { + return undefined; + } +} + +/** + * Resolve the memory directory path for a given agent + scope + cwd. + * Throws if agentName contains path traversal characters. + */ +export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string { + if (isUnsafeName(agentName)) { + throw new Error(`Unsafe agent name for memory directory: "${agentName}"`); + } + switch (scope) { + case "user": { + const current = join(getAgentDir(), "agent-memory", agentName); + // Legacy location from when this path was hardcoded. Keep using it if it + // already holds this agent's memory and the new location hasn't been + // created yet — otherwise existing memories would be silently orphaned. + const legacy = join(homedir(), ".pi", "agent-memory", agentName); + if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) { + return legacy; + } + return current; + } + case "project": + return join(cwd, ".pi", "agent-memory", agentName); + case "local": + return join(cwd, ".pi", "agent-memory-local", agentName); + } +} + +/** + * Ensure the memory directory exists, creating it if needed. + * Refuses to create directories if any component in the path is a symlink + * to prevent symlink-based directory traversal attacks. + */ +export function ensureMemoryDir(memoryDir: string): void { + // If the directory already exists, verify it's not a symlink + if (existsSync(memoryDir)) { + if (isSymlink(memoryDir)) { + throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`); + } + return; + } + mkdirSync(memoryDir, { recursive: true }); +} + +/** + * Read the first N lines of MEMORY.md from the memory directory, if it exists. + * Returns undefined if no MEMORY.md exists or if the path is a symlink. + */ +export function readMemoryIndex(memoryDir: string): string | undefined { + // Reject symlinked memory directories + if (isSymlink(memoryDir)) return undefined; + + const memoryFile = join(memoryDir, "MEMORY.md"); + const content = safeReadFile(memoryFile); + if (content === undefined) return undefined; + + const lines = content.split("\n"); + if (lines.length > MAX_MEMORY_LINES) { + return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)"; + } + return content; +} + +/** + * Build the memory block to inject into the agent's system prompt. + * Also ensures the memory directory exists (creates it if needed). + */ +export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string { + const memoryDir = resolveMemoryDir(agentName, scope, cwd); + // Create the memory directory so the agent can immediately write to it + ensureMemoryDir(memoryDir); + + const existingMemory = readMemoryIndex(memoryDir); + + const header = `# Agent Memory + +You have a persistent memory directory at: ${memoryDir}/ +Memory scope: ${scope} + +This memory persists across sessions. Use it to build up knowledge over time.`; + + const memoryContent = existingMemory + ? `\n\n## Current MEMORY.md\n${existingMemory}` + : `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`; + + const instructions = ` + +## Memory Instructions +- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated. +- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md. +- Each memory file should use this frontmatter format: + \`\`\`markdown + --- + name: + description: + type: + --- + + \`\`\` +- Update or remove memories that become outdated. Check for existing memories before creating duplicates. +- You have Read, Write, and Edit tools available for managing memory files.`; + + return header + memoryContent + instructions; +} + +/** + * Build a read-only memory block for agents that lack write/edit tools. + * Does NOT create the memory directory — agents can only consume existing memory. + */ +export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string { + const memoryDir = resolveMemoryDir(agentName, scope, cwd); + const existingMemory = readMemoryIndex(memoryDir); + + const header = `# Agent Memory (read-only) + +Memory scope: ${scope} +You have read-only access to memory. You can reference existing memories but cannot create or modify them.`; + + const memoryContent = existingMemory + ? `\n\n## Current MEMORY.md\n${existingMemory}` + : `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`; + + return header + memoryContent; +} diff --git a/pi-subagents/src/mention-clone.ts b/pi-subagents/src/mention-clone.ts new file mode 100644 index 0000000..9ef7ed2 --- /dev/null +++ b/pi-subagents/src/mention-clone.ts @@ -0,0 +1,211 @@ +/** + * mention-clone.ts — start a mentioned agent through a clone of this + * conversation, without putting anything in the chat. + * + * Claude Code routes `@agent-` through the main model: the mention + * becomes a `` appended to the prompt and the model makes the + * tool call (see `agentMentionReminder`). That buys the spawned agent a prompt + * written with conversation context, and costs a visible turn — the model's + * reasoning and its tool block land in the transcript, for a decision the user + * already made when they typed the handle. + * + * So the turn happens somewhere else. The conversation is cloned into a + * throwaway in-memory session — same messages, same system prompt, same model — + * and that copy takes the turn off-screen. A literal clone: the session's own + * entries, projected by pi's own `sessionEntryToContextMessages`, not + * `inherit_context`'s text rendering of them. + * + * Cloned from memory rather than from the session file, which cannot be relied + * on: `SessionManager._persist` withholds every write until the first assistant + * message lands, so a fork taken before then reads an empty file and throws. + * `buildSessionContext()` has no such timing, and is compaction-aware — it walks + * the leaf path and substitutes the summary for entries folded into it, so a + * long conversation clones as what the main model is actually working from. A + * conversation with nothing in it yet clones to nothing in it yet, which is the + * correct answer rather than a failure. + * + * It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on + * ReadonlySessionManager and the `sessionEntryToContextMessages` export both + * arrived in 0.80.5 — where this one has been exported unchanged from before + * the declared peer floor, and is the same code path (`byId` is only an index + * cache, so passing it or not cannot change the result). Keeping the floor + * honest costs nothing here: see the `compat-floor-pi` job. + * + * Its `thinkingLevel` is NOT used, and is the one place the newer API would be + * better. `getSessionContextSettings` starts at "off" and moves only on an + * explicit `thinking_level_change` entry, so a session where nobody ran + * `/think` reports "off" rather than the level it is really using. Omitting the + * field instead lets `createAgentSession` resolve it from settings, which is + * that real level. + * + * Three details make the spawn belong to the real session rather than the + * clone: + * + * - the clone is handed the *registered* `Agent` tool, whose handler closes + * over the main activation, so it spawns top-level: widget, fleet row, + * handle, completion notification, all as if the main model had called it; + * - that tool is re-bound to the main `ExtensionContext`, because the handler + * reads `cwd`, `model` and `sessionManager.getSessionId()` off it to place + * the transcript and the `rootSessionId`. The clone's own context would + * file both under the throwaway fork; + * - it is called with no tool-call id. The clone's turn produces one, but the + * real session never issued it, and a `` pointing at nothing + * is exactly the bug the mention-resume path had to fix; + * - and it is forced into the background. A foreground agent returns its + * answer as the tool result and is marked `resultConsumed` so no completion + * notification is sent — correct when the caller is the real conversation, + * silent loss when the caller is a fork about to be discarded. Background + * delivery is the only route from a mention back to the main model. + * + * The clone gets one tool and one job. It cannot read, write or run anything — + * an invisible turn with the full toolset could do invisible work. + */ + +import type { Model } from "@earendil-works/pi-ai"; +import { + buildSessionContext, + createAgentSession, + DefaultResourceLoader, + type ExtensionContext, + getAgentDir, + SessionManager, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { runInChildSessionContext } from "./child-context.js"; +import { agentMentionReminder } from "./mention.js"; +import type { SubagentType, ThinkingLevel } from "./types.js"; + +export interface MentionCloneOptions { + /** The MAIN session's context — what the spawn is attributed to, and the + * source of both the conversation and the live system prompt. */ + ctx: ExtensionContext; + /** Agent type the handle resolved to. */ + type: SubagentType; + /** What the user typed after the handle. */ + message: string; + /** The registered `Agent` tool, reused so the spawn is an ordinary one. */ + agentTool: ToolDefinition; +} + +export interface MentionCloneResult { + /** True once the clone actually called `Agent`. */ + spawned: boolean; + /** Why not, when it didn't. Absent on success. */ + error?: string; +} + +/** + * Fork the conversation, let the copy make the tool call, throw the copy away. + * Never rejects: a clone that cannot run is reported so the caller can fall + * back to starting the agent directly. + */ +export async function runMentionClone(opts: MentionCloneOptions): Promise { + const { ctx, type, message, agentTool } = opts; + + let spawned = false; + const cloneAgentTool: ToolDefinition = { + ...agentTool, + execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => { + // One spawn per mention. The clone has a single tool and every reason to + // stop after using it, but a model that decides to "also" launch a second + // agent would do it where nobody can see and nobody asked. + if (spawned) { + return Promise.resolve({ + content: [{ type: "text" as const, text: "Already started an agent for this mention. Stop here." }], + details: undefined, + isError: true, + }); + } + spawned = true; + // undefined tool-call id + the main ctx: see the header. Background is + // forced rather than left to the clone: `run_in_background` defaults to + // false, and a foreground agent answers through its TOOL RESULT — which + // here is delivered into a session that is disposed moments later, so the + // agent would run, appear in the widget and the fleet, and reach nobody. + return agentTool.execute( + undefined as never, + { ...(params as Record), run_in_background: true } as typeof params, + signal, + onUpdate, + ctx, + ); + }, + }; + + let session: Awaited>["session"] | undefined; + try { + // Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime; + // agent-runner.ts carries the same shim for the same reason — pass both so + // the clone keeps the parent's providers across the supported range. + const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime; + // The conversation as the main session resolves it: compaction applied, + // branch summaries substituted. + const conversation = buildSessionContext( + ctx.sessionManager.getEntries(), + ctx.sessionManager.getLeafId(), + ); + // Pi 0.82.0 added this; below it the field is absent and the clone takes + // the settings level instead, which is what a session that never ran + // `/think` is on anyway. Same shim shape as `modelRuntime` below. + const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel; + // This off-screen clone must expose only its synthetic Agent tool. Loading + // host/project extensions would let active-tool owners such as Tool Search + // replace the clone's allowlist during session_start, and would also run + // unrelated extension lifecycle in a session the user never sees. + const resourceLoader = new DefaultResourceLoader({ + cwd: ctx.cwd, + agentDir: getAgentDir(), + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + }); + const created = await runInChildSessionContext(() => + createAgentSession({ + cwd: ctx.cwd, + // Nothing about the copy is worth persisting, and an in-memory manager + // is also what keeps the real session untouched. + sessionManager: SessionManager.inMemory(ctx.cwd), + resourceLoader, + model: ctx.model as Model | undefined, + ...(thinkingLevel && { thinkingLevel }), + modelRegistry: ctx.modelRegistry, + ...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }), + // An allowlist naming exactly the clone's own tool. NOT `noTools: + // "all"`, whose doc comment ("start with no tools enabled") reads like + // it spares custom tools and does not: it resolves to an EMPTY + // allowlist, and `isAllowedTool` then drops every tool from the + // registry — the custom one included. The clone would be prompted with + // nothing to call, answer in prose, and every mention would fall + // through to the direct start with a warning. Same idiom as + // agent-runner's `tools: sessionTools` beside its nested `customTools`. + tools: [cloneAgentTool.name], + customTools: [cloneAgentTool], + } as Parameters[0]), + ); + session = created.session; + + // The clone rebuilds a system prompt from cwd and agentDir, which is close + // but not the live one — extensions contribute to it per turn. Copy the + // real thing, so the copy reasons under the instructions the user's model + // is actually working under. + const systemPrompt = ctx.getSystemPrompt?.(); + if (systemPrompt) session.agent.state.systemPrompt = systemPrompt; + + // The conversation itself. Pushed rather than assigned so the array the + // session was built around stays the one it goes on using. + session.agent.state.messages.push(...conversation.messages); + + // User text first, reminder after — the order Claude Code's attachment + // renderer produces, where the reminder trails the message it is about. + await session.prompt(`${message}\n\n${agentMentionReminder(type)}`); + } catch (err) { + return { spawned, error: err instanceof Error ? err.message : String(err) }; + } finally { + session?.dispose?.(); + } + + return spawned + ? { spawned: true } + : { spawned: false, error: "the conversation clone did not start it" }; +} diff --git a/pi-subagents/src/mention.ts b/pi-subagents/src/mention.ts new file mode 100644 index 0000000..903dea8 --- /dev/null +++ b/pi-subagents/src/mention.ts @@ -0,0 +1,141 @@ +/** + * mention.ts — the `@handle` grammar for messaging a subagent from the prompt. + * + * Claude Code lets you type `@code-review take another look` at the prompt and + * routes the message to that agent instead of the main model. Its grammar is + * reproduced here so the two behave identically: + * + * - suggestions fire on `@` at the start of the input or after whitespace, + * followed by `[\w-]*` (so `@src/foo.ts` is a file, never an agent); + * - a send is recognized only at the START of the input, and only with a + * non-empty message after the handle. That is why a bare `@code-review` + * goes to the main model rather than anywhere near the agent. + * + * A record's own identity is a UUID plus a deliberately non-unique description, + * neither of which is typeable, so the handle is derived from the agent type. + * Colliding handles are numbered (`explore`, `explore-2`), which is also what + * Claude Code's `allocateName` does — it recycles a name only once the task + * behind it is gone. Its SendMessage prompt describes the *registry* as + * latest-wins, which is a different thing and not how names are allocated. + */ + +/** + * Suggestion trigger: `@` at a token boundary plus the partial handle typed so + * far. Ported from Claude Code, including the CJK sentence-ending punctuation + * it accepts as a boundary. + */ +export const MENTION_TRIGGER = /(^|[\s。、?!])@([\w-]*)$/; + +/** Send grammar: leading `@handle`, then a non-empty message. */ +const MENTION_SEND = /^@([\w-]+)\s+([\s\S]+)$/; + +/** + * Upper bound on a handle, matching Claude Code's `dSS`. Nothing here generates + * a name this long, but an agent type or a model-supplied name can be arbitrary + * text, and an unbounded handle would wrap the suggestion popup. + */ +const MAX_HANDLE_LENGTH = 64; + +/** + * Handles that address something other than a subagent, and so can never be + * allocated to one. Claude Code reserves exactly this name (`Vq = "main"`), + * refusing it at spawn and routing it to the main conversation instead. + */ +const RESERVED_HANDLES: ReadonlySet = new Set(["main"]); + +/** Whether `@handle` names the main conversation rather than any subagent. */ +export function isReservedHandle(handle: string): boolean { + return RESERVED_HANDLES.has(handle.toLowerCase()); +} + +/** Slug of an agent type or name, restricted to the `[\w-]` the grammar allows. */ +export function handleBase(type: string): string { + const slug = type.toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_HANDLE_LENGTH) + // The slice can land mid-run and leave the trailing hyphen back. + .replace(/-+$/, ""); + return slug || "agent"; +} + +/** + * `base`, else `base-2`, `base-3`, … — the first form that is neither `taken` + * nor reserved. Callers pass one shared `taken` set covering type-derived + * handles and model-supplied aliases alike, so the two can never collide. + */ +export function assignHandle(base: string, taken: ReadonlySet): string { + let candidate = base; + let n = 1; + while (taken.has(candidate) || RESERVED_HANDLES.has(candidate)) { + n++; + candidate = `${base}-${n}`; + } + return candidate; +} + +/** + * Map a typed handle back to a registered agent type, so `@explore fix it` + * reaches the Explore agent even when no instance has ever run. `handleBase` is + * the single source of truth in both directions, so a type is addressable by + * exactly the handle its instances would be given. + */ +export function resolveHandleToType(handle: string, types: readonly string[]): string | undefined { + const wanted = handle.toLowerCase(); + // A type slugging to a reserved name is unaddressable rather than shadowing + // it — `assignHandle` refuses that name too, so its instances never hold one. + if (RESERVED_HANDLES.has(wanted)) return undefined; + return types.find(type => handleBase(type) === wanted); +} + +/** + * Claude Code documents `@agent-` as the form you type by hand when the + * picker isn't involved. Accepted here as an exact synonym: the caller tries the + * handle as written first, so an agent genuinely called `agent-foo` still wins + * over `@agent-` + `foo`, and only falls back to this when that finds nothing. + * Returns undefined when the prefix is absent or is the whole handle. + */ +export function stripAgentPrefix(handle: string): string | undefined { + const rest = /^agent-(.+)$/i.exec(handle)?.[1]; + return rest || undefined; +} + +/** + * A spawn needs the short description every agent surface renders. A mention + * carries no separate label, so the message itself becomes one: first line, + * whitespace collapsed, clipped to roughly the 3-5 words the Agent tool asks of + * the model. + */ +export function describeMention(message: string): string { + const oneLine = message.split("\n", 1)[0].replace(/\s+/g, " ").trim(); + return oneLine.length > 40 ? `${oneLine.slice(0, 39).trimEnd()}…` : oneLine; +} + +/** + * What Claude Code sends the main model when a mention names an agent it could + * start. Its `@agent-` mention is not a spawn at all: it becomes an + * `agent_mention` attachment, which renders to a synthetic `isMeta` user + * message placed after the user's own untouched text — no tool forcing, no + * allowed-tools narrowing, and the Task tool is not even named. The model reads + * this and calls the tool itself. + * + * Ported verbatim from the 2.1.233 bundle's attachment renderer, trailing space + * before the closing newline included, so the wording the model was trained + * against is the wording it gets. The one substitution is ours: pi's equivalent + * of Task is the `Agent` tool, and the agent listing that teaches valid + * `subagent_type` values is the tool spec rather than a separate attachment. + */ +export function agentMentionReminder(type: string): string { + return `\nThe user has expressed a desire to invoke the agent "${type}". Please invoke the agent appropriately, passing in the required context to it. \n`; +} + +/** + * Split `@handle message` into its parts, or null when the text isn't a send — + * a bare handle, a leading file path, or a mention that isn't at the start. + */ +export function parseMention(text: string): { handle: string; message: string } | null { + const match = MENTION_SEND.exec(text); + if (!match) return null; + const message = match[2].trim(); + return message ? { handle: match[1], message } : null; +} diff --git a/pi-subagents/src/model-resolver.ts b/pi-subagents/src/model-resolver.ts new file mode 100644 index 0000000..e6ddadc --- /dev/null +++ b/pi-subagents/src/model-resolver.ts @@ -0,0 +1,100 @@ +/** + * Model resolution: exact match ("provider/modelId") with fuzzy fallback. + */ + +export interface ModelEntry { + id: string; + name: string; + provider: string; +} + +export interface ModelRegistry { + find(provider: string, modelId: string): any; + getAll(): any[]; + getAvailable?(): any[]; +} + +/** + * Resolve a model string to a Model instance. + * Tries exact match first ("provider/modelId"), then fuzzy match against all available models. + * Returns the Model on success, or an error message string on failure. + */ +export function resolveModel( + input: string, + registry: ModelRegistry, +): any | string { + // Available models (those with auth configured) + const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[]; + const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase())); + + // 1. Exact match: "provider/modelId" — only if available (has auth) + const slashIdx = input.indexOf("/"); + if (slashIdx !== -1) { + const provider = input.slice(0, slashIdx); + const modelId = input.slice(slashIdx + 1); + if (availableSet.has(input.toLowerCase())) { + const found = registry.find(provider, modelId); + if (found) return found; + } + } + + // 2. Fuzzy match against available models. Normalize separators so cosmetic + // punctuation differences still match — e.g. "claude-haiku-4.5" and + // "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model. + const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-"); + const query = normalize(input); + + // Score each model: prefer exact id match > id contains > name contains > provider+id contains + let bestMatch: ModelEntry | undefined; + let bestScore = 0; + + for (const m of all) { + const id = normalize(m.id); + const name = normalize(m.name); + const full = normalize(`${m.provider}/${m.id}`); + + let score = 0; + if (id === query || full === query) { + score = 100; // exact + } else if (id.includes(query) || full.includes(query)) { + score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches + } else if (name.includes(query)) { + score = 40 + (query.length / name.length) * 20; + } else if ( + // A trailing date-stamp token (e.g. "20251001") is optional, so a + // date-pinned config like "claude-haiku-4-5-20251001" still matches an + // undated registry id like "claude-haiku-4-5". + query + .split(/[\s\-/]+/) + .every(part => /^\d{8}$/.test(part) || id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part)) + ) { + score = 20; // all parts present somewhere + } + + if (score > bestScore) { + bestScore = score; + bestMatch = m; + } + } + + if (bestMatch && bestScore >= 20) { + const found = registry.find(bestMatch.provider, bestMatch.id); + if (found) return found; + } + + // 3. Provider fallback: a "provider/modelId" query that didn't match under the + // named provider (exact or fuzzy above) retries against all providers. The + // named provider is preferred when present; this only kicks in when it isn't, + // so the same model from another provider beats falling back to "inherit". + if (slashIdx !== -1) { + const bare = resolveModel(input.slice(slashIdx + 1), registry); + if (typeof bare !== "string") return bare; + } + + // 4. No match — list available models + const modelList = all + .map(m => ` ${m.provider}/${m.id}`) + .sort() + .join("\n"); + return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`; +} diff --git a/pi-subagents/src/model-scope.ts b/pi-subagents/src/model-scope.ts new file mode 100644 index 0000000..4b24574 --- /dev/null +++ b/pi-subagents/src/model-scope.ts @@ -0,0 +1,70 @@ +/** + * model-scope.ts — `scopeModels` policy, shared by the top-level Agent tool and + * the nested delegation tools so a nested spawn can't escape the allowlist the + * top-level path enforces. + * + * State lives here (rather than in an index.ts closure) for the same reason + * `disableDefaults` lives in agent-types.ts: both entry points need it. + */ + +import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js"; + +/** + * When enabled, subagent model choices are validated against `enabledModels` + * from pi's settings — both global `/settings.json` and project-local + * `/.pi/settings.json` (project overrides global). Off by default; opt-in + * via `/agents → Settings`. See the SubagentsSettings.scopeModels docstring for + * the hard-error vs warn-and-proceed policy and its rationale. + */ +let scopeModelsEnabled = false; + +export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; } +export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; } + +export type ModelScopeVerdict = + /** In scope, or nothing to validate against (feature off / no allowlist). */ + | { kind: "ok" } + /** Caller-supplied out-of-scope choice — refuse the spawn with this message. */ + | { kind: "error"; message: string } + /** Frontmatter-pinned or parent-inherited — proceed, but tell the user. */ + | { kind: "warn"; message: string }; + +/** + * Check the effective resolved model against the user's enabledModels list. + * + * scopeModels guards against *runtime* LLM choices, not user-level config: + * - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit + * out-of-scope choice; surface it so it picks differently). + * - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the + * user authored/installed this agent or chose the parent's model; trust it). + */ +export function checkModelScope(args: { + model: { provider: string; id: string } | undefined; + cwd: string; + modelRegistry: ModelRegistryRef; + /** True when the model came from the tool call rather than frontmatter. */ + callerSupplied: boolean; + /** Display name used in the warning toast. */ + agentLabel: string; + /** The raw `model:` input, when there was one. */ + modelInput?: string; +}): ModelScopeVerdict { + const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args; + if (!scopeModelsEnabled || !model) return { kind: "ok" }; + + const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd); + if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" }; + + if (callerSupplied) { + const list = [...allowed].sort().map(m => ` ${m}`).join("\n"); + return { + kind: "error", + message: `Model not in scope: "${modelInput}".\n\nAllowed models (from enabledModels):\n${list}`, + }; + } + const modelLabel = modelInput ?? `${model.provider}/${model.id}`; + return { + kind: "warn", + message: `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`, + }; +} diff --git a/pi-subagents/src/nested-tools.ts b/pi-subagents/src/nested-tools.ts new file mode 100644 index 0000000..5f003a6 --- /dev/null +++ b/pi-subagents/src/nested-tools.ts @@ -0,0 +1,417 @@ +import type { Model } from "@earendil-works/pi-ai"; +import { + type AgentSession, + defineTool, + type ExtensionAPI, + type ExtensionContext, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { abortable } from "./abortable.js"; +import { + buildAgentRegistry, + getAgentConfigIn, + getAvailableTypesIn, + resolveEnabledTypeIn, + resolveTypeIn, +} from "./agent-types.js"; +import { loadCustomAgents } from "./custom-agents.js"; +import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js"; +import { resolveModel } from "./model-resolver.js"; +import { checkModelScope } from "./model-scope.js"; +import { + createOutputFilePath, + getOutputTranscriptDefault, + streamToOutputFile, + writeInitialEntry, +} from "./output-file.js"; +import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js"; +import type { + AgentConfig, + AgentInvocation, + AgentRecord, + IsolationMode, + ThinkingLevel, +} from "./types.js"; +import { addUsage } from "./usage.js"; +import { isWorktreeIsolationEnabled } from "./worktree.js"; + +/** + * Hard ceiling on nesting for every branch: main session = 0, its subagents = 1, + * their children = 2. `0`/`1` disables nesting entirely. Set from + * `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built, + * so a change applies to sessions started after it. + */ +let maxSubagentDepth = 2; + +export function getMaxSubagentDepth(): number { return maxSubagentDepth; } +export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); } + +const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as const; + +interface NestedSpawnOptions { + description: string; + model?: Model; + maxTurns?: number; + isolated?: boolean; + inheritContext?: boolean; + thinkingLevel?: ThinkingLevel; + isBackground?: boolean; + isolation?: IsolationMode; + invocation?: AgentInvocation; + signal?: AbortSignal; + onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; + onSessionCreated?: (session: AgentSession) => void; + depth: number; + parentAgentId: string; + maxSubagentDepth: number; + configCwd?: string; + rootSessionId?: string; +} + +export interface NestedAgentManager { + spawn( + pi: ExtensionAPI, + ctx: ExtensionContext, + type: string, + prompt: string, + options: NestedSpawnOptions, + ): string; + spawnAndWait( + pi: ExtensionAPI, + ctx: ExtensionContext, + type: string, + prompt: string, + options: Omit, + /** Fires synchronously after spawn, before the session exists — where the transcript is attached. */ + onSpawned?: (id: string) => void, + ): Promise<{ id: string; record: AgentRecord }>; + getRecord(id: string): AgentRecord | undefined; + resume(id: string, prompt: string, signal?: AbortSignal): Promise; +} + +export interface NestedToolContext { + manager: NestedAgentManager; + pi: ExtensionAPI; + parentAgentId: string; + depth: number; + maxSubagentDepth: number; + /** "all" = any enabled agent; string[] = only those types. Never empty. */ + allowedSubagents: "all" | string[]; + /** Root used for agent/config discovery; may differ from the agent's working directory. */ + configCwd: string; +} + +function textResult(text: string, isError = false) { + return { content: [{ type: "text" as const, text }], isError, details: {} }; +} + +function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord { + return record?.parentAgentId === parentAgentId; +} + +/** + * How the caller received this record, which decides the outcome wording. + * + * - "inline": a foreground spawn or a resume. The full output is in this very + * result and no agent id was handed back, so the note must say there is + * nothing left to fetch — otherwise the parent invents an id, calls + * `get_subagent_result`, and hits "not owned by this parent" (#174, the same + * trap the top-level foreground path fell into). + * - "fetched": `get_subagent_result` on a background child. The parent holds a + * valid id and can poll again, so the background wording applies. + */ +type ResultPosition = "inline" | "fetched"; + +function formatRecord(record: AgentRecord, position: ResultPosition): string { + if (record.status === "error") { + return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`; + } + if (record.status === "queued" || record.status === "running") { + return `Agent ${record.id} is ${record.status}.`; + } + // A truncated run must not read as a finished one. The top-level path carries + // this in its result headline; a nested result has no headline, so the note + // leads — appended, it would look like part of the child's own output. + const text = record.result?.trim() || record.error?.trim() || "No output."; + const note = position === "inline" + ? getForegroundOutcomeNote(record.status) + : getStatusNote(record.status); + return note ? `Nested agent${note}.\n\n${text}` : text; +} + +/** Build child-safe orchestration tools scoped to one parent agent instance. */ +export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] { + // Agents resolve from a registry built for THIS branch's config root (under + // worktree isolation, the copy). Never via registerAgents — that is + // process-global state shared with the main session and every other agent. + const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd)); + const allowedTypesIn = (registry: Map): Set | undefined => + context.allowedSubagents === "all" + ? undefined + : new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name)); + const availableIn = (registry: Map): string[] => { + const allowed = allowedTypesIn(registry); + return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name)); + }; + + const agentTool = defineTool({ + name: NESTED_TOOL_NAMES[0], + label: "Agent", + description: + "Launch a child-safe nested subagent for bounded delegated work. " + + "Only use agent types allowed by this parent agent; nesting is depth-limited.", + parameters: Type.Object({ + prompt: Type.String({ description: "Self-contained task for the nested agent." }), + description: Type.String({ description: "Short 3-5 word task description." }), + subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }), + model: Type.Optional(Type.String({ description: "Optional provider/model override." })), + thinking: Type.Optional(Type.String({ description: "Optional thinking level." })), + max_turns: Type.Optional(Type.Number({ minimum: 1 })), + run_in_background: Type.Optional( + Type.Boolean({ + description: "Defaults to false for nested spawns — the call blocks and returns the child's result inline. Set true only for work you will collect later with get_subagent_result; a detached child is stopped when you finish.", + }), + ), + resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })), + isolated: Type.Optional(Type.Boolean()), + inherit_context: Type.Optional(Type.Boolean()), + ...isolationParam(isWorktreeIsolationEnabled()), + }), + execute: async (_toolCallId, params, signal, _onUpdate, ctx) => { + if (params.resume) { + const existing = context.manager.getRecord(params.resume); + if (!ownsRecord(existing, context.parentAgentId)) { + return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true); + } + const resumed = await context.manager.resume(params.resume, params.prompt, signal); + return resumed + ? textResult(formatRecord(resumed, "inline"), resumed.status === "error") + : textResult(`Failed to resume nested agent "${params.resume}".`, true); + } + + if (context.depth >= context.maxSubagentDepth) { + return textResult( + `Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`, + true, + ); + } + + // Reloaded per call so new agent files are picked up without a restart. + const registry = loadRegistry(); + const rawType = params.subagent_type; + // Strict resolve, never the fallback policy: a project-level + // `fallbackSubagent` must not hand a nested caller an agent its allowlist + // never named. The list stays allowlist-filtered so a typo can't enumerate + // agents this parent may not reach. + const resolvedType = resolveEnabledTypeIn(registry, rawType); + if (resolvedType === undefined) { + return textResult( + `Unknown or disabled nested agent type: "${rawType}". Allowed: ${availableIn(registry).join(", ") || "none"}.`, + true, + ); + } + const allowed = allowedTypesIn(registry); + if (allowed !== undefined && !allowed.has(resolvedType)) { + return textResult( + `Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`, + true, + ); + } + + const config = getAgentConfigIn(registry, resolvedType); + // Foreground regardless of `backgroundByDefault` — see the reasoning on + // ResolveOptions. An explicit `true` here still opts in. + const invocation = resolveAgentInvocationConfig(config, params, { + worktreeAllowed: isWorktreeIsolationEnabled(), + defaultRunInBackground: false, + }); + let model = ctx.model; + if (invocation.modelInput) { + const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry); + if (typeof resolvedModel === "string") { + if (invocation.modelFromParams) return textResult(resolvedModel, true); + } else { + model = resolvedModel; + } + } + + // Same scopeModels policy as the top-level Agent tool — a nested spawn + // must not escape the allowlist. A "warn" verdict proceeds silently: + // child sessions have no UI surface to toast to. + const scopeVerdict = checkModelScope({ + model, + cwd: context.configCwd, + modelRegistry: ctx.modelRegistry, + callerSupplied: invocation.modelFromParams, + agentLabel: config?.displayName ?? resolvedType, + modelInput: invocation.modelInput, + }); + if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true); + + // The whole branch shares the root session's transcript directory; read it + // off the owning parent rather than this child session's own id. + const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId; + const childDepth = context.depth + 1; + const options: NestedSpawnOptions = { + description: params.description, + model, + maxTurns: invocation.maxTurns, + isolated: invocation.isolated, + inheritContext: invocation.inheritContext, + thinkingLevel: invocation.thinking, + isolation: invocation.isolation, + invocation: { + thinking: invocation.thinking, + maxTurns: invocation.maxTurns, + isolated: invocation.isolated, + inheritContext: invocation.inheritContext, + runInBackground: invocation.runInBackground, + isolation: invocation.isolation, + }, + // Nested children are hidden from every reporting surface, so their spend + // would otherwise be unattributable. Fold it into every ancestor's record: + // the top-level one appears in lifecycle events, completion notifications, + // and `/agents`, and those all read `lifetimeUsage`. The whole chain is + // walked, not just the immediate parent — a spawn callback only fires for + // that child's OWN turns, so stopping at one level would hide a + // great-grandchild from the only record anyone can see. (The live + // widget/fleet counters read their own per-agent activity tracker, which + // still sees only the top-level agent's own turns.) + onAssistantUsage: (usage) => { + for (let id: string | undefined = context.parentAgentId; id !== undefined; ) { + const ancestor = context.manager.getRecord(id); + if (!ancestor) break; + addUsage(ancestor.lifetimeUsage, usage); + id = ancestor.parentAgentId; + } + }, + depth: childDepth, + parentAgentId: context.parentAgentId, + maxSubagentDepth: context.maxSubagentDepth, + configCwd: context.configCwd, + rootSessionId, + }; + + // Transcript wiring, same gate as the top-level path: the child's + // `output_transcript` frontmatter wins, else the project default. Without + // it a nested run leaves no artifact but the string it returned — the + // parent's own transcript records the call and the answer, never the tool + // calls in between, which is exactly what a misbehaving child needs to + // explain itself. Filed under the ROOT session and this branch's config + // root, so a nested transcript lands in the same `tasks/` directory as its + // ancestors' rather than in a directory of its own. + const transcriptSessionId = + rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault()) + ? rootSessionId + : undefined; + let childId: string | undefined; + const attachTranscript = (id: string): void => { + childId = id; + if (transcriptSessionId === undefined) return; + const rec = context.manager.getRecord(id); + if (!rec) return; + rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId); + writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd); + }; + options.onSessionCreated = (session) => { + const rec = childId === undefined ? undefined : context.manager.getRecord(childId); + if (rec?.outputFile && childId !== undefined) { + rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd); + } + }; + + // `ctx` is forwarded to the manager unmodified, never captured at tool-build + // time: each AgentSession builds its own ExtensionRunner from that session's + // cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing + // one earlier would silently give a grandchild the wrong worktree base, the + // wrong conversation under inherit_context, and the wrong inherited model. + // + // spawn() throws on strict worktree-isolation failure and cwd validation — + // report it as a tool error, like the top-level Agent tool does, instead of + // letting it escape into the child's turn. + try { + if (invocation.runInBackground) { + const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, { + ...options, + isBackground: true, + }); + // Synchronous, before the event loop yields — onSessionCreated fires + // asynchronously inside runAgent, so the file is attached in time. + attachTranscript(id); + return textResult(`Nested agent started in background. Agent ID: ${id}`); + } + + const { record } = await context.manager.spawnAndWait( + context.pi, + ctx, + resolvedType, + params.prompt, + { ...options, signal }, + attachTranscript, + ); + return textResult(formatRecord(record, "inline"), record.status === "error"); + } catch (err) { + return textResult(err instanceof Error ? err.message : String(err), true); + } + }, + }); + + const resultTool = defineTool({ + name: NESTED_TOOL_NAMES[1], + label: "Get Nested Agent Result", + description: "Check or wait for a background nested agent owned by this parent.", + parameters: Type.Object({ + agent_id: Type.String(), + wait: Type.Optional(Type.Boolean()), + }), + execute: async (_toolCallId, params, signal) => { + const record = context.manager.getRecord(params.agent_id); + if (!ownsRecord(record, context.parentAgentId)) { + return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true); + } + // Wait for completion if requested. Cancellation (e.g. the parent's tool + // call is aborted) stops only this wait; the nested child keeps running and + // stays unconsumed. Queued records have no promise until the manager starts + // them, so poll — abortably — until they leave the queue, then await. + if (params.wait && (record.status === "queued" || record.status === "running")) { + while (record.status === "queued") { + await abortable(new Promise(resolve => setTimeout(resolve, 250)), signal); + } + if (record.promise) await abortable(record.promise, signal); + } + return textResult(formatRecord(record, "fetched"), record.status === "error"); + }, + }); + + const steerTool = defineTool({ + name: NESTED_TOOL_NAMES[2], + label: "Steer Nested Agent", + description: "Send guidance to a running nested agent owned by this parent.", + parameters: Type.Object({ + agent_id: Type.String(), + message: Type.String(), + }), + execute: async (_toolCallId, params) => { + const record = context.manager.getRecord(params.agent_id); + if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") { + return textResult(`Running nested agent not found or not owned by this parent: "${params.agent_id}".`, true); + } + // Session not ready yet — queue the steer. The manager flushes pending + // steers when the session is created (same contract as the top-level tool). + if (!record.session) { + if (!record.pendingSteers) record.pendingSteers = []; + record.pendingSteers.push(params.message); + return textResult(`Steering message queued for nested agent ${params.agent_id}.`); + } + try { + await record.session.steer(params.message); + } catch (err) { + return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true); + } + return textResult(`Steering message sent to nested agent ${params.agent_id}.`); + }, + }); + + return [agentTool, resultTool, steerTool]; +} diff --git a/pi-subagents/src/output-file.ts b/pi-subagents/src/output-file.ts new file mode 100644 index 0000000..ba940fa --- /dev/null +++ b/pi-subagents/src/output-file.ts @@ -0,0 +1,145 @@ +/** + * output-file.ts — Streaming JSONL output file for agent transcripts. + * + * Creates a per-agent output file that streams conversation turns as JSONL, + * matching Claude Code's task output file format. + */ + +import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent"; + +/** + * Project/global default for writing a subagent's `.output` transcript; a custom + * agent's `output_transcript` overrides it per agent. + * + * State lives here rather than in an index.ts closure because both spawn paths + * need it — the top-level Agent tool and the nested delegation tools. Same + * reason `scopeModels` lives in model-scope.ts: a setting only one path can read + * is a setting the other path silently ignores. + */ +let outputTranscriptDefault = true; + +export function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; } +export function setOutputTranscriptDefault(b: boolean): void { outputTranscriptDefault = b; } + +/** + * Encode a cwd path as a filesystem-safe directory name. Handles: + * - POSIX: "/home/user/project" → "home-user-project" + * - Windows: "C:\Users\foo\project" → "Users-foo-project" + * - UNC: "\\\\server\\share\\project" → "server-share-project" + */ +export function encodeCwd(cwd: string): string { + return cwd + .replace(/[/\\]/g, "-") // both separators → dash + .replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-") + .replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC) +} + +/** Create the output file path, ensuring the directory exists. + * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */ +export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string { + const encoded = encodeCwd(cwd); + const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`); + mkdirSync(root, { recursive: true, mode: 0o700 }); + // chmod is a no-op on Windows and throws on some Windows filesystems. + // On Unix we still want to enforce 0o700 past umask, so only swallow on Windows. + try { + chmodSync(root, 0o700); + } catch (err) { + if (process.platform !== "win32") throw err; + } + const dir = join(root, encoded, sessionId, "tasks"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${agentId}.output`); +} + +/** + * Ensure a transcript file exists without disturbing what is already in it. + * + * A resume reuses the agent's existing transcript (same deterministic path), so + * it must never call `writeInitialEntry` — that truncates, discarding turns the + * completion notification still points the user at, and any history the session + * has since compacted away is gone for good. Appending nothing creates the file + * when this is the agent's first transcript and is a no-op when it is not. + */ +export function ensureOutputFile(path: string): void { + try { + appendFileSync(path, "", "utf-8"); + } catch { /* ignore — streaming writes are best-effort too */ } +} + +/** Write the initial user prompt entry. */ +export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void { + const entry = { + isSidechain: true, + agentId, + type: "user", + message: { role: "user", content: prompt }, + timestamp: new Date().toISOString(), + cwd, + }; + writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8"); +} + +/** + * Subscribe to session events and flush new messages to the output file on each turn_end. + * Returns a cleanup function that does a final flush and unsubscribes. + */ +export function streamToOutputFile( + session: AgentSession, + path: string, + agentId: string, + cwd: string, + startIndex?: number, +): () => void { + // Index of the first message this stream is responsible for. A spawn writes + // messages[0] as the initial prompt entry, so it starts at 1. A resume hands + // in the session's length as of just before the run: the session already + // holds every prior turn, and re-emitting those would duplicate history that + // is already in the file. + let writtenCount = startIndex ?? 1; + + const flush = () => { + const messages = session.messages; + while (writtenCount < messages.length) { + const msg = messages[writtenCount]; + const entry = { + isSidechain: true, + agentId, + type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult", + message: msg, + timestamp: new Date().toISOString(), + cwd, + }; + try { + appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8"); + } catch { /* ignore write errors */ } + writtenCount++; + } + }; + + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (event.type === "turn_end") flush(); + // Compaction replaces session.messages with a shorter, summarized array, + // leaving writtenCount past the new end — without re-anchoring, the flush + // loop would never match again and streaming would halt for good (#145). + // Flush before it runs so any not-yet-flushed tail still reaches the file, + // then re-anchor to the rebuilt array once it lands. The re-anchor is + // deferred a microtask because on the overflow-retry path pi trims the + // trailing error assistant message AFTER emitting compaction_end — + // anchoring synchronously would sit one past the trimmed array and skip + // the first post-compaction message. Aborted/failed compactions leave + // session.messages untouched, so only successful ones re-anchor. + if (event.type === "compaction_start") flush(); + if (event.type === "compaction_end" && !event.aborted && event.result) { + queueMicrotask(() => { writtenCount = session.messages.length; }); + } + }); + + return () => { + flush(); + unsubscribe(); + }; +} diff --git a/pi-subagents/src/permission-system-bridge.ts b/pi-subagents/src/permission-system-bridge.ts new file mode 100644 index 0000000..07d8a32 --- /dev/null +++ b/pi-subagents/src/permission-system-bridge.ts @@ -0,0 +1,90 @@ +import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +/** + * Process-local lifecycle contract consumed by pi-permission-system. + * + * Keep these string channels local rather than importing permission-system + * internals: pi-subagents only publishes child identity, while the permission + * extension remains the sole owner of policy and authorization decisions. + */ +export const PERMISSION_CHILD_CREATED_CHANNEL = "subagents:child:session-created"; +export const PERMISSION_CHILD_DISPOSED_CHANNEL = "subagents:child:disposed"; + +interface EventPublisher { + events?: { + emit(channel: string, payload: unknown): void; + }; +} + +interface SessionIdentity { + sessionManager?: { + getSessionId?: () => string | undefined; + }; +} + +interface ChildRegistration { + events: NonNullable; + sessionId: string; +} + +const childRegistrations = new WeakMap(); +const ACTIVE_AGENT_TAG = /]*>\s*/gi; +const SAFE_AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function normalizedSessionId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + return normalized || undefined; +} + +/** + * Replace inherited agent identity with this child's stable config key. + * The permission system reads the first active_agent tag, so inherited parent + * tags must be removed rather than merely appending another one. + */ +export function withActiveAgentIdentity(systemPrompt: string, agentName: string): string { + const withoutInheritedIdentity = systemPrompt.replace(ACTIVE_AGENT_TAG, "").trimEnd(); + const normalizedAgentName = agentName.trim(); + if (!SAFE_AGENT_NAME.test(normalizedAgentName)) return withoutInheritedIdentity; + return `${withoutInheritedIdentity}\n\n`; +} + +/** + * Register a child synchronously before bindExtensions(). This is deliberately + * fail-closed when an event publisher exists: a thrown lifecycle listener must + * abort child startup rather than bind a headless permission system as if it + * were an ordinary top-level session. + */ +export function registerPermissionChildSession( + pi: Pick | EventPublisher, + session: Pick | SessionIdentity, + parentSessionId: string | undefined, +): boolean { + const events = (pi as EventPublisher).events; + const childSessionId = normalizedSessionId( + (session as SessionIdentity).sessionManager?.getSessionId?.(), + ); + const normalizedParentSessionId = normalizedSessionId(parentSessionId); + if (!events || !childSessionId || !normalizedParentSessionId) return false; + + const sessionKey = session as object; + if (childRegistrations.has(sessionKey)) return true; + + events.emit(PERMISSION_CHILD_CREATED_CHANNEL, { + sessionId: childSessionId, + parentSessionId: normalizedParentSessionId, + }); + childRegistrations.set(sessionKey, { events, sessionId: childSessionId }); + return true; +} + +/** Publish disposal at most once, after the child session has actually closed. */ +export function unregisterPermissionChildSession(session: object | undefined): void { + if (!session) return; + const registration = childRegistrations.get(session); + if (!registration) return; + childRegistrations.delete(session); + registration.events.emit(PERMISSION_CHILD_DISPOSED_CHANNEL, { + sessionId: registration.sessionId, + }); +} diff --git a/pi-subagents/src/prompts.ts b/pi-subagents/src/prompts.ts new file mode 100644 index 0000000..c74b518 --- /dev/null +++ b/pi-subagents/src/prompts.ts @@ -0,0 +1,116 @@ +/** + * prompts.ts — System prompt builder for agents. + */ + +import type { AgentConfig, EnvInfo } from "./types.js"; + +/** Extra sections to inject into the system prompt (memory, skills, etc.). */ +export interface PromptExtras { + /** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */ + memoryBlock?: string; + /** Preloaded skill contents to inject. */ + skillBlocks?: { name: string; content: string }[]; + /** + * Parent directory the worktree copy was created from. Set only for + * `isolation: "worktree"` spawns — triggers the block that tells the agent + * to stay in the copy. + */ + worktreeBase?: string; +} + +/** + * Build the system prompt for an agent from its config. + * + * - "replace" mode: env header + config.systemPrompt (full control, no parent identity) + * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt + * - "append" with empty systemPrompt: pure parent clone + * + * Both modes include an `` tag so downstream + * extensions (e.g. permission/policy systems) can resolve per-agent policy + * inside the child session by parsing the system prompt. In replace mode the tag + * is prepended; in append mode it follows the shared inherited content so the + * parent prompt forms an identical, cacheable byte prefix with the parent + * session (the LLM's KV cache can then reuse those tokens across every spawn). + * + * @param parentSystemPrompt The parent agent's effective system prompt (for append mode). + * @param extras Optional extra sections to inject (memory, preloaded skills). + */ +export function buildAgentPrompt( + config: AgentConfig, + cwd: string, + env: EnvInfo, + parentSystemPrompt?: string, + extras?: PromptExtras, +): string { + const activeAgentTag = `\n\n`; + + const envBlock = `# Environment +Working directory: ${cwd} +${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"} +Platform: ${env.platform}`; + + // A worktree agent is told its cwd twice: by the env block above (the copy) + // and by whatever names the main checkout — the inherited parent prompt in + // append mode, or the task prompt in either mode. It follows the latter and + // works in the shared tree (#187), so resolve the contradiction explicitly. + const worktreeBlock = extras?.worktreeBase + ? `\n\n +Your working directory is an isolated git worktree copy of ${extras.worktreeBase}. +Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory. +` + : ""; + + // Build optional extras suffix + const extraSections: string[] = []; + if (extras?.memoryBlock) { + extraSections.push(extras.memoryBlock); + } + if (extras?.skillBlocks?.length) { + for (const skill of extras.skillBlocks) { + extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`); + } + } + const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : ""; + + if (config.promptMode === "append") { + const identity = parentSystemPrompt || genericBase; + + const bridge = ` +You are operating as a sub-agent invoked to handle a specific task. +- Use the read tool instead of cat/head/tail +- Use the edit tool instead of sed/awk +- Use the write tool instead of echo/heredoc +- Use the find tool instead of bash find/ls for file search +- Use the grep tool instead of bash grep/rg for content search +- Make independent tool calls in parallel +- Use absolute file paths +- Do not use emojis +- Be concise but complete +`; + + const customSection = config.systemPrompt?.trim() + ? `\n\n\n${config.systemPrompt}\n` + : ""; + + // Place shared/stable content first so the LLM's KV cache can reuse the + // inherited prefix across all subagent invocations. The parent prompt is + // placed verbatim (no wrapper tag) so it forms an identical byte prefix + // with the parent session, maximising KV cache hits. The + // tag and env block vary per call and are placed after the cached prefix. + return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + customSection + extrasSuffix; + } + + // "replace" mode — env header + the config's full system prompt + const replaceHeader = `You are a pi coding agent sub-agent. +You have been invoked to handle a specific task autonomously. + +${envBlock}`; + + return activeAgentTag + replaceHeader + worktreeBlock + "\n\n" + config.systemPrompt + extrasSuffix; +} + +/** Fallback base prompt when parent system prompt is unavailable in append mode. */ +const genericBase = `# Role +You are a general-purpose coding agent for complex, multi-step tasks. +You have full access to read, write, edit files, and execute commands. +Do what has been asked; nothing more, nothing less.`; diff --git a/pi-subagents/src/schedule-store.ts b/pi-subagents/src/schedule-store.ts new file mode 100644 index 0000000..e189e0d --- /dev/null +++ b/pi-subagents/src/schedule-store.ts @@ -0,0 +1,153 @@ +/** + * schedule-store.ts — File-backed store for scheduled subagents. + * + * Session-scoped: each pi session owns its own schedules at + * `/.pi/subagent-schedules/.json`. `/new` starts a fresh + * empty store; `/resume` reloads. + * + * Concurrency model lifted from pi-chonky-tasks/src/task-store.ts: every + * mutation acquires a PID-based exclusion lock, re-reads the latest state + * from disk, applies the change, atomic-writes via temp+rename, releases. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { ScheduledSubagent, ScheduleStoreData } from "./types.js"; + +const LOCK_RETRY_MS = 50; +const LOCK_MAX_RETRIES = 100; + +function isProcessRunning(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function acquireLock(lockPath: string): void { + for (let i = 0; i < LOCK_MAX_RETRIES; i++) { + try { + writeFileSync(lockPath, `${process.pid}`, { flag: "wx" }); + return; + } catch (e: any) { + if (e.code === "EEXIST") { + try { + const pid = parseInt(readFileSync(lockPath, "utf-8"), 10); + if (pid && !isProcessRunning(pid)) { + unlinkSync(lockPath); + continue; + } + } catch { /* ignore — try again */ } + const start = Date.now(); + while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ } + continue; + } + throw e; + } + } + throw new Error(`Failed to acquire schedule lock: ${lockPath}`); +} + +function releaseLock(lockPath: string): void { + try { unlinkSync(lockPath); } catch { /* ignore */ } +} + +/** Resolve the storage path for a session-scoped store. */ +export function resolveStorePath(cwd: string, sessionId: string): string { + return join(cwd, ".pi", "subagent-schedules", `${sessionId}.json`); +} + +export class ScheduleStore { + private filePath: string; + private lockPath: string; + private jobs = new Map(); + + constructor(filePath: string) { + this.filePath = filePath; + this.lockPath = filePath + ".lock"; + this.load(); + } + + /** Create the backing directory lazily — only when we're about to persist. */ + private ensureDir(): void { + mkdirSync(dirname(this.filePath), { recursive: true }); + } + + /** Load from disk into the in-memory cache. Silent on parse errors. */ + private load(): void { + if (!existsSync(this.filePath)) return; + try { + const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8")); + this.jobs.clear(); + for (const j of data.jobs ?? []) this.jobs.set(j.id, j); + } catch { /* corrupt — start fresh, next save rewrites */ } + } + + /** Atomic write via temp file + rename (POSIX-atomic). */ + private save(): void { + const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] }; + const tmp = this.filePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, this.filePath); + } + + /** Acquire lock → reload → mutate → save → release. */ + private withLock(fn: () => T): T { + this.ensureDir(); + acquireLock(this.lockPath); + try { + this.load(); + const result = fn(); + this.save(); + return result; + } finally { + releaseLock(this.lockPath); + } + } + + /** Read-only — returns a snapshot of the in-memory cache. */ + list(): ScheduledSubagent[] { + return [...this.jobs.values()]; + } + + /** Read-only check — uses the cache. */ + hasName(name: string, exceptId?: string): boolean { + for (const j of this.jobs.values()) { + if (j.id !== exceptId && j.name === name) return true; + } + return false; + } + + get(id: string): ScheduledSubagent | undefined { + return this.jobs.get(id); + } + + add(job: ScheduledSubagent): void { + this.withLock(() => { + this.jobs.set(job.id, job); + }); + } + + update(id: string, patch: Partial): ScheduledSubagent | undefined { + // No-op fast path — an unknown id changes nothing, so don't lock or touch + // disk (which would otherwise lazily create the backing directory). + if (!this.jobs.has(id)) return undefined; + return this.withLock(() => { + const existing = this.jobs.get(id); + if (!existing) return undefined; + const updated = { ...existing, ...patch }; + this.jobs.set(id, updated); + return updated; + }); + } + + remove(id: string): boolean { + // No-op fast path — see update(). + if (!this.jobs.has(id)) return false; + return this.withLock(() => this.jobs.delete(id)); + } + + /** Delete the backing file (used when no jobs remain, optional cleanup). */ + deleteFileIfEmpty(): void { + if (this.jobs.size === 0 && existsSync(this.filePath)) { + try { unlinkSync(this.filePath); } catch { /* ignore */ } + } + } +} diff --git a/pi-subagents/src/schedule.ts b/pi-subagents/src/schedule.ts new file mode 100644 index 0000000..4a7f71b --- /dev/null +++ b/pi-subagents/src/schedule.ts @@ -0,0 +1,374 @@ +/** + * schedule.ts — `SubagentScheduler`: timer-driven dispatcher of scheduled subagents. + * + * Mirrors the engine shape of pi-cron-schedule/src/scheduler.ts: + * - two-Map split (jobs = croner Cron, intervals = setInterval/setTimeout) + * - addJob/removeJob/updateJob/scheduleJob/unscheduleJob/executeJob + * - static parsers for cron / "+10m" / "5m" / ISO formats + * + * Differences vs pi-cron-schedule: + * - Persistence is via ScheduleStore (PID-locked, session-scoped, atomic). + * - `executeJob` calls `manager.spawn(..., { bypassQueue: true })` instead + * of dispatching a user message — schedule fires bypass maxConcurrent so + * a 5-minute interval can't be deferred behind 4 long-running agents. + * - Result delivery is implicit: spawn → background completion → existing + * `subagent-notification` followUp path. No new delivery code. + */ + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { Cron } from "croner"; +import { nanoid } from "nanoid"; +import type { AgentManager } from "./agent-manager.js"; +import { resolveSpawnType } from "./agent-types.js"; +import { resolveModel } from "./model-resolver.js"; +import type { ScheduleStore } from "./schedule-store.js"; +import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js"; + +/** Event emitted on `pi.events` for cross-extension consumers. */ +export type ScheduleChangeEvent = + | { type: "added"; job: ScheduledSubagent } + | { type: "removed"; jobId: string } + | { type: "updated"; job: ScheduledSubagent } + | { type: "fired"; jobId: string; agentId: string; name: string } + | { type: "error"; jobId: string; error: string }; + +/** Params accepted at job creation — ID, timestamps, and state are derived. */ +export interface NewJobInput { + name: string; + description: string; + schedule: string; + subagent_type: SubagentType; + prompt: string; + model?: string; + thinking?: ThinkingLevel; + max_turns?: number; + isolated?: boolean; + isolation?: IsolationMode; +} + +export class SubagentScheduler { + private jobs = new Map(); + private intervals = new Map(); + private store: ScheduleStore | undefined; + private pi: ExtensionAPI | undefined; + private ctx: ExtensionContext | undefined; + private manager: AgentManager | undefined; + + /** Start the scheduler: bind to a session's store and arm enabled jobs. */ + start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void { + this.pi = pi; + this.ctx = ctx; + this.manager = manager; + this.store = store; + + for (const job of store.list()) { + if (job.enabled) this.scheduleJob(job); + } + } + + /** Stop all timers; drop refs. Safe to call repeatedly. */ + stop(): void { + for (const cron of this.jobs.values()) cron.stop(); + this.jobs.clear(); + for (const t of this.intervals.values()) clearTimeout(t); + this.intervals.clear(); + this.store = undefined; + this.pi = undefined; + this.ctx = undefined; + this.manager = undefined; + } + + /** True if start() has bound a store and the scheduler is active. */ + isActive(): boolean { + return this.store !== undefined; + } + + list(): ScheduledSubagent[] { + return this.store?.list() ?? []; + } + + /** + * Build a `ScheduledSubagent` from user input. Validates the schedule + * format and tags `scheduleType`. Throws on invalid input. + */ + buildJob(input: NewJobInput): ScheduledSubagent { + const detected = SubagentScheduler.detectSchedule(input.schedule); + return { + id: nanoid(10), + name: input.name, + description: input.description, + schedule: detected.normalized, + scheduleType: detected.type, + intervalMs: detected.intervalMs, + subagent_type: input.subagent_type, + prompt: input.prompt, + model: input.model, + thinking: input.thinking, + max_turns: input.max_turns, + isolated: input.isolated, + isolation: input.isolation, + enabled: true, + createdAt: new Date().toISOString(), + runCount: 0, + }; + } + + /** Add a job, persist, and arm if enabled. Returns the stored job. */ + addJob(input: NewJobInput): ScheduledSubagent { + const store = this.requireStore(); + if (store.hasName(input.name)) { + throw new Error(`A scheduled job named "${input.name}" already exists.`); + } + const job = this.buildJob(input); + store.add(job); + if (job.enabled) this.scheduleJob(job); + this.emit({ type: "added", job }); + return job; + } + + removeJob(id: string): boolean { + const store = this.requireStore(); + if (!store.get(id)) return false; + this.unscheduleJob(id); + const ok = store.remove(id); + if (ok) this.emit({ type: "removed", jobId: id }); + return ok; + } + + /** Toggle / mutate a job. Re-arms based on the new `enabled` state. */ + updateJob(id: string, patch: Partial): ScheduledSubagent | undefined { + const store = this.requireStore(); + const updated = store.update(id, patch); + if (!updated) return undefined; + this.unscheduleJob(id); + if (updated.enabled) this.scheduleJob(updated); + this.emit({ type: "updated", job: updated }); + return updated; + } + + /** Next-run time as ISO, or undefined if not currently armed. */ + getNextRun(jobId: string): string | undefined { + const cron = this.jobs.get(jobId); + if (cron) return cron.nextRun()?.toISOString(); + const job = this.store?.get(jobId); + if (!job?.enabled) return undefined; + if (job.scheduleType === "once") return job.schedule; + if (job.scheduleType === "interval" && job.intervalMs) { + // Before the first fire there's no `lastRun`, so fall back to "now" — + // accurate at create time (setInterval was just armed) and within + // intervalMs of correct in any pre-first-fire view. + const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now(); + return new Date(base + job.intervalMs).toISOString(); + } + return undefined; + } + + // ── Scheduling primitives ──────────────────────────────────────────── + + private scheduleJob(job: ScheduledSubagent): void { + const store = this.store; + if (!store) return; + try { + if (job.scheduleType === "interval" && job.intervalMs) { + const t = setInterval(() => this.executeJob(job.id), job.intervalMs); + this.intervals.set(job.id, t); + } else if (job.scheduleType === "once") { + const target = new Date(job.schedule).getTime(); + const delay = target - Date.now(); + if (delay > 0) { + const t = setTimeout(() => { + this.executeJob(job.id); + // Auto-disable one-shots after they fire (mirrors pi-cron-schedule) + store.update(job.id, { enabled: false }); + const updated = store.get(job.id); + if (updated) this.emit({ type: "updated", job: updated }); + }, delay); + this.intervals.set(job.id, t); + } else { + // Past timestamp — disable, mark error, never fire + store.update(job.id, { enabled: false, lastStatus: "error" }); + this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` }); + } + } else { + const cron = new Cron(job.schedule, () => this.executeJob(job.id)); + this.jobs.set(job.id, cron); + } + } catch (err) { + this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) }); + } + } + + private unscheduleJob(id: string): void { + const cron = this.jobs.get(id); + if (cron) { + cron.stop(); + this.jobs.delete(id); + } + const t = this.intervals.get(id); + if (t) { + clearTimeout(t); + clearInterval(t); + this.intervals.delete(id); + } + } + + /** + * Fire a job: persist running state, spawn (bypassing the concurrency + * queue), persist completion. Fire-and-forget: the timer tick returns + * immediately so other jobs keep firing. + */ + private executeJob(id: string): void { + const store = this.store; + const pi = this.pi; + const ctx = this.ctx; + const manager = this.manager; + if (!store || !pi || !ctx || !manager) return; + const job = store.get(id); + if (!job?.enabled) return; + + store.update(id, { lastStatus: "running" }); + + // Resolve model at fire time — registry contents may have changed since the + // job was created (auth added/removed). Fall back silently to spawn-default + // if resolution fails; the spawn path handles undefined model gracefully. + let resolvedModel: any | undefined; + if (job.model) { + const r = resolveModel(job.model, ctx.modelRegistry); + if (typeof r !== "string") resolvedModel = r; + } + + let agentId: string; + try { + // Re-resolve at fire time against the registry as it stands. This does not + // reload from disk (the scheduler has no reason to rebuild process-global + // state from a timer), so it catches changes that went through /agents or + // an Agent call — not a file deleted directly from a shell. The catch below turns + // this into lastStatus: "error" plus an error event, like any other + // fire-time failure. + const dispatch = resolveSpawnType(job.subagent_type); + if (!dispatch.ok) throw new Error(dispatch.message); + agentId = manager.spawn(pi, ctx, dispatch.type, job.prompt, { + description: job.description, + isBackground: true, + bypassQueue: true, + model: resolvedModel, + maxTurns: job.max_turns, + isolated: job.isolated, + thinkingLevel: job.thinking, + isolation: job.isolation, + }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + store.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" }); + this.emit({ type: "error", jobId: id, error }); + return; + } + + this.emit({ type: "fired", jobId: id, agentId, name: job.name }); + + const record = manager.getRecord(agentId); + const finalize = (status: "success" | "error") => { + const next = this.getNextRun(id); + const current = store.get(id); + store.update(id, { + lastRun: new Date().toISOString(), + lastStatus: status, + runCount: (current?.runCount ?? 0) + 1, + nextRun: next, + }); + }; + + // AgentManager's promise resolves either way (its .catch returns ""), so we + // can't infer success/failure from the promise — read record.status instead. + // Terminal states: completed/steered = success; error/aborted/stopped = error. + if (record?.promise) { + record.promise + .then(() => { + const r = manager.getRecord(agentId); + const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped"; + finalize(failed ? "error" : "success"); + }) + .catch(() => finalize("error")); + } else { + // Spawn returned without a promise (defensive — bypassQueue path always sets one). + finalize("success"); + } + } + + private emit(event: ScheduleChangeEvent): void { + if (this.pi) this.pi.events.emit("subagents:scheduled", event); + } + + private requireStore(): ScheduleStore { + if (!this.store) throw new Error("Scheduler not started — no active session."); + return this.store; + } + + // ── Format detection / parsers (statics — pure) ────────────────────── + + /** + * Sniff a schedule string and tag its type. Throws on invalid input. + * Order matters: relative ("+10m") and interval ("5m") both match digit+unit; + * relative requires the leading "+" to disambiguate. + */ + static detectSchedule(s: string): { type: "cron" | "once" | "interval"; intervalMs?: number; normalized: string } { + const trimmed = s.trim(); + // "+10m" — relative one-shot + const rel = SubagentScheduler.parseRelativeTime(trimmed); + if (rel !== null) return { type: "once", normalized: rel }; + // "5m" — interval + const ivl = SubagentScheduler.parseInterval(trimmed); + if (ivl !== null) return { type: "interval", intervalMs: ivl, normalized: trimmed }; + // ISO timestamp — one-shot. Reject past timestamps upfront so we never + // create a dead-on-arrival record (scheduleJob's safety net still catches + // micro-races from `+0s`-style relatives). + if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + const d = new Date(trimmed); + if (!Number.isNaN(d.getTime())) { + if (d.getTime() <= Date.now()) { + throw new Error(`Scheduled time ${d.toISOString()} is in the past.`); + } + return { type: "once", normalized: d.toISOString() }; + } + } + // Cron — 6-field + const cronCheck = SubagentScheduler.validateCronExpression(trimmed); + if (cronCheck.valid) return { type: "cron", normalized: trimmed }; + throw new Error( + `Invalid schedule "${s}". Use 6-field cron (e.g. "0 0 9 * * 1" — 9am every Monday), interval ("5m"/"1h"), or one-shot ("+10m" / ISO).` + ); + } + + /** 6-field cron — 'second minute hour dom month dow'. */ + static validateCronExpression(expr: string): { valid: boolean; error?: string } { + const fields = expr.trim().split(/\s+/); + if (fields.length !== 6) { + return { + valid: false, + error: `Cron must have 6 fields (second minute hour dom month dow), got ${fields.length}. Example: "0 0 9 * * 1" for 9am every Monday.`, + }; + } + try { + // Croner validates by construction. + new Cron(expr, () => {}); + return { valid: true }; + } catch (e) { + return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" }; + } + } + + /** "+10s"/"+5m"/"+1h"/"+2d" → ISO timestamp. */ + static parseRelativeTime(s: string): string | null { + const m = s.match(/^\+(\d+)(s|m|h|d)$/); + if (!m) return null; + const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"]; + return new Date(Date.now() + ms).toISOString(); + } + + /** "10s"/"5m"/"1h"/"2d" → milliseconds. */ + static parseInterval(s: string): number | null { + const m = s.match(/^(\d+)(s|m|h|d)$/); + if (!m) return null; + return parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"]; + } +} diff --git a/pi-subagents/src/settings.ts b/pi-subagents/src/settings.ts new file mode 100644 index 0000000..62ae999 --- /dev/null +++ b/pi-subagents/src/settings.ts @@ -0,0 +1,490 @@ +// Persistence for pi-subagents operational settings. +// - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here +// - Project: /.pi/subagents.json — written by /agents → Settings; overrides global on load + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { NO_FALLBACK } from "./agent-types.js"; +import type { AgentMentionMode, JoinMode, WidgetMode } from "./types.js"; + +export interface SubagentsSettings { + maxConcurrent?: number; + /** + * 0 = unlimited — the extension's single source of truth for that convention: + * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the + * `/agents` → Settings input prompt explicitly says "0 = unlimited". + */ + defaultMaxTurns?: number; + graceTurns?: number; + defaultJoinMode?: JoinMode; + /** + * Whether a top-level `Agent` spawn that doesn't say runs detached. + * Defaults to `true`, following Claude Code, where the agent backgrounds + * unless the caller passes `run_in_background: false`. Set `false` to restore + * the previous behaviour, where an unqualified spawn blocked the turn and + * returned its result inline. + * + * Top-level only. Nested spawns (a subagent spawning its own) always default + * to foreground regardless of this setting — see `nested-tools.ts`, where a + * detached child would be killed by `abortOwnedChildren` when its parent + * settles, with no notification path to deliver its result. + * + * An explicit `run_in_background` on the call, or in the agent file's + * frontmatter, overrides this in both directions; the setting only decides + * what "unspecified" means. + */ + backgroundByDefault?: boolean; + /** + * Master switch for the schedule subagent feature. Defaults to `true`. + * When `false`: the `Agent` tool's `schedule` param + its guideline are + * stripped from the tool spec at registration (zero LLM-context cost), the + * scheduler doesn't bind to the session, and the `/agents → Scheduled jobs` + * menu entry is hidden. Schema-level removal applies at extension load + * (next pi session); runtime menu/runtime-fire short-circuit is immediate. + */ + schedulingEnabled?: boolean; + /** + * When true, the effective model of each subagent spawn is validated + * against `enabledModels` from pi's settings — both global + * (`/settings.json`) and project-local (`/.pi/settings.json`), + * with project overriding global (mirrors pi's SettingsManager deep-merge). + * + * scopeModels guards against runtime LLM choices, not user-level config. + * Out-of-scope handling reflects this: + * - Caller-supplied via `Agent({ model: "..." })` (only when frontmatter + * has no `model:`, since frontmatter is authoritative): hard error + * returned to the orchestrator, listing the allowed models. The LLM + * made an explicit out-of-scope choice and gets explicit feedback. + * - Frontmatter-pinned: warning toast + the pinned model runs. The + * agent's author/installer chose this; trust it. + * - Parent-inherited (neither caller nor frontmatter sets a model): + * warning toast + parent's model runs. The user chose the parent's + * model when starting the session; trust it. + * + * No-op when pi's `enabledModels` is empty or absent — nothing to validate + * against. Defaults to false: subagents may use any model. + */ + scopeModels?: boolean; + /** + * When true, an unreadable or unparseable agent `.md` aborts extension load + * instead of being skipped with a warning — pi exits, naming the file. + * + * Startup only, by design. Mid-session reloads (one per `Agent` call) keep + * warning: a bad edit at 3pm should not kill the session on the next + * unrelated spawn, where the failure would look disconnected from its cause. + * For a checked-in `.pi/agents/`, failing at startup is the point — the + * alternative is running a *different* agent than the file names. + * Defaults to false. + */ + strictAgentFiles?: boolean; + /** + * When true, the three built-in default agents (general-purpose, Explore, Plan) + * are not registered at startup. User-defined agents from project/global custom + * agent dirs are completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed. + * Defaults to false. + */ + disableDefaultAgents?: boolean; + /** + * Which Agent tool description the LLM sees. "full" (default) is the rich + * Claude Code-style prompt; "compact" is a ~75% smaller version (one-line + * agent type list, terse usage notes) for small/local models where tool-spec + * tokens are expensive; "custom" reads `.pi/agent-tool-description.md` + * (project, falling back to `/agent-tool-description.md`) with + * `{{placeholder}}` substitution — a missing/empty file falls back to "full". + * The mode is read once at tool registration — changing it applies on the + * next pi session. + */ + toolDescriptionMode?: ToolDescriptionMode; + /** + * Whether the Claude Code-style FleetView (the navigable main+subagents list + * rendered below the editor) is shown. Defaults to `true`. Pure-UI: when off, + * the list never registers and the global key handler never captures input. + */ + fleetView?: boolean; + /** + * Whether `@handle message` typed at the prompt is routed to that subagent + * instead of the main model, and whether `@` offers running agents alongside + * pi's file completion. Defaults to `model`. Applied live. + * + * - `model`: mentioning an agent that is not running asks the main model to + * spawn it with the `Agent` tool, Claude Code's behaviour. Costs a turn, + * and the model writes the agent's prompt rather than your text being it. + * - `direct`: that agent is started here instead, with the typed message as + * its prompt and no main-model turn spent. + * - `off`: the input hook falls straight through and the stacked + * autocomplete provider delegates everything back to pi's built-in one. + * + * Messaging a running agent and resuming a finished one are direct in both + * `model` and `direct`. The legacy booleans are still accepted: `true` reads + * as `model`, `false` as `off`. + */ + agentMentions?: AgentMentionMode; + /** + * Whether subagents persist their pi session by default, so `@handle` can + * reopen an agent's conversation long after its in-memory record is gone. + * Defaults to `true`. Per-agent `persist_session:` frontmatter overrides it + * in both directions. Turning it off restores the previous behaviour, where + * a handle stops resolving roughly ten minutes after the agent finishes and + * mentioning it starts a fresh run instead. Persisted sessions also appear + * nested under the spawning session in pi's `/resume`. + */ + rememberAgents?: boolean; + /** + * Display mode for the persistent above-editor agent widget: + * - `all`: show every agent (foreground + background). + * - `background`: hide foreground agents — they already render inline as the + * Agent tool result, so the widget would otherwise double-render them + * (#118); everything else (background, queued, scheduled, RPC) stays. + * - `off`: hide the widget entirely. + * Defaults to `background`. Pure-UI and applied live (toggling refreshes the + * widget). + */ + widgetMode?: WidgetMode; + /** + * Project/global default for writing each subagent's `.output` transcript + * (a JSON-lines copy of the run, stored under the OS temp dir). + * Defaults to `true`. Set `false` to make transcripts opt-in for the whole + * project (e.g. a repo that shouldn't leave run transcripts on disk for backup + * or DLP tooling to ingest). A custom agent's `output_transcript` frontmatter + * overrides this per agent. This governs only the transcript — it does NOT + * affect the persisted pi session (`persist_session`), worktree commits + * (`isolation: worktree`), or memory files. + */ + outputTranscript?: boolean; + /** + * Whether `isolation: "worktree"` may create a worktree at all. Defaults to + * `true`. Set `false` on a repo where worktrees are too slow or too large to + * be worth it (#184): a requested worktree is then dropped and the agent runs + * in the main checkout. + * + * The drop is deliberately silent — there is no per-result note, because the + * setting exists for projects whose model asks for a worktree on every call, + * where a note would be noise on every result. What keeps the orchestrator + * from claiming a `pi-agent-*` branch anyway is that it is never told the + * capability exists: `isolationParam` (invocation-config.ts) drops the field + * from both tool schemas, and `isolationGuideline` (index.ts) drops the + * matching prose from the full and compact descriptions — a custom one opts + * in via the `{{isolationGuideline}}` placeholder. Anything that + * reintroduces the prose has to reintroduce a note with it. + * + * Deliberately a downgrade rather than an error. The fail-loud rule covers + * worktrees that *cannot* be created; this is the user declining one, and + * throwing would reject exactly the calls that the `isolation: "off"` value + * exists to tolerate. Enforced below the tool boundary, so it also covers the + * scheduler and the unvalidated cross-extension RPC path. + */ + worktreeIsolation?: boolean; + /** + * Hard ceiling on nested subagent delegation, counted from the main session: + * main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1` + * disables nesting project-wide. Read when a subagent session is built, so a + * change applies to agents started after it. + */ + maxSubagentDepth?: number; + /** + * Agent type substituted when a caller-supplied `subagent_type` doesn't + * resolve to exactly one enabled agent (unknown, disabled, or ambiguous by + * case). Omitted keeps the historical `general-purpose` fallback; a type name + * routes those calls to that agent instead; `"none"` disables the fallback so + * dispatch fails closed with an error naming the available types. + * + * The boolean `false` is accepted as a spelling of `"none"`, because a boolean + * would otherwise be dropped as the wrong type and silently leave the + * PERMISSIVE default in place while the author believes strict dispatch is on + * — the wrong direction to fail for this setting. Every other value is an + * agent name, so a mistaken `"off"` fails loudly at dispatch rather than + * meaning one thing here and another in the resolver. + */ + fallbackSubagent?: string; + /** + * Whether this extension's tool results carry a `usage` field, so subagent + * spend reaches the parent session's own accounting. Defaults to `false`. + * + * Subagents run in their own pi sessions, so by default the parent's footer, + * statusline and `/cost` show only what the main model spent — a session that + * delegated most of its work reads as nearly free. Pi folds + * `toolResult.usage` into `getSessionStats()`, so attaching it makes those + * surfaces count subagents too, under `/cost`'s "Tools/summaries" bucket. + * + * Off by default because it changes numbers the user may already be tracking + * (a statusline reading session cost will step up), not because the numbers + * are wrong. + * + * Three properties of what gets reported: + * - Tokens exclude `cacheRead`, for the reason in `usage.ts` — the parent's + * token total therefore rises by billed tokens only. + * - Cost is pi's own per-message `usage.cost.total`; we price nothing, and + * a model pi has no rates for contributes 0. + * - The context-window percentage is untouched. Pi derives it from assistant + * messages alone (`getContextUsage`), so a delegating session's context + * does not appear to fill up faster. + */ + reportUsage?: boolean; + /** + * Whether the subagent surfaces show an estimated dollar cost next to their + * token counts (widget, FleetView, conversation viewer, foreground results, + * completion notifications). Defaults to `false`. Applied live. + * + * Rendered as `~$0.0042` — the tilde marks it as pi's reported estimate + * rather than a billed figure, and it is omitted entirely when the model has + * no pricing data, so a local model shows tokens and no dollars. + * + * Independent of `reportUsage`: this one is what a human reads, that one is + * what the parent session counts. + */ + showCost?: boolean; +} + +export type ToolDescriptionMode = "full" | "compact" | "custom"; + +/** Setter hooks used by applySettings to wire persisted values into in-memory state. */ +export interface SettingsAppliers { + setMaxConcurrent: (n: number) => void; + setDefaultMaxTurns: (n: number) => void; + setGraceTurns: (n: number) => void; + setDefaultJoinMode: (mode: JoinMode) => void; + setBackgroundByDefault: (b: boolean) => void; + setSchedulingEnabled: (b: boolean) => void; + setScopeModels: (enabled: boolean) => void; + setStrictAgentFiles: (b: boolean) => void; + setDisableDefaultAgents: (b: boolean) => void; + setToolDescriptionMode: (mode: ToolDescriptionMode) => void; + setFleetView: (b: boolean) => void; + setAgentMentions: (mode: AgentMentionMode) => void; + setRememberAgents: (b: boolean) => void; + setWidgetMode: (mode: WidgetMode) => void; + setOutputTranscript: (b: boolean) => void; + setWorktreeIsolation: (b: boolean) => void; + setMaxSubagentDepth: (n: number) => void; + setFallbackSubagent: (v: string | undefined) => void; + setReportUsage: (b: boolean) => void; + setShowCost: (b: boolean) => void; +} + +/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */ +export type SettingsEmit = (event: string, payload: unknown) => void; + +const VALID_JOIN_MODES: ReadonlySet = new Set(["async", "group", "smart"]); +const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet = new Set(["full", "compact", "custom"]); +const VALID_WIDGET_MODES: ReadonlySet = new Set(["all", "background", "off"]); +const VALID_AGENT_MENTION_MODES: ReadonlySet = new Set(["model", "direct", "off"]); + +// Sanity ceilings — prevent hand-edited configs from asking for values that +// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough +// that any realistic power-user setting passes through. +const MAX_CONCURRENT_CEILING = 1024; +const MAX_TURNS_CEILING = 10_000; +const GRACE_TURNS_CEILING = 1_000; +const SUBAGENT_DEPTH_CEILING = 16; + +/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */ +function sanitize(raw: unknown): SubagentsSettings { + if (!raw || typeof raw !== "object") return {}; + const r = raw as Record; + const out: SubagentsSettings = {}; + if ( + Number.isInteger(r.maxConcurrent) && + (r.maxConcurrent as number) >= 1 && + (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING + ) { + out.maxConcurrent = r.maxConcurrent as number; + } + if ( + Number.isInteger(r.defaultMaxTurns) && + (r.defaultMaxTurns as number) >= 0 && + (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING + ) { + out.defaultMaxTurns = r.defaultMaxTurns as number; + } + if ( + Number.isInteger(r.graceTurns) && + (r.graceTurns as number) >= 1 && + (r.graceTurns as number) <= GRACE_TURNS_CEILING + ) { + out.graceTurns = r.graceTurns as number; + } + if ( + Number.isInteger(r.maxSubagentDepth) && + (r.maxSubagentDepth as number) >= 0 && + (r.maxSubagentDepth as number) <= SUBAGENT_DEPTH_CEILING + ) { + out.maxSubagentDepth = r.maxSubagentDepth as number; + } + if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) { + out.defaultJoinMode = r.defaultJoinMode as JoinMode; + } + if (typeof r.backgroundByDefault === "boolean") { + out.backgroundByDefault = r.backgroundByDefault; + } + if (typeof r.schedulingEnabled === "boolean") { + out.schedulingEnabled = r.schedulingEnabled; + } + if (typeof r.scopeModels === "boolean") { + out.scopeModels = r.scopeModels; + } + if (typeof r.strictAgentFiles === "boolean") { + out.strictAgentFiles = r.strictAgentFiles; + } + if (typeof r.disableDefaultAgents === "boolean") { + out.disableDefaultAgents = r.disableDefaultAgents; + } + if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) { + out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode; + } + if (typeof r.fleetView === "boolean") { + out.fleetView = r.fleetView; + } + // Was a boolean before the `model` mode existed. A hand-written or + // previously-written `true` means "on", which is now the default `model`. + if (typeof r.agentMentions === "boolean") { + out.agentMentions = r.agentMentions ? "model" : "off"; + } else if (typeof r.agentMentions === "string" && VALID_AGENT_MENTION_MODES.has(r.agentMentions)) { + out.agentMentions = r.agentMentions as AgentMentionMode; + } + if (typeof r.rememberAgents === "boolean") { + out.rememberAgents = r.rememberAgents; + } + if (typeof r.widgetMode === "string" && VALID_WIDGET_MODES.has(r.widgetMode)) { + out.widgetMode = r.widgetMode as WidgetMode; + } + if (typeof r.outputTranscript === "boolean") { + out.outputTranscript = r.outputTranscript; + } + if (typeof r.worktreeIsolation === "boolean") { + out.worktreeIsolation = r.worktreeIsolation; + } + if (typeof r.reportUsage === "boolean") { + out.reportUsage = r.reportUsage; + } + if (typeof r.showCost === "boolean") { + out.showCost = r.showCost; + } + if (r.fallbackSubagent === false) { + // The only non-string spelling worth accepting: a boolean would otherwise be + // dropped, silently leaving the PERMISSIVE default in place. Every string is + // an agent name except the `none` sentinel, which the resolver recognizes — + // so a mistaken "off" fails loudly at dispatch instead of meaning something + // different here than it does there. + out.fallbackSubagent = NO_FALLBACK; + } else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) { + out.fallbackSubagent = r.fallbackSubagent.trim(); + } + return out; +} + +function globalPath(): string { + return join(getAgentDir(), "subagents.json"); +} + +function projectPath(cwd: string): string { + return join(cwd, ".pi", "subagents.json"); +} + +/** + * Read a settings file. Missing file is silent (returns `{}`). A file that + * exists but can't be parsed emits a warning to stderr so users aren't + * silently reverted to defaults — and still returns `{}` so startup proceeds. + */ +function readSettingsFile(path: string): SubagentsSettings { + if (!existsSync(path)) return {}; + try { + return sanitize(JSON.parse(readFileSync(path, "utf-8"))); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`); + return {}; + } +} + +/** Load merged settings: global provides defaults, project overrides. */ +export function loadSettings(cwd: string = process.cwd()): SubagentsSettings { + return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) }; +} + +/** + * Write project-local settings. Global is never touched from code. + * Returns `true` on success, `false` if the write (or mkdir) failed so the + * caller can surface a warning — persistence isn't fatal but isn't silent. + */ +export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean { + const path = projectPath(cwd); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(s, null, 2), "utf-8"); + return true; + } catch { + return false; + } +} + +/** Apply persisted settings to the in-memory state via caller-supplied setters. */ +export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void { + if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent); + if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns); + if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns); + if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth); + if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent); + if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode); + if (typeof s.backgroundByDefault === "boolean") appliers.setBackgroundByDefault(s.backgroundByDefault); + if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled); + if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels); + if (typeof s.strictAgentFiles === "boolean") appliers.setStrictAgentFiles(s.strictAgentFiles); + if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents); + if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode); + if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView); + if (s.agentMentions) appliers.setAgentMentions(s.agentMentions); + if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents); + if (s.widgetMode) appliers.setWidgetMode(s.widgetMode); + if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript); + if (typeof s.worktreeIsolation === "boolean") appliers.setWorktreeIsolation(s.worktreeIsolation); + if (typeof s.reportUsage === "boolean") appliers.setReportUsage(s.reportUsage); + if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost); +} + +/** + * Format the user-facing toast for a settings mutation. Pure function — + * routes the success/failure of `saveSettings` into the right message + level + * so the UI layer (index.ts) stays a thin wire between input and notification. + */ +export function persistToastFor( + successMsg: string, + persisted: boolean, +): { message: string; level: "info" | "warning" } { + return persisted + ? { message: successMsg, level: "info" } + : { message: `${successMsg} (session only; failed to persist)`, level: "warning" }; +} + +/** + * Load merged settings, apply them to in-memory state, and emit the + * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so + * callers can log/inspect. Extension init wires this once. + */ +export function applyAndEmitLoaded( + appliers: SettingsAppliers, + emit: SettingsEmit, + cwd: string = process.cwd(), +): SubagentsSettings { + const settings = loadSettings(cwd); + applySettings(settings, appliers); + emit("subagents:settings_loaded", { settings }); + return settings; +} + +/** + * Persist a settings snapshot, emit the `subagents:settings_changed` event + * (regardless of persist outcome so listeners see the in-memory change), and + * return the toast the UI should display. Event payload carries the `persisted` + * flag so listeners can react to write failures. + */ +export function saveAndEmitChanged( + snapshot: SubagentsSettings, + successMsg: string, + emit: SettingsEmit, + cwd: string = process.cwd(), +): { message: string; level: "info" | "warning" } { + const persisted = saveSettings(snapshot, cwd); + emit("subagents:settings_changed", { settings: snapshot, persisted }); + return persistToastFor(successMsg, persisted); +} diff --git a/pi-subagents/src/skill-loader.ts b/pi-subagents/src/skill-loader.ts new file mode 100644 index 0000000..643589e --- /dev/null +++ b/pi-subagents/src/skill-loader.ts @@ -0,0 +1,102 @@ +/** + * skill-loader.ts — Preload named skills. + * + * Roots, in precedence order: + * - /.pi/skills (project, Pi's standard) + * - /.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io) + * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard) + * - ~/.agents/skills (user, cross-tool Agent Skills spec) + * - ~/.pi/skills (legacy global, pre-Pi) + * + * Layout per root: + * - /.md (flat file at the top level) + * - /...//SKILL.md (directory skill, may be nested — Pi's standard) + * + * Recursion skips dotfile entries and node_modules. A directory that itself contains + * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest). + * + * Symlinks are rejected for security (deviation from Pi, which follows them). + */ + +import type { Dirent } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js"; + +export interface PreloadedSkill { + name: string; + content: string; +} + +export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] { + return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) })); +} + +function loadSkillContent(name: string, cwd: string): string { + if (isUnsafeName(name)) { + return `(Skill "${name}" skipped: name contains path traversal characters)`; + } + const roots = [ + join(cwd, ".pi", "skills"), // project — Pi standard + join(cwd, ".agents", "skills"), // project — Agent Skills spec + join(getAgentDir(), "skills"), // user — Pi standard + join(homedir(), ".agents", "skills"), // user — Agent Skills spec + join(homedir(), ".pi", "skills"), // legacy global, pre-Pi + ]; + for (const root of roots) { + const content = findInRoot(root, name); + if (content !== undefined) return content; + } + return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`; +} + +function findInRoot(root: string, name: string): string | undefined { + if (isSymlink(root)) return undefined; // reject symlinked roots entirely + const flat = safeReadFile(join(root, `${name}.md`))?.trim(); + if (flat !== undefined) return flat; + return findSkillDirectory(root, name); +} + +/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */ +function findSkillDirectory(root: string, name: string): string | undefined { + if (!existsSync(root)) return undefined; + const queue: string[] = [root]; + + while (queue.length > 0) { + const current = queue.shift(); + if (current === undefined) continue; + + let entries: Dirent[]; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + // Deterministic byte-order traversal — locale-independent. + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + + // Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics. + const path = join(current, entry.name); + const skillMd = join(path, "SKILL.md"); + const isSkillDir = existsSync(skillMd); + + if (isSkillDir) { + if (entry.name === name) { + const content = safeReadFile(skillMd)?.trim(); + if (content !== undefined) return content; + } + continue; // Pi rule: skills don't nest — don't descend into a skill dir + } + + queue.push(path); + } + } + return undefined; +} diff --git a/pi-subagents/src/status-note.ts b/pi-subagents/src/status-note.ts new file mode 100644 index 0000000..0831c36 --- /dev/null +++ b/pi-subagents/src/status-note.ts @@ -0,0 +1,90 @@ +/** + * status-note.ts — Honest framing for an agent result: the parenthetical status + * note for a non-normal outcome, and the salvaged partial output of a failure. + * + * Lives here rather than in an index.ts closure because both entry points need + * it — the top-level tools and the nested delegation tools, which can't import + * from index.ts (that is the extension entry, and it already reaches these tools + * through agent-runner). + */ + +import type { AgentRecord } from "./types.js"; + +/** + * Explicit parenthetical note for a non-normal terminal outcome, so the parent + * agent can't mistake partial output for a completed result. Empty string for a + * clean completion (and any unknown/non-terminal status). + * + * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the + * turn limit was hit) — the parent should treat human intervention differently + * from a budget cutoff. + */ +export function getStatusNote(status: string): string { + switch (status) { + case "stopped": + return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)"; + case "aborted": + return " (aborted — hit the turn limit before completion; output may be incomplete)"; + case "steered": + return " (wrapped up at the turn limit — output may be partial)"; + default: + return ""; + } +} + +/** + * Foreground variant of `getStatusNote`. A foreground caller is in a different + * position from a background one, so it needs different text: + * + * - It already holds the agent's ENTIRE output inline, whereas the background + * notification carries a 500-char preview. So only here can we truthfully + * say there is nothing more to fetch — which is the whole point, because + * - it has no agent id. The id travels in the tool result's renderer + * `details`, which is never serialized to the model. A parent that reads + * "output may be partial" as "truncated, go retrieve the rest" therefore + * has nothing valid to call `get_subagent_result` with, and will invent an + * id (#174). + * + * Only the lead clause varies between the three, and each variation carries + * information: `wrapped up` vs `aborted` tells the parent whether the output is + * a considered final answer or a fragment, and `stopped` shouts because a human + * intervening outranks everything else in the string. Only `steered` hedges on + * completion — it was told to wrap up and did, so it may well have finished at + * the limit; an aborted run blew through its grace turns while still working, + * and `stopped` can only fire on a running agent, so neither ever delivered a + * final answer. Identical confidence gets identical wording: phrasing one fact + * two ways invites a hunt for a distinction that isn't there. + * + * Every clause is a statement about state, never an instruction to act, and + * `get_subagent_result` is never named — naming the tool we steer away from only + * raises its salience. Two instructions were tried here and cut: "re-spawn with + * a higher max_turns" (pushes a fresh multi-minute run to save one wasted tool + * call) and, on `stopped`, "ask before restarting it" (restates the lead, and + * presumes someone is present to ask — false under `pi -p`, in scheduled jobs, + * and in any background-driven run). Nothing here can measure whether wording + * improves parent behavior, so removing a false cue (which cannot induce new + * behavior) and adding an instruction (which can) are not equally safe bets. + * Don't add either back without a way to measure it. + */ +export function getForegroundOutcomeNote(status: string): string { + switch (status) { + case "stopped": + return " (STOPPED BY THE USER — everything the agent produced is above; the task is unfinished)"; + case "aborted": + return " (aborted at the turn limit — everything the agent produced is above; the task is unfinished)"; + case "steered": + return " (wrapped up at the turn limit — everything the agent produced is above; the task may be unfinished)"; + default: + return ""; + } +} + +/** + * Salvaged partial output of a failed run, as a labeled suffix for the error + * surfaces (or "" if the run produced nothing). `record.result` is bounded to + * the run's own turns, so this is never a stale earlier answer (#144). + */ +export function partialOutputSuffix(record: AgentRecord): string { + const partial = record.result?.trim(); + return partial ? `\n\nPartial output before the failure:\n${partial}` : ""; +} diff --git a/pi-subagents/src/types.ts b/pi-subagents/src/types.ts new file mode 100644 index 0000000..99f751f --- /dev/null +++ b/pi-subagents/src/types.ts @@ -0,0 +1,307 @@ +/** + * types.ts — Type definitions for the subagent system. + */ + +import type { ThinkingLevel } from "@earendil-works/pi-ai"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { LifetimeUsage } from "./usage.js"; + +export type { ThinkingLevel }; + +/** Agent type: any string name (built-in defaults or user-defined). */ +export type SubagentType = string; + +/** Names of the three embedded default agents. */ +export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const; + +/** Memory scope for persistent agent memory. */ +export type MemoryScope = "user" | "project" | "local"; + +/** + * Isolation mode for agent execution. + * + * `"off"` exists for the caller's benefit, not the runtime's: models that fill + * every optional parameter had no legal way to decline a single-value + * `isolation` field and kept spawning worktrees they had just reasoned their + * way out of (#231, #184). It is an input spelling only — + * `resolveAgentInvocationConfig` collapses it to `undefined`, so nothing + * downstream sees a value other than `"worktree"`. In an agent file it is a + * genuine veto, since agent config outranks tool-call params. + */ +export type IsolationMode = "worktree" | "off"; + +/** Unified agent configuration — used for both default and user-defined agents. */ +export interface AgentConfig { + name: string; + /** UI name. `display_name` wins; Claude Code's `name` is accepted as a fallback. */ + displayName?: string; + /** Claude Code-compatible name color (named color or #RRGGBB). */ + color?: string; + description: string; + builtinToolNames?: string[]; + /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"]. + * Presence of any entry flips extension tools to an explicit allowlist. */ + extSelectors?: string[]; + /** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */ + disallowedTools?: string[]; + /** true = inherit all, string[] = only listed, false = none */ + extensions: true | string[] | false; + /** Extension-name denylist applied after the `extensions:` include set. Exclude wins. + * Plain canonical names only (case-insensitive); no paths, no wildcard. */ + excludeExtensions?: string[]; + /** true = inherit all, string[] = only listed, false = none */ + skills: true | string[] | false; + model?: string; + thinking?: ThinkingLevel; + maxTurns?: number; + /** Persist this subagent as a normal pi session instead of keeping it in memory only. */ + persistSession?: boolean; + /** Write the subagent's .output transcript. Defaults to true; false suppresses only that transcript. */ + outputTranscript?: boolean; + /** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */ + sessionDir?: string; + /** + * Nested delegation, off by default: undefined = no nested tools; + * "all" = any enabled agent; string[] = only those agent types. + */ + allowedSubagents?: "all" | string[]; + systemPrompt: string; + promptMode: "replace" | "append"; + /** Default for spawn: fork parent conversation. undefined = caller decides. */ + inheritContext?: boolean; + /** Default for spawn: run in background. undefined = caller decides. */ + runInBackground?: boolean; + /** Default for spawn: no extension tools. undefined = caller decides. */ + isolated?: boolean; + /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */ + memory?: MemoryScope; + /** + * Isolation mode — "worktree" runs the agent in a temporary git worktree, + * "off" refuses one even when the caller asks (frontmatter outranks params). + */ + isolation?: IsolationMode; + /** true = this is an embedded default agent (informational) */ + isDefault?: boolean; + /** false = agent is hidden from the registry */ + enabled?: boolean; + /** Where this agent was loaded from */ + source?: "default" | "project" | "global"; + /** Path of the .md it was loaded from. Unset for embedded defaults. */ + sourcePath?: string; +} + +export type JoinMode = 'async' | 'group' | 'smart'; + +/** + * Display mode for the persistent above-editor agent widget. + * - `all`: show every agent (foreground + background). + * - `background`: hide foreground agents (they already render inline as the + * Agent tool result, #118); show background/queued/scheduled/RPC. + * - `off`: hide the widget entirely. + */ +export type WidgetMode = 'all' | 'background' | 'off'; + +/** + * How `@handle message` starts an agent that is not already running. + * - `model`: inject Claude Code's `agent_mention` reminder and let the main + * model spawn it with the `Agent` tool, which is what Claude Code does. + * - `direct`: spawn it here, immediately, with the typed message as its prompt + * and no main-model turn spent. + * - `off`: `@` means only "attach a file" again. + * + * Messaging a running agent and resuming a finished one are direct in every + * mode — Claude Code only differs from us on the *new* invocation. + */ +export type AgentMentionMode = 'model' | 'direct' | 'off'; + +/** + * What survives a record's eviction so `@handle` keeps working. The live record + * is discarded after ~10 minutes, but the pi session it wrote is still on disk, + * and this is the little that is needed to find and describe it again. + */ +export interface AgentTombstone { + handle: string; + alias?: string; + id: string; + type: SubagentType; + description: string; + /** Always set — a record with no session file is never tombstoned. */ + sessionFile: string; + completedAt: number; +} + +/** + * What `@handle` resolved to: an agent still in memory, or the remains of one + * whose conversation can be reopened from disk. + */ +export type MentionResolution = + | { kind: "live"; record: AgentRecord } + | { kind: "tombstone"; entry: AgentTombstone }; + +export interface AgentRecord { + id: string; + type: SubagentType; + /** + * Typeable name for the `@handle message` prompt mention, derived from the + * agent type and numbered when siblings collide (`explore`, `explore-2`). + * Top-level agents only — nested children are hidden from every top-level + * surface, so nothing can address them. + */ + handle?: string; + /** + * A second, memorable handle from the spawner's `name` (`@auth-audit`), drawn + * from the same namespace as `handle` so the two can never collide. Purely + * additive: `handle` is assigned regardless, so a named agent stays reachable + * by its type and `@explore` never comes to mean "start another one". + */ + alias?: string; + description: string; + status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error"; + result?: string; + error?: string; + toolUses: number; + startedAt: number; + completedAt?: number; + session?: AgentSession; + abortController?: AbortController; + promise?: Promise; + groupId?: string; + joinMode?: JoinMode; + /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */ + resultConsumed?: boolean; + /** Steering messages queued before the session was ready. */ + pendingSteers?: string[]; + /** Worktree info if the agent is running in an isolated worktree. */ + worktree?: { path: string; branch: string; baseSha: string; workPath: string }; + /** Worktree cleanup result after agent completion. */ + worktreeResult?: { hasChanges: boolean; branch?: string }; + /** The tool_use_id from the original Agent tool call. */ + toolCallId?: string; + /** Path to the streaming output transcript file. */ + outputFile?: string; + /** + * The agent's pi session file, when it was persisted (`persist_session`, or + * the `rememberAgents` default). Captured so a mention can reopen the + * conversation after the record itself has been evicted; undefined for an + * in-memory session, which leaves nothing to reopen. + */ + sessionFile?: string; + /** Cleanup function for the output file stream subscription. */ + outputCleanup?: () => void; + /** + * Lifetime usage breakdown, accumulated via `message_end` events. Survives + * compaction. Total = input + output + cacheWrite (cacheRead deliberately + * excluded — see issue #38). Initialized to zeros at spawn. + */ + lifetimeUsage: LifetimeUsage; + /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */ + compactionCount: number; + /** + * Whether this agent was spawned to run in the background. Tri-state, set at + * spawn from `SpawnOptions.isBackground`: `true` = background, `false` = + * foreground (has an inline Agent tool-result surface), `undefined` = the + * caller never declared it (e.g. a cross-extension RPC spawn, which is detached + * and has no inline surface). The widget's background-only filter keys off this + * — and excludes only explicit `false`, so `undefined` agents stay visible. + * Reliable across ALL spawn paths, unlike the UI-only `invocation` snapshot, + * which only the Agent-tool path populates. + */ + isBackground?: boolean; + /** Resolved spawn params, captured for UI display. Fixed at spawn time. */ + invocation?: AgentInvocation; + /** Nesting depth: top-level subagent = 1. */ + depth?: number; + /** Parent agent ID for ownership-scoped nested controls. */ + parentAgentId?: string; + /** Effective inherited nesting cap for this branch. */ + maxSubagentDepth?: number; + /** + * Session id of the root (main) session this branch descends from. Nested + * spawns inherit it so their transcripts file under the same session + * directory as their ancestors' instead of the child session's own id. + */ + rootSessionId?: string; +} + +export interface AgentInvocation { + /** Short display name, e.g. "haiku" — only set when different from parent. */ + modelName?: string; + thinking?: ThinkingLevel; + maxTurns?: number; + isolated?: boolean; + inheritContext?: boolean; + runInBackground?: boolean; + isolation?: IsolationMode; +} + +/** Details attached to custom notification messages for visual rendering. */ +export interface NotificationDetails { + id: string; + description: string; + status: string; + toolUses: number; + turnCount: number; + maxTurns?: number; + totalTokens: number; + /** + * Estimated cost in USD, from pi's per-message `usage.cost.total`. Always + * populated (0 when the model has no pricing); the renderer decides whether + * to show it, per the `showCost` setting. + */ + totalCost?: number; + durationMs: number; + outputFile?: string; + error?: string; + resultPreview: string; + /** Additional agents in a group notification. */ + others?: NotificationDetails[]; +} + +export interface EnvInfo { + isGitRepo: boolean; + branch: string; + platform: string; +} + +/** + * A subagent spawn registered to fire on a schedule. + * + * Stored at `/.pi/subagent-schedules/.json`. Session-scoped: + * survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks. + */ +export interface ScheduledSubagent { + id: string; + /** Unique within store. Defaults to `description`. */ + name: string; + description: string; + /** Raw user input — cron expr | "+10m" | ISO | "5m". */ + schedule: string; + scheduleType: "cron" | "once" | "interval"; + /** Computed at create time for interval/once. */ + intervalMs?: number; + + // spawn params (subset of Agent tool params; no inherit_context, no resume) + subagent_type: SubagentType; + prompt: string; + model?: string; + thinking?: ThinkingLevel; + max_turns?: number; + isolated?: boolean; + isolation?: IsolationMode; + + // state + enabled: boolean; + /** ISO timestamp. */ + createdAt: string; + lastRun?: string; + lastStatus?: "success" | "error" | "running"; + /** Refreshed on every fire and on store load. */ + nextRun?: string; + runCount: number; +} + +export interface ScheduleStoreData { + /** For future migrations. */ + version: 1; + jobs: ScheduledSubagent[]; +} diff --git a/pi-subagents/src/ui/agent-mention.ts b/pi-subagents/src/ui/agent-mention.ts new file mode 100644 index 0000000..f845456 --- /dev/null +++ b/pi-subagents/src/ui/agent-mention.ts @@ -0,0 +1,216 @@ +/** + * agent-mention.ts — what `@` can address, and the suggestions pi renders for it. + * + * A subagent is addressable whether or not it is currently running: a live + * record is messaged or resumed, an evicted one whose session is still on disk + * is reopened, and an agent *type* with no instance at all is started. That is + * the point of the handle — `@explore` means the Explore agent, not "the + * Explore process that happens to exist right now" — so the roster below unions + * all three, and the dispatcher and the popup read the same list. + * + * Rows are per *agent*, not per handle. An agent given a `name` holds two names + * (its alias and its type-derived handle) and both resolve, but it lists once, + * under the alias, with its type moved into the description so the row still + * says what it is. + * + * pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a + * file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this + * provider adds the `@` tokens that name an agent and delegates everything else + * — including all of `applyCompletion`, whose `@`-branch already inserts + * `item.value` plus a trailing space, which is exactly what a handle needs. + * + * Matching mirrors Claude Code: case-insensitive prefix, not fuzzy. What it does + * NOT mirror is Claude Code dropping files whenever an agent matches. Here `@` is + * pi's file picker first, and the handles are additive, so a token matching both + * lists both — agents first. Suppressing on any match sounds narrow and is not: + * an empty token prefix-matches every handle, so a bare `@` — the gesture people + * use to browse files — would offer no files at all, and a single letter + * beginning any handle would do the same. + * + * Both halves ship under ONE `prefix`, which is sound because wherever BOTH sides + * produce rows they measured the same span. pi's `extractAtPrefix` takes the + * token after the last of `{space, tab, ", ', =}` and keeps it only if it starts + * with `@`; `MENTION_TRIGGER` matches `@[\w-]*` at the cursor, after start-of-line + * or `[\s。、?!]`. Where those two disagree, exactly one side answers and there + * is nothing to merge: `@src/index.ts` and `@"my file` are pi's alone (no handle + * matches), `=@ex` is pi's alone (`=` is a delimiter to pi, not a boundary to us), + * and `。@ex` is ours alone (the reverse). A merged response therefore never + * carries a prefix from one side and an item from the other. + * + * Offering never-started types is a deliberate step beyond Claude Code, whose + * registry holds only live tasks, so an agent you had not launched yet was + * unaddressable. + */ + +import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui"; +import type { AgentManager } from "../agent-manager.js"; +import { handleBase, MENTION_TRIGGER } from "../mention.js"; +import type { AgentRecord, AgentTombstone } from "../types.js"; + +/** + * One thing `@` can address, and what sending to it will do. `typeLabel` is the + * agent's `display_name`, resolved by the caller: this module stays independent + * of the type registry, but the popup must agree with FleetView and the widget, + * which both render the label rather than the raw type. + */ +export type MentionTarget = + | { kind: "record"; handle: string; record: AgentRecord; typeLabel: string } + | { kind: "tombstone"; handle: string; entry: AgentTombstone; typeLabel: string } + | { kind: "type"; handle: string; type: string; description: string }; + +/** The registry facts the roster needs, so it stays independent of agent-types. */ +export type TypeInfo = { name: string; description: string }; + +/** + * Everything `@` can reach, in the order the popup lists it: steerable agents + * first, then the other live ones earliest-launched, then agent types with no + * live instance. A type whose handle a record already holds is omitted — that + * name addresses the existing agent, which is what makes `@explore` mean + * "message the one that's running" and only otherwise "start one". + */ +export function mentionRoster( + manager: AgentManager, + types: readonly TypeInfo[], + // Identity by default: a caller with no registry to consult gets the raw + // type, which is also what `getConfig` falls back to when no label is set. + displayNameOf: (type: string) => string = type => type, +): MentionTarget[] { + const live = (r: AgentRecord) => r.status === "running" || r.status === "queued"; + const records = manager.listAgents() + .filter(r => r.handle !== undefined && r.parentAgentId === undefined) + .sort((a, b) => (Number(live(b)) - Number(live(a))) || (a.startedAt - b.startedAt)); + + const taken = new Set(); + const targets: MentionTarget[] = []; + + // One row per agent, not per handle. An aliased agent lists under its alias + // only — both names resolve, but showing two rows for one agent reads as two + // agents. The type handle stays addressable whether or not it is listed. + for (const record of records) { + const handle = record.alias ?? record.handle!; + taken.add(handle.toLowerCase()); + if (record.handle) taken.add(record.handle.toLowerCase()); + targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) }); + } + + // Then agents that are gone but whose conversation can be reopened. After the + // live ones: a running agent is the likelier target, and this keeps the + // ordering "what exists now, then what can be brought back, then what can be + // started". + for (const entry of manager.listTombstones()) { + const handle = entry.alias ?? entry.handle; + if (taken.has(handle.toLowerCase())) continue; + taken.add(handle.toLowerCase()); + taken.add(entry.handle.toLowerCase()); + targets.push({ kind: "tombstone", handle, entry, typeLabel: displayNameOf(entry.type) }); + } + + for (const type of types) { + const handle = handleBase(type.name); + if (taken.has(handle)) continue; + taken.add(handle); + targets.push({ kind: "type", handle, type: type.name, description: type.description }); + } + return targets; +} + +export function createMentionProvider( + current: AutocompleteProvider, + roster: () => MentionTarget[], + isEnabled: () => boolean, +): AutocompleteProvider { + // One warning per provider, not per keystroke: `getSuggestions` runs on every + // character typed after `@`, so an unguarded log would bury the terminal in + // the time it takes to finish a word. + let warnedInnerFailure = false; + return { + // Only `@` — the contract is "characters that should naturally trigger + // THIS provider", and pi unions each wrapper's own set onto the outermost + // one itself (interactive-mode.js:432), so re-declaring the wrapped + // provider's characters here would both misreport us and duplicate that. + triggerCharacters: ["@"], + + async getSuggestions(lines, cursorLine, cursorCol, options): Promise { + const mine = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null; + // Asked unconditionally: pi owns `@` and must keep answering for it even + // when a handle matches too. That is the same work vanilla pi does on any + // `@` keystroke — a capped `fd` search, or nothing at all when the host + // configured no `fd` path — but we now do it on tokens we used to answer + // alone, so it must not be able to take the popup down with it. The + // wrapped provider is not always pi's: another extension may sit inside + // us, and before this it was never called for a token naming an agent. + // try/catch, not `.catch()`: a provider that throws SYNCHRONOUSLY never + // returns the promise a `.catch()` would attach to, and the throw escapes + // this method as a rejection — which pi does not handle either + // (components/editor.js:1892 awaits with no catch of its own). + let theirs: AutocompleteSuggestions | null = null; + try { + theirs = await current.getSuggestions(lines, cursorLine, cursorCol, options); + } catch (err) { + // Safe to treat as "no files": pi discards any response whose request is + // no longer current, so an aborted search that surfaces as a rejection + // cannot leave a stale popup behind (`isAutocompleteRequestCurrent`). + // Warned rather than swallowed outright — the failure is invisible in + // the popup, and the same `console.warn` channel already carries this + // extension's other non-fatal failures. + if (!warnedInnerFailure) { + warnedInnerFailure = true; + console.warn("[pi-subagents] the autocomplete provider below us failed; showing agent rows only:", err); + } + theirs = null; + } + if (!mine) return theirs; + if (!theirs) return mine; + // Agents first: there are a handful of them against pi's 20 file rows, and + // a handle buried under fuzzy path matches is a handle nobody finds. The + // prefix is ours by the span argument in the header — identical to pi's + // whenever both sides have something to say. + return { items: [...mine.items, ...theirs.items], prefix: mine.prefix }; + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + }; +} + +/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */ +function mentionItems(roster: MentionTarget[], line: string, cursorCol: number): AutocompleteSuggestions | null { + const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol)); + if (!match) return null; + + const typed = match[2].toLowerCase(); + const items: AutocompleteItem[] = []; + for (const target of roster) { + if (!target.handle.toLowerCase().startsWith(typed)) continue; + items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) }); + } + return items.length > 0 ? { items, prefix: `@${match[2]}` } : null; +} + +/** Name the action that will actually happen, so the list never mispromises. */ +function describeTarget(target: MentionTarget): string { + if (target.kind === "type") return `start agent · ${summarize(target.description)}`; + if (target.kind === "tombstone") { + // No status: the record is gone, and "completed" would imply one is still + // being tracked. The type carries the identity the handle may not. + return `resume · ${target.typeLabel} · ${target.entry.description}`; + } + const { status, description, alias } = target.record; + const action = status === "running" || status === "queued" ? "send message" : "resume"; + // A row listed under its alias has lost the type its handle would have shown, + // so name it — `@auth-audit` alone says nothing about what the agent is. + // A type-derived row already reads as its type and would just repeat itself. + const identity = alias ? `${target.typeLabel} · ` : ""; + return `${action} · ${identity}${status} · ${description}`; +} + +/** First sentence of an agent description, clipped — these run to paragraphs. */ +function summarize(description: string): string { + const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim(); + return first.length > 60 ? `${first.slice(0, 59).trimEnd()}…` : first; +} diff --git a/pi-subagents/src/ui/agent-widget.ts b/pi-subagents/src/ui/agent-widget.ts new file mode 100644 index 0000000..81a3b98 --- /dev/null +++ b/pi-subagents/src/ui/agent-widget.ts @@ -0,0 +1,629 @@ +/** + * agent-widget.ts — Persistent widget showing running/completed agents above the editor. + * + * Displays a tree of agents with animated spinners, live stats, and activity descriptions. + * Uses the callback form of setWidget for themed rendering. + */ + +import { truncateToWidth } from "@earendil-works/pi-tui"; +import { renderAgentName } from "../agent-color.js"; +import type { AgentManager } from "../agent-manager.js"; +import { getConfig } from "../agent-types.js"; +import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js"; +import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js"; + +// ---- Constants ---- + +/** Maximum number of rendered lines before overflow collapse kicks in. */ +const MAX_WIDGET_LINES = 12; + +/** Braille spinner frames for animated running indicator. */ +export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */ +export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]); + +/** Tool name → human-readable action for activity descriptions. */ +const TOOL_DISPLAY: Record = { + read: "reading", + bash: "running command", + edit: "editing", + write: "writing", + grep: "searching", + find: "finding files", + ls: "listing", +}; + +// ---- Types ---- + +export type Theme = { + fg(color: string, text: string): string; + bold(text: string): string; +}; + +export type UICtx = { + setStatus(key: string, text: string | undefined): void; + setWidget( + key: string, + content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }), + options?: { placement?: "aboveEditor" | "belowEditor" }, + ): void; +}; + +/** Per-agent live activity state. */ +export interface AgentActivity { + activeTools: Map; + toolUses: number; + responseText: string; + session?: SessionLike; + /** Current turn count. */ + turnCount: number; + /** Effective max turns for this agent (undefined = unlimited). */ + maxTurns?: number; +} + +/** Metadata attached to Agent tool results for custom rendering. */ +export interface AgentDetails { + displayName: string; + description: string; + subagentType: string; + toolUses: number; + tokens: string; + durationMs: number; + status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background"; + /** Human-readable description of what the agent is currently doing. */ + activity?: string; + /** Current spinner frame index (for animated running indicator). */ + spinnerFrame?: number; + /** Short model name if different from parent (e.g. "haiku", "sonnet"). */ + modelName?: string; + /** Notable config tags (e.g. ["thinking: high", "isolated"]). */ + tags?: string[]; + /** Current turn count. */ + turnCount?: number; + /** Effective max turns (undefined = unlimited). */ + maxTurns?: number; + /** Estimated cost in USD; 0 when the model has no pricing data. */ + cost?: number; + agentId?: string; + error?: string; +} + +// ---- Formatting helpers ---- + +/** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */ +export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string { + const styledEmpty = theme.fg(color, ""); + const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, ""); + return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`)); +} + +/** Format a token count compactly: "33.8k token", "1.2M token". */ +export function formatTokens(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`; + return `${count} token`; +} + +/** + * Format a cost as `~$0.0042`, or "" when there is nothing to show. + * + * The tilde is load-bearing: this is pi's own estimate from the model's listed + * rates, not a billed figure, and the surfaces that print it sit next to token + * counts that ARE exact. + * + * Nothing is printed for zero, which is also what a model with no pricing data + * reports: `$0.00` beside a local model's tokens would claim its cost was + * measured and found to be nothing, rather than never measured at all. For the + * same reason a real cost too small for four decimals reads `<$0.0001` — it was + * measured, and rounding it to `~$0.0000` would say the opposite. + */ +export function formatCost(cost: number): string { + if (!(cost > 0)) return ""; // also catches NaN + if (cost < 0.0001) return "<$0.0001"; + if (cost >= 1) return `~$${cost.toFixed(2)}`; + // Under a dollar: cents at minimum, four decimals at most, nothing trailing. + // Most single runs land between a tenth of a cent and a dime, where rounding + // to cents would collapse a 4x difference in spend into the same figure. + const rounded = Number(cost.toFixed(4)); + const decimals = (String(rounded).split(".")[1] ?? "").length; + return `~$${rounded.toFixed(Math.max(2, decimals))}`; +} + +/** + * Token count with optional context-fill % and compaction-count annotations. + * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error. + * Compaction count rendered as `⇊N` in dim. + * + * "12.3k token" — no annotations + * "12.3k token (45%)" — percent only + * "12.3k token (⇊2)" — compactions only (e.g. right after compact) + * "12.3k token (45% · ⇊2)" — both + */ +export function formatSessionTokens( + tokens: number, + percent: number | null, + theme: Theme, + compactions = 0, +): string { + const tokenStr = formatTokens(tokens); + const annot: string[] = []; + if (percent !== null) { + const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim"; + annot.push(theme.fg(color, `${Math.round(percent)}%`)); + } + if (compactions > 0) { + annot.push(theme.fg("dim", `⇊${compactions}`)); + } + if (annot.length === 0) return tokenStr; + return `${tokenStr} (${annot.join(" · ")})`; +} + +/** Format turn count with optional max limit: "↻5≤30" or "↻5". */ +export function formatTurns(turnCount: number, maxTurns?: number | null): string { + return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`; +} + +/** Format milliseconds as human-readable duration. */ +export function formatMs(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +/** Format duration from start/completed timestamps. */ +export function formatDuration(startedAt: number, completedAt?: number): string { + if (completedAt) return formatMs(completedAt - startedAt); + return `${formatMs(Date.now() - startedAt)} (running)`; +} + +/** Get display name for any agent type (built-in or custom). */ +export function getDisplayName(type: SubagentType): string { + return getConfig(type).displayName; +} + +/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */ +export function getPromptModeLabel(type: SubagentType): string | undefined { + const config = getConfig(type); + return config.promptMode === "append" ? "twin" : undefined; +} + +/** Mode label is not included — callers add it where they want it. */ +export function buildInvocationTags( + invocation: AgentInvocation | undefined, +): { modelName?: string; tags: string[] } { + const tags: string[] = []; + if (!invocation) return { tags }; + if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`); + if (invocation.isolated) tags.push("isolated"); + if (invocation.isolation === "worktree") tags.push("worktree"); + if (invocation.inheritContext) tags.push("inherit context"); + if (invocation.runInBackground) tags.push("background"); + if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`); + return { modelName: invocation.modelName, tags }; +} + +/** Truncate text to a single line, max `len` chars. */ +function truncateLine(text: string, len = 60): string { + const line = text.split("\n").find(l => l.trim())?.trim() ?? ""; + if (line.length <= len) return line; + return line.slice(0, len) + "…"; +} + +/** Build a human-readable activity string from currently-running tools or response text. */ +export function describeActivity(activeTools: Map, responseText?: string): string { + if (activeTools.size > 0) { + const groups = new Map(); + for (const toolName of activeTools.values()) { + const action = TOOL_DISPLAY[toolName] ?? toolName; + groups.set(action, (groups.get(action) ?? 0) + 1); + } + + const parts: string[] = []; + for (const [action, count] of groups) { + if (count > 1) { + parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`); + } else { + parts.push(action); + } + } + return parts.join(", ") + "…"; + } + + // No tools active — show truncated response text if available + if (responseText && responseText.trim().length > 0) { + return truncateLine(responseText); + } + + return "thinking…"; +} + +// ---- Widget manager ---- + +export class AgentWidget { + private uiCtx: UICtx | undefined; + private widgetFrame = 0; + private widgetInterval: ReturnType | undefined; + /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */ + private finishedTurnAge = new Map(); + /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */ + private static readonly ERROR_LINGER_TURNS = 2; + + /** Whether the widget callback is currently registered with the TUI. */ + private widgetRegistered = false; + /** Cached TUI reference from widget factory callback, used for requestRender(). */ + private tui: any | undefined; + /** Last status bar text, used to avoid redundant setStatus calls. */ + private lastStatusText: string | undefined; + + constructor( + private manager: AgentManager, + private agentActivity: Map, + /** + * Read live at render time. Selects which agents the widget shows — see + * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the + * extension supplies one defaulting to `"background"`. + */ + private mode: () => WidgetMode = () => "all", + /** + * Read live at render time, like `mode`. Whether running agents show an + * estimated cost beside their token count. Defaults to off — the extension + * supplies the user's `showCost` setting. + */ + private showCost: () => boolean = () => false, + ) {} + + /** + * Agents eligible for the widget, per the current `WidgetMode`: + * - `off`: none (the widget's existing empty-state path hides it entirely). + * - `background`: drop only agents *known* to be foreground + * (`isBackground === false`); keep everything else — background, queued, + * scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground` + * record flag rather than the UI-only `invocation` snapshot (which only the + * Agent-tool path sets), and excluding rather than allow-listing, means + * only proven-foreground runs drop out — nothing else silently vanishes. + * - `all`: every agent. + */ + private widgetAgents() { + const all = this.manager.listAgents().filter(a => !a.parentAgentId); + switch (this.mode()) { + case "off": return []; + case "background": return all.filter(a => a.isBackground !== false); + default: return all; + } + } + + /** Set the UI context (grabbed from first tool execution). */ + setUICtx(ctx: UICtx) { + if (ctx !== this.uiCtx) { + // UICtx changed — the widget registered on the old context is gone. + // Force re-registration on next update(). + this.uiCtx = ctx; + this.widgetRegistered = false; + this.tui = undefined; + this.lastStatusText = undefined; + } + } + + /** + * Called on each new turn (tool_execution_start). + * Ages finished agents and clears those that have lingered long enough. + */ + onTurnStart() { + // Age all finished agents + for (const [id, age] of this.finishedTurnAge) { + this.finishedTurnAge.set(id, age + 1); + } + // Trigger a widget refresh (will filter out expired agents) + this.update(); + } + + /** Ensure the widget update timer is running. */ + ensureTimer() { + if (!this.widgetInterval) { + this.widgetInterval = setInterval(() => this.update(), 80); + } + } + + /** Check if a finished agent should still be shown in the widget. */ + private shouldShowFinished(agentId: string, status: string): boolean { + const age = this.finishedTurnAge.get(agentId) ?? 0; + const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1; + return age < maxAge; + } + + /** Record an agent as finished (call when agent completes). */ + markFinished(agentId: string) { + if (!this.finishedTurnAge.has(agentId)) { + this.finishedTurnAge.set(agentId, 0); + } + } + + /** + * Drop an agent's finished-age (call when a settled agent starts running + * again, i.e. a background resume). markFinished only seeds an age it has not + * seen before, so a resumed agent would otherwise keep the age from its + * previous run — already past the linger limit, hiding the new run's + * completion line entirely. + */ + markRunning(agentId: string) { + this.finishedTurnAge.delete(agentId); + } + + /** Render a finished agent line. */ + private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string { + const modeLabel = getPromptModeLabel(a.type); + const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt); + + let icon: string; + let statusText: string; + if (a.status === "completed") { + icon = theme.fg("success", "✓"); + statusText = ""; + } else if (a.status === "steered") { + icon = theme.fg("warning", "✓"); + statusText = theme.fg("warning", " (turn limit)"); + } else if (a.status === "stopped") { + icon = theme.fg("dim", "■"); + statusText = theme.fg("dim", " stopped"); + } else if (a.status === "error") { + icon = theme.fg("error", "✗"); + const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : ""; + statusText = theme.fg("error", ` error${errMsg}`); + } else { + // aborted + icon = theme.fg("error", "✗"); + statusText = theme.fg("warning", " aborted"); + } + + const parts: string[] = []; + const activity = this.agentActivity.get(a.id); + if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns)); + if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`); + // From the record, not the activity tracker: that entry is deleted the + // moment an agent finishes, and "what did it cost" is a question asked + // about finished agents. + const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : ""; + if (costText) parts.push(costText); + parts.push(duration); + + const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : ""; + return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`; + } + + /** + * Render the widget content. Called from the registered widget's render() callback, + * reading live state each time instead of capturing it in a closure. + */ + private renderWidget(tui: any, theme: Theme): string[] { + const allAgents = this.widgetAgents(); + const running = allAgents.filter(a => a.status === "running"); + const queued = allAgents.filter(a => a.status === "queued"); + const finished = allAgents.filter(a => + a.status !== "running" && a.status !== "queued" && a.completedAt + && this.shouldShowFinished(a.id, a.status), + ); + + const hasActive = running.length > 0 || queued.length > 0; + const hasFinished = finished.length > 0; + + // Nothing to show — return empty (widget will be unregistered by update()) + if (!hasActive && !hasFinished) return []; + + const w = tui.terminal.columns; + const truncate = (line: string) => truncateToWidth(line, w); + const headingColor = hasActive ? "accent" : "dim"; + const headingIcon = hasActive ? "●" : "○"; + const frame = SPINNER[this.widgetFrame % SPINNER.length]; + + // Build sections separately for overflow-aware assembly. + // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line. + + const finishedLines: string[] = []; + for (const a of finished) { + finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme))); + } + + const runningLines: string[][] = []; // each entry is [header, activity] + for (const a of running) { + const modeLabel = getPromptModeLabel(a.type); + const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : ""; + const elapsed = formatMs(Date.now() - a.startedAt); + + const bg = this.agentActivity.get(a.id); + const toolUses = bg?.toolUses ?? a.toolUses; + // Spend comes from the record, never from the activity tracker: the record + // is the one that survives the agent finishing, and the one nested-tools + // folds a hidden child's spend into. Reading the tracker while an agent + // runs and the record once it stops made the figure jump at completion. + const tokens = getLifetimeTotal(a.lifetimeUsage); + const contextPercent = getSessionContextPercent(bg?.session); + const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : ""; + const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : ""; + + const parts: string[] = []; + if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns)); + if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`); + if (tokenText) parts.push(tokenText); + if (costText) parts.push(costText); + parts.push(elapsed); + const statsText = parts.join(" · "); + + const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…"; + + runningLines.push([ + truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`), + truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)), + ]); + } + + const queuedLine = queued.length > 0 + ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`) + : undefined; + + // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines). + const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line + const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0); + + const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))]; + + if (totalBody <= maxBody) { + // Everything fits — add all lines and fix up connectors for the last item. + lines.push(...finishedLines); + for (const pair of runningLines) lines.push(...pair); + if (queuedLine) lines.push(queuedLine); + + // Fix last connector: swap ├─ → └─ and │ → space for activity lines. + if (lines.length > 1) { + const last = lines.length - 1; + lines[last] = lines[last].replace("├─", "└─"); + // If last item is a running agent activity line, fix indent of that line + // and fix the header line above it. + if (runningLines.length > 0 && !queuedLine) { + // The last two lines are the last running agent's header + activity. + if (last >= 2) { + lines[last - 1] = lines[last - 1].replace("├─", "└─"); + lines[last] = lines[last].replace("│ ", " "); + } + } + } + } else { + // Overflow — prioritize: running > queued > finished. + // Reserve 1 line for overflow indicator. + let budget = maxBody - 1; + let hiddenRunning = 0; + let hiddenFinished = 0; + + // Reserve the queued line's row up front. It is a single summary of N + // waiting agents, so it cannot be folded into the "+N more" count (which + // is denominated in agents) without either under-reporting it as 1 or + // inflating the total with agents that were never getting their own rows. + // Reserving costs at most one running agent — which IS counted below — + // and makes the drop unreachable. It matters most exactly when it used to + // vanish: the pool is saturated and the queue is what the user needs to see. + const queuedReserve = queuedLine ? 1 : 0; + budget -= queuedReserve; + + // 1. Running agents (2 lines each) + for (const pair of runningLines) { + if (budget >= 2) { + lines.push(...pair); + budget -= 2; + } else { + hiddenRunning++; + } + } + + // 2. Queued line (always fits — its row was reserved above) + if (queuedLine) { + budget += queuedReserve; + lines.push(queuedLine); + budget--; + } + + // 3. Finished agents + for (const fl of finishedLines) { + if (budget >= 1) { + lines.push(fl); + budget--; + } else { + hiddenFinished++; + } + } + + // Overflow summary + const overflowParts: string[] = []; + if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`); + if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`); + const overflowText = overflowParts.join(", "); + lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`) + ); + } + + return lines; + } + + /** Force an immediate widget update. */ + update() { + if (!this.uiCtx) return; + const allAgents = this.widgetAgents(); + + // Lightweight existence checks — full categorization happens in renderWidget() + let runningCount = 0; + let queuedCount = 0; + let hasFinished = false; + for (const a of allAgents) { + if (a.status === "running") { runningCount++; } + else if (a.status === "queued") { queuedCount++; } + else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; } + } + const hasActive = runningCount > 0 || queuedCount > 0; + + // Nothing to show — clear widget + if (!hasActive && !hasFinished) { + if (this.widgetRegistered) { + this.uiCtx.setWidget("agents", undefined); + this.widgetRegistered = false; + this.tui = undefined; + } + if (this.lastStatusText !== undefined) { + this.uiCtx.setStatus("subagents", undefined); + this.lastStatusText = undefined; + } + if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } + // Clean up stale entries + for (const [id] of this.finishedTurnAge) { + if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id); + } + return; + } + + // Status bar — only call setStatus when the text actually changes + let newStatusText: string | undefined; + if (hasActive) { + const statusParts: string[] = []; + if (runningCount > 0) statusParts.push(`${runningCount} running`); + if (queuedCount > 0) statusParts.push(`${queuedCount} queued`); + const total = runningCount + queuedCount; + newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`; + } + if (newStatusText !== this.lastStatusText) { + this.uiCtx.setStatus("subagents", newStatusText); + this.lastStatusText = newStatusText; + } + + this.widgetFrame++; + + // Register widget callback once; subsequent updates use requestRender() + // which re-invokes render() without replacing the component (avoids layout thrashing). + if (!this.widgetRegistered) { + this.uiCtx.setWidget("agents", (tui, theme) => { + this.tui = tui; + return { + render: () => this.renderWidget(tui, theme), + invalidate: () => { + // Theme changed — force re-registration so factory captures fresh theme. + this.widgetRegistered = false; + this.tui = undefined; + }, + }; + }, { placement: "aboveEditor" }); + this.widgetRegistered = true; + } else { + // Widget already registered — just request a re-render of existing components. + this.tui?.requestRender(); + } + } + + dispose() { + if (this.widgetInterval) { + clearInterval(this.widgetInterval); + this.widgetInterval = undefined; + } + if (this.uiCtx) { + this.uiCtx.setWidget("agents", undefined); + this.uiCtx.setStatus("subagents", undefined); + } + this.widgetRegistered = false; + this.tui = undefined; + this.lastStatusText = undefined; + } +} diff --git a/pi-subagents/src/ui/conversation-viewer.ts b/pi-subagents/src/ui/conversation-viewer.ts new file mode 100644 index 0000000..cd57605 --- /dev/null +++ b/pi-subagents/src/ui/conversation-viewer.ts @@ -0,0 +1,373 @@ +/** + * conversation-viewer.ts — Live conversation overlay for viewing agent sessions. + * + * Displays a scrollable, live-updating view of an agent's conversation. + * Subscribes to session events for real-time streaming updates. + */ + +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { renderAgentName } from "../agent-color.js"; +import { extractText } from "../context.js"; +import type { AgentRecord } from "../types.js"; +import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js"; +import type { Theme } from "./agent-widget.js"; +import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js"; +import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js"; + +/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */ +const CHROME_LINES_BASE = 6; +const MIN_VIEWPORT = 3; +/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */ +export const VIEWPORT_HEIGHT_PCT = 70; + +export class ConversationViewer implements Component { + private scrollOffset = 0; + private autoScroll = true; + private unsubscribe: (() => void) | undefined; + private lastInnerW = 0; + private closed = false; + /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */ + private stopArmed = false; + private keys: ViewerKeys; + /** Steering composer — present while the user is typing a message to the agent. */ + private composer: Input | undefined; + + constructor( + private tui: TUI, + private session: AgentSession, + private record: AgentRecord, + private activity: AgentActivity | undefined, + private theme: Theme, + private done: (result: undefined) => void, + /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */ + private onStop?: () => void, + /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */ + keybindings?: ViewerKeybindings, + /** Send a steering message to the agent. Omitted → no compose affordance. */ + private onSteer?: (message: string) => void, + /** + * Whether the header shows an estimated cost after the token count. Read + * once, at construction: the overlay is opened from a menu, so the setting + * cannot change while it is on screen. + */ + private showCost = false, + ) { + this.keys = createViewerKeys(keybindings); + this.unsubscribe = session.subscribe(() => { + if (this.closed) return; + this.tui.requestRender(); + }); + } + + handleInput(data: string): void { + // While composing a steer message, the input owns all keys (Enter sends, + // Esc cancels — both wired in openComposer()). Editing keys flow through. + if (this.composer) { + this.composer.handleInput(data); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, "escape") || matchesKey(data, "q")) { + this.closed = true; + this.done(undefined); + return; + } + + // Enter opens the steering composer (only while the agent can still be + // steered) — then type + Enter sends, Esc or an empty submit returns. When + // not steerable, fall through so the key still disarms a pending stop. + if (matchesKey(data, "enter") && this.canSteer()) { + this.stopArmed = false; + this.openComposer(); + return; + } + + // Stop/abort the agent (only while it can still be stopped). Two-press: + // first "x" arms, second confirms — any other key disarms. + if (matchesKey(data, "x")) { + if (this.isStoppable()) { + if (this.stopArmed) { + this.stopArmed = false; + this.onStop?.(); + } else { + this.stopArmed = true; + } + this.tui.requestRender(); + } + return; + } + if (this.stopArmed) this.stopArmed = false; + + const totalLines = this.buildContentLines(this.lastInnerW).length; + const viewportHeight = this.viewportHeight(); + const maxScroll = Math.max(0, totalLines - viewportHeight); + + if (this.keys.scrollUp(data)) { + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + this.autoScroll = this.scrollOffset >= maxScroll; + } else if (this.keys.scrollDown(data)) { + this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); + this.autoScroll = this.scrollOffset >= maxScroll; + } else if (this.keys.pageUp(data)) { + this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight); + this.autoScroll = false; + } else if (this.keys.pageDown(data)) { + this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight); + this.autoScroll = this.scrollOffset >= maxScroll; + } else if (matchesKey(data, "home")) { + this.scrollOffset = 0; + this.autoScroll = false; + } else if (matchesKey(data, "end")) { + this.scrollOffset = maxScroll; + this.autoScroll = true; + } + } + + render(width: number): string[] { + if (width < 6) return []; // too narrow for any meaningful rendering + const th = this.theme; + const innerW = width - 4; // border + padding + this.lastInnerW = innerW; + const lines: string[] = []; + + const pad = (s: string, len: number) => { + const vis = visibleWidth(s); + return s + " ".repeat(Math.max(0, len - vis)); + }; + const row = (content: string) => + th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│"); + const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`); + const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`); + const hrMid = row(th.fg("dim", "─".repeat(innerW))); + + // Header + lines.push(hrTop); + const modeLabel = getPromptModeLabel(this.record.type); + const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : ""; + const statusIcon = this.record.status === "running" + ? th.fg("accent", "●") + : this.record.status === "completed" + ? th.fg("success", "✓") + : this.record.status === "error" + ? th.fg("error", "✗") + : th.fg("dim", "○"); + const duration = formatDuration(this.record.startedAt, this.record.completedAt); + + const headerParts: string[] = [duration]; + const toolUses = this.activity?.toolUses ?? this.record.toolUses; + if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`); + // Spend from the record, context from the live session: the record is the + // only total that survives the agent finishing and the only one carrying a + // nested child's spend. + const tokens = getLifetimeTotal(this.record.lifetimeUsage); + if (tokens > 0) { + const percent = getSessionContextPercent(this.activity?.session); + headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount)); + } + const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : ""; + if (cost) headerParts.push(cost); + + lines.push(row( + `${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`, + )); + const invocationLine = this.invocationLine(); + if (invocationLine) lines.push(row(invocationLine)); + lines.push(hrMid); + + // Content area — rebuild every render (live data, no cache needed) + const contentLines = this.buildContentLines(innerW); + const viewportHeight = this.viewportHeight(); + const maxScroll = Math.max(0, contentLines.length - viewportHeight); + + if (this.autoScroll) { + this.scrollOffset = maxScroll; + } + + const visibleStart = Math.min(this.scrollOffset, maxScroll); + const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight); + + for (let i = 0; i < viewportHeight; i++) { + lines.push(row(visible[i] ?? "")); + } + + // Footer + lines.push(hrMid); + if (this.composer) { + // Composer row: the Input renders its own `> ` prompt and cursor. + lines.push(row(this.composer.render(innerW)[0] ?? "")); + const composeHint = th.fg("dim", "Enter send · Esc cancel"); + const composeLeft = th.fg("accent", "✎ steer"); + const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint)); + lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint)); + } else { + // Actions on the left, navigation on the right. The scroll hint keeps its + // full key list so the less-obvious bindings stay discoverable; it leads + // the right group so "Esc close" is the only part that truncates first. + const sep = th.fg("dim", " · "); + const actions: string[] = []; + if (this.canSteer()) actions.push(th.fg("dim", "Enter steer")); + if (this.isStoppable()) { + actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")); + } + const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close"); + + // Prepend the line-count/scroll-% readout only when there's spare width — + // it's the first thing dropped so it never crowds out the hints. + const scrollPct = contentLines.length <= viewportHeight + ? "100%" + : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`; + const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`); + const withCount = [count, ...actions].join(sep); + const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW + ? withCount + : actions.join(sep); + + const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight)); + lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); + } + lines.push(hrBot); + + return lines; + } + + /** Stoppable only when a stop handler exists and the agent is still active. */ + private isStoppable(): boolean { + return !!this.onStop && (this.record.status === "running" || this.record.status === "queued"); + } + + /** Steerable only when a steer handler exists and the agent is still active. */ + private canSteer(): boolean { + return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued"); + } + + /** Open the inline steering composer and route subsequent input to it. */ + private openComposer(): void { + const input = new Input(); + input.focused = true; + input.onSubmit = (value: string) => { + const message = value.trim(); + this.composer = undefined; + if (message) this.onSteer?.(message); + this.tui.requestRender(); + }; + input.onEscape = () => { + this.composer = undefined; + this.tui.requestRender(); + }; + this.composer = input; + this.tui.requestRender(); + } + + invalidate(): void { /* no cached state to clear */ } + + dispose(): void { + this.closed = true; + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + } + + // ---- Private ---- + + private viewportHeight(): number { + // Cap mirrors the overlay's maxHeight — otherwise the viewer would render + // more lines than the overlay shows and clip the footer. + const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100); + return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines()); + } + + private chromeLines(): number { + // The composer adds one row above the footer hint while it's open. + return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0); + } + + private invocationLine(): string | undefined { + const { modelName, tags } = buildInvocationTags(this.record.invocation); + const parts = modelName ? [modelName, ...tags] : tags; + if (parts.length === 0) return undefined; + return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`); + } + + private buildContentLines(width: number): string[] { + if (width <= 0) return []; + + const th = this.theme; + const messages = this.session.messages; + const lines: string[] = []; + + if (messages.length === 0) { + lines.push(th.fg("dim", "(waiting for first message...)")); + return lines; + } + + let needsSeparator = false; + for (const msg of messages) { + if (msg.role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : extractText(msg.content); + if (!text.trim()) continue; + if (needsSeparator) lines.push(th.fg("dim", "───")); + lines.push(th.fg("accent", "[User]")); + for (const line of wrapTextWithAnsi(text.trim(), width)) { + lines.push(line); + } + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const toolCalls: string[] = []; + for (const c of msg.content) { + if (c.type === "text" && c.text) textParts.push(c.text); + else if (c.type === "toolCall") { + toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown"); + } + } + if (needsSeparator) lines.push(th.fg("dim", "───")); + lines.push(th.bold("[Assistant]")); + if (textParts.length > 0) { + for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) { + lines.push(line); + } + } + for (const name of toolCalls) { + lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width)); + } + } else if (msg.role === "toolResult") { + const text = extractText(msg.content); + const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text; + if (!truncated.trim()) continue; + if (needsSeparator) lines.push(th.fg("dim", "───")); + lines.push(th.fg("dim", "[Result]")); + for (const line of wrapTextWithAnsi(truncated.trim(), width)) { + lines.push(th.fg("dim", line)); + } + } else if ((msg as any).role === "bashExecution") { + const bash = msg as any; + if (needsSeparator) lines.push(th.fg("dim", "───")); + lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width)); + if (bash.output?.trim()) { + const out = bash.output.length > 500 + ? bash.output.slice(0, 500) + "... (truncated)" + : bash.output; + for (const line of wrapTextWithAnsi(out.trim(), width)) { + lines.push(th.fg("dim", line)); + } + } + } else { + continue; + } + needsSeparator = true; + } + + // Streaming indicator for running agents + if (this.record.status === "running" && this.activity) { + const act = describeActivity(this.activity.activeTools, this.activity.responseText); + lines.push(""); + lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width)); + } + + return lines.map(l => truncateToWidth(l, width)); + } +} diff --git a/pi-subagents/src/ui/fleet-list.ts b/pi-subagents/src/ui/fleet-list.ts new file mode 100644 index 0000000..b4d4c77 --- /dev/null +++ b/pi-subagents/src/ui/fleet-list.ts @@ -0,0 +1,402 @@ +/** + * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor. + * + * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or + * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker), + * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt. + * A viewer stays open when its agent finishes; finished agents linger briefly in the list. + * + * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key + * handling goes through `onTerminalInput` — which fires before the focused editor and + * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched. + */ + +import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { hasAgentBadge, renderAgentName } from "../agent-color.js"; +import type { AgentManager } from "../agent-manager.js"; +import type { AgentRecord } from "../types.js"; +import { getLifetimeCost, getLifetimeTotal } from "../usage.js"; +import { type AgentActivity, formatCost, type Theme } from "./agent-widget.js"; +import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js"; + +/** Widget key for the below-editor fleet list. */ +const FLEET_KEY = "fleet"; +/** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */ +const MAX_AGENT_ROWS = 5; +/** Re-render cadence so elapsed/token stats tick while agents run. */ +const TICK_MS = 200; +/** How long a finished agent lingers in the list before it drops out. */ +const FINISHED_LINGER_MS = 4000; + +/** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */ +export type FleetUICtx = { + setWidget( + key: string, + content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }), + options?: { placement?: "aboveEditor" | "belowEditor" }, + ): void; + onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void; + getEditorText(): string; + notify(message: string, type?: "info" | "warning" | "error"): void; + custom( + factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void }, + options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void }, + ): Promise; +}; + +type MainEntry = { kind: "main" }; +type AgentEntry = { kind: "agent"; record: AgentRecord }; +type FleetEntry = MainEntry | AgentEntry; + +/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */ +export function formatFleetElapsed(ms: number): string { + return `${Math.max(0, Math.round(ms / 1000))}s`; +} + +/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */ +export function formatFleetTokens(count: number): string { + let compact: string; + if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`; + else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`; + else compact = `${count}`; + return `↓ ${compact} tokens`; +} + +/** + * Place `right` flush to `width`, truncating `left` first so the stats survive. + * The final clamp guarantees the line never exceeds `width` (which would wrap and + * desync pi's line-diff → flicker) even on a terminal too narrow for the stats. + */ +function rightAlign(left: string, right: string, width: number): string { + const rightW = visibleWidth(right); + const maxLeft = Math.max(0, width - rightW - 1); + const leftClamped = truncateToWidth(left, maxLeft); + const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW); + return truncateToWidth(leftClamped + " ".repeat(gap) + right, width); +} + +export class FleetList { + private ui: FleetUICtx | undefined; + private tui: any | undefined; + private inputUnsub: (() => void) | undefined; + private widgetRegistered = false; + private timer: ReturnType | undefined; + + private enabled = true; + /** Whether arrow keys currently navigate the list (vs. flow to the editor). */ + private active = false; + /** 0 = `main`, 1..N = subagents. */ + private selectedIndex = 0; + /** Set while a conversation overlay is open; calling it closes the overlay. */ + private viewerClose: (() => void) | undefined; + private viewingAgentId: string | undefined; + + constructor( + private manager: AgentManager, + private agentActivity: Map, + /** + * Read live at render time. Whether each row shows an estimated cost after + * its token count. Defaults to off — the extension supplies the user's + * `showCost` setting. + */ + private showCost: () => boolean = () => false, + ) {} + + // ---- Lifecycle ---- + + setEnabled(enabled: boolean): void { + if (enabled === this.enabled) return; + this.enabled = enabled; + if (!enabled) this.active = false; + this.update(); + } + + /** Capture the UI context and (re)register the global input handler. */ + setUICtx(ui: FleetUICtx): void { + if (ui === this.ui) return; + this.inputUnsub?.(); + this.ui = ui; + this.widgetRegistered = false; + this.tui = undefined; + this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data)); + } + + /** Ensure the re-render timer is running (called when an agent spawns). */ + ensureTimer(): void { + if (!this.timer) this.timer = setInterval(() => this.update(), TICK_MS); + } + + /** + * Called when an agent finishes. The viewer (if open on it) stays open so the + * final output remains readable, and the row lingers in the list — just refresh. + */ + onAgentFinished(_id: string): void { + this.update(); + } + + dispose(): void { + if (this.timer) { clearInterval(this.timer); this.timer = undefined; } + this.inputUnsub?.(); + this.inputUnsub = undefined; + if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; } + this.viewingAgentId = undefined; + if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined); + this.widgetRegistered = false; + this.tui = undefined; + this.active = false; + // Null last so a `viewerClose()` microtask above can't re-register the widget. + this.ui = undefined; + } + + /** Re-register/refresh the below-editor widget; clears it when no agents remain. */ + update(): void { + if (!this.ui) return; + const hasAgents = this.enabled && this.agentRecords().length > 0; + + if (!hasAgents) { + if (this.widgetRegistered) { + this.ui.setWidget(FLEET_KEY, undefined); + this.widgetRegistered = false; + this.tui = undefined; + } + if (this.timer) { clearInterval(this.timer); this.timer = undefined; } + this.active = false; + this.selectedIndex = 0; + return; + } + + this.clampSelection(); + this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable) + + if (!this.widgetRegistered) { + this.ui.setWidget(FLEET_KEY, (tui, theme) => { + this.tui = tui; + return { + render: (w: number) => this.renderBar(w, theme), + invalidate: () => { this.widgetRegistered = false; this.tui = undefined; }, + }; + }, { placement: "belowEditor" }); + this.widgetRegistered = true; + } else { + this.tui?.requestRender(); + } + } + + // ---- Roster ---- + + /** + * Agents shown in the list, ordered earliest-launched first so the ones you + * started sooner sit at the top. Every row is openable (has a session), so Enter + * never dead-ends. Included: running/queued, plus the agent currently being + * viewed, plus recently-finished ones (they linger briefly before dropping out). + * Pending agents with no session yet are hidden until they start. + * (`listAgents()` is newest-first, so we re-sort.) + */ + private agentRecords(): AgentRecord[] { + const now = Date.now(); + return this.manager.listAgents() + .filter(a => !a.parentAgentId && a.session && ( + a.status === "running" || a.status === "queued" + || a.id === this.viewingAgentId + || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS) + )) + .sort((a, b) => a.startedAt - b.startedAt); + } + + private roster(): FleetEntry[] { + return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))]; + } + + private clampSelection(): void { + const max = this.roster().length - 1; + if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max); + if (this.selectedIndex < 0) this.selectedIndex = 0; + } + + // ---- Key handling ---- + + /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */ + handleKey(data: string): { consume?: boolean; data?: string } | undefined { + if (!this.enabled || !this.ui) return undefined; + // Input listeners receive BOTH key-press and key-release (the kitty protocol + // emits both, and matchesKey matches either) — act on press only, or every + // tap would move/fire twice. Repeats still pass through for held-key nav. + if (isKeyRelease(data)) return undefined; + // While an overlay is open, let it own all input. + if (this.viewerClose) return undefined; + // Input listeners fire BEFORE the focused component, and dialogs + // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out + // while getEditorText() still reads the detached — empty — editor. So when + // anything but the editor owns the keyboard, stay out of its keys (#123). + if (!this.editorHasFocus()) { + if (this.active) this.deactivate(); + return undefined; + } + + if (!this.active) { + // Activate: ↓ or ← at an empty prompt moves focus into the list. + const isActivator = matchesKey(data, "down") || matchesKey(data, "left"); + if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") { + this.active = true; + this.selectedIndex = 0; + this.update(); + return { consume: true }; + } + return undefined; + } + + // Active — arrows navigate, Enter opens, Esc / Up-past-top exits. + if (matchesKey(data, "down")) { + const max = this.roster().length - 1; + this.selectedIndex = Math.min(max, this.selectedIndex + 1); + this.update(); + return { consume: true }; + } + if (matchesKey(data, "up")) { + if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; } + this.selectedIndex -= 1; + this.update(); + return { consume: true }; + } + if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; } + if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; } + + // Any other key cancels navigation and flows to the editor. + this.deactivate(); + return undefined; + } + + /** + * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor` + * subclass (CustomEditor) while every dialog/selector is not, and the loader + * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity + * check. `focusedComponent` is TUI-private (no public accessor), hence the + * best-effort peek: unknowable focus (no tui seen yet, nothing focused) + * counts as the editor so activation keeps working. + */ + private editorHasFocus(): boolean { + const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent; + return focused == null || focused instanceof Editor; + } + + private deactivate(): void { + this.active = false; + this.selectedIndex = 0; + this.update(); + } + + private openSelected(): void { + const entry = this.roster()[this.selectedIndex]; + if (!entry || entry.kind === "main") { + // `main` = return to the prompt; the native transcript is already shown. + this.deactivate(); + return; + } + const record = entry.record; + if (!this.ui) return; + if (!record.session) { + this.ui.notify(`Agent is ${record.status} — no session available.`, "info"); + return; + } + const session = record.session; + const activity = this.agentActivity.get(record.id); + this.viewingAgentId = record.id; + + void this.ui.custom( + (tui, theme, keybindings, done) => { + this.viewerClose = () => done(undefined); + return new ConversationViewer( + tui, + session, + record, + activity, + theme, + done, + () => { + if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info"); + }, + keybindings, + (message: string) => this.manager.steer(record.id, message), + this.showCost(), + ); + }, + { + overlay: true, + overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` }, + }, + ).then(() => this.clearViewer(), () => this.clearViewer()); + } + + /** Reset overlay state and return to the list (on close, auto-close, or error). */ + private clearViewer(): void { + // Keep the cursor on the agent we were viewing — re-resolve by id so it + // still feels natural if the list reordered (an earlier agent finished) + // while the overlay was open. If that agent is gone, leave the index for + // update()'s clamp to settle. + if (this.viewingAgentId) { + const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId); + if (idx >= 0) this.selectedIndex = idx; + } + this.viewerClose = undefined; + this.viewingAgentId = undefined; + this.update(); + } + + // ---- Rendering ---- + + private renderBar(width: number, theme: Theme): string[] { + const agents = this.roster().slice(1) as AgentEntry[]; + if (agents.length === 0) return []; + // Clamp locally so a render between a roster shrink and the next update() + // (e.g. on terminal resize) never loses the selection marker. + const sel = Math.min(this.selectedIndex, agents.length); + + const hint = this.active + ? "↑↓ select · enter view · esc back" + : "esc to interrupt · ← for agents · ↓ to manage"; + const lines: string[] = []; + lines.push(truncateToWidth(" " + theme.fg("dim", hint), width)); + lines.push(""); + lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width)); + + // Window the agent rows so the selected one stays visible. + const visible = Math.min(MAX_AGENT_ROWS, agents.length); + const selAgent = Math.max(0, sel - 1); + const start = selAgent < visible ? 0 : selAgent - visible + 1; + const hiddenBelow = agents.length - (start + visible); + + if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width)); + for (let a = start; a < start + visible; a++) { + lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme)); + } + if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width)); + + return lines; + } + + private bullet(rosterIndex: number, sel: number, theme: Theme): string { + return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○"); + } + + private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string { + // The selected row renders in the theme's primary text color so it reads as + // one selection (#230). A configured badge survives — Claude Code's FleetView + // keeps the agent color on the selected row too and only bolds it — which also + // keeps the row's width fixed as the selection moves. + const selected = rosterIndex === sel; + const name = renderAgentName(record.type, theme, selected + ? { fallbackColor: "text", bold: hasAgentBadge(record.type) } + : { fallbackColor: "muted" }); + const description = selected ? theme.fg("text", record.description) : record.description; + const left = ` ${this.bullet(rosterIndex, sel, theme)} ${name} ${description}`; + // The record, not the activity tracker — see the note in AgentWidget's + // running line: only the record carries a nested child's spend, and only it + // outlives the agent. + const tokens = getLifetimeTotal(record.lifetimeUsage); + const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished + const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : ""; + const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`; + const right = selected ? theme.fg("text", stats) : theme.fg("dim", stats); + return rightAlign(left, right, width); + } +} diff --git a/pi-subagents/src/ui/schedule-menu.ts b/pi-subagents/src/ui/schedule-menu.ts new file mode 100644 index 0000000..53e1925 --- /dev/null +++ b/pi-subagents/src/ui/schedule-menu.ts @@ -0,0 +1,105 @@ +/** + * schedule-menu.ts — `/agents → Scheduled jobs` submenu. + * + * Minimal v1 surface: list scheduled jobs, select one to inspect details + + * confirm cancellation. No create wizard (the `Agent` tool's `schedule` param + * is the canonical creation path), no toggle/cleanup (cancel is enough for + * "I scheduled something dumb, get rid of it"). Add management surfaces here + * if real demand emerges. + */ + +import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import type { SubagentScheduler } from "../schedule.js"; +import type { ScheduledSubagent } from "../types.js"; +import { selectItem } from "./select-item.js"; + +/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */ +function relTime(iso: string | undefined, now = Date.now()): string { + if (!iso) return "—"; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return "—"; + const diff = t - now; + const abs = Math.abs(diff); + const future = diff > 0; + if (abs < 60_000) return future ? "in <1m" : "<1m ago"; + const m = Math.round(abs / 60_000); + if (m < 60) return future ? `in ${m}m` : `${m}m ago`; + const h = Math.round(abs / 3_600_000); + if (h < 24) return future ? `in ${h}h` : `${h}h ago`; + const d = Math.round(abs / 86_400_000); + return future ? `in ${d}d` : `${d}d ago`; +} + +/** One-line status icon. */ +function statusIcon(j: ScheduledSubagent): string { + if (!j.enabled) return "✗"; + if (j.lastStatus === "error") return "!"; + if (j.lastStatus === "running") return "⋯"; + return "✓"; +} + +/** Compact selectable row — name, schedule, agent type, next/last run, count. */ +function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string { + const next = scheduler.getNextRun(j.id); + return [ + statusIcon(j), + j.name.padEnd(18).slice(0, 18), + j.schedule.padEnd(14).slice(0, 14), + `[${j.subagent_type}]`, + `next ${relTime(next)}`, + `last ${relTime(j.lastRun)}`, + `runs ${j.runCount}`, + ].join(" "); +} + +/** Multi-line details block for the cancel confirm. */ +function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string { + const next = scheduler.getNextRun(j.id) ?? "—"; + return [ + `name: ${j.name}`, + `schedule: ${j.schedule} (${j.scheduleType})`, + `agent: ${j.subagent_type}`, + `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`, + `created: ${j.createdAt}`, + `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`, + `next run: ${next}`, + `runs: ${j.runCount}`, + ].join("\n"); +} + +/** + * List scheduled jobs; selecting one opens a cancel-confirm with details. + * Returns when the user backs out or after a cancellation. + */ +export async function showSchedulesMenu( + ctx: ExtensionCommandContext, + scheduler: SubagentScheduler, +): Promise { + if (!scheduler.isActive()) { + ctx.ui.notify("Scheduler is not active in this session.", "warning"); + return; + } + + const jobs = scheduler.list(); + if (jobs.length === 0) { + ctx.ui.notify("No scheduled jobs.", "info"); + return; + } + + // Numbered + item-paired: two jobs whose names agree in the first 18 + // characters format identically, and matching the returned string back + // against a parallel label array cancelled whichever came first. + const job = await selectItem( + ctx.ui, + `Scheduled jobs (${jobs.length}) — select to cancel`, + jobs, + j => formatJob(j, scheduler), + ); + if (!job) return; + + const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler)); + if (!ok) return; + + scheduler.removeJob(job.id); + ctx.ui.notify(`Cancelled "${job.name}".`, "info"); +} diff --git a/pi-subagents/src/ui/select-item.ts b/pi-subagents/src/ui/select-item.ts new file mode 100644 index 0000000..cc35d57 --- /dev/null +++ b/pi-subagents/src/ui/select-item.ts @@ -0,0 +1,45 @@ +/** + * select-item.ts — pick an item from a list via `ctx.ui.select`, safely. + * + * Pi's dialog API is `select(title, options: string[]) => Promise`: + * strings in, string out, with no index or value form. Callers therefore have to + * map the returned string back to the item it came from, and the obvious way — + * `labels.indexOf(choice)` over a parallel array — silently resolves to the + * FIRST match whenever two rows format identically. Row formatters here truncate + * (job names to 18 chars, agent descriptions to whatever fits), and the text they + * truncate is LLM-authored, so collisions are ordinary rather than exotic. + * + * This numbers every row, which makes the labels unique by construction — no + * data-dependent branch that only executes in the case nobody exercises — and + * keeps each label paired with its item so a later edit that sorts or filters + * between building and resolving cannot desync them. + */ + +/** Minimal shape of the `ctx.ui` surface this needs. */ +export interface SelectUI { + select(title: string, options: string[]): Promise; +} + +/** + * Show a numbered picker and return the chosen item (not its label). + * + * Returns undefined when the user escapes, or when the returned string is not + * one we offered. + */ +export async function selectItem( + ui: SelectUI, + title: string, + items: readonly T[], + format: (item: T, index: number) => string, +): Promise { + // Pad the number so a 10+ item list keeps its columns aligned. + const width = String(items.length).length; + const rows = items.map((item, i) => ({ + item, + label: `${String(i + 1).padStart(width)}. ${format(item, i)}`, + })); + + const choice = await ui.select(title, rows.map(r => r.label)); + if (!choice) return undefined; + return rows.find(r => r.label === choice)?.item; +} diff --git a/pi-subagents/src/ui/viewer-keys.ts b/pi-subagents/src/ui/viewer-keys.ts new file mode 100644 index 0000000..31b2206 --- /dev/null +++ b/pi-subagents/src/ui/viewer-keys.ts @@ -0,0 +1,39 @@ +/** + * viewer-keys.ts — Scroll key matchers for the conversation viewer. + * + * Resolves `tui.select.*` through the user's keybindings when pi provides a + * manager, falling back to the previous hardcoded keys otherwise. The viewer's + * k/j and shift+arrow aliases always work alongside whatever is bound. + */ + +import { type KeyId, matchesKey } from "@earendil-works/pi-tui"; + +/** The `tui.select.*` keybinding ids the viewer resolves. */ +export type ViewerScrollKeybinding = + | "tui.select.up" + | "tui.select.down" + | "tui.select.pageUp" + | "tui.select.pageDown"; + +/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */ +export interface ViewerKeybindings { + matches(data: string, keybinding: ViewerScrollKeybinding): boolean; +} + +export interface ViewerKeys { + scrollUp(data: string): boolean; + scrollDown(data: string): boolean; + pageUp(data: string): boolean; + pageDown(data: string): boolean; +} + +export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys { + const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean => + keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback); + return { + scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"), + scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"), + pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"), + pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"), + }; +} diff --git a/pi-subagents/src/usage.ts b/pi-subagents/src/usage.ts new file mode 100644 index 0000000..4b391c7 --- /dev/null +++ b/pi-subagents/src/usage.ts @@ -0,0 +1,167 @@ +/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */ + +/** + * Lifetime usage components, accumulated via `message_end` events. Survives + * compaction (which replaces session.state.messages and would reset any + * stats-derived sum). cacheRead is excluded because each turn's cacheRead is + * the cumulative cached prefix re-read on that one call — summing across + * turns counts the prefix N times. See issue #38. + * + * That exclusion is about this *display* total, not about what was billed: the + * prefix really is re-read and re-charged on every call. So `cacheRead` is + * accumulated here anyway, kept out of `getLifetimeTotal` and used only where + * billing is the question — reporting to the parent session, whose own messages + * pi counts the same way (`addUsageToTotals`). Reporting 0 there would make a + * subagent's rows count differently from every other row in one total. + * + * `cost` is a plain sum for the same reason: it is what pi charged for that one + * message (`usage.cost.total`, priced from the model's rates), not a cumulative + * figure. Both are optional because a model with no pricing data reports no + * cost, and because every accumulator predates them; absent reads as 0. + */ +export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number }; + +/** + * Sum of lifetime *token* components for DISPLAY, or 0 if undefined. + * Deliberately excludes `cacheRead` (see above) and `cost` — that is money, not + * tokens, and lives on the same object only because it accumulates on the same + * events. + */ +export function getLifetimeTotal(u?: LifetimeUsage): number { + return u ? u.input + u.output + u.cacheWrite : 0; +} + +/** Accumulated cost in USD, or 0 when unpriced/undefined. */ +export function getLifetimeCost(u?: LifetimeUsage): number { + return u?.cost ?? 0; +} + +/** Add a usage delta into a target accumulator (mutates target). */ +export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void { + into.input += delta.input; + into.output += delta.output; + into.cacheWrite += delta.cacheWrite; + if (delta.cacheRead) into.cacheRead = (into.cacheRead ?? 0) + delta.cacheRead; + if (delta.cost) into.cost = (into.cost ?? 0) + delta.cost; +} + +/** + * A pi `Usage`. Rebuilt here rather than imported so this module stays + * dependency-free for tests; the fields are pi's, and every one of them must be + * present: pi's `addUsageToTotals` dereferences `usage.cost.total` with no + * guard, so a partial object throws inside pi rather than at the call site. + * + * This is pi's convention for spend in anything handed to a consumer — every + * extension-facing payload that carries it takes the whole object + * (`ToolResultEvent`, `ToolResultEventResult`, `AssistantMessage`, …), never a + * flattened cost. Pi flattens only in computed read APIs it expects you to + * render, like `SessionStats`. So both places we hand usage to someone else — + * `AgentToolResult.usage` and the `subagents:completed` / `subagents:failed` + * events — carry this, and gain whatever pi adds to `Usage` for free. + */ +export type ReportedUsage = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; +}; + +/** + * Render an accumulator as a pi `Usage`, or undefined when nothing was spent — + * callers attach nothing rather than a zero, so a consumer can tell "spent + * nothing" from "never ran". + * + * `cacheRead` IS included, unlike in `getLifetimeTotal`: pi sums it across a + * session's own assistant messages, and the prefix genuinely is re-read and + * re-billed on every call. Only `total` is populated on the cost breakdown; pi + * reads nothing else from it, and the per-kind split is not tracked. + */ +export function toReportedUsage(u: LifetimeUsage): ReportedUsage | undefined { + const { input, output, cacheWrite, cacheRead = 0, cost = 0 } = u; + if (input === 0 && output === 0 && cacheWrite === 0 && cacheRead === 0 && cost === 0) return undefined; + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost }, + }; +} + +/** + * Subagent spend that the parent session has not been told about yet. + * + * Subagents run in their own pi sessions, so none of what they spend appears in + * the parent's `getSessionStats()`. Pi does aggregate `toolResult.usage` into + * those stats, though — so the way back into the parent's footer and `/cost` is + * to hang the spend on a tool result. Background and scheduled agents finish + * between tool calls with nothing to hang it on, hence a pool: every assistant + * message lands here as it happens, and the next tool result we return carries + * whatever has accumulated. + * + * Drain empties it, so each message is reported exactly once no matter how many + * results are returned or how many agents were running. + */ +export class PendingUsagePool { + private pending: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }; + private dirty = false; + + add(delta: LifetimeUsage): void { + addUsage(this.pending, delta); + this.dirty = true; + } + + /** + * Take everything accumulated so far as a pi `Usage`, resetting the pool. + * Returns undefined when nothing is pending, so callers can leave the tool + * result untouched rather than attaching a zero. + */ + drain(): ReportedUsage | undefined { + if (!this.dirty) return undefined; + const drained = toReportedUsage(this.pending); + this.pending = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }; + this.dirty = false; + return drained; + } +} + +/** Minimal shape we read from upstream `getSessionStats()`. */ +export type SessionStatsLike = { + tokens: { input: number; output: number; cacheWrite: number }; + contextUsage?: { percent: number | null }; +}; +export type SessionLike = { getSessionStats(): SessionStatsLike }; + +/** + * Session-scoped token count: input + output + cacheWrite as reported by + * upstream `getSessionStats().tokens` for the *current* session window. + * + * RESETS at compaction — upstream replaces `session.state.messages` and the + * stats are derived from that array. For a lifetime total that survives + * compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads + * from an independent accumulator fed by `message_end` events. + * + * Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead` + * and so counts the cumulative cached prefix N times across N turns + * (issue #38). + */ +export function getSessionTokens(session: SessionLike | undefined): number { + if (!session) return 0; + try { + const t = session.getSessionStats().tokens; + return t.input + t.output + t.cacheWrite; + } catch { return 0; } +} + +/** + * Context-window utilization (0–100), or null when unavailable + * (no model contextWindow, or post-compaction before the next response). + */ +export function getSessionContextPercent(session: SessionLike | undefined): number | null { + if (!session) return null; + try { return session.getSessionStats().contextUsage?.percent ?? null; } + catch { return null; } +} diff --git a/pi-subagents/src/worktree.ts b/pi-subagents/src/worktree.ts new file mode 100644 index 0000000..b4a33cf --- /dev/null +++ b/pi-subagents/src/worktree.ts @@ -0,0 +1,211 @@ +/** + * worktree.ts — Git worktree isolation for agents. + * + * Creates a temporary git worktree so the agent works on an isolated copy of the repo. + * On completion, if no changes were made, the worktree is cleaned up. + * If changes exist, a branch is created and returned in the result. + */ + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +export interface WorktreeInfo { + /** Absolute path to the worktree directory (the copied repo's root). */ + path: string; + /** Branch name created for this worktree (if changes exist). */ + branch: string; + /** Commit SHA that the worktree was created from. */ + baseSha: string; + /** + * Where the agent should work inside the worktree: the equivalent of the + * cwd the worktree was created from. Equals `path` when that cwd was the + * repo root; points at the copied subdirectory when it was deeper (e.g. a + * monorepo package), so the requested scoping survives isolation. + */ + workPath: string; +} + +/** + * Project-wide switch for worktree isolation (`worktreeIsolation` in + * subagents.json). Default `true` — unchanged behaviour. + * + * The `"off"` isolation value gives a model a legal way to decline a worktree, + * but it still depends on the model choosing it. This is the deterministic half + * of the same fix: on a large repo where every worktree costs real time and + * disk (#184), turning it off means no caller can create one, whatever it + * passes. + */ +let worktreeIsolationEnabled = true; + +export function setWorktreeIsolationEnabled(enabled: boolean): void { + worktreeIsolationEnabled = enabled; +} + +export function isWorktreeIsolationEnabled(): boolean { + return worktreeIsolationEnabled; +} + +export interface WorktreeCleanupResult { + /** Whether changes were found in the worktree. */ + hasChanges: boolean; + /** Branch name if changes were committed. */ + branch?: string; + /** Worktree path if it was kept. */ + path?: string; +} + +/** + * Create a temporary git worktree for an agent. + * Returns the worktree path, or undefined if not in a git repo. + */ +export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined { + // Verify we're in a git repo with at least one commit (HEAD must exist) + let baseSha: string; + let subdir: string; + try { + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 }); + baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 }) + .toString() + .trim(); + // Where cwd sits inside the repo ("" at the root): the agent must work at + // the same subdirectory inside the copy, or a monorepo-package cwd would + // silently widen to the whole repo. realpath both sides — git emits + // resolved paths while cwd may arrive through a symlink (macOS /tmp). + const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 }) + .toString() + .trim(); + subdir = relative(realpathSync(topLevel), realpathSync(cwd)); + } catch { + return undefined; + } + + const branch = `pi-agent-${agentId}`; + const suffix = randomUUID().slice(0, 8); + const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`); + + try { + // Create detached worktree at HEAD + execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], { + cwd, + stdio: "pipe", + timeout: 30000, + }); + return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath }; + } catch { + // If worktree creation fails, return undefined (agent runs in normal cwd) + return undefined; + } +} + +/** + * Clean up a worktree after agent completion. + * - If no changes: remove worktree entirely. + * - If changes exist: create a branch, commit changes, return branch info. + */ +export function cleanupWorktree( + cwd: string, + worktree: WorktreeInfo, + agentDescription: string, +): WorktreeCleanupResult { + if (!existsSync(worktree.path)) { + return { hasChanges: false }; + } + + try { + // Check for uncommitted changes in the worktree + const status = execFileSync("git", ["status", "--porcelain"], { + cwd: worktree.path, + stdio: "pipe", + timeout: 10000, + }).toString().trim(); + + if (status) { + // Changes exist — stage, commit, and create a branch + execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 }); + // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv) + const safeDesc = agentDescription.slice(0, 200); + const commitMsg = `pi-agent: ${safeDesc}`; + execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], { + cwd: worktree.path, + stdio: "pipe", + timeout: 10000, + }); + } else { + const currentSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: worktree.path, + stdio: "pipe", + timeout: 5000, + }).toString().trim(); + + if (currentSha === worktree.baseSha) { + // No changes — remove worktree + removeWorktree(cwd, worktree.path); + return { hasChanges: false }; + } + } + + // Create a branch pointing to the worktree's HEAD. + // If the branch already exists, append a suffix to avoid overwriting previous work. + let branchName = worktree.branch; + try { + execFileSync("git", ["branch", branchName], { + cwd: worktree.path, + stdio: "pipe", + timeout: 5000, + }); + } catch { + // Branch already exists — use a unique suffix + branchName = `${worktree.branch}-${Date.now()}`; + execFileSync("git", ["branch", branchName], { + cwd: worktree.path, + stdio: "pipe", + timeout: 5000, + }); + } + // Update branch name in worktree info for the caller + worktree.branch = branchName; + + // Remove the worktree (branch persists in main repo) + removeWorktree(cwd, worktree.path); + + return { + hasChanges: true, + branch: worktree.branch, + path: worktree.path, + }; + } catch { + // Best effort cleanup on error + try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ } + return { hasChanges: false }; + } +} + +/** + * Force-remove a worktree. + */ +function removeWorktree(cwd: string, worktreePath: string): void { + try { + execFileSync("git", ["worktree", "remove", "--force", worktreePath], { + cwd, + stdio: "pipe", + timeout: 10000, + }); + } catch { + // If git worktree remove fails, try pruning + try { + execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 }); + } catch { /* ignore */ } + } +} + +/** + * Prune any orphaned worktrees (crash recovery). + */ +export function pruneWorktrees(cwd: string): void { + try { + execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 }); + } catch { /* ignore */ } +} diff --git a/pi-subagents/test/abortable.test.ts b/pi-subagents/test/abortable.test.ts new file mode 100644 index 0000000..b8f9506 --- /dev/null +++ b/pi-subagents/test/abortable.test.ts @@ -0,0 +1,164 @@ +// src/abortable.ts was the only module in the repo that no test imported. It is +// reached indirectly through the get_subagent_result wait paths, but its two +// real invariants were never asserted: +// +// 1. Aborting cancels the CALLER'S WAIT, not the work (#159). The background +// child keeps running and its result stays unconsumed — so the wrapped +// promise settles later, after `abortable` already rejected. What makes +// that safe is that `abortable` subscribes to the child with BOTH handlers +// (`promise.then(onFulfilled, onRejected)`): the child's eventual rejection +// is therefore already handled and goes nowhere. Drop the rejection handler +// and a child that fails after the user pressed Esc raises an unhandled +// rejection at the process level, long after the abort, with nothing +// pointing back here. +// +// (The `settled` latch is defensive, not load-bearing: settling an already +// settled promise a second time is a no-op per spec, so removing the latch +// changes nothing observable. No test here claims to protect it.) +// 2. The abort listener is removed on every settle path. The parent's signal +// outlives many waits, so a leak here accumulates handlers across a fleet. +// +// Both failures are invisible to a test that only checks the happy path, which +// is why they are asserted directly rather than through a consumer. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { abortable } from "../src/abortable.js"; + +/** + * Collect unhandled rejections for the duration of a block. + * + * Vitest installs its own handler, so `process.on` alone would not observe them — + * `removeAllListeners` is used and the originals restored afterwards. + */ +async function withUnhandledRejectionSpy(fn: () => Promise): Promise { + const seen: unknown[] = []; + const prior = process.listeners("unhandledRejection"); + process.removeAllListeners("unhandledRejection"); + const capture = (reason: unknown) => { seen.push(reason); }; + process.on("unhandledRejection", capture); + try { + await fn(); + // Unhandled rejections are reported on a later macrotask, not a microtask. + await new Promise((r) => setTimeout(r, 20)); + } finally { + process.off("unhandledRejection", capture); + for (const l of prior) process.on("unhandledRejection", l as never); + } + return seen; +} + +describe("abortable", () => { + afterEach(() => vi.useRealTimers()); + + it("returns the original promise untouched when there is no signal", async () => { + const promise = Promise.resolve("value"); + // Identity, not just equality: with no signal there is nothing to race, so + // wrapping would add a needless tick and a needless rejection surface. + expect(abortable(promise)).toBe(promise); + await expect(promise).resolves.toBe("value"); + }); + + it("rejects immediately with the signal's reason when already aborted", async () => { + const controller = new AbortController(); + controller.abort(new Error("gone before we started")); + await expect(abortable(new Promise(() => {}), controller.signal)) + .rejects.toThrow("gone before we started"); + }); + + it("resolves normally when the signal never fires", async () => { + const controller = new AbortController(); + await expect(abortable(Promise.resolve(42), controller.signal)).resolves.toBe(42); + }); + + it("propagates a rejection from the wrapped promise", async () => { + const controller = new AbortController(); + await expect(abortable(Promise.reject(new Error("work failed")), controller.signal)) + .rejects.toThrow("work failed"); + }); + + it("rejects with the abort reason when the signal fires mid-wait", async () => { + const controller = new AbortController(); + const pending = abortable(new Promise(() => {}), controller.signal); + controller.abort(new Error("user pressed Esc")); + await expect(pending).rejects.toThrow("user pressed Esc"); + }); + + it("absorbs a late REJECTION of the wrapped promise after an abort", async () => { + // The #159 guarantee: the child was not cancelled, so it can still fail + // after we stopped waiting. That failure must go nowhere, not to the + // process's unhandled-rejection handler. Verified to fail if `abortable` + // stops attaching a rejection handler to the wrapped promise. + const unhandled = await withUnhandledRejectionSpy(async () => { + const controller = new AbortController(); + let failChild: ((e: unknown) => void) | undefined; + const childWork = new Promise((_resolve, reject) => { failChild = reject; }); + + const pending = abortable(childWork, controller.signal); + controller.abort(new Error("stopped waiting")); + await expect(pending).rejects.toThrow("stopped waiting"); + + failChild!(new Error("child died later")); + await new Promise((r) => setTimeout(r, 5)); + }); + + expect(unhandled).toEqual([]); + }); + + it("absorbs a late RESOLUTION of the wrapped promise after an abort", async () => { + const controller = new AbortController(); + let finishChild: ((v: string) => void) | undefined; + const childWork = new Promise((resolve) => { finishChild = resolve; }); + + const pending = abortable(childWork, controller.signal); + controller.abort(new Error("stopped waiting")); + await expect(pending).rejects.toThrow("stopped waiting"); + + finishChild!("child finished anyway"); + // The child's own result is still available to whoever holds it — abortable + // cancelled the wait, not the work. + await expect(childWork).resolves.toBe("child finished anyway"); + }); + + it("removes its abort listener once the promise resolves", async () => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + + await abortable(Promise.resolve("done"), controller.signal); + + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("removes its abort listener once the promise rejects", async () => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + + await expect(abortable(Promise.reject(new Error("nope")), controller.signal)).rejects.toThrow(); + + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("a late abort after normal settlement is inert", async () => { + // A long-lived parent signal is aborted for unrelated reasons after this + // wait already finished; nothing should be re-rejected or throw. + const unhandled = await withUnhandledRejectionSpy(async () => { + const controller = new AbortController(); + await expect(abortable(Promise.resolve("done"), controller.signal)).resolves.toBe("done"); + controller.abort(new Error("much later")); + await new Promise((r) => setTimeout(r, 5)); + }); + + expect(unhandled).toEqual([]); + }); + + it("does not leak a listener per wait on a shared parent signal", async () => { + // Many children wait on one parent signal over a session's lifetime. + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + + for (let i = 0; i < 20; i++) { + await abortable(Promise.resolve(i), controller.signal); + } + + expect(remove).toHaveBeenCalledTimes(20); + }); +}); diff --git a/pi-subagents/test/agent-color-surfaces.test.ts b/pi-subagents/test/agent-color-surfaces.test.ts new file mode 100644 index 0000000..d1ecfd8 --- /dev/null +++ b/pi-subagents/test/agent-color-surfaces.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAgents } from "../src/agent-types.js"; +import subagentsExtension from "../src/index.js"; +import type { AgentConfig, AgentRecord } from "../src/types.js"; +import { type AgentActivity, AgentWidget } from "../src/ui/agent-widget.js"; +import { ConversationViewer } from "../src/ui/conversation-viewer.js"; +import { FleetList, type FleetUICtx } from "../src/ui/fleet-list.js"; + +const TYPE = "colored-reviewer"; +const DISPLAY_NAME = "Code Reviewer"; +const PURPLE_BACKGROUND = "\u001b[48;2;130;125;189m"; + +const config: AgentConfig = { + name: TYPE, + displayName: DISPLAY_NAME, + color: "purple", + description: "Reviews code", + extensions: false, + skills: false, + systemPrompt: "Review code.", + promptMode: "replace", +}; + +const theme = { + fg: (color: string, text: string) => `<${color}>${text}`, + bold: (text: string) => `*${text}*`, + getBgAnsi: (color: string) => `<${color}>`, + getColorMode: () => "truecolor" as const, +}; + +type RenderedComponent = { render(width?: number): string[] }; +type WidgetFactory = ( + tui: { terminal: { columns: number; rows?: number }; requestRender: ReturnType }, + activeTheme: typeof theme, +) => RenderedComponent; +type SessionHandler = (...args: unknown[]) => unknown; + +interface RegisteredTool { + name: string; + renderCall( + args: Record, + activeTheme: typeof theme, + context: { isPartial: boolean; isError: boolean }, + ): RenderedComponent; +} + +function registerColoredReviewer(color = "purple"): void { + registerAgents(new Map([[TYPE, { ...config, color }]])); +} + +function makeRecord(): AgentRecord { + return { + id: "review-1", + type: TYPE, + description: "Review this change", + status: "running", + toolUses: 0, + startedAt: Date.now(), + session: { + messages: [], + subscribe: vi.fn(() => vi.fn()), + } as unknown as AgentRecord["session"], + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, + compactionCount: 0, + }; +} + +function makeActivity(): AgentActivity { + return { + activeTools: new Map(), + toolUses: 0, + responseText: "", + turnCount: 1, + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, + }; +} + +function makePi() { + const tools = new Map(); + const handlers = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: unknown) => { + const registered = tool as RegisteredTool; + tools.set(registered.name, registered); + }), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: unknown) => handlers.set(event, handler as SessionHandler)), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as unknown as Parameters[0]; + return { pi, tools, handlers }; +} + +beforeEach(() => { + registerColoredReviewer(); +}); + +afterEach(() => { + registerAgents(new Map()); +}); + +describe("custom agent color runtime surfaces", () => { + it("renders the registered Agent tool call header with the display name and color", async () => { + const { pi, tools, handlers } = makePi(); + subagentsExtension(pi); + registerColoredReviewer(); + + try { + const tool = tools.get("Agent"); + if (!tool) throw new Error("Agent tool was not registered"); + const render = (context: { isPartial: boolean; isError: boolean }) => tool.renderCall( + { subagent_type: TYPE, description: "Review this change" }, + theme, + context, + ).render(120).join("\n"); + const output = render({ isPartial: false, isError: false }); + + expect(output).toContain(DISPLAY_NAME); + expect(output).toContain(PURPLE_BACKGROUND); + // The row tint is opened by the line itself and restored after the badge, so the + // line reads the same whether or not the caller paints one (HTML export does not). + expect(output.indexOf("")).toBeLessThan(output.indexOf(PURPLE_BACKGROUND)); + expect(output.split("")).toHaveLength(3); // opened once, restored after the badge + // Left open on purpose: Box pads to width and then wraps, so a reset here would + // leave the padding untinted. Nothing after the badge may close the background. + expect(output).not.toContain("\u001b[49m"); + expect(render({ isPartial: true, isError: false })).toContain(""); + expect(render({ isPartial: false, isError: true })).toContain(""); + + const missingType = tool.renderCall( + { description: "Review this change" }, + theme, + { isPartial: false, isError: false }, + ).render(120).join("\n"); + expect(missingType).toContain("*Agent*"); + expect(missingType).not.toContain(PURPLE_BACKGROUND); + + // An agent without a color must render the pre-badge line byte for byte: + // no badge, and no row background of our own for HTML export to pick up. + registerAgents(new Map([[TYPE, { ...config, color: undefined }]])); + const uncolored = render({ isPartial: false, isError: false }); + expect(uncolored.trimEnd()).toBe(`▸ *${DISPLAY_NAME}* Review this change`); + } finally { + await handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} }); + } + }); + + it("renders the above-editor Agent widget with the display name and color", () => { + const record = makeRecord(); + const widget = new AgentWidget( + { listAgents: () => [record] } as unknown as ConstructorParameters[0], + new Map([[record.id, makeActivity()]]), + () => "all", + ); + let factory: WidgetFactory | undefined; + let placement: string | undefined; + widget.setUICtx({ + setStatus: vi.fn(), + setWidget: (_key, content, options) => { + if (typeof content === "function") factory = content as WidgetFactory; + placement = options?.placement; + }, + }); + + try { + widget.update(); + const output = factory?.( + { terminal: { columns: 120 }, requestRender: vi.fn() }, + theme, + ).render().join("\n"); + + expect(placement).toBe("aboveEditor"); + expect(output).toContain(DISPLAY_NAME); + expect(output).toContain(PURPLE_BACKGROUND); + } finally { + widget.dispose(); + } + }); + + it("renders the FleetView row with the display name and color", () => { + const record = makeRecord(); + const manager = { + listAgents: () => [record], + abort: vi.fn(() => true), + steer: vi.fn(() => true), + } as unknown as ConstructorParameters[0]; + const fleet = new FleetList(manager, new Map()); + let factory: WidgetFactory | undefined; + fleet.setUICtx({ + setWidget: (_key, content) => { + if (typeof content === "function") factory = content as WidgetFactory; + }, + onTerminalInput: vi.fn(() => vi.fn()), + getEditorText: vi.fn(() => ""), + notify: vi.fn(), + custom: (() => new Promise(() => {})) as FleetUICtx["custom"], + }); + + try { + fleet.update(); + const output = factory?.( + { requestRender: vi.fn(), terminal: { columns: 120, rows: 40 } }, + theme, + ).render(120).join("\n"); + + expect(output).toContain(DISPLAY_NAME); + expect(output).toContain(PURPLE_BACKGROUND); + + registerColoredReviewer("invalid"); + const fallback = factory?.( + { requestRender: vi.fn(), terminal: { columns: 120, rows: 40 } }, + theme, + ).render(120).join("\n"); + expect(fallback).toContain(`${DISPLAY_NAME}`); + expect(fallback).not.toContain(PURPLE_BACKGROUND); + } finally { + fleet.dispose(); + } + }); + + it("renders the conversation viewer header with the display name and color", () => { + const record = makeRecord(); + const viewer = new ConversationViewer( + { terminal: { rows: 30, columns: 120 }, requestRender: vi.fn() } as unknown as ConstructorParameters[0], + record.session!, + record, + undefined, + theme, + vi.fn(), + ); + + try { + const output = viewer.render(120).join("\n"); + expect(output).toContain(DISPLAY_NAME); + expect(output).toContain(PURPLE_BACKGROUND); + } finally { + viewer.dispose(); + } + }); +}); diff --git a/pi-subagents/test/agent-color.test.ts b/pi-subagents/test/agent-color.test.ts new file mode 100644 index 0000000..cdb492e --- /dev/null +++ b/pi-subagents/test/agent-color.test.ts @@ -0,0 +1,69 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it } from "vitest"; +import { renderAgentNameLabel, resolveAgentColor } from "../src/agent-color.js"; + +const theme = { + fg: (color: string, text: string) => `<${color}>${text}`, + bold: (text: string) => `*${text}*`, + getColorMode: () => "truecolor" as const, +}; + +describe("resolveAgentColor", () => { + it("resolves Claude Code names and Agency Agents aliases", () => { + expect(resolveAgentColor("purple")).toBe("#827DBD"); + expect(resolveAgentColor("neon-cyan")).toBe("#06B6D4"); + expect(resolveAgentColor("slate")).toBe("#64748B"); + }); + + it("normalizes six-digit hex and rejects unsupported values", () => { + expect(resolveAgentColor(" #8b5cf6 ")).toBe("#8B5CF6"); + expect(resolveAgentColor("not-a-color")).toBeUndefined(); + expect(resolveAgentColor("#123")).toBeUndefined(); + }); +}); + +describe("renderAgentNameLabel", () => { + it("renders a padded truecolor badge with readable foreground", () => { + const ansiTheme = { + ...theme, + bold: (text: string) => `\u001b[1m${text}\u001b[22m`, + }; + const badge = renderAgentNameLabel("Code Reviewer", "purple", ansiTheme, { bold: true }); + expect(badge).toContain("\u001b[48;2;130;125;189m"); + expect(badge).toContain(" Code Reviewer "); + expect(visibleWidth(badge)).toBe("Code Reviewer".length + 2); + + // Badge text follows the background's luminance: #827DBD is light enough for black, + // #1E3A8A is not. Both sit either side of the 0.179 WCAG threshold. + expect(badge).toContain("\u001b[38;2;0;0;0m"); + expect(renderAgentNameLabel("Tester", "navy", theme)).toContain("\u001b[38;2;255;255;255m"); + }); + + it("judges contrast against the effective color in 256-color mode", () => { + const ansiTheme = { ...theme, getColorMode: () => "256color" as const }; + const label = renderAgentNameLabel("Reviewer", "#C430C4", ansiTheme); + expect(label).toContain("\u001b[48;5;170m"); + expect(label).toContain("\u001b[38;5;16m"); + + const neutral = renderAgentNameLabel("Reviewer", "#808080", ansiTheme); + expect(neutral).toContain("\u001b[48;5;244m"); + }); + + it("restores an enclosing tool background after the badge", () => { + const label = renderAgentNameLabel("Reviewer", "purple", theme, { + restoreBackground: "\u001b[48;2;1;2;3m", + }); + expect(label).toMatch(/\u001b\[39m\u001b\[48;2;1;2;3m$/); + }); + + it("resets the background when the caller paints none", () => { + expect(renderAgentNameLabel("Reviewer", "purple", theme)).toMatch(/\u001b\[39m\u001b\[49m$/); + }); + + it("preserves existing theme styling without a valid color", () => { + expect(renderAgentNameLabel("Agent", undefined, theme, { fallbackColor: "toolTitle", bold: true })) + .toBe("*Agent*"); + expect(renderAgentNameLabel("Agent", "invalid", theme, { fallbackColor: "muted" })) + .toBe("Agent"); + }); +}); diff --git a/pi-subagents/test/agent-file-toggle.test.ts b/pi-subagents/test/agent-file-toggle.test.ts new file mode 100644 index 0000000..3debf3e --- /dev/null +++ b/pi-subagents/test/agent-file-toggle.test.ts @@ -0,0 +1,386 @@ +// The `/agents` enable/disable operations edit an agent .md file's frontmatter. +// The LOAD side (src/custom-agents.ts) parses that frontmatter with a real YAML +// parser, so `enabled: false` is honored wherever it appears in the block. The +// WRITE side here must agree — README.md documents `enabled: false` as a field +// users hand-write, and a hand-authored file puts it wherever the author likes. +// +// These live in src/agent-file-toggle.ts rather than inside the `/agents` +// command closure because `registerCommand` is mocked in every wiring test, +// which is why none of this had coverage. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildNewAgentFile, + disableInContent, + enableInContent, + findAgentFile, + isDisabledContent, + isEmptyStub, + locateAgentFile, +} from "../src/agent-file-toggle.js"; + +/** What the loader concludes about a file, via the same parser it really uses. */ +function loaderSeesDisabled(content: string): boolean { + return parseFrontmatter>(content).frontmatter.enabled === false; +} + +const HAND_AUTHORED_DISABLED = "---\ndescription: Scout the repo\nenabled: false\n---\n\nYou are a scout.\n"; +const EXTENSION_WRITTEN_DISABLED = "---\nenabled: false\ndescription: Scout the repo\n---\n\nYou are a scout.\n"; +const ENABLED = "---\ndescription: Scout the repo\n---\n\nYou are a scout.\n"; + +describe("enableInContent", () => { + it("strips enabled: false when it is the first frontmatter line", () => { + const { content, changed } = enableInContent(EXTENSION_WRITTEN_DISABLED); + expect(changed).toBe(true); + expect(loaderSeesDisabled(content)).toBe(false); + }); + + it("strips enabled: false when another key precedes it", () => { + // The shape a user gets by following README.md — `description` first. + const { content, changed } = enableInContent(HAND_AUTHORED_DISABLED); + expect(changed).toBe(true); + expect(loaderSeesDisabled(content)).toBe(false); + }); + + it("strips enabled: false when it is the last frontmatter line", () => { + const src = "---\ndescription: Scout\ndisplay_name: Scout\nenabled: false\n---\n\nBody.\n"; + const { content, changed } = enableInContent(src); + expect(changed).toBe(true); + expect(loaderSeesDisabled(content)).toBe(false); + }); + + it("reports changed: false when there is nothing to strip", () => { + const { content, changed } = enableInContent(ENABLED); + expect(changed).toBe(false); + expect(content).toBe(ENABLED); + }); + + it("leaves the body and other frontmatter keys untouched", () => { + const src = "---\ndescription: Scout\nenabled: false\n# a comment\nmodel: haiku\n---\n\nLine 1.\n\nLine 2.\n"; + const { content } = enableInContent(src); + expect(content).toContain("# a comment"); + expect(content).toContain("model: haiku"); + expect(content).toContain("Line 1.\n\nLine 2.\n"); + expect(loaderSeesDisabled(content)).toBe(false); + }); + + it("handles CRLF line endings", () => { + const src = "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n"; + const { content, changed } = enableInContent(src); + expect(changed).toBe(true); + expect(loaderSeesDisabled(content)).toBe(false); + }); +}); + +describe("disableInContent", () => { + it("inserts enabled: false into a normal frontmatter block", () => { + const { content, outcome } = disableInContent(ENABLED); + expect(outcome).toBe("disabled"); + expect(loaderSeesDisabled(content)).toBe(true); + }); + + it("is idempotent when the key is already first", () => { + expect(disableInContent(EXTENSION_WRITTEN_DISABLED).outcome).toBe("already-disabled"); + }); + + it("is idempotent when the key is already present mid-block", () => { + expect(disableInContent(HAND_AUTHORED_DISABLED).outcome).toBe("already-disabled"); + }); + + // The previous guard was `content.includes("\nenabled: false\n")`, which a + // trailing space defeats. It then inserted a SECOND `enabled: false`, and + // duplicate map keys make the whole file unparseable — so an agent the user + // asked to disable disappeared from `/agents` entirely (since #212 an + // unparseable agent file is skipped, not surfaced). + it("never writes a file the loader cannot parse", () => { + for (const src of [ + "---\ndescription: x\nenabled: false \n---\nbody\n", + "---\ndescription: x\nenabled: false\t\n---\nbody\n", + HAND_AUTHORED_DISABLED, + EXTENSION_WRITTEN_DISABLED, + ENABLED, + ]) { + const { content } = disableInContent(src); + expect(() => parseFrontmatter(content), JSON.stringify(src)).not.toThrow(); + expect(loaderSeesDisabled(content), JSON.stringify(src)).toBe(true); + } + }); + + it("reports no-frontmatter rather than claiming success on a fence-less file", () => { + const src = "Just a body, no frontmatter at all.\n"; + const { content, outcome } = disableInContent(src); + expect(outcome).toBe("no-frontmatter"); + expect(content).toBe(src); + }); + + it("disables a CRLF file instead of misreporting it as frontmatter-less", () => { + const src = "---\r\ndescription: Scout\r\n---\r\n\r\nBody.\r\n"; + const { content, outcome } = disableInContent(src); + expect(outcome).toBe("disabled"); + expect(loaderSeesDisabled(content)).toBe(true); + }); + + it("reports no-frontmatter for a BOM-prefixed file, matching what the loader sees", () => { + // The parser doesn't accept a BOM either — it reports an empty frontmatter + // and treats the whole file as body. So refusing here is agreement with the + // read side, not a gap: inserting the key would have no effect on loading. + const src = "---\ndescription: Scout\n---\n\nBody.\n"; + expect(parseFrontmatter>(src).frontmatter).toEqual({}); + const { content, outcome } = disableInContent(src); + expect(outcome).toBe("no-frontmatter"); + expect(content).toBe(src); + }); +}); + +describe("isDisabledContent", () => { + it("sees the key at the first frontmatter line", () => { + expect(isDisabledContent(EXTENSION_WRITTEN_DISABLED)).toBe(true); + }); + + it("sees the key mid-block", () => { + expect(isDisabledContent(HAND_AUTHORED_DISABLED)).toBe(true); + }); + + it("sees the key in a CRLF file", () => { + expect(isDisabledContent("---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n")).toBe(true); + }); + + it("is false for an enabled file", () => { + expect(isDisabledContent(ENABLED)).toBe(false); + }); + + // Detection is a read, so it asks the loader's parser instead of mirroring it. + // A mirror has to be right about two independent things, and a regex was wrong + // about both: YAML's other spellings of `false`, and pi's fence scan, which + // closes the block at any line *starting* `---` — so `----` ends it early and + // the keys after it are body text, not frontmatter. Getting that backwards told + // a user their running agent was "already disabled". + it.each([ + ["lowercase bare false", "---\ndescription: x\nenabled: false\n---\nbody\n", true], + ["False", "---\ndescription: x\nenabled: False\n---\nbody\n", true], + ["FALSE", "---\ndescription: x\nenabled: FALSE\n---\nbody\n", true], + ["trailing comment", "---\ndescription: x\nenabled: false # off for now\n---\nbody\n", true], + ["quoted key", '---\ndescription: x\n"enabled": false\n---\nbody\n', true], + ["trailing whitespace", "---\ndescription: x\nenabled: false \n---\nbody\n", true], + ["'----' closes the block early", "---\ndescription: x\n----\nenabled: false\n---\nbody\n", false], + ["'--- x' closes the block early", "---\ndescription: x\n--- x\nenabled: false\n---\nbody\n", false], + ["quoted string, not a boolean", '---\ndescription: x\nenabled: "false"\n---\nbody\n', false], + ["YAML 1.1 'no' is a string here", "---\ndescription: x\nenabled: no\n---\nbody\n", false], + ["key only in the body", "---\ndescription: x\n---\nenabled: false\n", false], + ])("agrees with the loader: %s", (_label, content, expected) => { + expect(loaderSeesDisabled(content)).toBe(expected); // premise: what the loader concludes + expect(isDisabledContent(content)).toBe(expected); // we must conclude the same + }); +}); + +// The defect, stated as an invariant rather than as a list of shapes: the two +// sides must never disagree about whether a file is disabled. Whatever the +// loader reads as disabled, `/agents → Enable` has to be able to re-enable. +describe("read and write paths agree", () => { + const shapes: Array<[string, string]> = [ + ["key first", EXTENSION_WRITTEN_DISABLED], + ["key after description", HAND_AUTHORED_DISABLED], + ["key last", "---\ndescription: Scout\ndisplay_name: S\nenabled: false\n---\n\nBody.\n"], + ["CRLF", "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n"], + ]; + + for (const [label, content] of shapes) { + it(`a file the loader reads as disabled can be enabled — ${label}`, () => { + expect(loaderSeesDisabled(content)).toBe(true); // premise: the loader agrees it's disabled + expect(enableInContent(content).changed).toBe(true); + }); + } + + it("disable → enable round-trips to the original hand-authored file", () => { + const disabled = disableInContent(ENABLED); + expect(disabled.outcome).toBe("disabled"); + expect(enableInContent(disabled.content).content).toBe(ENABLED); + }); +}); + +describe("isEmptyStub", () => { + it("recognizes the stub /agents writes to disable a built-in default", () => { + expect(isEmptyStub("---\n---\n")).toBe(true); + expect(isEmptyStub(enableInContent("---\nenabled: false\n---\n").content)).toBe(true); + }); + + it("is false for a file with real frontmatter", () => { + expect(isEmptyStub(ENABLED)).toBe(false); + }); +}); + +describe("findAgentFile", () => { + let tmpDir: string; + let agentDir: string; + let prevAgentDir: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-toggle-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-toggle-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + }); + + afterEach(() => { + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + }); + + function write(dir: string, name: string) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.md`), ENABLED); + } + + it("prefers .pi/agents over .agents/agents and the personal dir", () => { + write(join(tmpDir, ".pi", "agents"), "scout"); + write(join(tmpDir, ".agents", "agents"), "scout"); + write(join(agentDir, "agents"), "scout"); + expect(findAgentFile("scout", tmpDir)).toEqual({ + path: join(tmpDir, ".pi", "agents", "scout.md"), + location: "project", + }); + }); + + it("falls back to the workspace .agents/agents dir", () => { + write(join(tmpDir, ".agents", "agents"), "scout"); + write(join(agentDir, "agents"), "scout"); + expect(findAgentFile("scout", tmpDir)).toEqual({ + path: join(tmpDir, ".agents", "agents", "scout.md"), + location: "workspace", + }); + }); + + it("falls back to the personal agent dir", () => { + write(join(agentDir, "agents"), "scout"); + expect(findAgentFile("scout", tmpDir)).toEqual({ + path: join(agentDir, "agents", "scout.md"), + location: "personal", + }); + }); + + it("returns undefined when the agent has no file anywhere", () => { + expect(findAgentFile("nope", tmpDir)).toBeUndefined(); + }); + + // An agent's type comes from its frontmatter `name:` now, so `.md` is a + // guess. Getting it wrong is not a harmless miss: `/agents → Disable` takes + // the no-file branch and writes a NEW stub, which loses to the real file on + // load — so the agent stays enabled while the toast reports success. + describe("locateAgentFile", () => { + it("uses the file the loader read, whatever it is called", () => { + write(join(tmpDir, ".pi", "agents"), "reviewer"); + const sourcePath = join(tmpDir, ".pi", "agents", "reviewer.md"); + + expect(locateAgentFile("code-reviewer", sourcePath, tmpDir)).toEqual({ + path: sourcePath, + location: "project", + }); + }); + + it("classifies a workspace and a personal source path", () => { + write(join(tmpDir, ".agents", "agents"), "reviewer"); + write(join(agentDir, "agents"), "auditor"); + + expect(locateAgentFile("code-reviewer", join(tmpDir, ".agents", "agents", "reviewer.md"), tmpDir)) + .toMatchObject({ location: "workspace" }); + expect(locateAgentFile("code-auditor", join(agentDir, "agents", "auditor.md"), tmpDir)) + .toMatchObject({ location: "personal" }); + }); + + it("falls back to the .md probe for a built-in with no source file", () => { + write(join(tmpDir, ".pi", "agents"), "scout"); + + expect(locateAgentFile("scout", undefined, tmpDir)).toEqual({ + path: join(tmpDir, ".pi", "agents", "scout.md"), + location: "project", + }); + }); + + it("falls back when the recorded path has since been deleted", () => { + write(join(tmpDir, ".pi", "agents"), "scout"); + + expect(locateAgentFile("scout", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toEqual({ + path: join(tmpDir, ".pi", "agents", "scout.md"), + location: "project", + }); + }); + + it("finds nothing when neither the source path nor the probe resolves", () => { + expect(locateAgentFile("nope", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toBeUndefined(); + }); + }); +}); + +// `/agents → Create agent → Manual` writes an agent file from free-text prompts. +// The description is whatever the user typed into `ctx.ui.input("Description +// (one line)")` — no validation, no escaping — and it was interpolated straight +// into a YAML scalar. +// +// That matters more than a formatting nit because of how a broken agent file is +// handled since #212: it is SKIPPED with a warning (or aborts startup under +// strictAgentFiles). So the wizard reports "Created ", and the agent the +// user just built silently does not exist. +describe("buildNewAgentFile", () => { + const base = { tools: "read, grep", systemPrompt: "Do the thing.", description: "Scout" }; + + /** What the loader makes of the generated file. */ + const parse = (content: string) => parseFrontmatter>(content).frontmatter; + + it("round-trips an ordinary description", () => { + expect(parse(buildNewAgentFile(base)).description).toBe("Scout"); + }); + + it("survives a description containing a colon", () => { + // "Scout: find things" is an entirely natural thing to type, and an + // unquoted YAML scalar treats the colon as a nested mapping — the parser + // throws and the whole file is unloadable. + const content = buildNewAgentFile({ ...base, description: "Scout: find things" }); + expect(() => parse(content)).not.toThrow(); + expect(parse(content).description).toBe("Scout: find things"); + }); + + it("keeps a description containing a # instead of truncating it", () => { + // `#` opens a YAML comment, so "audit #security" silently becomes "audit". + const content = buildNewAgentFile({ ...base, description: "audit #security" }); + expect(parse(content).description).toBe("audit #security"); + }); + + it("leaves a `provider/model:thinking` suffix intact", () => { + // Not a hazard on its own — YAML splits on ": " (colon then space), so a + // bare `:high` suffix is a valid plain scalar either way. Pinned because + // it's the shape most likely to be typed into the custom-model prompt, and + // quoting must not mangle it. + const content = buildNewAgentFile({ ...base, model: "anthropic/claude-sonnet-4-6:high" }); + expect(parse(content).model).toBe("anthropic/claude-sonnet-4-6:high"); + }); + + it("survives a custom model containing a colon-space or a #", () => { + // The custom-model prompt is free text with no validation, so it has the + // same two hazards the description does. + for (const model of ["anthropic/foo: bar", "anthropic/x #c"]) { + const content = buildNewAgentFile({ ...base, model }); + expect(() => parse(content), model).not.toThrow(); + expect(parse(content).model, model).toBe(model); + } + }); + + it("emits the fields the wizard collects, and omits the ones left on inherit", () => { + const full = parse(buildNewAgentFile({ ...base, model: "anthropic/x", thinking: "high" })); + expect(full).toMatchObject({ tools: "read, grep", model: "anthropic/x", thinking: "high", prompt_mode: "replace" }); + + const minimal = parse(buildNewAgentFile(base)); + expect(minimal.model).toBeUndefined(); + expect(minimal.thinking).toBeUndefined(); + }); + + it("keeps the system prompt as the body", () => { + const content = buildNewAgentFile({ ...base, systemPrompt: "Line 1.\n\nLine 2." }); + expect(parseFrontmatter>(content).body).toBe("Line 1.\n\nLine 2."); + }); +}); diff --git a/pi-subagents/test/agent-manager-gc.test.ts b/pi-subagents/test/agent-manager-gc.test.ts new file mode 100644 index 0000000..b40483b --- /dev/null +++ b/pi-subagents/test/agent-manager-gc.test.ts @@ -0,0 +1,323 @@ +// The 10-minute record GC (AgentManager.cleanup) has never run in a test: the +// main agent-manager suite uses real timers throughout, and nothing calls the +// private method. Its two guards are load-bearing in opposite directions — +// inverting the cutoff disposes results the LLM hasn't read yet, and dropping +// the running/queued skip disposes a LIVE agent's session mid-run. +// +// It lives in its own file because vi.useFakeTimers() has to be installed +// BEFORE `new AgentManager()` (the constructor starts the interval), and fake +// timers are hostile to the promise-settling style of the main suite. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentManager } from "../src/agent-manager.js"; + +vi.mock("../src/agent-runner.js", () => ({ + runAgent: vi.fn(), + resumeAgent: vi.fn(), +})); + +vi.mock("../src/worktree.js", () => ({ + createWorktree: vi.fn(), + cleanupWorktree: vi.fn(() => ({ hasChanges: false })), + pruneWorktrees: vi.fn(), +})); + +import { runAgent } from "../src/agent-runner.js"; + +const mockPi = {} as any; +const mockCtx = { cwd: "/tmp" } as any; + +const TEN_MINUTES = 10 * 60_000; +const TICK = 60_000; + +describe("AgentManager — record GC", () => { + let manager: AgentManager; + + beforeEach(() => { + // Before construction: the cleanup interval is started in the constructor. + vi.useFakeTimers(); + }); + + afterEach(() => { + manager?.dispose(); + vi.useRealTimers(); + }); + + /** Spawn a background agent and settle it, returning its id and record. */ + async function settled(prompt: string) { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + } as any); + manager ??= new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "X", prompt, { description: prompt, isBackground: true }); + await manager.getRecord(id)!.promise; + return { id, record: manager.getRecord(id)! }; + } + + it("keeps a record that completed inside the retention window", async () => { + manager = new AgentManager(); + const { id, record } = await settled("recent"); + // Age at sweep time is (TEN_MINUTES - 2*TICK) + TICK — just inside the window. + // Advancing the timers moves Date.now() too, so the margin has to outlast it. + record.completedAt = Date.now() - (TEN_MINUTES - 2 * TICK); + + await vi.advanceTimersByTimeAsync(TICK); + + expect(manager.getRecord(id)).toBeDefined(); + }); + + it("evicts a record that completed before the cutoff and disposes its session", async () => { + manager = new AgentManager(); + const { id, record } = await settled("stale"); + const dispose = vi.fn(); + record.session = { dispose } as any; + record.completedAt = Date.now() - (TEN_MINUTES + 30_000); + + await vi.advanceTimersByTimeAsync(TICK); + + expect(manager.getRecord(id)).toBeUndefined(); + expect(manager.listAgents().map(a => a.id)).not.toContain(id); + expect(dispose).toHaveBeenCalled(); + }); + + it("closes the evicted session's extension lifecycle before disposing it (#242)", async () => { + // The reported crash, on its own path: this sweep is what fires ~10 min after a + // subagent finishes. Disposing only invalidates the ExtensionRunner, so whatever + // an extension armed in `session_start` stayed armed — and its next tick threw + // `assertActive()` from a bare timer callback, killing interactive pi. + manager = new AgentManager(); + const { id, record } = await settled("stale"); + const emit = vi.fn(async () => {}); + const dispose = vi.fn(); + record.session = { + dispose, + extensionRunner: { hasHandlers: (event: string) => event === "session_shutdown", emit }, + } as any; + record.completedAt = Date.now() - (TEN_MINUTES + 30_000); + + await vi.advanceTimersByTimeAsync(TICK); + + expect(manager.getRecord(id)).toBeUndefined(); + expect(emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" }); + // After dispose() the runner is invalidated and every ctx getter throws, so an + // emit that landed afterwards would be worse than none. + expect(emit.mock.invocationCallOrder[0]).toBeLessThan(dispose.mock.invocationCallOrder[0]); + }); + + it("never evicts a running agent, however old its timestamp looks", async () => { + // A live agent's session being disposed mid-run is the worst failure this + // guard prevents, and `completedAt` on a running record is meaningless. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "X", "live", { description: "live", isBackground: true }); + const record = manager.getRecord(id)!; + expect(record.status).toBe("running"); + record.completedAt = Date.now() - 10 * TEN_MINUTES; + const dispose = vi.fn(); + record.session = { dispose } as any; + + await vi.advanceTimersByTimeAsync(TICK * 5); + + expect(manager.getRecord(id)).toBeDefined(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it("never evicts a queued agent", async () => { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager = new AgentManager(undefined, 1); + manager.spawn(mockPi, mockCtx, "X", "holder", { description: "holder", isBackground: true }); + const queuedId = manager.spawn(mockPi, mockCtx, "X", "waiter", { description: "waiter", isBackground: true }); + const queued = manager.getRecord(queuedId)!; + expect(queued.status).toBe("queued"); + queued.completedAt = Date.now() - 10 * TEN_MINUTES; + + await vi.advanceTimersByTimeAsync(TICK * 5); + + expect(manager.getRecord(queuedId)?.status).toBe("queued"); + }); + + it("sweeps repeatedly, not just once", async () => { + // The interval must keep firing: a record that ages past the cutoff on a + // later tick has to be collected too. + manager = new AgentManager(); + const { id, record } = await settled("ages-out"); + record.completedAt = Date.now() - (TEN_MINUTES - 3 * TICK); + + await vi.advanceTimersByTimeAsync(TICK); + expect(manager.getRecord(id)).toBeDefined(); // still inside the window + + await vi.advanceTimersByTimeAsync(TICK * 4); + expect(manager.getRecord(id)).toBeUndefined(); // aged out on a later tick + }); +}); + +// Eviction is exactly the moment a handle would otherwise stop working. These +// cover what outlives it: enough to find the agent's session on disk and +// reopen the conversation, and the name held so nothing else claims it. +describe("AgentManager — tombstones outliving the GC", () => { + let manager: AgentManager; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + manager?.dispose(); + vi.useRealTimers(); + }); + + /** Spawn, settle, and age past the cutoff so the next tick evicts it. */ + async function evictable(type: string, prompt: string, sessionFile?: string) { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + } as any); + manager ??= new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, type, prompt, { description: prompt, isBackground: true }); + const record = manager.getRecord(id)!; + await record.promise; + record.sessionFile = sessionFile; + record.completedAt = Date.now() - (TEN_MINUTES + 30_000); + return { id, record }; + } + + it("keeps an evicted agent reachable by name when its session is on disk", async () => { + manager = new AgentManager(); + await evictable("Explore", "audit the RPC path", "/sessions/explore.jsonl"); + + await vi.advanceTimersByTimeAsync(TICK); + + const resolved = manager.resolveMention("explore"); + expect(resolved?.kind).toBe("tombstone"); + expect(resolved).toMatchObject({ + entry: { handle: "explore", type: "Explore", description: "audit the RPC path", sessionFile: "/sessions/explore.jsonl" }, + }); + }); + + it("leaves nothing behind when the session was only ever in memory", async () => { + // Without a file there is no conversation to reopen, so promising a resume + // would be a lie — the mention has to fall through to starting a new agent. + manager = new AgentManager(); + await evictable("Explore", "ephemeral", undefined); + + await vi.advanceTimersByTimeAsync(TICK); + + expect(manager.resolveMention("explore")).toBeUndefined(); + expect(manager.listTombstones()).toHaveLength(0); + }); + + it("holds the evicted handle so a later agent of the same type can't shadow it", async () => { + manager = new AgentManager(); + await evictable("Explore", "first", "/sessions/first.jsonl"); + await vi.advanceTimersByTimeAsync(TICK); + + const id = manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true }); + + expect(manager.getRecord(id)!.handle).toBe("explore-2"); + // ...and `@explore` still means the conversation you can resume. + expect(manager.resolveMention("explore")?.kind).toBe("tombstone"); + }); + + it("prefers a live agent over a tombstone holding the same name", async () => { + manager = new AgentManager(); + await evictable("Explore", "old", "/sessions/old.jsonl"); + await vi.advanceTimersByTimeAsync(TICK); + // Give the live agent the tombstone's name directly: the collision this + // guards against is resolution order, not allocation. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "Explore", "live", { description: "live", isBackground: true }); + manager.getRecord(id)!.handle = "explore"; + + const resolved = manager.resolveMention("explore"); + expect(resolved?.kind).toBe("live"); + expect(resolved).toMatchObject({ record: { id } }); + }); + + it("drops the oldest once the cap is reached, keeping the most recent", async () => { + manager = new AgentManager(); + for (let i = 0; i < 101; i++) { + const { record } = await evictable("Explore", `run-${i}`, `/sessions/${i}.jsonl`); + // Distinct ages so "oldest" is well defined; run-0 is the oldest. Set on + // the record we just made — under fake timers every startedAt is equal, + // so listAgents() has no meaningful order to index into. + record.completedAt = Date.now() - (TEN_MINUTES + 101_000 - i * 1000); + } + await vi.advanceTimersByTimeAsync(TICK); + + expect(manager.listTombstones()).toHaveLength(100); + expect(manager.resolveMention("explore")).toBeUndefined(); // run-0's handle + expect(manager.resolveMention("explore-101")?.kind).toBe("tombstone"); + }); + + it("hands a reclaimed name straight back, numbering nothing", async () => { + // The resume path's whole contract: `handleBase(type)` cannot reproduce a + // numbered handle, so the spawn takes the tombstone's names verbatim. + manager = new AgentManager(); + await evictable("Explore", "first", "/sessions/first.jsonl"); + manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true }); + await vi.advanceTimersByTimeAsync(TICK); + + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "Explore", "resumed", { + description: "first", + isBackground: true, + reclaim: { handle: "explore", alias: "auth-audit" }, + } as any); + + expect(manager.getRecord(id)).toMatchObject({ handle: "explore", alias: "auth-audit" }); + }); + + it("ignores a reclaim on a nested child, which has no name to hold", async () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "Explore", "child", { + description: "child", + parentAgentId: "parent-id", + reclaim: { handle: "explore", alias: "auth-audit" }, + } as any); + + expect(manager.getRecord(id)).toMatchObject({ handle: undefined, alias: undefined }); + }); + + it("stops answering a name once its tombstone is dropped", async () => { + manager = new AgentManager(); + await evictable("Explore", "done", "/sessions/done.jsonl"); + await vi.advanceTimersByTimeAsync(TICK); + expect(manager.resolveMention("explore")?.kind).toBe("tombstone"); + + manager.dropTombstone("explore"); + + expect(manager.resolveMention("explore")).toBeUndefined(); + expect(manager.listTombstones()).toHaveLength(0); + }); + + it("frees a dropped name for the next agent of that type", async () => { + // Otherwise a resumed agent's own handle stays reserved by the corpse and + // every later spawn climbs: explore-2, explore-3, … + manager = new AgentManager(); + await evictable("Explore", "done", "/sessions/done.jsonl"); + await vi.advanceTimersByTimeAsync(TICK); + manager.dropTombstone("explore"); + + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "Explore", "next", { description: "next", isBackground: true }); + + expect(manager.getRecord(id)!.handle).toBe("explore"); + }); + + it("forgets every name when the session ends", async () => { + // A handle from a conversation the user has left must not resolve, or + // `@explore` reaches an agent they have no memory of starting. + manager = new AgentManager(); + await evictable("Explore", "prior session", "/sessions/prior.jsonl"); + await vi.advanceTimersByTimeAsync(TICK); + expect(manager.listTombstones()).toHaveLength(1); + + manager.clearCompleted(true); + + expect(manager.listTombstones()).toHaveLength(0); + expect(manager.resolveMention("explore")).toBeUndefined(); + }); +}); diff --git a/pi-subagents/test/agent-manager.test.ts b/pi-subagents/test/agent-manager.test.ts new file mode 100644 index 0000000..5aaa83f --- /dev/null +++ b/pi-subagents/test/agent-manager.test.ts @@ -0,0 +1,2046 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentManager } from "../src/agent-manager.js"; +import type { AgentRecord } from "../src/types.js"; + +vi.mock("../src/agent-runner.js", () => ({ + runAgent: vi.fn(), + resumeAgent: vi.fn(), +})); + +vi.mock("../src/worktree.js", () => ({ + createWorktree: vi.fn(), + cleanupWorktree: vi.fn(() => ({ hasChanges: false })), + pruneWorktrees: vi.fn(), + isWorktreeIsolationEnabled: vi.fn(() => true), +})); + +import { resumeAgent, runAgent } from "../src/agent-runner.js"; +import { addUsage } from "../src/usage.js"; +import { isWorktreeIsolationEnabled } from "../src/worktree.js"; + +const mockPi = {} as any; +const mockCtx = { cwd: "/tmp" } as any; + +const mockSession = () => ({ dispose: vi.fn() } as any); + +const resolvedRun = () => + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: mockSession(), + aborted: false, + steered: false, + }); + +describe("AgentManager — Bug 1 race condition (resultConsumed vs onComplete)", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("reproduces bug: onComplete fires with resultConsumed=false when set after await", async () => { + let seenConsumed: boolean | undefined; + manager = new AgentManager((r) => { + seenConsumed = r.resultConsumed; + }); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + const record = manager.getRecord(id)!; + + // Simulate the buggy get_subagent_result: await THEN mark consumed + await record.promise; + record.resultConsumed = true; // too late — onComplete already fired + + // onComplete saw resultConsumed as falsy (undefined) — would queue a notification (the bug) + expect(seenConsumed).toBeFalsy(); + }); + + it("fix: onComplete sees resultConsumed=true when pre-marked before await", async () => { + let seenConsumed: boolean | undefined; + manager = new AgentManager((r) => { + seenConsumed = r.resultConsumed; + }); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + const record = manager.getRecord(id)!; + + // The fix: pre-mark BEFORE awaiting + record.resultConsumed = true; + await record.promise; + + expect(seenConsumed).toBe(true); + }); + + it("normal case: onComplete fires with resultConsumed falsy when no explicit polling", async () => { + let completedRecord: AgentRecord | undefined; + manager = new AgentManager((r) => { + completedRecord = r; + }); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(completedRecord).toBeDefined(); + expect(completedRecord!.resultConsumed).toBeFalsy(); + }); + + it("onComplete IS called for foreground agents (lifecycle symmetry)", async () => { + let completedRecord: AgentRecord | undefined; + manager = new AgentManager((r) => { + completedRecord = r; + }); + resolvedRun(); + + const { record } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }); + + expect(completedRecord).toBeDefined(); + expect(completedRecord!.status).toBe("completed"); + // resultConsumed is set by spawnAndWait so onComplete skips notifications + expect(completedRecord!.resultConsumed).toBe(true); + expect(record).toBe(completedRecord); + }); +}); + +describe("AgentManager — spawnAndWait onSpawned + foreground output file wiring (#105)", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("fields set on the record in onSpawned are visible when onSessionCreated fires", async () => { + // The load-bearing ordering guarantee: onSpawned fires synchronously inside + // spawn(), before runAgent's async onSessionCreated fires. index.ts relies on + // this to set record.outputFile so streamToOutputFile can pick it up. + manager = new AgentManager(); + let capturedId: string | undefined; + let outputFileSeenAtSessionCreated: string | undefined; + + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + const session = mockSession(); + // Yield one microtask to mirror real behavior: in production, onSessionCreated + // fires async (after network/session setup). onSpawned fires synchronously + // inside spawn() before runAgent's promise even starts. This await lets the + // remainder of startAgent (record.promise = …, onSpawned?.()) finish first. + await Promise.resolve(); + opts.onSessionCreated?.(session); + outputFileSeenAtSessionCreated = capturedId + ? manager.getRecord(capturedId)?.outputFile + : undefined; + return { responseText: "done", session, aborted: false, steered: false }; + }); + + await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }, (fgId) => { + capturedId = fgId; + manager.getRecord(fgId)!.outputFile = "/fake/agent.jsonl"; + }); + + expect(outputFileSeenAtSessionCreated).toBe("/fake/agent.jsonl"); + }); + + it("onSpawned id matches the id returned by spawnAndWait", async () => { + manager = new AgentManager(); + let spawnedId: string | undefined; + resolvedRun(); + + const { id } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }, (fgId) => { spawnedId = fgId; }); + + expect(spawnedId).toBe(id); + }); + + it("restores the shared onSpawned callback before awaiting the foreground run", async () => { + manager = new AgentManager(); + let finishFirst: ((value: any) => void) | undefined; + vi.mocked(runAgent) + .mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve; })) + .mockResolvedValueOnce({ + responseText: "second", + session: mockSession(), + aborted: false, + steered: false, + }); + const firstCallback = vi.fn(); + + const first = manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "first", { + description: "first", + }, firstCallback); + const secondId = manager.spawn(mockPi, mockCtx, "general-purpose", "second", { + description: "second", + isBackground: true, + }); + + expect(firstCallback).toHaveBeenCalledTimes(1); + await manager.getRecord(secondId)!.promise; + finishFirst?.({ + responseText: "first", + session: mockSession(), + aborted: false, + steered: false, + }); + await first; + }); + + it("onComplete fires on the error path with resultConsumed=true", async () => { + // The .then path is covered by the lifecycle-symmetry test above; this guards + // the .catch path which lacks try/catch around onComplete (a known asymmetry). + let completedRecord: AgentRecord | undefined; + manager = new AgentManager((r) => { completedRecord = r; }); + vi.mocked(runAgent).mockRejectedValue(new Error("agent failed")); + + const { record } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }); + + expect(completedRecord).toBeDefined(); + expect(completedRecord!.status).toBe("error"); + expect(completedRecord!.resultConsumed).toBe(true); + expect(record).toBe(completedRecord); + }); +}); + +describe("AgentManager — nested runtime propagation", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + it("stores nesting metadata and passes the owning manager/runtime to runAgent", async () => { + resolvedRun(); + const mandatoryPath = fileURLToPath(import.meta.url); + manager = new AgentManager( + undefined, undefined, undefined, undefined, undefined, + { mandatoryExtensionPaths: [mandatoryPath] }, + ); + const id = manager.spawn(mockPi, mockCtx, "scout", "nested", { + description: "nested", + isBackground: true, + depth: 2, + parentAgentId: "parent-1", + maxSubagentDepth: 3, + configCwd: "/trusted/config", + rootSessionId: "root-authority", + }); + await manager.getRecord(id)!.promise; + + expect(manager.getRecord(id)).toEqual(expect.objectContaining({ + depth: 2, + parentAgentId: "parent-1", + maxSubagentDepth: 3, + rootSessionId: "root-authority", + })); + expect(runAgent).toHaveBeenLastCalledWith( + mockCtx, + "scout", + "nested", + expect.objectContaining({ + configCwd: "/trusted/config", + authoritySessionId: "root-authority", + mandatoryExtensionPaths: [mandatoryPath], + nestedRuntime: { + manager, + parentAgentId: id, + depth: 2, + maxSubagentDepth: 3, + }, + }), + ); + }); + + it("tells the runner which spawns are nested, so only top-level ones persist", async () => { + // `rememberAgents` exists so `@handle` can reopen a conversation. A nested + // child never gets a handle, so persisting it writes a session file nothing + // can ever reach — the runner needs the fact to decline. + resolvedRun(); + manager = new AgentManager(); + const child = manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", isBackground: true, depth: 2, parentAgentId: "parent-1", + }); + await manager.getRecord(child)!.promise; + expect(runAgent).toHaveBeenLastCalledWith( + mockCtx, "scout", "child", expect.objectContaining({ nested: true }), + ); + + const top = manager.spawn(mockPi, mockCtx, "scout", "top", { description: "top", isBackground: true }); + await manager.getRecord(top)!.promise; + expect(runAgent).toHaveBeenLastCalledWith( + mockCtx, "scout", "top", expect.objectContaining({ nested: false }), + ); + }); + + it("defaults top-level subagents to depth one", async () => { + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "scout", "top", { + description: "top", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(manager.getRecord(id)?.depth).toBe(1); + expect(vi.mocked(runAgent).mock.lastCall?.[3].nestedRuntime).toEqual(expect.objectContaining({ + parentAgentId: id, + depth: 1, + })); + }); + + it("starts a nested background child even when the concurrency pool is full", async () => { + // A parent holding the only slot and waiting on its own child would + // otherwise deadlock: the child can never be drained from the queue. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager = new AgentManager(undefined, 1); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", + isBackground: true, + }); + const childId = manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", + isBackground: true, + depth: 2, + parentAgentId: parentId, + }); + // A second top-level background agent still queues — the pool is untouched. + const siblingId = manager.spawn(mockPi, mockCtx, "general-purpose", "sibling", { + description: "sibling", + isBackground: true, + }); + + expect(manager.getRecord(childId)?.status).toBe("running"); + expect(manager.getRecord(siblingId)?.status).toBe("queued"); + }); + + it("aborts owned children when the parent settles", async () => { + let finishParent: ((value: any) => void) | undefined; + // Children settle on abort, as a real run does when its signal fires. + const abortable = (_ctx: any, _type: any, _prompt: any, opts: any) => + new Promise(resolve => { + opts.signal?.addEventListener("abort", () => + resolve({ responseText: "", session: mockSession(), aborted: true, steered: false }), + ); + }); + vi.mocked(runAgent) + .mockImplementationOnce(() => new Promise(resolve => { finishParent = resolve; })) + .mockImplementation(abortable as any); + manager = new AgentManager(); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", + isBackground: true, + }); + const runningChild = manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", + isBackground: true, + depth: 2, + parentAgentId: parentId, + }); + const grandchild = manager.spawn(mockPi, mockCtx, "scout", "grandchild", { + description: "grandchild", + isBackground: true, + depth: 3, + parentAgentId: runningChild, + }); + + finishParent?.({ responseText: "done", session: mockSession(), aborted: false, steered: false }); + await manager.getRecord(parentId)!.promise; + + expect(manager.getRecord(runningChild)?.status).toBe("stopped"); + // The child's own settle path stops the generation below it. + await manager.getRecord(runningChild)!.promise; + expect(manager.getRecord(grandchild)?.status).toBe("stopped"); + }); + + it("aborts children spawned during a resumed turn", async () => { + // The spawn settle path already ran, so only resume() can stop what the + // resumed turn launched — otherwise the child runs on, invisible. + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: mockSession(), + aborted: false, + steered: false, + }); + manager = new AgentManager(); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", + isBackground: true, + }); + await manager.getRecord(parentId)!.promise; + + let childId = ""; + vi.mocked(resumeAgent).mockImplementation(async () => { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + childId = manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", + isBackground: true, + depth: 2, + parentAgentId: parentId, + }); + return { text: "resumed" } as any; + }); + + await manager.resume(parentId, "keep going"); + + expect(manager.getRecord(childId)?.status).toBe("stopped"); + }); +}); + +describe("AgentManager — completion callbacks", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("does not let onComplete errors turn a completed agent into a failed run", async () => { + manager = new AgentManager(() => { + throw new Error("stale extension context"); + }); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await expect(manager.getRecord(id)!.promise).resolves.toBe("done"); + + expect(manager.getRecord(id)!.status).toBe("completed"); + }); +}); + +describe("AgentManager — cleanup timer", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("does not keep the process alive on its own", () => { + manager = new AgentManager(); + + expect((manager as any).cleanupInterval.hasRef()).toBe(false); + }); +}); + +describe("AgentManager — Bug 3 clearCompleted", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("clearCompleted removes completed records", async () => { + manager = new AgentManager(); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(manager.listAgents()).toHaveLength(1); + manager.clearCompleted(); + expect(manager.listAgents()).toHaveLength(0); + }); + + it("clearCompleted does not remove running or queued agents", async () => { + // Use maxConcurrent=0 to keep agents queued, then spawn one running via foreground + manager = new AgentManager(undefined, 1); + + // Mock runAgent to never resolve (keeps agent "running") + vi.mocked(runAgent).mockImplementation( + () => new Promise(() => {}), // hangs forever + ); + + const id1 = manager.spawn(mockPi, mockCtx, "general-purpose", "test1", { + description: "running agent", + isBackground: true, + }); + // Second agent should be queued (limit=1) + const id2 = manager.spawn(mockPi, mockCtx, "general-purpose", "test2", { + description: "queued agent", + isBackground: true, + }); + + expect(manager.getRecord(id1)!.status).toBe("running"); + expect(manager.getRecord(id2)!.status).toBe("queued"); + + manager.clearCompleted(); + + // Both should still be present + expect(manager.getRecord(id1)).toBeDefined(); + expect(manager.getRecord(id2)).toBeDefined(); + + // Abort to allow cleanup + manager.abort(id1); + manager.abort(id2); + }); + + it("clearCompleted calls dispose on sessions of removed records", async () => { + manager = new AgentManager(); + const disposeSpy = vi.fn(); + const sess = { dispose: disposeSpy }; + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: sess as any, + aborted: false, + steered: false, + }); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + manager.clearCompleted(); + + expect(disposeSpy).toHaveBeenCalledOnce(); + }); + + it("clearCompleted removes error and stopped records", async () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockRejectedValue(new Error("boom")); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + expect(manager.getRecord(id)!.status).toBe("error"); + + manager.clearCompleted(); + expect(manager.getRecord(id)).toBeUndefined(); + }); + + it("clearCompleted(true) preserves completed records with resultConsumed=false", async () => { + manager = new AgentManager(); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + expect(manager.getRecord(id)!.status).toBe("completed"); + expect(manager.getRecord(id)!.resultConsumed).toBeFalsy(); + + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeDefined(); + }); + + it("clearCompleted(true) removes completed records with resultConsumed=true", async () => { + manager = new AgentManager(); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + const record = manager.getRecord(id)!; + await record.promise; + record.resultConsumed = true; + + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeUndefined(); + }); + + it("clearCompleted(true) still removes running=false queued=false records when resultConsumed=false for error status", async () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockRejectedValue(new Error("boom")); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + expect(manager.getRecord(id)!.status).toBe("error"); + expect(manager.getRecord(id)!.resultConsumed).toBeFalsy(); + + // Error records with unread results are also preserved — the LLM should + // be able to read the error message via get_subagent_result before the + // record is evicted. + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeDefined(); + }); +}); + +// The manager-level usage hook is the ONE place every assistant message is seen +// exactly once, which is what parent-session accounting (#193) is built on. +// `record.lifetimeUsage` cannot serve: nested spend is deliberately double-booked +// into every ancestor so a hidden child shows up on a record a human can see. +describe("AgentManager — the usage hook fires once per assistant message", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("fires once per message, with the same delta the record accumulates", async () => { + const seen: any[] = []; + manager = new AgentManager(undefined, undefined, undefined, undefined, (r, u) => seen.push({ id: r.id, u })); + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 }); + opts.onAssistantUsage?.({ input: 200, output: 80, cacheWrite: 20, cost: 0.02 }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false }; + }); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(seen.map(s => s.u)).toEqual([ + { input: 100, output: 50, cacheWrite: 10, cost: 0.01 }, + { input: 200, output: 80, cacheWrite: 20, cost: 0.02 }, + ]); + expect(seen.every(s => s.id === id)).toBe(true); + }); + + it("fires once for a nested child, even though its spend is booked to ancestors too", async () => { + // Mimics `nested-tools.ts`: the caller's own onAssistantUsage walks the + // ancestor chain. If the hook sat below that walk — or if accounting read + // the records it writes — one child message would be billed twice. + const seen: any[] = []; + manager = new AgentManager(undefined, undefined, undefined, undefined, (_r, u) => seen.push(u)); + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + opts.onAssistantUsage?.({ input: 10, output: 5, cacheWrite: 0, cost: 0.001 }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false }; + }); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", + isBackground: true, + }); + await manager.getRecord(parentId)!.promise; + seen.length = 0; + + const childId = manager.spawn(mockPi, mockCtx, "general-purpose", "child", { + description: "child", + isBackground: true, + parentAgentId: parentId, + onAssistantUsage: (u: any) => { addUsage(manager.getRecord(parentId)!.lifetimeUsage, u); }, + } as any); + await manager.getRecord(childId)!.promise; + + expect(seen).toEqual([{ input: 10, output: 5, cacheWrite: 0, cost: 0.001 }]); + // And here is why the hook has to exist: the parent's record now carries the + // child's message on top of its own identical one, so anything that summed + // records would bill this session for two messages when one was sent. The + // double-booking stays — it is what makes a hidden child visible. + expect(manager.getRecord(parentId)!.lifetimeUsage).toEqual({ input: 20, output: 10, cacheWrite: 0, cost: 0.002 }); + }); +}); + +// Eager init removes the optional/required asymmetry that previously required +// `??=` defaults at the callback sites and `?? 0` / `?? 1` at the read sites. +describe("AgentManager — lifetime usage + compaction count are eagerly initialized", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("spawn initializes lifetimeUsage to zeros and compactionCount to 0", () => { + manager = new AgentManager(); + // Don't resolve the run — we just want to inspect the record at spawn time. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + const record = manager.getRecord(id)!; + + expect(record.lifetimeUsage).toEqual({ input: 0, output: 0, cacheWrite: 0, cost: 0 }); + expect(record.compactionCount).toBe(0); + + manager.abort(id); + }); + + it("onAssistantUsage from runAgent accumulates into record.lifetimeUsage", async () => { + manager = new AgentManager(); + + // Capture the options passed to runAgent so we can drive callbacks + let captured: any; + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + captured = opts; + // Two assistant messages with usage + opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 }); + opts.onAssistantUsage?.({ input: 200, output: 80, cacheWrite: 20, cost: 0.02 }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false }; + }); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(captured).toBeDefined(); + expect(manager.getRecord(id)!.lifetimeUsage).toEqual({ + input: 300, output: 130, cacheWrite: 30, cost: 0.03, + }); + }); + + it("onCompaction from runAgent increments record.compactionCount", async () => { + manager = new AgentManager(); + const compactSeen: any[] = []; + + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + // Compaction fires while the agent is still running — the record passed to + // onCompact should reflect the just-incremented count. + opts.onCompaction?.({ reason: "threshold", tokensBefore: 12345 }); + opts.onCompaction?.({ reason: "manual", tokensBefore: 22222 }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false }; + }); + + manager = new AgentManager(undefined, undefined, undefined, (record, info) => { + compactSeen.push({ count: record.compactionCount, reason: info.reason }); + }); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + expect(compactSeen).toEqual([ + { count: 1, reason: "threshold" }, + { count: 2, reason: "manual" }, + ]); + expect(manager.getRecord(id)!.compactionCount).toBe(2); + }); + + it("resume() also accumulates usage and increments compactions on the same record", async () => { + manager = new AgentManager(); + + // First, spawn with a session that resume can latch onto + const session = { ...mockSession() }; + vi.mocked(runAgent).mockResolvedValue({ + responseText: "first", + session: session as any, + aborted: false, + steered: false, + }); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + + // Pre-resume: lifetimeUsage from spawn was zero (mock didn't call onAssistantUsage) + expect(manager.getRecord(id)!.lifetimeUsage).toEqual({ input: 0, output: 0, cacheWrite: 0, cost: 0 }); + expect(manager.getRecord(id)!.compactionCount).toBe(0); + + // Now resume — drive callbacks via the mocked resumeAgent + const { resumeAgent: resumeMock } = await import("../src/agent-runner.js"); + vi.mocked(resumeMock).mockImplementation(async (_session, _prompt, opts: any) => { + opts.onAssistantUsage?.({ input: 70, output: 30, cacheWrite: 5, cost: 0.007 }); + opts.onCompaction?.({ reason: "overflow", tokensBefore: 999 }); + return { text: "second" }; + }); + + await manager.resume(id, "more"); + + expect(manager.getRecord(id)!.lifetimeUsage).toEqual({ input: 70, output: 30, cacheWrite: 5, cost: 0.007 }); + expect(manager.getRecord(id)!.compactionCount).toBe(1); + }); +}); + +// Regression: `isolation: "worktree"` MUST fail loud when the cwd can't host +// a worktree. The previous behavior silently fell back to the main tree and +// injected a warning into the LLM's prompt — invisible to the caller. +describe("AgentManager — isolation: worktree fails loud, no silent fallback", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + it("spawn() throws when createWorktree returns undefined; no orphan record left behind", async () => { + const { createWorktree } = await import("../src/worktree.js"); + vi.mocked(createWorktree).mockReturnValueOnce(undefined); + vi.mocked(runAgent).mockClear(); + + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isolation: "worktree", + })).toThrow(/isolation: "worktree"/); + + // Cleaned up — no orphan in listAgents() + expect(manager.listAgents()).toEqual([]); + // runAgent never invoked — strict, no silent fallback + expect(runAgent).not.toHaveBeenCalled(); + }); +}); + +// The project switch has to bite below the tool boundary: cross-extension RPC +// forwards its options straight to spawn(), so a schema that omits the +// isolation parameter can't stop a caller that never saw the schema (#184). +describe("AgentManager — worktreeIsolation: false refuses worktrees", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + vi.mocked(isWorktreeIsolationEnabled).mockReturnValue(true); + }); + + it("creates no worktree for an RPC-shaped spawn when the project disabled it", async () => { + const { createWorktree } = await import("../src/worktree.js"); + vi.mocked(createWorktree).mockClear(); + vi.mocked(isWorktreeIsolationEnabled).mockReturnValue(false); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isolation: "worktree", + }); + + // Downgraded, not rejected — the user opted out, so the call still runs. + expect(createWorktree).not.toHaveBeenCalled(); + expect(manager.getRecord(id)!.worktree).toBeUndefined(); + }); + + it("does not mask a genuine worktree failure while enabled", async () => { + const { createWorktree } = await import("../src/worktree.js"); + vi.mocked(createWorktree).mockReturnValueOnce(undefined); + vi.mocked(isWorktreeIsolationEnabled).mockReturnValue(true); + + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isolation: "worktree", + })).toThrow(/isolation: "worktree"/); + }); +}); + +describe("AgentManager — SpawnOptions.cwd passthrough (#96)", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("passes cwd to runAgent as the working dir, parent cwd as configCwd", async () => { + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: "/", // absolute and always exists + }); + await manager.getRecord(id)!.promise; + + expect(runAgent).toHaveBeenCalledWith( + mockCtx, "general-purpose", "test", + expect.objectContaining({ cwd: "/", configCwd: "/tmp" }), + ); + }); + + it("without cwd, configCwd stays unset — existing behavior untouched", async () => { + // mockClear + lastCall: toHaveBeenCalledWith would scan the file's whole + // accumulated call history, where earlier no-cwd spawns already match. + vi.mocked(runAgent).mockClear(); + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }); + await manager.getRecord(id)!.promise; + + const opts = vi.mocked(runAgent).mock.lastCall![3]; + expect(opts.cwd).toBeUndefined(); + expect(opts.configCwd).toBeUndefined(); + }); + + it("cwd: null (RPC 'unset') behaves exactly like omitting cwd", async () => { + vi.mocked(runAgent).mockClear(); + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: null as any, + }); + await manager.getRecord(id)!.promise; + + const opts = vi.mocked(runAgent).mock.lastCall![3]; + expect(opts.cwd).toBeUndefined(); + expect(opts.configCwd).toBeUndefined(); + }); + + it("cwd + isolation: worktree — worktree created FROM cwd, session runs at the copy's workPath, cleanup targets cwd's repo", async () => { + const { createWorktree, cleanupWorktree } = await import("../src/worktree.js"); + vi.mocked(createWorktree).mockReturnValueOnce({ + path: "/wt/copy", branch: "pi-agent-x", baseSha: "abc", workPath: "/wt/copy/packages/api", + }); + resolvedRun(); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: "/", + isolation: "worktree", + }); + await manager.getRecord(id)!.promise; + + expect(createWorktree).toHaveBeenCalledWith("/", id); + // Worktree wins for the working dir — at workPath, so subdirectory scoping + // survives isolation. Config still anchored to the parent. + expect(runAgent).toHaveBeenCalledWith( + mockCtx, "general-purpose", "test", + expect.objectContaining({ cwd: "/wt/copy/packages/api", configCwd: "/tmp", worktreeBase: "/" }), + ); + expect(cleanupWorktree).toHaveBeenCalledWith("/", expect.anything(), "test"); + }); + + it("plain worktree (no cwd) keeps the historical root working dir even when workPath differs", async () => { + // Parent session sitting in a repo subdirectory: workPath would point at + // the copied subdir. Without SpawnOptions.cwd the agent must stay at the + // copy's root — moving it would also move .pi config discovery. + const { createWorktree } = await import("../src/worktree.js"); + vi.mocked(createWorktree).mockReturnValueOnce({ + path: "/wt/copy", branch: "pi-agent-x", baseSha: "abc", workPath: "/wt/copy/sub/dir", + }); + vi.mocked(runAgent).mockClear(); + resolvedRun(); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isolation: "worktree", + }); + await manager.getRecord(id)!.promise; + + const opts = vi.mocked(runAgent).mock.lastCall![3]; + expect(opts.cwd).toBe("/wt/copy"); + expect(opts.configCwd).toBeUndefined(); + // The copy came from the parent session's cwd — that is what the prompt + // must name as off-limits (#187). + expect(opts.worktreeBase).toBe("/tmp"); + }); + + it("no worktree — no worktreeBase, so no isolation block in the prompt", async () => { + vi.mocked(runAgent).mockClear(); + resolvedRun(); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { description: "test" }); + await manager.getRecord(id)!.promise; + + expect(vi.mocked(runAgent).mock.lastCall![3].worktreeBase).toBeUndefined(); + }); + + it("relative cwd throws immediately; no orphan record", () => { + vi.mocked(runAgent).mockClear(); + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: "relative/path", + })).toThrow(/absolute path/); + expect(manager.listAgents()).toEqual([]); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it("nonexistent cwd throws immediately; no orphan record", () => { + vi.mocked(runAgent).mockClear(); + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: "/nonexistent-pi-subagents-test-dir", + })).toThrow(/does not exist/); + expect(manager.listAgents()).toEqual([]); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it("cwd pointing at a regular file throws a curated 'not a directory' error", () => { + vi.mocked(runAgent).mockClear(); + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: fileURLToPath(import.meta.url), // this test file: absolute, exists, not a directory + })).toThrow(/not a directory/); + expect(manager.listAgents()).toEqual([]); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it("non-string cwd (RPC junk) throws the curated error, not a TypeError from path internals", () => { + vi.mocked(runAgent).mockClear(); + manager = new AgentManager(); + expect(() => manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + cwd: 123 as any, + })).toThrow(/must be an absolute path/); + expect(manager.listAgents()).toEqual([]); + }); +}); + +describe("AgentManager — abort() state machine", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("returns false for an unknown id (no record, no side-effects)", () => { + manager = new AgentManager(); + expect(manager.abort("does-not-exist")).toBe(false); + }); + + it("removes a queued agent from the queue and marks it stopped", () => { + // Concurrency=1: the second background spawn queues behind the first + manager = new AgentManager(undefined, 1); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + + manager.spawn(mockPi, mockCtx, "X", "blocker", { description: "block", isBackground: true }); + const queuedId = manager.spawn(mockPi, mockCtx, "Y", "queued", { + description: "q", + isBackground: true, + }); + const queuedRecord = manager.getRecord(queuedId)!; + expect(queuedRecord.status).toBe("queued"); + + expect(manager.abort(queuedId)).toBe(true); + expect(queuedRecord.status).toBe("stopped"); + expect(queuedRecord.completedAt).toBeGreaterThan(0); + // Aborting again is a no-op — status is no longer "queued" or "running" + expect(manager.abort(queuedId)).toBe(false); + }); + + it("aborts a running agent by firing its AbortController and setting status='stopped'", () => { + manager = new AgentManager(); + let receivedSignal: AbortSignal | undefined; + vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, opts) => { + receivedSignal = (opts as { signal?: AbortSignal })?.signal; + return new Promise(() => {}); + }); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { + description: "r", + isBackground: true, + }); + const record = manager.getRecord(id)!; + expect(record.status).toBe("running"); + expect(receivedSignal?.aborted).toBe(false); + + expect(manager.abort(id)).toBe(true); + expect(record.status).toBe("stopped"); + expect(record.completedAt).toBeGreaterThan(0); + expect(receivedSignal?.aborted).toBe(true); + }); + + it("returns false (and does not change status) for an already-completed agent", async () => { + manager = new AgentManager(); + resolvedRun(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { + description: "x", + isBackground: false, + }); + await manager.getRecord(id)?.promise; + expect(manager.getRecord(id)?.status).toBe("completed"); + + expect(manager.abort(id)).toBe(false); + expect(manager.getRecord(id)?.status).toBe("completed"); + }); + + it("a user abort survives the agent settling — stays 'stopped', never 'completed'", async () => { + // Guards the `if (record.status !== "stopped")` check in the completion + // handler: after a user abort, runAgent's promise still settles (here with + // aborted:false, as a non-cooperative mock would), and must NOT flip the + // user-stopped status back to "completed" — otherwise the parent agent + // would read the partial output as a finished result. + manager = new AgentManager(); + let resolveRun!: (v: unknown) => void; + vi.mocked(runAgent).mockImplementation(() => new Promise((res) => { resolveRun = res as (v: unknown) => void; })); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "r", isBackground: true }); + const record = manager.getRecord(id)!; + expect(record.status).toBe("running"); + + expect(manager.abort(id)).toBe(true); + expect(record.status).toBe("stopped"); + + // The agent loop ends and the promise settles "normally". + resolveRun({ responseText: "partial output", session: mockSession(), aborted: false, steered: false }); + await record.promise; + + expect(record.status).toBe("stopped"); // not overwritten to "completed" + expect(record.result).toBe("partial output"); // partial result still captured + }); +}); + +// Regression for #44: ESC during a foreground Agent call must propagate to +// the child. Pi delivers parent abort via AbortSignal; the manager wires the +// signal's "abort" event to this.abort(id). +describe("AgentManager — steer()", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("returns false for an unknown id", () => { + manager = new AgentManager(); + expect(manager.steer("nope", "hi")).toBe(false); + }); + + it("delivers to a live session via session.steer()", () => { + manager = new AgentManager(); + const steer = vi.fn(() => Promise.resolve()); + let captured: ((s: any) => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, opts) => { + captured = (opts as any)?.onSessionCreated; + return new Promise(() => {}); + }); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "r", isBackground: true }); + // Simulate the session becoming ready. + captured?.({ steer, dispose: vi.fn() }); + + expect(manager.steer(id, "go left")).toBe(true); + expect(steer).toHaveBeenCalledWith("go left"); + }); + + it("queues onto pendingSteers when the session isn't ready yet", () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "r", isBackground: true }); + const record = manager.getRecord(id)!; + record.session = undefined; // not ready + + expect(manager.steer(id, "first")).toBe(true); + expect(manager.steer(id, "second")).toBe(true); + expect(record.pendingSteers).toEqual(["first", "second"]); + }); + + it("refuses to steer an agent that is no longer running", async () => { + manager = new AgentManager(); + resolvedRun(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: false }); + await manager.getRecord(id)?.promise; + expect(manager.getRecord(id)?.status).toBe("completed"); + expect(manager.steer(id, "too late")).toBe(false); + }); +}); + +describe("AgentManager — parent abort signal forwarding (#44)", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("aborts the child when the parent signal aborts", () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + + const parent = new AbortController(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { + description: "x", + isBackground: false, + signal: parent.signal, + }); + const record = manager.getRecord(id)!; + expect(record.status).toBe("running"); + + parent.abort(); + expect(record.status).toBe("stopped"); + expect(record.completedAt).toBeGreaterThan(0); + }); +}); + +describe("AgentManager — listAgents() ordering", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("returns records sorted by startedAt descending (most recent first)", () => { + manager = new AgentManager(); + resolvedRun(); + + const a = manager.spawn(mockPi, mockCtx, "X", "1", { description: "a" }); + const b = manager.spawn(mockPi, mockCtx, "X", "2", { description: "b" }); + const c = manager.spawn(mockPi, mockCtx, "X", "3", { description: "c" }); + + // Force deterministic startedAt — Date.now() can collide on fast runs + manager.getRecord(a)!.startedAt = 100; + manager.getRecord(b)!.startedAt = 200; + manager.getRecord(c)!.startedAt = 300; + + expect(manager.listAgents().map((r) => r.id)).toEqual([c, b, a]); + }); +}); + +describe("AgentManager — abortAll", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("stops both queued and running agents and returns the total count", () => { + manager = new AgentManager(undefined, 1); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + + const running = manager.spawn(mockPi, mockCtx, "X", "r", { + description: "r", + isBackground: true, + }); + const queued = manager.spawn(mockPi, mockCtx, "Y", "q", { + description: "q", + isBackground: true, + }); + expect(manager.getRecord(running)?.status).toBe("running"); + expect(manager.getRecord(queued)?.status).toBe("queued"); + + expect(manager.abortAll()).toBe(2); + expect(manager.getRecord(running)?.status).toBe("stopped"); + expect(manager.getRecord(queued)?.status).toBe("stopped"); + expect(manager.hasRunning()).toBe(false); + }); + + it("returns 0 when there are no running or queued agents", () => { + manager = new AgentManager(); + expect(manager.abortAll()).toBe(0); + }); +}); + +describe("AgentManager — hasRunning", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("is true while a background agent is running, false after it completes", async () => { + manager = new AgentManager(); + resolvedRun(); + + expect(manager.hasRunning()).toBe(false); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { + description: "x", + isBackground: true, + }); + expect(manager.hasRunning()).toBe(true); + + await manager.getRecord(id)?.promise; + expect(manager.hasRunning()).toBe(false); + }); + + it("is true when an agent is queued behind the concurrency limit", () => { + manager = new AgentManager(undefined, 1); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + + manager.spawn(mockPi, mockCtx, "X", "r", { description: "r", isBackground: true }); + manager.spawn(mockPi, mockCtx, "Y", "q", { description: "q", isBackground: true }); + expect(manager.hasRunning()).toBe(true); + }); +}); + +describe("AgentManager — runAgent rejection leaves the record visible with error status", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("sets status='error', captures the error message, and stamps completedAt", async () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockRejectedValue(new Error("boom")); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { + description: "x", + isBackground: false, + }); + const record = manager.getRecord(id)!; + await record.promise; + + expect(record.status).toBe("error"); + expect(record.error).toBe("boom"); + expect(record.completedAt).toBeGreaterThan(0); + }); +}); + +// #144 — a run that RESOLVES with a failed final turn (pi never rejects on +// retry exhaustion) must map to status "error", not "completed". +describe("AgentManager — resolved runs with a failed final turn map to error (#144)", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + const failedRun = (failure: string, responseText = "") => + vi.mocked(runAgent).mockResolvedValue({ + responseText, + session: mockSession(), + aborted: false, + steered: false, + failure, + } as any); + + it("sets status='error' and captures the provider message", async () => { + manager = new AgentManager(); + failedRun("retries exhausted: 529 overloaded"); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + const record = manager.getRecord(id)!; + await record.promise; + + expect(record.status).toBe("error"); + expect(record.error).toBe("retries exhausted: 529 overloaded"); + expect(record.completedAt).toBeGreaterThan(0); + }); + + it("keeps earlier-turn text available as result context, but never as a clean completion", async () => { + manager = new AgentManager(); + failedRun("provider died", "partial progress from an earlier turn"); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + const record = manager.getRecord(id)!; + await record.promise; + + expect(record.status).toBe("error"); + expect(record.result).toBe("partial progress from an earlier turn"); + }); + + it("onComplete sees the error status (routes to subagents:failed in the host)", async () => { + let completed: AgentRecord | undefined; + manager = new AgentManager((r) => { completed = r; }); + failedRun("boom"); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + await manager.getRecord(id)!.promise; + + expect(completed?.status).toBe("error"); + }); + + it("an external stop still wins over a late failure resolution", async () => { + manager = new AgentManager(); + let resolveRun: ((v: unknown) => void) | undefined; + const session = mockSession(); + vi.mocked(runAgent).mockImplementation(() => new Promise((r) => { resolveRun = r; })); + + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + const record = manager.getRecord(id)!; + record.status = "stopped"; // external abort() path + resolveRun!({ responseText: "", session, aborted: false, steered: false, failure: "late error" }); + await record.promise; + + expect(record.status).toBe("stopped"); + expect(record.error).toBeUndefined(); + }); + + it("resume(): a failed final turn on the resumed prompt maps to error too", async () => { + manager = new AgentManager(); + resolvedRun(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + const record = manager.getRecord(id)!; + await record.promise; + expect(record.status).toBe("completed"); + + const { resumeAgent: resumeMock } = await import("../src/agent-runner.js"); + // resumeAgent bounds its fallback to this invocation, so a failed empty + // resume yields text "" — never the prior turn's answer (#144 root-fix). + vi.mocked(resumeMock).mockResolvedValue({ + text: "", + failure: "retries exhausted on resume", + }); + + await manager.resume(id, "more"); + + expect(record.status).toBe("error"); + expect(record.error).toBe("retries exhausted on resume"); + expect(record.result).toBe(""); // no stale prior answer + }); + + it("resume(): partial text produced before the failure is kept as result", async () => { + manager = new AgentManager(); + resolvedRun(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "x", isBackground: true }); + const record = manager.getRecord(id)!; + await record.promise; + + const { resumeAgent: resumeMock } = await import("../src/agent-runner.js"); + vi.mocked(resumeMock).mockResolvedValue({ + text: "new partial progress", + failure: "provider died mid-turn", + }); + + await manager.resume(id, "more"); + + expect(record.status).toBe("error"); + expect(record.result).toBe("new partial progress"); // salvageable, this-run text + }); +}); + +// The pool counter is decremented when a background agent settles, but ONLY for +// records that took a slot in the first place. Nested children bypass the pool +// (occupiesPoolSlot), so decrementing on their behalf drives runningBackground +// negative and permanently lifts maxConcurrent. Only the START side of that rule +// had coverage. +describe("AgentManager — pool slot accounting on settle", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + /** A run that only settles when its returned resolver is called. */ + function controllableRuns() { + const resolvers = new Map void>(); + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, prompt: any) => + new Promise(resolve => { + resolvers.set(prompt as string, () => resolve({ + responseText: "done", + session: mockSession(), + aborted: false, + steered: false, + })); + }), + ); + return resolvers; + } + + it("a nested child settling does not free a pool slot it never held", async () => { + const resolvers = controllableRuns(); + manager = new AgentManager(undefined, 1); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", isBackground: true, + }); + manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", isBackground: true, depth: 2, parentAgentId: parentId, + }); + const siblingId = manager.spawn(mockPi, mockCtx, "general-purpose", "sibling", { + description: "sibling", isBackground: true, + }); + expect(manager.getRecord(siblingId)?.status).toBe("queued"); + + resolvers.get("child")!(undefined); + await manager.getRecord(manager.listAgents().find(a => a.description === "child")!.id)?.promise; + + // The parent still holds the only slot, so the sibling must stay queued. + expect(manager.getRecord(siblingId)?.status).toBe("queued"); + }); + + it("a nested child failing does not free a pool slot either", async () => { + const rejectors = new Map void>(); + // Nothing resolves here — only the child is settled, by rejection. + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, prompt: any) => + new Promise((_resolve, reject) => { + rejectors.set(prompt as string, reject); + }), + ); + manager = new AgentManager(undefined, 1); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", isBackground: true, + }); + const childId = manager.spawn(mockPi, mockCtx, "scout", "child", { + description: "child", isBackground: true, depth: 2, parentAgentId: parentId, + }); + const siblingId = manager.spawn(mockPi, mockCtx, "general-purpose", "sibling", { + description: "sibling", isBackground: true, + }); + + rejectors.get("child")!(new Error("child blew up")); + await manager.getRecord(childId)?.promise; + + expect(manager.getRecord(childId)?.status).toBe("error"); + expect(manager.getRecord(siblingId)?.status).toBe("queued"); + }); + + it("a top-level agent settling DOES free its slot", async () => { + // The other half of the rule — guards against over-correcting the fix into + // "never decrement", which would wedge the queue permanently. + const resolvers = controllableRuns(); + manager = new AgentManager(undefined, 1); + + const parentId = manager.spawn(mockPi, mockCtx, "general-purpose", "parent", { + description: "parent", isBackground: true, + }); + const siblingId = manager.spawn(mockPi, mockCtx, "general-purpose", "sibling", { + description: "sibling", isBackground: true, + }); + expect(manager.getRecord(siblingId)?.status).toBe("queued"); + + resolvers.get("parent")!(undefined); + await manager.getRecord(parentId)?.promise; + + expect(manager.getRecord(siblingId)?.status).toBe("running"); + }); +}); + +describe("AgentManager — drainQueue failure handling", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + it("a spawn that throws at drain time errors that record and keeps draining", async () => { + // Strict worktree isolation is the documented drain-time failure: the check + // runs in startAgent, which drainQueue calls minutes after spawn() returned. + // If the throw escaped drainQueue, every agent still queued behind it would + // be stranded forever — a hang, not an error. + const { createWorktree } = await import("../src/worktree.js"); + const completed: AgentRecord[] = []; + manager = new AgentManager(r => { completed.push(r); }, 1); + + let blocker: ((v: any) => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, prompt: any) => + new Promise(resolve => { + if (prompt === "first") blocker = () => resolve({ + responseText: "ok", session: mockSession(), aborted: false, steered: false, + }); + }), + ); + vi.mocked(createWorktree).mockReturnValueOnce(undefined); // "not a git repo" + + const firstId = manager.spawn(mockPi, mockCtx, "X", "first", { description: "first", isBackground: true }); + const boomId = manager.spawn(mockPi, mockCtx, "X", "boom", { + description: "boom", isBackground: true, isolation: "worktree", + }); + const lastId = manager.spawn(mockPi, mockCtx, "X", "last", { description: "last", isBackground: true }); + expect(manager.getRecord(boomId)?.status).toBe("queued"); + + blocker!(undefined); + await manager.getRecord(firstId)?.promise; + + const boom = manager.getRecord(boomId)!; + expect(boom.status).toBe("error"); + expect(boom.error).toContain('isolation: "worktree"'); + expect(boom.completedAt).toBeGreaterThan(0); + expect(completed.map(r => r.id)).toContain(boomId); + // ...and the drain continued past the failure rather than stopping there. + expect(manager.getRecord(lastId)?.status).toBe("running"); + }); + + it("a cwd deleted between enqueue and drain is caught by the re-validation", async () => { + // spawn() validated this cwd when it was still there. startAgent checks + // again precisely because a queued agent can start minutes later (TOCTOU) — + // that second check has never run in a test. + manager = new AgentManager(undefined, 1); + + let blocker: ((v: any) => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, prompt: any) => + new Promise(resolve => { + if (prompt === "first") blocker = () => resolve({ + responseText: "ok", session: mockSession(), aborted: false, steered: false, + }); + }), + ); + + const firstId = manager.spawn(mockPi, mockCtx, "X", "first", { description: "first", isBackground: true }); + const goneDir = mkdtempSync(join(tmpdir(), "pi-mgr-gone-")); + const goneId = manager.spawn(mockPi, mockCtx, "X", "gone", { + description: "gone", isBackground: true, cwd: goneDir, + }); + expect(manager.getRecord(goneId)?.status).toBe("queued"); + + rmSync(goneDir, { recursive: true, force: true }); // vanishes while queued + + blocker!(undefined); + await manager.getRecord(firstId)?.promise; + + const gone = manager.getRecord(goneId)!; + expect(gone.status).toBe("error"); + expect(gone.error).toContain(goneDir); // the curated message, not a raw ENOENT + }); + + it("raising maxConcurrent releases queued agents immediately", async () => { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager = new AgentManager(undefined, 1); + + manager.spawn(mockPi, mockCtx, "X", "a", { description: "a", isBackground: true }); + const bId = manager.spawn(mockPi, mockCtx, "X", "b", { description: "b", isBackground: true }); + expect(manager.getRecord(bId)?.status).toBe("queued"); + + manager.setMaxConcurrent(2); + expect(manager.getRecord(bId)?.status).toBe("running"); + }); + + it("setMaxConcurrent clamps to at least 1", () => { + manager = new AgentManager(undefined, 4); + manager.setMaxConcurrent(0); + expect(manager.getMaxConcurrent()).toBe(1); + }); +}); + +describe("AgentManager — pendingSteers flush", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + it("delivers steers queued before the session existed, in order, then clears them", async () => { + // A steer sent in the window between spawn and session creation is parked on + // the record. If the flush breaks, the user's course correction is silently + // dropped — steer_subagent already told them it was queued. + const steer = vi.fn().mockResolvedValue(undefined); + let release: (() => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, _prompt: any, opts: any) => + new Promise(resolve => { + release = () => { + opts.onSessionCreated?.({ steer, dispose: vi.fn() }); + resolve({ responseText: "ok", session: mockSession(), aborted: false, steered: false }); + }; + }), + ); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "p", isBackground: true }); + const record = manager.getRecord(id)!; + + manager.steer(id, "first correction"); + manager.steer(id, "second correction"); + expect(record.pendingSteers).toEqual(["first correction", "second correction"]); + + release!(); + await record.promise; + + expect(steer.mock.calls.map(c => c[0])).toEqual(["first correction", "second correction"]); + // Cleared, or every later session creation would re-deliver the same steers. + expect(record.pendingSteers).toBeUndefined(); + }); + + it("a steer that rejects does not fail the run", async () => { + const steer = vi.fn().mockRejectedValue(new Error("session closed")); + let release: (() => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, _prompt: any, opts: any) => + new Promise(resolve => { + release = () => { + opts.onSessionCreated?.({ steer, dispose: vi.fn() }); + resolve({ responseText: "ok", session: mockSession(), aborted: false, steered: false }); + }; + }), + ); + + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "p", isBackground: true }); + const record = manager.getRecord(id)!; + manager.steer(id, "hello"); + + release!(); + await expect(record.promise).resolves.toBe("ok"); + expect(record.status).toBe("completed"); + }); +}); + +// waitForAll() had zero coverage despite backing the Symbol.for registry entry +// and the print-mode host's shutdown hold. Its loop exists BECAUSE drainQueue +// respects maxConcurrent: a single Promise.allSettled pass would return while +// queued agents had not even started. That reads like a redundant loop, which +// is exactly why it needs a test — collapsing it resolves early and silently. +describe("AgentManager — waitForAll", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + it("waits for agents that were still QUEUED when it was called", async () => { + const resolvers = new Map void>(); + vi.mocked(runAgent).mockImplementation((_ctx: any, _type: any, prompt: any) => + new Promise(resolve => { + resolvers.set(prompt as string, () => resolve({ + responseText: "done", session: mockSession(), aborted: false, steered: false, + })); + }), + ); + + manager = new AgentManager(undefined, 1); + const ids = ["a", "b", "c"].map(p => + manager.spawn(mockPi, mockCtx, "X", p, { description: p, isBackground: true }), + ); + // Only the first can be running; the other two are behind the pool. + expect(manager.getRecord(ids[1])?.status).toBe("queued"); + + let settled = false; + const all = manager.waitForAll().then(() => { settled = true; }); + + // Release ONLY the running one. A queued record has no `.promise` yet — it + // is created when the queue starts it — so a single `Promise.allSettled` + // pass sees just this one agent and would resolve here, with two agents + // still unstarted. The retry loop is what makes that not happen, and + // asserting after releasing everything would hide the difference entirely. + await new Promise(r => setImmediate(r)); + resolvers.get("a")!(); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + expect(settled, "waitForAll resolved while agents were still queued").toBe(false); + + for (const p of ["b", "c"]) { + resolvers.get(p)!(); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + } + await all; + + expect(settled).toBe(true); + for (const id of ids) { + expect(manager.getRecord(id)?.status, id).toBe("completed"); + } + }); + + it("resolves immediately when nothing is pending", async () => { + manager = new AgentManager(); + await expect(manager.waitForAll()).resolves.toBeUndefined(); + }); + + it("does not reject when an agent fails", async () => { + // allSettled, not all — one failing agent must not leave the caller hanging + // on a rejection it never asked for. + vi.mocked(runAgent).mockRejectedValue(new Error("boom")); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "X", "p", { description: "p", isBackground: true }); + + await expect(manager.waitForAll()).resolves.toBeUndefined(); + expect(manager.getRecord(id)?.status).toBe("error"); + }); +}); + +describe("AgentManager — background resume", () => { + let manager: AgentManager; + + afterEach(() => { + manager?.dispose(); + }); + + // Spawn a background agent and let it settle so it holds a session to resume. + async function spawnSettled(mgr: AgentManager): Promise { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "first", + session: mockSession(), + aborted: false, + steered: false, + }); + const id = mgr.spawn(mockPi, mockCtx, "general-purpose", "task", { + description: "task", + isBackground: true, + }); + await mgr.getRecord(id)!.promise; + return id; + } + + it("returns immediately with a running record + promise, then settles and fires onComplete", async () => { + const onComplete = vi.fn(); + manager = new AgentManager(onComplete); + const id = await spawnSettled(manager); + onComplete.mockClear(); // drop the spawn's own completion + + // Deferred resumeAgent so we can observe the mid-flight state. + let finish!: (v: { text: string; failure?: string }) => void; + vi.mocked(resumeAgent).mockImplementation( + () => new Promise((resolve) => { finish = resolve; }), + ); + + const record = await manager.resume(id, "keep going", undefined, { isBackground: true }); + // Returned immediately: still running, with a tracked promise, no notify yet. + expect(record?.status).toBe("running"); + expect(record?.promise).toBeDefined(); + expect(onComplete).not.toHaveBeenCalled(); + + finish({ text: "second" }); + await record!.promise; + + expect(manager.getRecord(id)!.status).toBe("completed"); + expect(manager.getRecord(id)!.result).toBe("second"); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("a failed final turn on a background resume maps to error and still notifies", async () => { + const onComplete = vi.fn(); + manager = new AgentManager(onComplete); + const id = await spawnSettled(manager); + onComplete.mockClear(); + + vi.mocked(resumeAgent).mockResolvedValue({ + text: "partial", + failure: "provider exploded", + } as any); + + const record = await manager.resume(id, "again", undefined, { isBackground: true }); + await record!.promise; + + expect(manager.getRecord(id)!.status).toBe("error"); + expect(manager.getRecord(id)!.error).toBe("provider exploded"); + expect(manager.getRecord(id)!.result).toBe("partial"); // #144: keep this-run text + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("forwards activity/usage callbacks to the resumed run", async () => { + manager = new AgentManager(); + const id = await spawnSettled(manager); + + const onToolActivity = vi.fn(); + const onAssistantUsage = vi.fn(); + vi.mocked(resumeAgent).mockImplementation(async (_session, _prompt, opts: any) => { + opts.onToolActivity?.({ type: "end", toolName: "grep" }); + opts.onAssistantUsage?.({ input: 5, output: 3, cacheWrite: 0, cost: 0 }); + return { text: "ok" }; + }); + + const record = await manager.resume(id, "go", undefined, { + isBackground: true, + onToolActivity, + onAssistantUsage, + }); + await record!.promise; + + expect(onToolActivity).toHaveBeenCalledWith({ type: "end", toolName: "grep" }); + expect(onAssistantUsage).toHaveBeenCalledWith({ input: 5, output: 3, cacheWrite: 0, cost: 0 }); + // Internal record bookkeeping still runs alongside the forwarded callbacks. + expect(manager.getRecord(id)!.toolUses).toBe(1); + expect(manager.getRecord(id)!.lifetimeUsage).toEqual({ input: 5, output: 3, cacheWrite: 0, cost: 0 }); + }); + + it("queues a background resume when the concurrency pool is full", async () => { + manager = new AgentManager(undefined, 1); // maxConcurrent = 1 + const id = await spawnSettled(manager); + + // Occupy the single slot with a never-settling background spawn. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + const blockerId = manager.spawn(mockPi, mockCtx, "general-purpose", "blocker", { + description: "blocker", + isBackground: true, + }); + expect(manager.getRecord(blockerId)!.status).toBe("running"); + + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + vi.mocked(resumeAgent).mockClear(); // drop call history from earlier tests + const record = await manager.resume(id, "later", undefined, { isBackground: true }); + + expect(record?.status).toBe("queued"); + expect(resumeAgent).not.toHaveBeenCalled(); + }); + + it("foreground resume is unchanged: awaits inline and does not fire onComplete", async () => { + const onComplete = vi.fn(); + manager = new AgentManager(onComplete); + const id = await spawnSettled(manager); + onComplete.mockClear(); + + vi.mocked(resumeAgent).mockResolvedValue({ text: "inline result" } as any); + const record = await manager.resume(id, "sync"); + + expect(record?.status).toBe("completed"); + expect(record?.result).toBe("inline result"); + // Foreground resume returns its result inline and never notified (historical). + expect(onComplete).not.toHaveBeenCalled(); + }); + + // A detached resume returns while the record is still "running", so nothing + // stops a second resume of the same agent. Starting one would replace + // record.abortController — leaving the live run unreachable from /agents stop + // and abortAll() — and then reject from session.prompt(), whose settle path + // would report a failure and abort the children of a run still in progress. + it("refuses to background-resume an agent whose run is still in flight", async () => { + const onComplete = vi.fn(); + manager = new AgentManager(onComplete); + const id = await spawnSettled(manager); + onComplete.mockClear(); + + vi.mocked(resumeAgent).mockClear(); + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + + const first = await manager.resume(id, "go", undefined, { isBackground: true }); + expect(first?.status).toBe("running"); + const liveController = manager.getRecord(id)!.abortController; + + const second = await manager.resume(id, "go again", undefined, { isBackground: true }); + + expect(second).toBeUndefined(); + expect(resumeAgent).toHaveBeenCalledTimes(1); + // The in-flight run is untouched: same controller, still running, no + // spurious completion notification. + expect(manager.getRecord(id)!.abortController).toBe(liveController); + expect(manager.getRecord(id)!.status).toBe("running"); + expect(onComplete).not.toHaveBeenCalled(); + // Still stoppable — the point of keeping the original controller. + expect(manager.abort(id)).toBe(true); + expect(liveController!.signal.aborted).toBe(true); + }); + + it("refuses to background-resume an agent that is still queued", async () => { + manager = new AgentManager(undefined, 1); // maxConcurrent = 1 + const id = await spawnSettled(manager); + + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager.spawn(mockPi, mockCtx, "general-purpose", "blocker", { + description: "blocker", + isBackground: true, + }); + + vi.mocked(resumeAgent).mockClear(); + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + expect((await manager.resume(id, "later", undefined, { isBackground: true }))?.status).toBe("queued"); + + expect(await manager.resume(id, "later again", undefined, { isBackground: true })).toBeUndefined(); + expect(resumeAgent).not.toHaveBeenCalled(); + }); + + // onStarted is where the Agent tool hangs output-file streaming. It must fire + // when the run actually begins — not when resume() returns — or a resume that + // is stopped while queued leaves a live session subscription behind: abort() + // drops a queued record without reaching settle(), which is what tears that + // subscription down. + it("fires onStarted when the run starts, not when a queued resume is registered", async () => { + manager = new AgentManager(undefined, 1); // maxConcurrent = 1 + const id = await spawnSettled(manager); + + // Occupy the only slot with a run we can release on demand. + let releaseBlocker!: (v: any) => void; + vi.mocked(runAgent).mockImplementation(() => new Promise((resolve) => { releaseBlocker = resolve; })); + manager.spawn(mockPi, mockCtx, "general-purpose", "blocker", { + description: "blocker", + isBackground: true, + }); + + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + const onStarted = vi.fn(); + const record = await manager.resume(id, "later", undefined, { isBackground: true, onStarted }); + + expect(record?.status).toBe("queued"); + expect(onStarted).not.toHaveBeenCalled(); + + releaseBlocker({ responseText: "blocker done", session: mockSession(), aborted: false, steered: false }); + await manager.getRecord(record!.id)!.promise?.catch(() => {}); + await new Promise((r) => setTimeout(r, 0)); + + expect(manager.getRecord(id)!.status).toBe("running"); + expect(onStarted).toHaveBeenCalledTimes(1); + }); + + it("never fires onStarted for a queued resume that is stopped before it drains", async () => { + manager = new AgentManager(undefined, 1); // maxConcurrent = 1 + const id = await spawnSettled(manager); + + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); + manager.spawn(mockPi, mockCtx, "general-purpose", "blocker", { + description: "blocker", + isBackground: true, + }); + + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + const onStarted = vi.fn(); + await manager.resume(id, "later", undefined, { isBackground: true, onStarted }); + + expect(manager.abort(id)).toBe(true); + expect(manager.getRecord(id)!.status).toBe("stopped"); + expect(onStarted).not.toHaveBeenCalled(); + }); +}); + +// A `name` on the spawn adds a SECOND handle rather than replacing the +// type-derived one. That is the property the whole design rests on: if naming +// freed up `explore`, then `@explore fix it` would quietly start a second +// Explore alongside the running one instead of reaching it. +describe("AgentManager — names as additive aliases", () => { + let manager: AgentManager; + + afterEach(() => manager?.dispose()); + + const spawnNamed = (m: AgentManager, type: string, name?: string) => + m.spawn(mockPi, mockCtx, type, "go", { + description: "go", + ...(name !== undefined && { name }), + isBackground: true, + }); + + it("assigns the type handle as well as the alias", () => { + resolvedRun(); + manager = new AgentManager(); + const record = manager.getRecord(spawnNamed(manager, "Explore", "auth-audit"))!; + + expect(record.handle).toBe("explore"); + expect(record.alias).toBe("auth-audit"); + }); + + it("reaches the same agent by either name", () => { + resolvedRun(); + manager = new AgentManager(); + const id = spawnNamed(manager, "Explore", "auth-audit"); + + expect(manager.resolveMention("auth-audit")).toMatchObject({ kind: "live", record: { id } }); + expect(manager.resolveMention("explore")).toMatchObject({ kind: "live", record: { id } }); + }); + + it("slugs a name that isn't typeable rather than rejecting the spawn", () => { + resolvedRun(); + manager = new AgentManager(); + const record = manager.getRecord(spawnNamed(manager, "Explore", "Auth Audit!"))!; + + expect(record.alias).toBe("auth-audit"); + }); + + it("numbers an alias that collides with its own type handle", () => { + // `name: "explore"` on an Explore would otherwise produce two identical + // names on one record, and later a second agent could take one of them. + resolvedRun(); + manager = new AgentManager(); + const record = manager.getRecord(spawnNamed(manager, "Explore", "explore"))!; + + expect(record.handle).toBe("explore"); + expect(record.alias).toBe("explore-2"); + }); + + it("stops a later type handle from colliding with an existing alias", () => { + resolvedRun(); + manager = new AgentManager(); + spawnNamed(manager, "Plan", "explore"); // alias squats the Explore name + const second = manager.getRecord(spawnNamed(manager, "Explore"))!; + + expect(second.handle).toBe("explore-2"); + }); + + it("refuses to alias an agent to the reserved main handle", () => { + resolvedRun(); + manager = new AgentManager(); + const record = manager.getRecord(spawnNamed(manager, "Explore", "main"))!; + + expect(record.alias).toBe("main-2"); + }); + + it("gives an unnamed agent no alias at all", () => { + resolvedRun(); + manager = new AgentManager(); + const record = manager.getRecord(spawnNamed(manager, "Explore"))!; + + expect(record.alias).toBeUndefined(); + expect(record.handle).toBe("explore"); + }); + + it("never names a nested child, however it was spawned", () => { + // Nested agents are hidden from every top-level surface; a name would make + // one addressable through a boundary only its owner may cross. + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "Explore", "go", { + description: "go", + name: "child", + parentAgentId: "parent-1", + isBackground: true, + }); + + const record = manager.getRecord(id)!; + expect(record.alias).toBeUndefined(); + expect(record.handle).toBeUndefined(); + expect(manager.resolveMention("child")).toBeUndefined(); + }); + + it("captures the session file so the agent can be resumed after eviction", async () => { + vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, options: any) => { + options.onSessionCreated?.({ + dispose: vi.fn(), + sessionManager: { getSessionFile: () => "/sessions/explore.jsonl" }, + }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false } as any; + }); + manager = new AgentManager(); + const id = spawnNamed(manager, "Explore"); + await manager.getRecord(id)!.promise; + + expect(manager.getRecord(id)!.sessionFile).toBe("/sessions/explore.jsonl"); + }); + + it("records no session file for an in-memory session", async () => { + vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, options: any) => { + options.onSessionCreated?.({ dispose: vi.fn(), sessionManager: { getSessionFile: () => undefined } }); + return { responseText: "done", session: mockSession(), aborted: false, steered: false } as any; + }); + manager = new AgentManager(); + const id = spawnNamed(manager, "Explore"); + await manager.getRecord(id)!.promise; + + expect(manager.getRecord(id)!.sessionFile).toBeUndefined(); + }); +}); diff --git a/pi-subagents/test/agent-mention-provider.test.ts b/pi-subagents/test/agent-mention-provider.test.ts new file mode 100644 index 0000000..ce41fe6 --- /dev/null +++ b/pi-subagents/test/agent-mention-provider.test.ts @@ -0,0 +1,616 @@ +/** + * agent-mention-provider.test.ts — the `@handle` suggestions stacked on pi's + * built-in autocomplete. + * + * The provider sits in front of file completion and must not take it away: `@` + * still means "attach a file" in pi, and agents are additive, so a token that + * matches both lists them both — agents first, then pi's rows, under one + * `prefix`. The risks are at the seam: dropping pi's list when an agent matches + * (which is what made a bare `@` stop offering files), inserting the wrong span + * because the two providers disagreed about the token, and claiming a token that + * was only ever a path. Every case below pins one of those. + */ +import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import type { AgentManager } from "../src/agent-manager.js"; +import type { AgentRecord, AgentTombstone } from "../src/types.js"; +import { createMentionProvider, mentionRoster } from "../src/ui/agent-mention.js"; + +const FILE_SUGGESTIONS = { items: [{ value: "@src/index.ts", label: "src/index.ts" }], prefix: "@src/" }; + +/** A stand-in for pi's CombinedAutocompleteProvider. */ +function builtIn() { + return { + triggerCharacters: ["@", "#"], + getSuggestions: vi.fn().mockResolvedValue(FILE_SUGGESTIONS), + applyCompletion: vi.fn().mockReturnValue({ lines: ["applied"], cursorLine: 0, cursorCol: 7 }), + shouldTriggerFileCompletion: vi.fn().mockReturnValue(false), + }; +} + +function record(over: Partial): AgentRecord { + return { + id: `id-${over.handle}`, + type: "Explore", + description: "find flaky tests", + status: "running", + toolUses: 0, + startedAt: 1000, + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, + compactionCount: 0, + ...over, + } as AgentRecord; +} + +function managerWith(...records: AgentRecord[]): AgentManager { + // listAgents() is newest-first, matching the real manager's contract. + return { + listAgents: () => [...records].sort((a, b) => b.startedAt - a.startedAt), + listTombstones: () => [], + } as unknown as AgentManager; +} + +/** A manager holding only evicted agents, to exercise the resume rows. */ +function managerWithTombstones(...entries: AgentTombstone[]): AgentManager { + return { + listAgents: () => [], + listTombstones: () => [...entries].sort((a, b) => b.completedAt - a.completedAt), + } as unknown as AgentManager; +} + +function tombstone(over: Partial = {}): AgentTombstone { + return { + handle: "explore", + id: "t1", + type: "Explore" as AgentTombstone["type"], + description: "audit the RPC path", + sessionFile: "/sessions/explore.jsonl", + completedAt: 5000, + ...over, + }; +} + +/** Ask for suggestions on a single line, cursor at the end. */ +const suggest = (provider: ReturnType, line: string) => + provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal }); + +/** + * Just the rows we contributed. pi's stub rows are dropped by identity, so a + * test about handle ordering stays about handle ordering — without pretending + * no file matched, which is the state that hid this bug in the first place. + */ +const agentRows = (result: Awaited>) => + (result?.items ?? []).filter(item => !(FILE_SUGGESTIONS.items as unknown[]).includes(item)); + +describe("agent suggestions", () => { + it("lists matching handles above pi's files rather than instead of them", async () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + const result = await suggest(provider, "@ex"); + + expect(result).toEqual({ + items: [ + { value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }, + ...FILE_SUGGESTIONS.items, + ], + // Ours, not the stub's: when both match, both describe the same span (see + // the real-provider describe below), and ours is the authority on where + // the handle token starts. + prefix: "@ex", + }); + // Verbatim: the inner provider has to see the same line, cursor and options + // the editor handed us, or its rows describe a different token than ours. + expect(current.getSuggestions).toHaveBeenCalledWith(["@ex"], 0, 3, expect.objectContaining({ signal: expect.anything() })); + }); + + it("offers every agent on a bare @, and still offers files", async () => { + // The regression this file exists for: an empty token prefix-matches EVERY + // handle, so suppressing files "when an agent matches" suppressed them on + // the one gesture people use to browse — `@` alone. + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "explore" }), record({ handle: "plan", startedAt: 2000 })), []), + () => true, + ); + + const result = await suggest(provider, "@"); + + expect(result?.items.map(i => i.value)).toEqual(["@explore", "@plan", "@src/index.ts"]); + }); + + it("offers agents alone when no file matched", async () => { + const current = builtIn(); + current.getSuggestions.mockResolvedValue(null); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "@ex")).toEqual({ + items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }], + prefix: "@ex", + }); + }); + + it("still offers agents when the wrapped provider throws", async () => { + // We now call the inner provider for tokens we used to answer alone, and it + // is not always pi's — an extension registered before us sits inside. A + // rejection there must not delete the handle rows too. + const current = builtIn(); + current.getSuggestions.mockRejectedValue(new Error("inner provider exploded")); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "@ex")).toEqual({ + items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }], + prefix: "@ex", + }); + }); + + it("still offers agents when the wrapped provider throws synchronously", async () => { + // The async case above is caught by any `.catch()`; this one is not — a sync + // throw never yields a promise to attach to, so only try/catch holds it. + const current = builtIn(); + current.getSuggestions.mockImplementation(() => { throw new Error("sync boom"); }); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "@ex")).toEqual({ + items: [{ value: "@explore", label: "@explore", description: "send message · running · find flaky tests" }], + prefix: "@ex", + }); + }); + + it("warns once about a failing inner provider, not once per keystroke", async () => { + // getSuggestions runs on every character after `@`; a log per call would + // bury the terminal. But swallowing it silently leaves a broken provider + // with no trace anywhere, since the popup just looks file-less. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const current = builtIn(); + current.getSuggestions.mockRejectedValue(new Error("inner provider exploded")); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + for (const line of ["@e", "@ex", "@exp"]) await suggest(provider, line); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("[pi-subagents]"); + } finally { + warn.mockRestore(); + } + }); + + it("returns pi's list untouched when no agent exists at all", async () => { + const provider = createMentionProvider(builtIn(), () => [], () => true); + + expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS); + }); + + it("puts steerable agents first, then earliest-launched", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith( + record({ handle: "explore", status: "completed", startedAt: 1000 }), + record({ handle: "explore-3", status: "running", startedAt: 3000 }), + record({ handle: "explore-2", status: "running", startedAt: 2000 }), + ), []), + () => true, + ); + + const result = await suggest(provider, "@ex"); + + expect(agentRows(result).map(i => i.value)).toEqual(["@explore-2", "@explore-3", "@explore"]); + }); + + it("matches case-insensitively", async () => { + const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(agentRows(await suggest(provider, "@EX")).map(i => i.value)).toEqual(["@explore"]); + }); + + it("completes a mention typed mid-message", async () => { + // The trigger fires at any token boundary, even though only a LEADING + // mention is actually sent — same split as Claude Code. + const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect((await suggest(provider, "ask @ex"))?.prefix).toBe("@ex"); + }); +}); + +describe("agents that have never run", () => { + const TYPES = [ + { name: "Explore", description: "Fast codebase exploration. Read-only, medium breadth." }, + { name: "code-review", description: "Reviews a diff." }, + ]; + + it("offers a registered type with no live instance, and says it will start one", async () => { + const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(), TYPES), () => true); + + const result = await suggest(provider, "@ex"); + + expect(agentRows(result)).toEqual([{ + value: "@explore", + label: "@explore", + description: "start agent · Fast codebase exploration.", + }]); + // Kept from before the merge: the span the rows are inserted over is the + // typed token, not the stub's "@src/". + expect(result?.prefix).toBe("@ex"); + }); + + it("lets a live agent own its handle instead of listing the type twice", async () => { + // `@explore` has to mean the running Explore, or the mention would start a + // second one while the first is mid-task. + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "explore" })), TYPES), + () => true, + ); + + const result = await suggest(provider, "@ex"); + + expect(agentRows(result).map(i => i.value)).toEqual(["@explore"]); + expect(agentRows(result)[0].description).toBe("send message · running · find flaky tests"); + }); + + it("still offers the type once its only instance has finished, as a resume", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "explore", status: "completed" })), TYPES), + () => true, + ); + + expect((await suggest(provider, "@ex"))?.items[0].description) + .toBe("resume · completed · find flaky tests"); + }); + + it("lists live agents before startable types", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "plan" })), TYPES), + () => true, + ); + + expect(agentRows(await suggest(provider, "@")).map(i => i.value)) + .toEqual(["@plan", "@explore", "@code-review"]); + }); +}); + +describe("delegation to pi's provider", () => { + it("delegates when no handle matches the typed prefix", async () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "@zz")).toBe(FILE_SUGGESTIONS); + expect(current.getSuggestions).toHaveBeenCalled(); + }); + + it("delegates a path-shaped token even when its first segment names an agent", async () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "@explore/notes.md")).toBe(FILE_SUGGESTIONS); + expect(current.getSuggestions).toHaveBeenCalled(); + }); + + it("delegates an @ that is not at a token boundary", async () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + expect(await suggest(provider, "mail@ex")).toBe(FILE_SUGGESTIONS); + }); + + it("never claims nested children — nothing can address them", async () => { + const current = builtIn(); + const nested = record({ handle: "explore", parentAgentId: "parent-1" }); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(nested), []), () => true); + + expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS); + }); + + it("delegates everything while mentions are disabled", async () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => false); + + expect(await suggest(provider, "@ex")).toBe(FILE_SUGGESTIONS); + }); +}); + +describe("composing with another extension's provider", () => { + // pi folds every registered wrapper over the base provider in registration + // order (interactive-mode.js:428), so an extension is either inside us or + // outside us depending on load order. Both directions have to work, and + // neither is under our control. + const TYPES = [{ name: "Explore", description: "Fast codebase exploration." }]; + + /** A foreign wrapper that owns `#` and delegates everything else. */ + function hashWrapper(current: any) { + return { + triggerCharacters: ["#"], + getSuggestions: vi.fn(async (lines: string[], line: number, col: number, opts: any) => + /(^|\s)#\w*$/.test(lines[line].slice(0, col)) + ? { items: [{ value: "#general", label: "#general" }], prefix: "#" } + : current.getSuggestions(lines, line, col, opts)), + applyCompletion: vi.fn((...args: any[]) => (current.applyCompletion as any)(...args)), + }; + } + + it("delegates to a provider registered before us", async () => { + const base = builtIn(); + const provider = createMentionProvider( + hashWrapper(base) as any, + () => mentionRoster(managerWith(), TYPES), + () => true, + ); + + // ours wins for @, the inner wrapper still owns #, files still reach base + expect(agentRows(await suggest(provider, "@ex")).map(i => i.value)).toEqual(["@explore"]); + expect((await suggest(provider, "#gen"))?.items.map(i => i.value)).toEqual(["#general"]); + expect(await suggest(provider, "@src/")).toBe(FILE_SUGGESTIONS); + }); + + it("is still reachable through a provider registered after us", async () => { + const base = builtIn(); + const ours = createMentionProvider(base, () => mentionRoster(managerWith(), TYPES), () => true); + const outer = hashWrapper(ours) as any; + + expect(agentRows(await outer.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal })) + .map(i => i.value)).toEqual(["@explore"]); + expect((await outer.getSuggestions(["#g"], 0, 2, { signal: new AbortController().signal })) + ?.items.map((i: any) => i.value)).toEqual(["#general"]); + }); + + it("lets pi union the chain's trigger characters, as it already does", () => { + // Replicates setupAutocompleteProvider (interactive-mode.js:428): pi folds + // the wrappers over the base, collecting each one's OWN characters, then + // stamps the union on the outermost. Declaring the inner set ourselves + // would duplicate that and misreport what we handle. + const wrappers = [ + (current: any) => hashWrapper(current), + (current: any) => createMentionProvider(current, () => mentionRoster(managerWith(), TYPES), () => true), + ]; + let provider: any = builtIn(); + const collected: string[] = []; + for (const wrap of wrappers) { + provider = wrap(provider); + collected.push(...(provider.triggerCharacters ?? [])); + } + + expect(provider.triggerCharacters).toEqual(["@"]); // ours declares only @ + expect([...new Set(collected)]).toEqual(["#", "@"]); // pi still ends up with both + }); + + it("can be rebuilt from the same factory without accumulating state", () => { + // Every later addAutocompleteProvider call re-runs the whole chain from a + // fresh base, so our factory is invoked again each time. + const roster = () => mentionRoster(managerWith(record({ handle: "explore" })), []); + const first = createMentionProvider(builtIn(), roster, () => true); + const second = createMentionProvider(builtIn(), roster, () => true); + + expect(second.triggerCharacters).toEqual(first.triggerCharacters); + }); +}); + +describe("against pi's real provider", () => { + // The mocks above pin our own branching; these pin the contract itself, by + // handing our items and prefix to the actual CombinedAutocompleteProvider. + // Getting `prefix` wrong by a character silently eats input on completion. + const real = () => new CombinedAutocompleteProvider([], process.cwd(), null); + const provider = () => + createMentionProvider(real(), () => mentionRoster(managerWith(record({ handle: "explore" })), []), () => true); + + it("inserts the handle and a trailing space at the start of a line", async () => { + const p = provider(); + const suggestions = (await suggest(p, "@ex"))!; + + expect(p.applyCompletion(["@ex"], 0, 3, suggestions.items[0], suggestions.prefix)).toEqual({ + lines: ["@explore "], + cursorLine: 0, + cursorCol: 9, + }); + }); + + it("replaces only the mention token when it sits mid-line", async () => { + const p = provider(); + const suggestions = (await suggest(p, "ask @ex"))!; + + expect(p.applyCompletion(["ask @ex"], 0, 7, suggestions.items[0], suggestions.prefix)).toEqual({ + lines: ["ask @explore "], + cursorLine: 0, + cursorCol: 13, + }); + }); + + it("keeps text after the cursor intact", async () => { + const p = provider(); + const suggestions = (await suggest(p, "@ex"))!; + + expect(p.applyCompletion(["@ex please"], 0, 3, suggestions.items[0], suggestions.prefix).lines) + .toEqual(["@explore please"]); + }); + + it("inserts a FILE-shaped row from OUR prefix, character for character", async () => { + // The merge ships both kinds of row under one `prefix`, so pi's real + // applyCompletion has to land a path from a span our regex measured. Off by + // one and it eats a character or leaves an `@` behind — silently. + // + // The file row is synthesized rather than discovered: pi's + // getFuzzyFileSuggestions returns [] the moment `fdPath` is null + // (pi-tui/dist/autocomplete.js:577), which is how every real-provider test + // here constructs it, and `fd` cannot be assumed on CI. What that leaves + // unverified is only pi's own token scan — `extractAtPrefix` takes the whole + // token after the last delimiter, and we only ever claim tokens of `[\w-]`, + // so the two spans cannot disagree where both produce rows. + const p = provider(); + const suggestions = (await suggest(p, "@ex"))!; + const fileRow = { value: "@examples/agent-tool-description.md", label: "agent-tool-description.md" }; + + expect(p.applyCompletion(["@ex"], 0, 3, fileRow, suggestions.prefix).lines) + .toEqual(["@examples/agent-tool-description.md "]); + expect(p.applyCompletion(["ask @ex now"], 0, 7, fileRow, suggestions.prefix).lines) + .toEqual(["ask @examples/agent-tool-description.md now"]); + }); + + it("declares a trigger character the editor will actually accept", () => { + // editor.js:1851 drops multi-char entries, "/" and whitespace silently. + for (const character of provider().triggerCharacters ?? []) { + expect(character.length).toBe(1); + expect(character).not.toBe("/"); + expect(character.trim()).toBe(character); + } + }); +}); + +describe("insertion and trigger plumbing", () => { + it("hands applyCompletion to pi — its @-branch already inserts value plus a space", () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(), []), () => true); + const item = { value: "@explore", label: "@explore" }; + + expect(provider.applyCompletion(["@ex"], 0, 3, item, "@ex")).toEqual({ + lines: ["applied"], + cursorLine: 0, + cursorCol: 7, + }); + expect(current.applyCompletion).toHaveBeenCalledWith(["@ex"], 0, 3, item, "@ex"); + }); + + it("keeps pi's file-completion gate rather than forcing it open", () => { + const current = builtIn(); + const provider = createMentionProvider(current, () => mentionRoster(managerWith(), []), () => true); + + expect(provider.shouldTriggerFileCompletion?.(["/model"], 0, 6)).toBe(false); + }); + + it("declares @ and nothing else", () => { + // `#` is pi's other default trigger, not ours — and the editor seeds its + // own set from DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS (editor.js:1849) + // and only adds, so claiming it here would gain nothing and mean nothing. + const provider = createMentionProvider(builtIn(), () => mentionRoster(managerWith(), []), () => true); + + expect(provider.triggerCharacters).toEqual(["@"]); + }); +}); + +describe("named agents and evicted ones", () => { + it("lists a named agent once, under its alias, and says what type it is", async () => { + // Two rows for one agent would read as two agents; and `@auth-audit` + // alone says nothing about what it is, which the handle used to carry. + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running", description: "audit the auth flow" })), []), + () => true, + ); + + expect(agentRows(await suggest(provider, "@"))).toEqual([ + { value: "@auth-audit", label: "@auth-audit", description: "send message · Explore · running · audit the auth flow" }, + ]); + }); + + it("leaves an unnamed agent's row free of a redundant type", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWith(record({ handle: "explore", type: "Explore", status: "running", description: "find flaky tests" })), []), + () => true, + ); + + expect((await suggest(provider, "@"))!.items[0].description).toBe("send message · running · find flaky tests"); + }); + + it("still resolves the unlisted type handle of a named agent", () => { + // The row shows the alias, but `@explore` must keep reaching this agent — + // that is what stops it from starting a second Explore. + const roster = mentionRoster( + managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running" })), + [{ name: "Explore", description: "search" }], + ); + + // The type is NOT offered as startable: its handle belongs to the live agent. + expect(roster.map(t => t.handle)).toEqual(["auth-audit"]); + }); + + it("offers an evicted agent as a resume, after the live ones", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWithTombstones(tombstone()), []), + () => true, + ); + + expect(agentRows(await suggest(provider, "@"))).toEqual([ + { value: "@explore", label: "@explore", description: "resume · Explore · audit the RPC path" }, + ]); + }); + + it("puts live agents ahead of resumable ones", async () => { + // A running agent is the likelier target, and a resume is the slower, + // more surprising action to land on by pressing Enter too quickly. + const manager = { + listAgents: () => [record({ handle: "plan", type: "Plan", status: "running" })], + listTombstones: () => [tombstone()], + } as unknown as AgentManager; + const provider = createMentionProvider(builtIn(), () => mentionRoster(manager, []), () => true); + + expect(agentRows(await suggest(provider, "@")).map(i => i.value)).toEqual(["@plan", "@explore"]); + }); + + it("keeps an aliased tombstone's type handle reserved too", () => { + // It lists under its alias, but `@explore` still resumes it — so the + // Explore type must not also be offered as startable under that name. + const roster = mentionRoster( + managerWithTombstones(tombstone({ handle: "explore", alias: "auth-audit" })), + [{ name: "Explore", description: "search" }], + ); + + expect(roster.map(t => t.handle)).toEqual(["auth-audit"]); + }); + + it("does not offer a startable type whose handle a tombstone still holds", () => { + // `@explore` resumes the old conversation, so advertising "start agent" + // under the same name would promise the wrong action. + const roster = mentionRoster(managerWithTombstones(tombstone()), [{ name: "Explore", description: "search" }]); + + expect(roster).toHaveLength(1); + expect(roster[0].kind).toBe("tombstone"); + }); +}); + +// FleetView, the widget, the tool header and the conversation viewer all render +// `display_name` (via getConfig). The popup rendering the raw type instead would +// make `@` the one surface that calls the same agent something else. +describe("rows carry the display name, not the raw type", () => { + const label = (type: string) => (type === "Explore" ? "Auth Auditor" : type); + + it("names an aliased agent by its label", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster( + managerWith(record({ handle: "explore", alias: "auth-audit", type: "Explore", status: "running", description: "audit the auth flow" })), + [], + label, + ), + () => true, + ); + + expect((await suggest(provider, "@"))!.items[0].description) + .toBe("send message · Auth Auditor · running · audit the auth flow"); + }); + + it("names a resumable agent by its label", async () => { + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWithTombstones(tombstone()), [], label), + () => true, + ); + + expect((await suggest(provider, "@"))!.items[0].description) + .toBe("resume · Auth Auditor · audit the RPC path"); + }); + + it("falls back to the raw type when no resolver is supplied", async () => { + // Which is also what getConfig does for an agent with no display_name, so + // the default keeps a caller without a registry honest rather than blank. + const provider = createMentionProvider( + builtIn(), + () => mentionRoster(managerWithTombstones(tombstone()), []), + () => true, + ); + + expect((await suggest(provider, "@"))!.items[0].description).toContain("· Explore ·"); + }); +}); diff --git a/pi-subagents/test/agent-mention-wiring.test.ts b/pi-subagents/test/agent-mention-wiring.test.ts new file mode 100644 index 0000000..ce7aeb6 --- /dev/null +++ b/pi-subagents/test/agent-mention-wiring.test.ts @@ -0,0 +1,1370 @@ +/** + * agent-mention-wiring.test.ts — the `input` hook that routes `@handle message` + * to a subagent. + * + * This handler sits in front of every prompt the user types, and returning + * `handled` discards the text. So the two failures that matter are opposite: + * claiming input that was meant for the main model (silently eating it), and + * failing to claim a real mention (sending "@explore fix it" to the main model + * as if it were prose). Each case below pins one side. + * + * Booted through the real extension so the assertions cover the actual wiring — + * handle assignment in AgentManager, resolution, and the steer/resume split. + */ +import { unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() }; +}); + +// The clone forks a real pi session and runs a real model turn. What this file +// pins is the wiring around it — when it is called, with what, and what happens +// when it comes back empty. mention-clone.test.ts covers the clone itself. +vi.mock("../src/mention-clone.js", () => ({ runMentionClone: vi.fn() })); + +import { getDefaultMaxTurns, resumeAgent, runAgent, setDefaultMaxTurns } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { runMentionClone } from "../src/mention-clone.js"; +import { ctx, flush, type Hermetic, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js"; + +let hermetic: Hermetic | undefined; +/** The most recently booted extension, so teardown runs even when a test throws. */ +let booted: Map | undefined; + +beforeEach(() => { + vi.mocked(runAgent).mockReset(); + vi.mocked(resumeAgent).mockReset(); + vi.mocked(runMentionClone).mockReset(); + vi.mocked(runMentionClone).mockResolvedValue({ spawned: true }); +}); + +afterEach(async () => { + // The manager registry is a globalThis symbol claimed by the first activation + // that finds it free and released only on shutdown. A test that throws before + // its own shutdown would otherwise leave it pointing at a dead manager, and + // the NEXT test's `managerRegistry()` would silently read that one instead. + await booted?.get("session_shutdown")?.(); + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + booted = undefined; + hermetic?.restore(); + hermetic = undefined; +}); + +/** Enough of an AgentSession for the manager's and index's hooks. */ +function fakeSession(overrides: Record = {}) { + return { + steer: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + messages: [], + getActiveToolNames: vi.fn(() => []), + ...overrides, + } as any; +} + +/** A runAgent that never settles, so the agent stays "running". */ +function heldRun(session: any) { + vi.mocked(runAgent).mockImplementation( + (_ctx: any, _type: any, _prompt: any, opts: any) => + new Promise(() => { + opts.onSessionCreated?.(session); + }) as any, + ); +} + +/** A runAgent that finishes immediately, leaving a resumable record behind. */ +function finishedRun(session: any) { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "first answer", + session, + aborted: false, + steered: false, + failure: undefined, + } as any); +} + +/** Boot the real extension. `outputTranscript: false` keeps the run off disk. */ +function boot(settings: Record = {}) { + hermetic = hermeticDir({ settings: { outputTranscript: false, ...settings } }); + const b = makePi(); + subagentsExtension(b.pi); + booted = b.lifecycle; + return b; +} + +/** + * Boot with a mention that names a not-yet-running agent starting it HERE + * rather than through a clone of the conversation. Messaging and resuming are + * direct in either mode, so only the tests that START an agent by mention need + * this — every other `boot()` below is on the default `model` mode. + */ +function bootDirect(settings: Record = {}) { + return boot({ agentMentions: "direct", ...settings }); +} + +async function spawnBackground(tools: Map, subagent_type = "Explore"): Promise { + const r = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "find flaky tests", subagent_type, run_in_background: true }, + undefined, + undefined, + ctx(), + ); + return /Agent ID: (\S+)/.exec(textOf(r))![1]; +} + +const send = (lifecycle: Map, text: string, source = "interactive") => + lifecycle.get("input")({ type: "input", text, source }, ctx()); + +describe("messaging a running agent", () => { + it("steers it, announces it, and spends no main-model turn", async () => { + const { pi, tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore also check the RPC path", source: "interactive" }, + uiCtx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(session.steer).toHaveBeenCalledWith("also check the RPC path"); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Sent to @explore", "info"); + expect(pi.sendMessage).not.toHaveBeenCalled(); + + }); + + it("un-consumes the result so the agent's reply is still relayed", async () => { + // get_subagent_result may already have read this agent's last answer, which + // suppresses its completion notification. Without the reset, the reply to + // the message just sent would never reach the main loop. + const { tools, lifecycle } = boot(); + heldRun(fakeSession()); + + const id = await spawnBackground(tools); + await flush(); + const record = (globalThis as any)[Symbol.for("pi-subagents:manager")].getRecord(id); + record.resultConsumed = true; + + await send(lifecycle, "@explore keep going"); + + expect(record.resultConsumed).toBe(false); + + }); + + it("addresses same-type siblings by their numbered handles", async () => { + const { tools, lifecycle } = boot(); + const first = fakeSession(); + const second = fakeSession(); + vi.mocked(runAgent) + .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(first)) as any) + .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(second)) as any); + + await spawnBackground(tools); + await spawnBackground(tools); + await flush(); + + await send(lifecycle, "@explore-2 you take the second half"); + + expect(second.steer).toHaveBeenCalledWith("you take the second half"); + expect(first.steer).not.toHaveBeenCalled(); + + }); +}); + +describe("messaging a finished agent", () => { + it("resumes it from its session in the background", async () => { + const { lifecycle, tools } = boot(); + const session = fakeSession(); + finishedRun(session); + vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any); + + await spawnBackground(tools); + await flush(); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore anything else?", source: "interactive" }, + uiCtx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(resumeAgent).toHaveBeenCalledWith(session, "anything else?", expect.anything()); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @explore", "info"); + + }); + + it("honours the agent's output_transcript: false when resuming", async () => { + // The frontmatter flag overrides the project default (README, Persistent + // settings), and record.outputFile is the sole gate every downstream + // consumer keys off — so a resume must not be the path that re-enables a + // transcript the agent's author switched off. + hermetic = hermeticDir({ + settings: { outputTranscript: true }, + agentFiles: { quiet: "---\ndescription: writes no transcript\noutput_transcript: false\n---\nbody" }, + }); + const b = makePi(); + subagentsExtension(b.pi); + booted = b.lifecycle; + finishedRun(fakeSession()); + vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any); + + const id = await spawnBackground(b.tools, "quiet"); + await flush(); + const record = b.pi.__manager?.getRecord?.(id) + ?? (globalThis as any)[Symbol.for("pi-subagents:manager")].getRecord(id); + expect(record.outputFile).toBeUndefined(); // spawn honoured it + + await send(b.lifecycle, "@quiet anything else?"); + + expect(record.outputFile).toBeUndefined(); + + }); + + it("does not attribute the new answer to the tool call that spawned it", async () => { + // The completion notification carries ``. A mention-resume has + // no tool call behind it, so leaving the spawning call's id on the record + // would point the orchestrator's new result at a call answered runs ago. + const { pi, lifecycle, tools } = boot(); + finishedRun(fakeSession()); + vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any); + + await spawnBackground(tools); + await flush(); + vi.mocked(pi.sendMessage).mockClear(); + + await send(lifecycle, "@explore anything else?"); + await new Promise(r => setTimeout(r, 400)); + + const [message] = vi.mocked(pi.sendMessage).mock.calls[0]; + expect(message.content).toContain("second answer"); + expect(message.content).not.toContain(""); + + }); + + it("relays the resumed answer through the ordinary completion notification", async () => { + // The whole point of resuming in the background rather than inline: the + // main model has to be told the answer came back, or the reply is stranded + // in the agent's transcript. + const { pi, lifecycle, tools } = boot(); + finishedRun(fakeSession()); + vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any); + + await spawnBackground(tools); + await flush(); + vi.mocked(pi.sendMessage).mockClear(); + + await send(lifecycle, "@explore anything else?"); + await new Promise(r => setTimeout(r, 400)); + + expect(pi.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ customType: "subagent-notification", content: expect.stringContaining("second answer") }), + expect.objectContaining({ triggerTurn: true }), + ); + + }); +}); + +describe("stacking the suggestion provider on pi's", () => { + /** A session_start ctx with the UI surface the registration path touches. */ + const uiCtx = (mode: string) => + ctx({ + mode, + hasUI: mode !== "print", + ui: { + setStatus: vi.fn(), + setWidget: vi.fn(), + notify: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), + addAutocompleteProvider: vi.fn(), + }, + }); + + it("registers exactly once, however often session_start fires", async () => { + // pi appends wrappers to a list it never prunes, so a second registration + // would layer a duplicate provider on top of the first. + const { lifecycle } = boot(); + const first = uiCtx("tui"); + const second = uiCtx("tui"); + + await lifecycle.get("session_start")({ type: "session_start" }, first); + await lifecycle.get("session_start")({ type: "session_start" }, second); + + expect(first.ui.addAutocompleteProvider).toHaveBeenCalledTimes(1); + expect(second.ui.addAutocompleteProvider).not.toHaveBeenCalled(); + + }); + + it("stays out of non-TUI modes, which have no editor to complete into", async () => { + const { lifecycle } = boot(); + const rpc = uiCtx("rpc"); + + await lifecycle.get("session_start")({ type: "session_start" }, rpc); + + expect(rpc.ui.addAutocompleteProvider).not.toHaveBeenCalled(); + + }); + + it("hands pi a provider that answers a live handle", async () => { + const { tools, lifecycle } = boot(); + heldRun(fakeSession()); + const tui = uiCtx("tui"); + + await lifecycle.get("session_start")({ type: "session_start" }, tui); + await spawnBackground(tools); + await flush(); + + const factory = vi.mocked(tui.ui.addAutocompleteProvider).mock.calls[0][0] as any; + const provider = factory({ getSuggestions: vi.fn().mockResolvedValue(null), applyCompletion: vi.fn() }); + const result = await provider.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal }); + + expect(result.items.map((i: any) => i.value)).toEqual(["@explore"]); + + }); +}); + +describe("resolving which agent a handle means", () => { + const managerRegistry = () => (globalThis as any)[Symbol.for("pi-subagents:manager")]; + + it("matches the handle case-insensitively", async () => { + // The popup lowercases as you type, but nothing stops you typing it out. + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@EXPLORE shout")).toEqual({ action: "handled" }); + expect(session.steer).toHaveBeenCalledWith("shout"); + + }); + + it("accepts the raw agent id, which the README offers as a fallback", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + const id = await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, `@${id} by id`)).toEqual({ action: "handled" }); + expect(session.steer).toHaveBeenCalledWith("by id"); + + }); + + it("queues the message for an agent still waiting on a concurrency slot", async () => { + // A queued agent has no session yet, so the manager parks the message and + // flushes it on session creation. Reporting "sent" without that would be a + // lie the user only discovers when the agent ignores them. + const { tools, lifecycle } = boot({ maxConcurrent: 1 }); + heldRun(fakeSession()); + + await spawnBackground(tools); + const queuedId = await spawnBackground(tools); + await flush(); + + const queued = managerRegistry().getRecord(queuedId); + expect(queued.status).toBe("queued"); + + expect(await send(lifecycle, "@explore-2 wait for me")).toEqual({ action: "handled" }); + expect(queued.pendingSteers).toEqual(["wait for me"]); + + }); + + it("never reaches into a nested child, and starts a top-level agent instead", async () => { + // Nested agents are hidden from every top-level surface and only their + // owner may steer them. The handle still resolves — to a NEW top-level + // Explore — rather than punching through the ownership boundary. + const { tools, lifecycle } = bootDirect(); + const child = fakeSession(); + heldRun(child); + + const id = await spawnBackground(tools); + await flush(); + managerRegistry().getRecord(id).parentAgentId = "some-parent"; + vi.mocked(runAgent).mockClear(); + + expect(await send(lifecycle, "@explore reach into a child")).toEqual({ action: "handled" }); + expect(child.steer).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith( + expect.anything(), "Explore", "reach into a child", expect.anything(), + ); + }); + + it("starts a fresh agent once the old record has been evicted", async () => { + // README: handles live as long as their record, and after that the same + // mention starts a new agent rather than resurrecting anything. + const { tools, lifecycle } = bootDirect(); + finishedRun(fakeSession()); + + const id = await spawnBackground(tools); + await flush(); + managerRegistry().getRecord(id).resultConsumed = true; + await lifecycle.get("session_before_switch")(); + expect(managerRegistry().getRecord(id)).toBeUndefined(); + + vi.mocked(resumeAgent).mockClear(); + vi.mocked(runAgent).mockClear(); + heldRun(fakeSession()); + + await send(lifecycle, "@explore start over"); + + expect(resumeAgent).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith(expect.anything(), "Explore", "start over", expect.anything()); + + }); +}); + +describe("mentioning an agent that has never run", () => { + it("starts one, using the message as its prompt", async () => { + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore find every retry marker", source: "interactive" }, + uiCtx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(runAgent).toHaveBeenCalledWith( + expect.anything(), + "Explore", + "find every retry marker", + expect.anything(), + ); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Started @explore", "info"); + + }); + + it("leaves model, thinking and max turns to the agent's own config", async () => { + // runAgent resolves all three from the config when the spawn omits them, + // so passing anything here would override frontmatter the user wrote. + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + await send(lifecycle, "@explore go"); + + const opts = vi.mocked(runAgent).mock.calls[0][3] as any; + expect(opts.model).toBeUndefined(); + expect(opts.thinkingLevel).toBeUndefined(); + expect(opts.maxTurns).toBeUndefined(); + + }); + + it("shows the turn limit it will actually be held to (#181)", async () => { + // The spawn passes no maxTurns on purpose (see the test above), so the + // tracker has to resolve the same limit runAgent will enforce — otherwise + // the row reads `↻1` where the Agent tool would read `↻1≤9`. + const prevMax = getDefaultMaxTurns(); + try { + const { lifecycle } = bootDirect({ defaultMaxTurns: 9 }); + heldRun(fakeSession()); + let factory: any; + const uiCtx = ctx({ + hasUI: true, + ui: { + setStatus: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), getEditorText: vi.fn(() => ""), custom: vi.fn(), + setWidget: vi.fn((key: string, content: any) => { if (key === "agents" && content) factory = content; }), + }, + }); + await lifecycle.get("session_start")({}, uiCtx); + + await lifecycle.get("input")({ type: "input", text: "@explore go", source: "interactive" }, uiCtx); + await flush(); + + const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; + const lines = factory({ terminal: { columns: 200 }, requestRender: vi.fn() }, theme).render().join("\n"); + expect(lines).toContain("≤9"); + } finally { + setDefaultMaxTurns(prevMax); + } + }); + + it("tracks its tool activity, so the widget shows what it is doing (#181)", async () => { + // A mention spawn never passes through the Agent tool, which is where the + // activity tracker is normally created. Without one the widget and + // FleetView have no tool name and no turn count for the agent, so its row + // reads `thinking…` from start to finish. + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + await send(lifecycle, "@explore go"); + + const opts = vi.mocked(runAgent).mock.calls[0][3] as any; + expect(opts.onToolActivity).toBeTypeOf("function"); + expect(opts.onTurnEnd).toBeTypeOf("function"); + expect(opts.onSessionCreated).toBeTypeOf("function"); + }); + + it("runs it in the background so the prompt is not blocked", async () => { + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + await send(lifecycle, "@explore go"); + const record = (globalThis as any)[Symbol.for("pi-subagents:manager")] + .getRecord(vi.mocked(runAgent).mock.calls[0][3].agentId); + + expect(record.isBackground).toBe(true); + expect(record.description).toBe("go"); + + }); + + it("messages the running agent rather than starting a second one", async () => { + const { lifecycle } = bootDirect(); + const session = fakeSession(); + heldRun(session); + + await send(lifecycle, "@explore first task"); + await flush(); + vi.mocked(runAgent).mockClear(); + + await send(lifecycle, "@explore actually do this instead"); + + expect(runAgent).not.toHaveBeenCalled(); + expect(session.steer).toHaveBeenCalledWith("actually do this instead"); + + }); + + it("reports a failed start instead of silently doing nothing", async () => { + const { lifecycle } = bootDirect(); + vi.mocked(runAgent).mockImplementation(() => { + throw new Error("worktree unavailable"); + }); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore go", source: "interactive" }, + uiCtx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining("Could not start @explore"), + "error", + ); + + }); +}); + +describe("letting a clone of the conversation start the agent", () => { + /** The clone stands in for a real forked session; mention-clone.test.ts covers the real one. */ + function cloneReturns(result: { spawned: boolean; error?: string }) { + vi.mocked(runMentionClone).mockResolvedValue(result); + } + + it("claims the turn, so nothing about the mention reaches the chat", async () => { + // The whole point of the clone: the model still decides how to invoke the + // agent, but it does so somewhere the user is not reading. + const { pi, lifecycle } = boot(); + cloneReturns({ spawned: true }); + + const result = await send(lifecycle, "@explore find the flaky test"); + await flush(); + + expect(result).toEqual({ action: "handled" }); + expect(pi.sendMessage).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it("hands the clone the resolved type, the message and the real Agent tool", async () => { + // The type, not the handle as typed — `subagent_type` is not lowercased. + // The tool must be the registered one, or the clone's spawn would be a + // second implementation rather than an ordinary top-level call. + const { tools, lifecycle } = boot(); + cloneReturns({ spawned: true }); + + await send(lifecycle, "@plan sketch the migration"); + await flush(); + + expect(runMentionClone).toHaveBeenCalledWith( + expect.objectContaining({ + type: "Plan", + message: "sketch the migration", + agentTool: tools.get("Agent"), + }), + ); + }); + + it("does not block the prompt on the clone's turn", async () => { + // prompt() is suspended until this hook returns, so awaiting a full model + // turn here would freeze the editor for its duration. + const { lifecycle } = boot(); + let release: (() => void) | undefined; + vi.mocked(runMentionClone).mockReturnValue( + new Promise((resolve) => { + release = () => resolve({ spawned: true }); + }), + ); + + const result = await send(lifecycle, "@explore find the flaky test"); + + expect(result).toEqual({ action: "handled" }); + release?.(); + }); + + it("says the agent is being prompted, not started — nothing runs yet", async () => { + const { lifecycle } = boot(); + cloneReturns({ spawned: true }); + + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore find the flaky test", source: "interactive" }, + uiCtx, + ); + + // The clone's turn happens first, so the agent does not exist yet. `direct` + // mode's "Started @explore" is the contrast: there, it does. + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Prompting @explore…", "info"); + }); + + it("starts the agent directly when the clone cannot", async () => { + // A mention that produced a toast and no agent would be the worst outcome: + // silent, and indistinguishable from success. + const { lifecycle } = boot(); + heldRun(fakeSession()); + cloneReturns({ spawned: false, error: "no session file" }); + + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore find the flaky test", source: "interactive" }, + uiCtx, + ); + await flush(); + + expect(runAgent).toHaveBeenCalled(); + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + "Started @explore directly — no session file", + "warning", + ); + }); + + it("reports a fallback that also fails rather than going quiet", async () => { + const { lifecycle } = boot(); + vi.mocked(runAgent).mockImplementation(() => { + throw new Error("worktree unavailable"); + }); + cloneReturns({ spawned: false, error: "no session file" }); + + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore find the flaky test", source: "interactive" }, + uiCtx, + ); + await flush(); + + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining("Could not start @explore"), + "error", + ); + }); + + it("still steers a running agent directly, without cloning anything", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@explore also check the RPC path")).toEqual({ action: "handled" }); + expect(session.steer).toHaveBeenCalledWith("also check the RPC path"); + expect(runMentionClone).not.toHaveBeenCalled(); + }); + + it("still resumes a finished agent directly, without cloning anything", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + finishedRun(session); + vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@explore anything else?")).toEqual({ action: "handled" }); + expect(resumeAgent).toHaveBeenCalledWith(session, "anything else?", expect.anything()); + expect(runMentionClone).not.toHaveBeenCalled(); + }); + + it("does not clone for a handle that names no agent", async () => { + // Otherwise `@nosuchagent hello` would spin up a conversation copy to + // invoke an agent that does not exist, and eat the prompt doing it. + const { lifecycle } = boot(); + + expect(await send(lifecycle, "@nosuchagent hello")).toEqual({ action: "continue" }); + expect(runMentionClone).not.toHaveBeenCalled(); + }); + + it("works headlessly, where the visible-turn version never could", async () => { + // The TUI-only guard exists because a claimed turn answers `pi -p` with + // silence. The clone starts a real agent whose completion still reports. + const { lifecycle } = boot(); + cloneReturns({ spawned: true }); + + const result = await lifecycle.get("input")( + { type: "input", text: "@explore find the flaky test", source: "interactive" }, + ctx({ mode: "print" }), + ); + await flush(); + + expect(result).toEqual({ action: "handled" }); + expect(runMentionClone).toHaveBeenCalled(); + }); + + it("leaves a running agent alone headlessly rather than starting a rival", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + const result = await lifecycle.get("input")( + { type: "input", text: "@explore also check the RPC path", source: "interactive" }, + ctx({ mode: "print" }), + ); + + expect(result).toEqual({ action: "continue" }); + expect(session.steer).not.toHaveBeenCalled(); + expect(runMentionClone).not.toHaveBeenCalled(); + }); + + it("clones nothing when mentions are off", async () => { + const { lifecycle } = boot({ agentMentions: "off" }); + + expect(await send(lifecycle, "@explore find the flaky test")).toEqual({ action: "continue" }); + expect(runMentionClone).not.toHaveBeenCalled(); + }); + + it("starts the agent here instead when the mode is direct", async () => { + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + expect(await send(lifecycle, "@explore find the flaky test")).toEqual({ action: "handled" }); + expect(runAgent).toHaveBeenCalled(); + expect(runMentionClone).not.toHaveBeenCalled(); + }); +}); + +describe("input that is not a mention", () => { + it("passes an unknown handle to the main model rather than eating it", async () => { + const { lifecycle } = boot(); + + expect(await send(lifecycle, "@nosuchagent hello")).toEqual({ action: "continue" }); + }); + + it("passes a bare handle to the main model", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@explore")).toEqual({ action: "continue" }); + expect(session.steer).not.toHaveBeenCalled(); + + }); + + it("leaves a leading file attachment alone", async () => { + const { tools, lifecycle } = boot(); + heldRun(fakeSession()); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@src/index.ts summarize this")).toEqual({ action: "continue" }); + + }); + + it("ignores input the extension layer submitted", async () => { + // pi.sendMessage text arrives through the same hook; a notification that + // happened to start with @something must not be re-routed at an agent. + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@explore relayed text", "extension")).toEqual({ action: "continue" }); + expect(session.steer).not.toHaveBeenCalled(); + + }); + + it("leaves a headless prompt to the main model", async () => { + // Pi defaults session.prompt() to source "interactive", so `pi -p "@explore + // …"` lands in this hook too. Claiming it there would answer with silence: + // the agent detaches, notify is a no-op outside the TUI, and print mode + // exits having printed nothing. Only `direct` has that problem — the model + // mode's headless behaviour is pinned separately below. + const { lifecycle } = bootDirect(); + heldRun(fakeSession()); + + const result = await lifecycle.get("input")( + { type: "input", text: "@explore go", source: "interactive" }, + ctx({ mode: "print" }), + ); + + expect(result).toEqual({ action: "continue" }); + expect(runAgent).not.toHaveBeenCalled(); + + }); + + it("leaves an RPC-driven prompt to the main model", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + const result = await lifecycle.get("input")( + { type: "input", text: "@explore also check this", source: "rpc" }, + ctx({ mode: "rpc" }), + ); + + expect(result).toEqual({ action: "continue" }); + expect(session.steer).not.toHaveBeenCalled(); + + }); + + it("falls through entirely when mentions are disabled", async () => { + const { tools, lifecycle } = boot({ agentMentions: false }); + const session = fakeSession(); + heldRun(session); + + await spawnBackground(tools); + await flush(); + + expect(await send(lifecycle, "@explore do this")).toEqual({ action: "continue" }); + expect(session.steer).not.toHaveBeenCalled(); + + }); + + it("disabled also blocks starting an agent, not just messaging one", async () => { + // The guard sits ahead of the parse, so every action is covered — but the + // start branch is the one that would otherwise spawn work nobody asked for. + const { lifecycle } = boot({ agentMentions: false }); + heldRun(fakeSession()); + + expect(await send(lifecycle, "@explore go")).toEqual({ action: "continue" }); + expect(runAgent).not.toHaveBeenCalled(); + + }); + + it("disabled also blocks resuming a finished agent", async () => { + const { tools, lifecycle } = boot({ agentMentions: false }); + finishedRun(fakeSession()); + + await spawnBackground(tools); + await flush(); + vi.mocked(resumeAgent).mockClear(); + + expect(await send(lifecycle, "@explore anything else?")).toEqual({ action: "continue" }); + expect(resumeAgent).not.toHaveBeenCalled(); + + }); + + it("the suggestion popup goes quiet too, so @ means only 'attach a file'", async () => { + const { tools, lifecycle } = boot({ agentMentions: false }); + heldRun(fakeSession()); + const tui = ctx({ + mode: "tui", + hasUI: true, + ui: { + setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), addAutocompleteProvider: vi.fn(), + }, + }); + + await lifecycle.get("session_start")({ type: "session_start" }, tui); + await spawnBackground(tools); + await flush(); + + const factory = vi.mocked(tui.ui.addAutocompleteProvider).mock.calls[0][0] as any; + const files = { items: [{ value: "@src/x.ts", label: "src/x.ts" }], prefix: "@ex" }; + const provider = factory({ getSuggestions: vi.fn().mockResolvedValue(files), applyCompletion: vi.fn() }); + + expect(await provider.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal })).toBe(files); + + }); +}); + +describe("@main — the escape hatch", () => { + it("strips the prefix and sends the rest to the main model", async () => { + // Without this there is no way to type text that merely *looks* like a + // mention. `transform`, not `handled`: the model must still get the turn. + const { lifecycle } = boot(); + + const result = await send(lifecycle, "@main @explore is not a mention"); + + expect(result).toEqual({ action: "transform", text: "@explore is not a mention" }); + }); + + it("carries attachments through with the text", async () => { + const { lifecycle } = boot(); + const images = [{ type: "image", data: "x" }] as any; + + const result = await lifecycle.get("input")( + { type: "input", text: "@main look at this", source: "interactive", images }, + ctx(), + ); + + expect(result).toEqual({ action: "transform", text: "look at this", images }); + }); + + it("never starts an agent, even when a type would slug to main", async () => { + const { pi, lifecycle } = boot(); + + const result = await send(lifecycle, "@main do the thing"); + + expect(result).toMatchObject({ action: "transform" }); + expect(vi.mocked(runAgent)).not.toHaveBeenCalled(); + expect(pi.sendMessage).not.toHaveBeenCalled(); + }); + + it("leaves a bare @main to the main model untouched", async () => { + // No message body is not a mention at all, reserved handle or not. + const { lifecycle } = boot(); + + expect(await send(lifecycle, "@main")).toEqual({ action: "continue" }); + }); +}); + +describe("@agent- — Claude Code's manual spelling", () => { + it("starts the agent the unprefixed handle would have", async () => { + const { lifecycle } = bootDirect(); + finishedRun(fakeSession()); + + const result = await send(lifecycle, "@agent-explore find the flaky test"); + await flush(); + + expect(result).toEqual({ action: "handled" }); + expect(vi.mocked(runAgent)).toHaveBeenCalledWith( + expect.anything(), "Explore", "find the flaky test", expect.anything(), + ); + }); + + it("reaches a running agent, not a second copy of it", async () => { + const { tools, lifecycle } = boot(); + const session = fakeSession(); + heldRun(session); + await spawnBackground(tools); + await flush(); + + await send(lifecycle, "@agent-explore also check the RPC path"); + + expect(session.steer).toHaveBeenCalledWith("also check the RPC path"); + }); + + it("prefers an agent literally named agent- over the unwrapped spelling", async () => { + // Both could answer `@agent-explore`. The literal name has to win, or an + // agent the model deliberately called `agent-explore` is unreachable. + const { tools, lifecycle } = boot(); + const literal = fakeSession(); + const plain = fakeSession(); + vi.mocked(runAgent) + .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(plain)) as any) + .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(literal)) as any); + + await spawnBackground(tools); // plain Explore → @explore + await tools.get("Agent").execute( + "tc-named", + { prompt: "go", description: "named", subagent_type: "Plan", name: "agent-explore", run_in_background: true }, + undefined, undefined, ctx(), + ); + await flush(); + + await send(lifecycle, "@agent-explore over here"); + + expect(literal.steer).toHaveBeenCalledWith("over here"); + expect(plain.steer).not.toHaveBeenCalled(); + }); + + it("still falls through when nothing answers either spelling", async () => { + const { lifecycle } = boot(); + + expect(await send(lifecycle, "@agent-nosuchtype hello")).toEqual({ action: "continue" }); + }); +}); + +describe("resuming an evicted agent by name", () => { + // Only the GC interval and Date are faked: `flush()` runs on setImmediate, + // and the manager's cleanup timer is started in its constructor, so the + // fake clock has to be installed before boot(). + beforeEach(() => vi.useFakeTimers({ toFake: ["setInterval", "Date"] })); + afterEach(() => vi.useRealTimers()); + + /** + * Age the record past the cutoff and let the real GC evict it. The session + * file has to exist on disk: the dispatcher refuses to resume one it cannot + * find, so a fictional path would take the "session is gone" branch and + * every resume assertion below would pass for the wrong reason. + */ + async function evict(id: string) { + const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + const record = manager.getRecord(id); + record.sessionFile = sessionPath(); + writeFileSync(record.sessionFile, ""); + record.completedAt = Date.now() - 11 * 60_000; + await vi.advanceTimersByTimeAsync(60_000); + return manager; + } + + /** Inside the hermetic cwd, so teardown takes it with the rest. */ + const sessionPath = () => join(process.cwd(), "explore-session.jsonl"); + + it("reopens the conversation instead of starting a fresh agent", async () => { + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + const id = await spawnBackground(tools); + await flush(); + const manager = await evict(id); + expect(manager.getRecord(id)).toBeUndefined(); + vi.mocked(runAgent).mockClear(); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore anything else?", source: "interactive" }, + uiCtx, + ); + await flush(); + + expect(result).toEqual({ action: "handled" }); + expect(vi.mocked(runAgent)).toHaveBeenCalledWith( + expect.anything(), + "Explore", + "anything else?", + expect.objectContaining({ resumeSessionFile: sessionPath() }), + ); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @explore", "info"); + }); + + it("hands the resumed agent the handle back instead of numbering it", async () => { + // Otherwise the resume lands on `@explore-2` and the tombstone keeps + // `@explore`, so the name the user just typed still points at the corpse. + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + vi.mocked(runAgent).mockClear(); + heldRun(fakeSession()); + + await send(lifecycle, "@explore anything else?"); + await flush(); + + const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId; + expect(manager.getRecord(resumedId).handle).toBe("explore"); + }); + + it("stops resolving to the tombstone once the resume has taken the name", async () => { + // The fork this prevents: every later `@explore` reopening the SAME stale + // transcript, discarding whatever the resumed agent did in between. + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + vi.mocked(runAgent).mockClear(); + const resumed = fakeSession(); + heldRun(resumed); + + await send(lifecycle, "@explore anything else?"); + await flush(); + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore and one more thing", source: "interactive" }, + uiCtx, + ); + + // Steered, not resumed again — and runAgent was called exactly once. + expect(resumed.steer).toHaveBeenCalledWith("and one more thing"); + expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(1); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Sent to @explore", "info"); + }); + + it("gives a named agent its alias back too", async () => { + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + const spawned = await tools.get("Agent").execute( + "tc-named", + { prompt: "audit", description: "audit the auth flow", subagent_type: "Explore", name: "auth-audit", run_in_background: true }, + undefined, undefined, ctx(), + ); + await flush(); + await evict(/Agent ID: (\S+)/.exec(textOf(spawned))![1]); + await flush(); + vi.mocked(runAgent).mockClear(); + heldRun(fakeSession()); + + await send(lifecycle, "@auth-audit anything else?"); + await flush(); + + const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId; + expect(manager.getRecord(resumedId)).toMatchObject({ handle: "explore", alias: "auth-audit" }); + }); + + it("refuses to reopen a conversation under a substitute agent", async () => { + // resolveSpawnType falls back to general-purpose for a type it cannot + // resolve (#183) — and "cannot resolve" includes merely disabled, which + // `/agents → Disable` does at any time. Inheriting that here would reopen + // an Explore transcript under general-purpose's prompt and tools while + // announcing "Resuming @scout", then re-tombstone under the substitute so + // the handle never finds its way back. + hermetic = hermeticDir({ + settings: { outputTranscript: false }, + agentFiles: { scout: "---\ndescription: scouts\n---\nbody" }, + }); + const b = makePi(); + subagentsExtension(b.pi); + booted = b.lifecycle; + finishedRun(fakeSession()); + await evict(await spawnBackground(b.tools, "scout")); + await flush(); + vi.mocked(runAgent).mockClear(); + writeFileSync( + join(process.cwd(), ".pi", "agents", "scout.md"), + "---\ndescription: scouts\nenabled: false\n---\nbody", + ); + + const uiCtx = ctx(); + await b.lifecycle.get("input")( + { type: "input", text: "@scout anything else?", source: "interactive" }, + uiCtx, + ); + + expect(vi.mocked(runAgent)).not.toHaveBeenCalled(); + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + "Could not resume @scout — the scout agent is no longer available.", "warning", + ); + }); + + it("resumes again once the agent is re-enabled", async () => { + // So the refusal keeps the tombstone: dropping it would make a temporary + // `/agents → Disable` permanently lose the conversation. + hermetic = hermeticDir({ + settings: { outputTranscript: false }, + agentFiles: { scout: "---\ndescription: scouts\nenabled: false\n---\nbody" }, + }); + const b = makePi(); + subagentsExtension(b.pi); + booted = b.lifecycle; + // Spawn while it is still enabled, then disable, mention, re-enable. + const file = join(process.cwd(), ".pi", "agents", "scout.md"); + writeFileSync(file, "---\ndescription: scouts\n---\nbody"); + finishedRun(fakeSession()); + await evict(await spawnBackground(b.tools, "scout")); + await flush(); + writeFileSync(file, "---\ndescription: scouts\nenabled: false\n---\nbody"); + await b.lifecycle.get("input")({ type: "input", text: "@scout hi", source: "interactive" }, ctx()); + + writeFileSync(file, "---\ndescription: scouts\n---\nbody"); + vi.mocked(runAgent).mockClear(); + heldRun(fakeSession()); + const uiCtx = ctx(); + await b.lifecycle.get("input")({ type: "input", text: "@scout hi again", source: "interactive" }, uiCtx); + await flush(); + + expect(vi.mocked(runAgent)).toHaveBeenCalledWith( + expect.anything(), "scout", "hi again", + expect.objectContaining({ resumeSessionFile: sessionPath() }), + ); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @scout", "info"); + }); + + it("keeps the agent resolvable when the resume itself fails", async () => { + // The type can have been deleted or disabled since the original run, and + // resolveSpawnType then refuses it. Dropping the tombstone on that path + // would lose the conversation for good, so the drop happens only after a + // spawn that actually succeeded. + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + vi.mocked(runAgent).mockClear(); + // Stands in for any spawn-time throw: startAgent calls runAgent inline, so + // a synchronous throw propagates back out of spawn(). + vi.mocked(runAgent).mockImplementationOnce(() => { throw new Error("Unknown agent type"); }); + + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore anything else?", source: "interactive" }, + uiCtx, + ); + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining("Could not resume @explore"), + "warning", + ); + + // Still reachable: try again and it reopens the same conversation. + heldRun(fakeSession()); + await send(lifecycle, "@explore try again"); + await flush(); + + expect(vi.mocked(runAgent)).toHaveBeenLastCalledWith( + expect.anything(), "Explore", "try again", + expect.objectContaining({ resumeSessionFile: sessionPath() }), + ); + }); + + it("keeps the original description rather than relabelling from the message", async () => { + // The row already says what this agent is; a resume is the same work + // continuing, not a new task. + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + vi.mocked(runAgent).mockClear(); + + await send(lifecycle, "@explore anything else?"); + await flush(); + + // runAgent receives the new record's id, which is the only handle a test + // has on an agent the dispatcher spawned without returning anything. + const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId; + const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + expect(manager.getRecord(resumedId).description).toBe("find flaky tests"); + }); + + it("does not let the tools steer or read an agent that is gone", async () => { + // A mention can resurrect it, but there is no live record to interrupt and + // no result to return — so the tools must say so rather than resolve to a + // tombstone and act on stale data. + const { tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + + const steered = await tools.get("steer_subagent").execute( + "tc", { agent_id: "explore", message: "hi" }, undefined, undefined, ctx(), + ); + const read = await tools.get("get_subagent_result").execute( + "tc", { agent_id: "explore" }, undefined, undefined, ctx(), + ); + + expect(textOf(steered)).toContain("Agent not found"); + expect(textOf(read)).toContain("Agent not found"); + }); + + it("reports a session that is gone, rather than starting something else", async () => { + // SessionManager.open runs inside runAgent, so a missing file rejects that + // promise as an ordinary agent error — the dispatcher never sees it. The + // check has to happen before the spawn, or this reports nothing at all. + const { lifecycle, tools } = boot(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + vi.mocked(runAgent).mockClear(); + unlinkSync(sessionPath()); + + const uiCtx = ctx(); + const result = await lifecycle.get("input")( + { type: "input", text: "@explore anything else?", source: "interactive" }, + uiCtx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(vi.mocked(runAgent)).not.toHaveBeenCalled(); + expect(uiCtx.ui.notify).toHaveBeenCalledWith( + "Could not resume @explore — its session is gone.", "warning", + ); + }); + + it("forgets an unopenable session so the next mention starts fresh", async () => { + // A row that can only ever fail is worse than no row: it holds the handle + // and refuses every message sent to it. + const { lifecycle, tools } = bootDirect(); + finishedRun(fakeSession()); + await evict(await spawnBackground(tools)); + await flush(); + unlinkSync(sessionPath()); + await send(lifecycle, "@explore anything else?"); + vi.mocked(runAgent).mockClear(); + heldRun(fakeSession()); + + const uiCtx = ctx(); + await lifecycle.get("input")( + { type: "input", text: "@explore start over", source: "interactive" }, + uiCtx, + ); + await flush(); + + expect(vi.mocked(runAgent)).toHaveBeenCalledWith( + expect.anything(), "Explore", "start over", + expect.not.objectContaining({ resumeSessionFile: expect.anything() }), + ); + expect(uiCtx.ui.notify).toHaveBeenCalledWith("Started @explore", "info"); + }); +}); + +// Claude Code's names work for the model as well as the human, because its +// `@` is backed by the same SendMessage the LLM calls. These give ours the +// same property: one name space, whoever is doing the addressing. +describe("handles as tool arguments", () => { + it("steers by handle, not just by raw id", async () => { + const { tools, lifecycle: _l } = boot(); + const session = fakeSession(); + heldRun(session); + await spawnBackground(tools); + await flush(); + + const r = await tools.get("steer_subagent").execute( + "tc", { agent_id: "explore", message: "look at the RPC path" }, undefined, undefined, ctx(), + ); + + expect(session.steer).toHaveBeenCalledWith("look at the RPC path"); + expect(textOf(r)).not.toContain("Agent not found"); + }); + + it("steers by the name the model gave the agent", async () => { + const { tools } = boot(); + const session = fakeSession(); + heldRun(session); + await tools.get("Agent").execute( + "tc-named", + { prompt: "go", description: "audit", subagent_type: "Explore", name: "auth-audit", run_in_background: true }, + undefined, undefined, ctx(), + ); + await flush(); + + await tools.get("steer_subagent").execute( + "tc", { agent_id: "auth-audit", message: "keep going" }, undefined, undefined, ctx(), + ); + + expect(session.steer).toHaveBeenCalledWith("keep going"); + }); + + it("reads a result by handle", async () => { + const { tools } = boot(); + finishedRun(fakeSession()); + await spawnBackground(tools); + await flush(); + + const r = await tools.get("get_subagent_result").execute( + "tc", { agent_id: "explore" }, undefined, undefined, ctx(), + ); + + expect(textOf(r)).toContain("first answer"); + }); + + it("still reports an unknown reference as not found", async () => { + const { tools } = boot(); + + const r = await tools.get("steer_subagent").execute( + "tc", { agent_id: "nosuchagent", message: "hi" }, undefined, undefined, ctx(), + ); + + expect(textOf(r)).toContain("Agent not found"); + }); +}); diff --git a/pi-subagents/test/agent-runner-e2e.test.ts b/pi-subagents/test/agent-runner-e2e.test.ts new file mode 100644 index 0000000..8b39631 --- /dev/null +++ b/pi-subagents/test/agent-runner-e2e.test.ts @@ -0,0 +1,173 @@ +/** + * agent-runner-e2e.test.ts — End-to-end test against the REAL pi-mono runtime. + * + * Every other agent-runner test mocks `@earendil-works/pi-coding-agent`: it + * asserts that `runAgent` hands the right `tools:` allowlist to a *simulated* + * `createAgentSession`. That proves our allowlist math, but not the assumption + * the math rests on — that real pi-mono actually gates a session to that + * allowlist, admitting extension-registered tools (the #47 fix) and dropping + * the rest. + * + * This test closes that loop with NO pi-mono mock: + * - a real extension fixture (`fixtures/e2e-probe-ext.mjs`) registers a tool, + * - the real `DefaultResourceLoader` loads it via `additionalExtensionPaths`, + * - the real `createAgentSession` builds the session, + * - we read the real `session.getActiveToolNames()` at `onSessionCreated` + * (fires after construction, before any prompt) and assert what the LLM + * would actually be allowed to call. + * + * No network/LLM: a faux Model object satisfies `createAgentSession`'s `model` + * param, and we never depend on a turn completing — the assertion is on the + * gated tool set, which is fixed at construction. (Driving a live faux model + * through `session.prompt()` is intentionally avoided: under Vite the faux + * provider registers in a different `pi-ai` module instance than the one + * pi-coding-agent streams through, which is brittle and orthogonal to gating.) + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { extensionCanonicalName, runAgent } from "../src/agent-runner.js"; +import { registerAgents } from "../src/agent-types.js"; +import type { AgentConfig } from "../src/types.js"; +import { registerFauxProvider } from "./helpers/pi-ai.js"; + +// These tests spin up the REAL pi-mono runtime (loader + dynamic extension +// import + session construction), so a cold first run under full-suite CPU +// contention can exceed vitest's 5s default. Give the file generous headroom — +// a genuine hang still fails, just later. +vi.setConfig({ testTimeout: 30_000 }); + +const FIXTURE = resolve(fileURLToPath(new URL("./fixtures/e2e-probe-ext.mjs", import.meta.url))); +/** The fixture registers exactly this tool. */ +const EXT_TOOL = "e2e_probe"; +const BUILTINS = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + +/** Minimal `pi` stub — `detectEnv` only needs `exec` (returns non-git). */ +function makePi() { + return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any; +} + +describe("agent-runner end-to-end (real pi-mono session + real extension)", () => { + let cwd: string; + let faux: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "subagents-e2e-")); + // Only used as a valid Model object for createAgentSession; we never rely + // on it actually streaming (we assert on the pre-prompt gated tool set). + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] }); + }); + afterEach(() => { + faux.unregister(); + rmSync(cwd, { recursive: true, force: true }); + }); + + /** + * Register `cfg` as agent type "e2e", run it through the REAL runAgent, and + * return the real session's active tool names captured at construction time. + */ + async function activeToolsFor( + cfg: Partial, + mandatoryExtensionPaths?: readonly string[], + ): Promise { + registerAgents( + new Map([ + [ + "e2e", + { + name: "e2e", + description: "e2e", + builtinToolNames: BUILTINS, + skills: false, + systemPrompt: "You are e2e.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + ...cfg, + } as AgentConfig, + ], + ]), + ); + const model = faux.getModel(); + const modelRegistry: any = { + find: () => model, + getAll: () => [model], + getAvailable: () => [model], + hasConfiguredAuth: () => true, + isUsingOAuth: () => false, + getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }), + registerProvider: () => {}, + unregisterProvider: () => {}, + }; + const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry }; + + let active: string[] = []; + try { + await runAgent(ctx, "e2e", "go", { + pi: makePi(), + model, + mandatoryExtensionPaths, + onSessionCreated: (s) => { + active = s.getActiveToolNames(); + }, + }); + } catch { + // A no-op/erroring prompt turn is fine — the gated tool set is fixed at + // construction, which `onSessionCreated` already captured. + } + return active; + } + + it("real pi-mono admits an extension-registered tool when it's in the allowlist (#47)", async () => { + const active = await activeToolsFor({ extensions: [FIXTURE] }); + // The extension actually loaded and its tool reached the live session. + expect(active).toContain(EXT_TOOL); + for (const b of BUILTINS) expect(active).toContain(b); + }); + + it("an extension tool is absent when extensions are disabled (not loaded)", async () => { + const active = await activeToolsFor({ extensions: false }); + expect(active).not.toContain(EXT_TOOL); + for (const b of BUILTINS) expect(active).toContain(b); + }); + + it("loads an exact mandatory extension under extensions:false but keeps its tool hidden", async () => { + const active = await activeToolsFor({ extensions: false }, [FIXTURE]); + // Reaching onSessionCreated proves exact-path post-reload verification passed + // against the real loader. Mandatory lifecycle must not imply tool visibility. + expect(active).not.toContain(EXT_TOOL); + for (const b of BUILTINS) expect(active).toContain(b); + }); + + it("disallowedTools removes a real extension tool from the live session", async () => { + const active = await activeToolsFor({ extensions: [FIXTURE], disallowedTools: [EXT_TOOL] }); + expect(active).not.toContain(EXT_TOOL); // loaded, then denied at construction + expect(active).toContain("read"); + }); + + it("the ext: allowlist flip mutes a loaded-but-unselected extension in real pi-mono", async () => { + // Extension loads (extensions: [FIXTURE]), but a single ext: selector for a + // *different* name flips extension tools to an allowlist — the unselected + // fixture contributes nothing, even though it loaded and ran its handlers. + const active = await activeToolsFor({ extensions: [FIXTURE], extSelectors: ["ext:not-the-fixture"] }); + expect(active).not.toContain(EXT_TOOL); + for (const b of BUILTINS) expect(active).toContain(b); + }); + + it("an ext: selector surfaces the loaded extension's tool through the flip", async () => { + // Derive the canonical name the loader/selector matcher uses, so the test + // tracks `extensionCanonicalName` rather than hard-coding a filename form. + const canon = extensionCanonicalName(FIXTURE); + const active = await activeToolsFor({ + extensions: [FIXTURE], + builtinToolNames: ["read"], + extSelectors: [`ext:${canon}`], + }); + expect(active).toContain(EXT_TOOL); // selected → surfaces despite the flip + expect(active).toContain("read"); + expect(active).not.toContain("bash"); // builtinToolNames: ["read"] only + }); +}); diff --git a/pi-subagents/test/agent-runner-settings.test.ts b/pi-subagents/test/agent-runner-settings.test.ts new file mode 100644 index 0000000..5366318 --- /dev/null +++ b/pi-subagents/test/agent-runner-settings.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getDefaultMaxTurns, + getGraceTurns, + normalizeMaxTurns, + setDefaultMaxTurns, + setGraceTurns, +} from "../src/agent-runner.js"; + +describe("setDefaultMaxTurns / getDefaultMaxTurns", () => { + beforeEach(() => { + setDefaultMaxTurns(undefined); + }); + + it("defaults to undefined (unlimited)", () => { + expect(getDefaultMaxTurns()).toBeUndefined(); + }); + + it("stores a positive integer", () => { + setDefaultMaxTurns(30); + expect(getDefaultMaxTurns()).toBe(30); + }); + + it("accepts boundary value 1", () => { + setDefaultMaxTurns(1); + expect(getDefaultMaxTurns()).toBe(1); + }); + + it("treats 0 as unlimited", () => { + setDefaultMaxTurns(0); + expect(getDefaultMaxTurns()).toBeUndefined(); + }); + + it("clamps negative values to 1", () => { + setDefaultMaxTurns(-10); + expect(getDefaultMaxTurns()).toBe(1); + }); + + it("undefined resets to unlimited after being set", () => { + setDefaultMaxTurns(50); + expect(getDefaultMaxTurns()).toBe(50); + setDefaultMaxTurns(undefined); + expect(getDefaultMaxTurns()).toBeUndefined(); + }); +}); + +describe("normalizeMaxTurns", () => { + it("treats undefined as unlimited", () => { + expect(normalizeMaxTurns(undefined)).toBeUndefined(); + }); + + it("treats 0 as unlimited", () => { + expect(normalizeMaxTurns(0)).toBeUndefined(); + }); + + it("keeps positive values", () => { + expect(normalizeMaxTurns(7)).toBe(7); + }); + + it("clamps negative values to 1", () => { + expect(normalizeMaxTurns(-3)).toBe(1); + }); +}); + +describe("setGraceTurns / getGraceTurns", () => { + beforeEach(() => { + setGraceTurns(5); + }); + + it("round-trips the value this suite set up", () => { + // NOT a default assertion — the beforeEach above sets 5, so this only + // proves the setter/getter pair agree. The real module default is asserted + // against a freshly-imported module in test/documented-defaults.test.ts. + expect(getGraceTurns()).toBe(5); + }); + + it("stores a positive integer", () => { + setGraceTurns(10); + expect(getGraceTurns()).toBe(10); + }); + + it("accepts boundary value 1", () => { + setGraceTurns(1); + expect(getGraceTurns()).toBe(1); + }); + + it("clamps 0 to 1", () => { + setGraceTurns(0); + expect(getGraceTurns()).toBe(1); + }); + + it("clamps negative values to 1", () => { + setGraceTurns(-5); + expect(getGraceTurns()).toBe(1); + }); +}); diff --git a/pi-subagents/test/agent-runner.test.ts b/pi-subagents/test/agent-runner.test.ts new file mode 100644 index 0000000..6fe5eb3 --- /dev/null +++ b/pi-subagents/test/agent-runner.test.ts @@ -0,0 +1,2672 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + createAgentSession, + defaultResourceLoaderCtor, + loaderExtensionsRef, + getAgentDir, + sessionManagerInMemory, + sessionManagerCreate, + sessionManagerOpen, + settingsManagerCreate, + settingsManagerGetSessionDir, +} = vi.hoisted(() => ({ + createAgentSession: vi.fn(), + defaultResourceLoaderCtor: vi.fn(), + loaderExtensionsRef: { + current: { extensions: [], errors: [], runtime: {} } as { + extensions: Array<{ path: string; tools: Map }>; + errors: Array<{ path: string; error: string }>; + runtime: Record; + }, + }, + getAgentDir: vi.fn(() => "/mock/agent-dir"), + sessionManagerInMemory: vi.fn(() => ({ kind: "memory-session-manager" })), + sessionManagerCreate: vi.fn(() => ({ kind: "persistent-session-manager" })), + sessionManagerOpen: vi.fn(() => ({ kind: "reopened-session-manager" })), + settingsManagerGetSessionDir: vi.fn(() => undefined as string | undefined), + settingsManagerCreate: vi.fn(() => ({ kind: "settings-manager", getSessionDir: settingsManagerGetSessionDir })), +})); + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + createAgentSession, + // Mock loader simulates pi-mono: reload() applies additionalExtensionPaths + // (an unknown path becomes an error row, mirroring a failed load) and then + // runs extensionsOverride over the result. + DefaultResourceLoader: class { + opts: any; + constructor(options: any) { + this.opts = options; + defaultResourceLoaderCtor(options); + } + + async reload() { + // Mirror the real loader: `noExtensions: true` zeros out the discovered set + // entirely. Otherwise tests pre-register the extensions a path should + // resolve to; an unregistered path simply yields no extension (a failed load). + if (this.opts.noExtensions) { + loaderExtensionsRef.current = { extensions: [], errors: [], runtime: {} }; + return; + } + if (this.opts.extensionsOverride) { + loaderExtensionsRef.current = this.opts.extensionsOverride(loaderExtensionsRef.current); + } + } + + getExtensions() { + return loaderExtensionsRef.current; + } + }, + getAgentDir, + SessionManager: { inMemory: sessionManagerInMemory, create: sessionManagerCreate, open: sessionManagerOpen }, + SettingsManager: { create: settingsManagerCreate }, +})); + +vi.mock("../src/agent-types.js", () => ({ + BUILTIN_TOOL_NAMES: ["read", "bash", "edit", "write", "grep", "find", "ls"], + getConfig: vi.fn(() => ({ + displayName: "Explore", + description: "Explore", + builtinToolNames: ["read"], + extensions: false, + skills: false, + promptMode: "replace", + })), + getAgentConfig: vi.fn(() => ({ + name: "Explore", + description: "Explore", + builtinToolNames: ["read"], + extensions: false, + skills: false, + systemPrompt: "You are Explore.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + })), + getMemoryToolNames: vi.fn(() => []), + getReadOnlyMemoryToolNames: vi.fn(() => []), + getToolNamesForType: vi.fn(() => ["read"]), +})); + +vi.mock("../src/env.js", () => ({ + detectEnv: vi.fn(async () => ({ isGitRepo: false, branch: "", platform: "linux" })), +})); + +vi.mock("../src/prompts.js", () => ({ + buildAgentPrompt: vi.fn(() => "system prompt"), +})); + +vi.mock("../src/memory.js", () => ({ + buildMemoryBlock: vi.fn(() => ""), + buildReadOnlyMemoryBlock: vi.fn(() => ""), +})); + +vi.mock("../src/skill-loader.js", () => ({ + preloadSkills: vi.fn(() => []), +})); + +vi.mock("../src/nested-tools.js", () => ({ + getMaxSubagentDepth: vi.fn(() => 2), + createNestedSubagentTools: vi.fn(() => [ + { name: "Agent" }, + { name: "get_subagent_result" }, + { name: "steer_subagent" }, + ]), +})); + +import { + extensionCanonicalName, + extensionCanonicalNames, + getAgentConversation, + getDefaultMaxTurns, + getGraceTurns, + parseExtensionsSpec, + parseExtSelectors, + resolveDefaultModel, + resolveEffectiveMaxTurns, + resumeAgent, + runAgent, + SUBAGENT_TOOL_NAMES, + setDefaultMaxTurns, + setGraceTurns, + setRememberAgents, +} from "../src/agent-runner.js"; + +/** The most recent session built by `createSession` — read by `lastToolsPassed()`. */ +let lastSession: ReturnType["session"] | undefined; + +function createSession(finalText: string) { + const listeners: Array<(event: any) => void> = []; + // pi activates only these four by default when no allowlist is given + // (agent-session.js `defaultActiveToolNames`). + let activeToolNames: string[] = ["read", "bash", "edit", "write"]; + const session = { + messages: [] as any[], + subscribe: vi.fn((listener: (event: any) => void) => { + listeners.push(listener); + return () => {}; + }), + prompt: vi.fn(async () => { + session.messages.push({ + role: "assistant", + content: [{ type: "text", text: finalText }], + }); + }), + abort: vi.fn(), + steer: vi.fn(), + // Stateful, so the active set reflects what the scope installer actually did + // and `renarrow`'s no-op guard behaves as it does against real pi. + getActiveToolNames: vi.fn(() => activeToolNames), + setActiveToolsByName: vi.fn((names: string[]) => { + activeToolNames = [...names]; + }), + // pi's tool REGISTRY (`_toolDefinitions`), read live so tests can simulate an + // extension registering after bind by mutating `loaderExtensionsRef`. + getAllTools: vi.fn(() => { + const opts = createAgentSession.mock.calls[0]?.[0]; + return opts ? mockRegistry(opts).map((name) => ({ name })) : []; + }), + // pi's Agent; `beforeToolCall` is an optional, assignable hook the scope + // installer wraps to block out-of-scope calls on turn 1. + agent: { beforeToolCall: undefined } as { + beforeToolCall?: (context: any, signal?: any) => Promise; + }, + setSessionName: vi.fn(), + bindExtensions: vi.fn(async () => {}), + }; + lastSession = session; + return { session, listeners }; +} + +const ctx = { + cwd: "/tmp", + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent prompt"), + sessionManager: { + getBranch: vi.fn(() => []), + getSessionFile: vi.fn(() => "/sessions/parent.jsonl"), + }, +} as any; + +const pi = {} as any; + +beforeEach(() => { + createAgentSession.mockReset(); + defaultResourceLoaderCtor.mockClear(); + getAgentDir.mockClear(); + sessionManagerInMemory.mockClear(); + sessionManagerCreate.mockClear(); + sessionManagerOpen.mockClear(); + // The setting is process-global; a test that flips it must not leak the + // flip into the next one. + setRememberAgents(true); + settingsManagerGetSessionDir.mockReset(); + settingsManagerGetSessionDir.mockReturnValue(undefined); + settingsManagerCreate.mockClear(); + vi.mocked(createNestedSubagentTools).mockClear(); + loaderExtensionsRef.current = { extensions: [], errors: [], runtime: {} }; + lastSession = undefined; +}); + +describe("agent-runner final output capture", () => { + it("returns the final assistant text even when no text_delta events were streamed", async () => { + const { session } = createSession("LOCKED"); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "Say LOCKED", { pi }); + + expect(result.responseText).toBe("LOCKED"); + }); + + it("binds extensions before prompting", async () => { + const { session } = createSession("BOUND"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "Say BOUND", { pi }); + + expect(session.bindExtensions).toHaveBeenCalledTimes(1); + expect(session.bindExtensions).toHaveBeenCalledWith( + expect.objectContaining({ onError: expect.any(Function) }), + ); + + const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; + const promptOrder = session.prompt.mock.invocationCallOrder[0]; + expect(bindOrder).toBeLessThan(promptOrder); + }); + + it("passes effective cwd and agentDir to the loader and settings manager", async () => { + const { session } = createSession("CONFIGURED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "Say CONFIGURED", { pi, cwd: "/tmp/worktree" }); + + expect(getAgentDir).toHaveBeenCalledTimes(1); + expect(defaultResourceLoaderCtor).toHaveBeenCalledWith(expect.objectContaining({ + cwd: "/tmp/worktree", + agentDir: "/mock/agent-dir", + })); + expect(settingsManagerCreate).toHaveBeenCalledWith("/tmp/worktree", "/mock/agent-dir"); + // Same claim as before `rememberAgents` flipped the default — the effective + // cwd reaches the session manager — now via the persistent constructor. + expect(sessionManagerCreate).toHaveBeenCalledWith("/tmp/worktree", undefined, expect.anything()); + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + cwd: "/tmp/worktree", + agentDir: "/mock/agent-dir", + })); + }); + + it("forwards worktreeBase to the prompt builder, and omits it otherwise", async () => { + const { buildAgentPrompt } = await import("../src/prompts.js"); + const { session } = createSession("ISOLATED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "Say ISOLATED", { pi, cwd: "/wt/copy", worktreeBase: "/repo" }); + expect(vi.mocked(buildAgentPrompt).mock.lastCall![4]).toMatchObject({ worktreeBase: "/repo" }); + + await runAgent(ctx, "Explore", "Say ISOLATED", { pi }); + expect(vi.mocked(buildAgentPrompt).mock.lastCall![4]).not.toHaveProperty("worktreeBase"); + }); + + it("passes the parent model runtime while retaining the legacy model registry", async () => { + const { session } = createSession("AUTHENTICATED"); + createAgentSession.mockResolvedValue({ session }); + const modelRuntime = { getAuth: vi.fn(), hasConfiguredAuth: vi.fn() }; + const context = { + ...ctx, + modelRegistry: { ...ctx.modelRegistry, runtime: modelRuntime }, + }; + + await runAgent(context, "Explore", "Say AUTHENTICATED", { pi }); + + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + modelRegistry: context.modelRegistry, + modelRuntime, + })); + }); + + it("omits modelRuntime when the legacy registry does not expose one", async () => { + const { session } = createSession("LEGACY"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "Say LEGACY", { pi }); + + expect(createAgentSession.mock.calls[0][0]).not.toHaveProperty("modelRuntime"); + }); + + it("suppresses AGENTS.md/CLAUDE.md/APPEND_SYSTEM.md for subagents", async () => { + const { session } = createSession("ISOLATED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "Say ISOLATED", { pi }); + + // noContextFiles skips AGENTS.md/CLAUDE.md at the loader source; + // appendSystemPromptOverride suppresses APPEND_SYSTEM.md (no flag equivalent). + expect(defaultResourceLoaderCtor).toHaveBeenCalledWith( + expect.objectContaining({ + noContextFiles: true, + appendSystemPromptOverride: expect.any(Function), + }), + ); + // The override returns an empty list so any loaded sources are discarded. + const ctorArgs = defaultResourceLoaderCtor.mock.calls[0][0]; + expect(ctorArgs.appendSystemPromptOverride(["would-be-loaded"])).toEqual([]); + }); + + it("resumeAgent also falls back to the final assistant message text", async () => { + const { session } = createSession("RESUMED"); + + const result = await resumeAgent(session as any, "Continue"); + + expect(result.text).toBe("RESUMED"); + expect(result.failure).toBeUndefined(); + }); + + it("sets the agent name as session name before binding extensions", async () => { + const { session } = createSession("NAMED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(session.setSessionName).toHaveBeenCalledWith("Explore"); + const setOrder = session.setSessionName.mock.invocationCallOrder[0]; + const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; + expect(setOrder).toBeLessThan(bindOrder); + }); + + it("registers the child with the root permission authority before binding extensions", async () => { + const order: string[] = []; + const { session } = createSession("REGISTERED"); + Object.assign(session, { + sessionManager: { getSessionId: () => "child-session" }, + }); + session.bindExtensions.mockImplementation(async () => { order.push("bind"); }); + createAgentSession.mockResolvedValue({ session }); + const bridgePi = { + events: { + emit: vi.fn((channel: string) => { order.push(channel); }), + }, + } as any; + const rootCtx = { + ...ctx, + sessionManager: { + ...ctx.sessionManager, + getSessionId: () => "root-session", + }, + }; + + await runAgent(rootCtx, "Explore", "go", { pi: bridgePi }); + + expect(order.slice(0, 2)).toEqual([ + "subagents:child:session-created", + "bind", + ]); + expect(bridgePi.events.emit).toHaveBeenCalledWith( + "subagents:child:session-created", + { sessionId: "child-session", parentSessionId: "root-session" }, + ); + const loaderOptions = defaultResourceLoaderCtor.mock.lastCall![0]; + expect(loaderOptions.systemPromptOverride()).toContain( + '', + ); + }); + + it("uses the agent definition filename as the per-agent permission key", async () => { + const { getAgentConfig } = await import("../src/agent-types.js"); + vi.mocked(getAgentConfig).mockReturnValueOnce({ + name: "declared-agent-name", + description: "Agent", + builtinToolNames: ["read"], + extensions: false, + skills: false, + systemPrompt: "Agent prompt", + promptMode: "replace", + sourcePath: "/repo/.pi/agents/policy-file-key.md", + }); + const { session } = createSession("IDENTIFIED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "declared-agent-name", "go", { pi }); + + const loaderOptions = defaultResourceLoaderCtor.mock.lastCall![0]; + expect(loaderOptions.systemPromptOverride()).toContain( + '', + ); + }); + + it("closes permission registration when extension binding fails", async () => { + const order: string[] = []; + const { session } = createSession("FAILED"); + Object.assign(session, { + sessionManager: { getSessionId: () => "child-session" }, + dispose: vi.fn(() => { order.push("session-disposed"); }), + }); + session.bindExtensions.mockRejectedValue(new Error("bind failed")); + createAgentSession.mockResolvedValue({ session }); + const bridgePi = { + events: { emit: vi.fn((channel: string) => { order.push(channel); }) }, + } as any; + const rootCtx = { + ...ctx, + sessionManager: { ...ctx.sessionManager, getSessionId: () => "root-session" }, + }; + + await expect(runAgent(rootCtx, "Explore", "go", { pi: bridgePi })) + .rejects.toThrow("bind failed"); + + expect(order).toEqual([ + "subagents:child:session-created", + "session-disposed", + "subagents:child:disposed", + ]); + }); + + it("suffixes the session name with a short agentId so parallel spawns are distinguishable", async () => { + const { session } = createSession("NAMED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, agentId: "a1b2c3d4e5f6" }); + + expect(session.setSessionName).toHaveBeenCalledWith("Explore#a1b2c3d4"); + }); +}); + +// #144 — a failed FINAL assistant turn (stopReason "error") must surface as +// `failure`; how the turn STOPPED decides, never whether it produced text. +describe("agent-runner failed-final-turn detection (#144)", () => { + /** Session whose prompt() appends the given messages to history. */ + function sessionEnding(...messages: any[]) { + const { session } = createSession(""); + session.prompt = vi.fn(async () => { + session.messages.push(...messages); + }) as any; + return session; + } + + const errorFinal = { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "retries exhausted: 529 overloaded", + }; + + it("flags a run whose final turn is an empty provider error", async () => { + const session = sessionEnding(errorFinal); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBe("retries exhausted: 529 overloaded"); + }); + + it("flags the failure even when an EARLIER turn produced text (no masking)", async () => { + const session = sessionEnding( + { role: "assistant", content: [{ type: "text", text: "partial progress" }] }, + { role: "toolResult", content: [] }, + errorFinal, + ); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBe("retries exhausted: 529 overloaded"); + // The earlier text stays available as context — status honesty, not data loss. + expect(result.responseText).toBe("partial progress"); + }); + + it("flags a provider error that left partial text in the SAME final message", async () => { + const session = sessionEnding({ + role: "assistant", + content: [{ type: "text", text: "truncated answ" }], + stopReason: "error", + errorMessage: "stream ended before message_stop", + }); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBe("stream ended before message_stop"); + expect(result.responseText).toBe("truncated answ"); + }); + + it("flags a run whose final turn hit the token limit with no text (#144 residual)", async () => { + // stopReason "length" with empty content is a silent max-token death — it + // reproduces the #144 "completed with No output." symptom, so it must fail. + const session = sessionEnding({ role: "assistant", content: [], stopReason: "length" }); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBe("run hit the output token limit before producing any text"); + }); + + it("does NOT flag a length stop that produced text (truncated answer completes)", async () => { + const session = sessionEnding({ + role: "assistant", + content: [{ type: "text", text: "truncated but useful answer" }], + stopReason: "length", + }); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBeUndefined(); + expect(result.responseText).toBe("truncated but useful answer"); + }); + + it("does NOT flag an empty final turn that stopped cleanly (no false failures)", async () => { + const session = sessionEnding( + { role: "assistant", content: [{ type: "text", text: "did the work" }] }, + { role: "toolResult", content: [] }, + { role: "assistant", content: [], stopReason: "stop" }, + ); + createAgentSession.mockResolvedValue({ session }); + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.failure).toBeUndefined(); + expect(result.responseText).toBe("did the work"); // walk-back fallback preserved + }); + + it("resumeAgent applies the same rule", async () => { + const { session } = createSession(""); + session.prompt = vi.fn(async () => { + session.messages.push(errorFinal); + }) as any; + + const result = await resumeAgent(session as any, "Continue"); + + expect(result.failure).toBe("retries exhausted: 529 overloaded"); + }); + + it("resume whose new turn fails empty does NOT return the previous turn's answer (#144)", async () => { + // The session already carries a completed prior turn; the resume prompt then + // fails empty. The walk-back must be bounded to this resume — result "". + const { session } = createSession(""); + session.messages.push( + { role: "user", content: "first question" }, + { role: "assistant", content: [{ type: "text", text: "PREVIOUS ANSWER" }], stopReason: "stop" }, + ); + session.prompt = vi.fn(async () => { + session.messages.push({ role: "user", content: "follow-up" }, errorFinal); + }) as any; + + const result = await resumeAgent(session as any, "follow-up"); + + expect(result.failure).toBe("retries exhausted: 529 overloaded"); + expect(result.text).toBe(""); // NOT "PREVIOUS ANSWER" + }); + + it("resume that produces partial text before failing returns only THIS resume's text", async () => { + const { session } = createSession(""); + session.messages.push( + { role: "assistant", content: [{ type: "text", text: "PREVIOUS ANSWER" }], stopReason: "stop" }, + ); + session.prompt = vi.fn(async () => { + session.messages.push( + { role: "assistant", content: [{ type: "text", text: "new partial" }] }, + { role: "toolResult", content: [] }, + errorFinal, + ); + }) as any; + + const result = await resumeAgent(session as any, "go"); + + expect(result.failure).toBe("retries exhausted: 529 overloaded"); + expect(result.text).toBe("new partial"); // this resume's progress, not the prior answer + }); + + it("collector: a toolResult/user message_start no longer wipes collected assistant text", async () => { + const { session, listeners } = createSession(""); + createAgentSession.mockResolvedValue({ session }); + session.prompt = vi.fn(async () => { + for (const l of listeners) { + l({ type: "message_start", message: { role: "assistant" } }); + l({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "STREAMED" } }); + // pi emits message_start for tool results and queued user messages too. + l({ type: "message_start", message: { role: "toolResult" } }); + l({ type: "message_start", message: { role: "user" } }); + } + }) as any; + + const result = await runAgent(ctx, "Explore", "go", { pi }); + + expect(result.responseText).toBe("STREAMED"); + }); +}); + +// ─── message_end → onAssistantUsage wiring (issue #38) ───────────────── +// Both runAgent and resumeAgent dispatch usage to the caller via this +// callback. The callback feeds the AgentRecord lifetime accumulator, which +// is the source of truth for total tokens (survives compaction). +describe("agent-runner usage callback wiring", () => { + function emitMessageEnd(listeners: Array<(e: any) => void>, usage: any) { + const event = { type: "message_end", message: { role: "assistant", usage } }; + for (const l of listeners) l(event); + } + + it("runAgent forwards full usage from message_end events", async () => { + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + const seen: Array<{ input: number; output: number; cacheWrite: number; cost?: number }> = []; + session.prompt = vi.fn(async () => { + // Two assistant messages over the run + emitMessageEnd(listeners, { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: { total: 0.002 } }); + emitMessageEnd(listeners, { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: { total: 0.004 } }); + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + + await runAgent(ctx, "Explore", "go", { + pi, + onAssistantUsage: (u) => seen.push(u), + }); + + // cacheRead rides along even though the display total drops it (#38): the + // prefix is genuinely re-billed per call, and the parent-session report needs it. + expect(seen).toEqual([ + { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.002 }, + { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.004 }, + ]); + }); + + it("runAgent normalizes partial usage objects to 0 for missing fields", async () => { + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + const seen: any[] = []; + session.prompt = vi.fn(async () => { + emitMessageEnd(listeners, { input: 50 }); // output, cacheWrite, cacheRead, cost missing + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + + await runAgent(ctx, "Explore", "go", { + pi, + onAssistantUsage: (u) => seen.push(u), + }); + + // An unpriced model reports no `cost` object at all — 0, never undefined, + // so accumulators never have to special-case it. + expect(seen).toEqual([{ input: 50, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }]); + }); + + it("runAgent skips the callback when message_end has no usage field", async () => { + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + const cb = vi.fn(); + session.prompt = vi.fn(async () => { + emitMessageEnd(listeners, undefined); + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + + await runAgent(ctx, "Explore", "go", { pi, onAssistantUsage: cb }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it("resumeAgent forwards usage on message_end the same way", async () => { + const { session, listeners } = createSession("RESUMED"); + const seen: any[] = []; + + session.prompt = vi.fn(async () => { + emitMessageEnd(listeners, { input: 10, output: 20, cacheWrite: 5, cacheRead: 90, cost: { total: 0.001 } }); + session.messages.push({ role: "assistant", content: [{ type: "text", text: "RESUMED" }] }); + }); + + await resumeAgent(session as any, "continue", { + onAssistantUsage: (u) => seen.push(u), + }); + + expect(seen).toEqual([{ input: 10, output: 20, cacheWrite: 5, cacheRead: 90, cost: 0.001 }]); + }); + + it("forwards compaction_end events to onCompaction (only when not aborted)", async () => { + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + const seen: any[] = []; + session.prompt = vi.fn(async () => { + // Successful compaction — should fire + for (const l of listeners) l({ + type: "compaction_end", + aborted: false, + reason: "threshold", + result: { tokensBefore: 12345 }, + }); + // Aborted compaction — should NOT fire + for (const l of listeners) l({ + type: "compaction_end", + aborted: true, + reason: "manual", + result: { tokensBefore: 99999 }, + }); + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + + await runAgent(ctx, "Explore", "go", { + pi, + onCompaction: (info) => seen.push(info), + }); + + expect(seen).toEqual([{ reason: "threshold", tokensBefore: 12345 }]); + }); +}); + +// getAgentConversation renders the subagent transcript shown in the /agents +// inspect overlay. Pure function over session.messages — no mocks needed +// beyond a literal-object session. +describe("getAgentConversation", () => { + function fakeSession(messages: unknown[]) { + return { messages } as never; + } + + it("returns an empty string for a session with no messages", () => { + expect(getAgentConversation(fakeSession([]))).toBe(""); + }); + + it("formats a user-then-assistant exchange with role-prefixed lines joined by blank lines", () => { + const out = getAgentConversation( + fakeSession([ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]), + ); + expect(out).toBe("[User]: hi\n\n[Assistant]: hello"); + }); + + it("accepts user content as content-blocks (not just strings)", () => { + const out = getAgentConversation( + fakeSession([{ role: "user", content: [{ type: "text", text: "from blocks" }] }]), + ); + expect(out).toBe("[User]: from blocks"); + }); + + it("emits a [Tool Calls] block listing each toolCall by name or toolName, falling back to 'unknown'", () => { + const out = getAgentConversation( + fakeSession([ + { + role: "assistant", + content: [ + { type: "text", text: "calling tools" }, + { type: "toolCall", name: "search" }, + { type: "toolCall", toolName: "edit" }, + { type: "toolCall" }, + ], + }, + ]), + ); + expect(out).toContain("[Assistant]: calling tools"); + expect(out).toContain("[Tool Calls]:\n Tool: search\n Tool: edit\n Tool: unknown"); + }); + + it("truncates toolResult content beyond 200 chars and tags it with the tool name", () => { + const longText = "x".repeat(300); + const out = getAgentConversation( + fakeSession([ + { + role: "toolResult", + toolName: "bash", + content: [{ type: "text", text: longText }], + }, + ]), + ); + expect(out.startsWith("[Tool Result (bash)]: ")).toBe(true); + expect(out.endsWith("...")).toBe(true); + // prefix + 200 chars + "..." + expect(out.length).toBe("[Tool Result (bash)]: ".length + 200 + 3); + }); + + it("emits [Tool Calls] but no [Assistant] when the assistant only made tool calls", () => { + const out = getAgentConversation( + fakeSession([ + { role: "user", content: "do it" }, + { role: "assistant", content: [{ type: "toolCall", name: "search" }] }, + ]), + ); + expect(out).toContain("[User]: do it"); + expect(out).not.toContain("[Assistant]:"); + expect(out).toContain("[Tool Calls]:\n Tool: search"); + }); +}); + +// ─── tool scoping (issues #47, #125) ───────────────────────────────────── +// runAgent scopes a subagent's tools in one of two ways: +// • Static allowlist (`tools:`) — ONLY for noExtensions/isolated. Nothing can +// register asynchronously there, so pi-mono's `allowedToolNames` gating both +// registration and the initial active set is exactly right. +// • Live scoping — whenever extensions load. `tools:` is left unset so pi's +// live `isAllowedTool` admits tools whenever they register (pi-mcp registers +// on session_start, context-mode on before_agent_start); `excludeTools:` +// carries the name-stable permanent scope; and `installExtensionToolScope` +// narrows the ACTIVE set for `ext:` selectors, re-deriving on every turn_end +// so late arrivals are judged too. +// `lastToolsPassed()` returns what the LLM can actually call under either shape. + +import { + getAgentConfig, + getConfig, + getToolNamesForType, +} from "../src/agent-types.js"; +import { createNestedSubagentTools } from "../src/nested-tools.js"; + +const BUILTINS_7 = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + +function makeAgentConfig(overrides: Record = {}) { + return { + name: "test-agent", + description: "Test", + builtinToolNames: BUILTINS_7, + extensions: true as boolean | string[], + skills: false as boolean | string[], + systemPrompt: "Test.", + promptMode: "replace" as const, + inheritContext: false, + runInBackground: false, + isolated: false, + ...overrides, + }; +} + +function makeConfig(overrides: Record = {}) { + return { + displayName: "test-agent", + description: "Test", + builtinToolNames: BUILTINS_7, + extensions: true as boolean | string[], + skills: false as boolean | string[], + promptMode: "replace" as const, + ...overrides, + }; +} + +/** Register extensions for the mock loader, keyed by extension path → tool names. */ +function withExtensions(spec: Record) { + loaderExtensionsRef.current = { + extensions: Object.entries(spec).map(([path, tools]) => ({ + path, + tools: new Map(tools.map((n) => [n, {}])), + })), + errors: [], + runtime: {}, + }; +} + +/** + * The tool REGISTRY pi would build for a given `createAgentSession` call — + * mirroring `_refreshToolRegistry`'s `isAllowedTool`: + * - `tools:` set → the allowlist gates the registry (nothing else registers). + * - `tools:` unset → every built-in plus every loaded extension tool, minus + * `excludeTools`, and it keeps growing as extensions register later. + * Read live from `loaderExtensionsRef`, so a test can simulate late registration. + */ +function mockRegistry(opts: Record): string[] { + const excluded = new Set(opts.excludeTools ?? []); + // pi registers customTools into the same registry, subject to the same gate. + const customNames: string[] = (opts.customTools ?? []).map((t: any) => t.name); + const all: string[] = opts.tools + ? [...opts.tools, ...customNames] + : [ + ...BUILTINS_7, + ...loaderExtensionsRef.current.extensions.flatMap((e) => [...e.tools.keys()]), + ...customNames, + ]; + return [...new Set(all)].filter((t) => !excluded.has(t)); +} + +/** + * What the LLM can actually call. + * + * Under the static allowlist (`noExtensions`/`isolated`) that is `tools:` verbatim. + * Otherwise the registry is scoped by `excludeTools` and then narrowed to the ACTIVE + * set by `installExtensionToolScope` — so the active set is the real answer, and + * asserting on it means these tests exercise the narrowing rather than a + * reimplementation of pi's gate. + */ +function lastToolsPassed(): string[] { + const opts = createAgentSession.mock.calls[0][0]; + if (opts.tools) return opts.tools; + return lastSession?.getActiveToolNames() ?? []; +} + +function lastLoaderOpts(): Record { + return defaultResourceLoaderCtor.mock.calls[0][0]; +} + +describe("agent-runner session persistence", () => { + it("persists by default, so a handle can reopen the conversation later", async () => { + // `rememberAgents` defaults on: the session file is the only thing an + // evicted agent leaves behind, so without it `@explore` after cleanup + // could only ever start a fresh agent. + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + expect(sessionManagerCreate).toHaveBeenCalled(); + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionManager: { kind: "persistent-session-manager" }, + })); + }); + + it("keeps the session in memory when rememberAgents is off", async () => { + setRememberAgents(false); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(sessionManagerInMemory).toHaveBeenCalledWith("/tmp"); + expect(sessionManagerCreate).not.toHaveBeenCalled(); + }); + + it("lets frontmatter override rememberAgents in both directions", async () => { + // The setting is only a default. An agent that declares itself ephemeral + // stays ephemeral with the setting on... + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: false })); + createAgentSession.mockResolvedValue({ session: createSession("OK").session }); + await runAgent(ctx, "Explore", "go", { pi }); + expect(sessionManagerInMemory).toHaveBeenCalled(); + expect(sessionManagerCreate).not.toHaveBeenCalled(); + + // ...and one that declares itself persistent still persists with it off. + sessionManagerInMemory.mockClear(); + setRememberAgents(false); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); + createAgentSession.mockResolvedValue({ session: createSession("OK").session }); + await runAgent(ctx, "Explore", "go", { pi }); + expect(sessionManagerCreate).toHaveBeenCalled(); + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + }); + + it("leaves a nested child in memory, since nothing can address it later", async () => { + // The default exists so `@handle` can reopen a conversation. A nested agent + // never gets a handle, so its transcript would be unreachable by anything — + // pure disk and /resume clutter. + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + createAgentSession.mockResolvedValue({ session: createSession("OK").session }); + + await runAgent(ctx, "Explore", "go", { pi, nested: true }); + + expect(sessionManagerInMemory).toHaveBeenCalled(); + expect(sessionManagerCreate).not.toHaveBeenCalled(); + }); + + it("still persists a nested child that asks for it in frontmatter", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); + createAgentSession.mockResolvedValue({ session: createSession("OK").session }); + + await runAgent(ctx, "Explore", "go", { pi, nested: true }); + + expect(sessionManagerCreate).toHaveBeenCalled(); + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + }); + + it("reopens an existing session file instead of starting a new conversation", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "carry on", { pi, resumeSessionFile: "/sessions/explore.jsonl" }); + + // Neither create nor inMemory: both would start an empty conversation, and + // the point of a resume is that the history is already there. + expect(sessionManagerCreate).not.toHaveBeenCalled(); + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + expect(sessionManagerOpen).toHaveBeenCalledWith("/sessions/explore.jsonl", "/normal/pi/sessions"); + }); + + it("uses pi's normal persistent session location and links to the parent session", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); + settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + expect(sessionManagerCreate).toHaveBeenCalledWith( + "/tmp", + "/normal/pi/sessions", + { parentSession: "/sessions/parent.jsonl" }, + ); + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionManager: { kind: "persistent-session-manager" }, + })); + }); + + it("uses a frontmatter sessionDir when persistSession is true and sessionDir is configured", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ persistSession: true, sessionDir: ".seams/pi-sessions/seam-plan-reviewer" }), + ); + settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, cwd: "/repo" }); + + expect(sessionManagerCreate).toHaveBeenCalledWith( + "/repo", + "/repo/.seams/pi-sessions/seam-plan-reviewer", + { parentSession: "/sessions/parent.jsonl" }, + ); + }); +}); + +describe("agent-runner master tool allowlist", () => { + it("extensions: true with extension tools — all 7 built-ins plus extension tools land in the allowlist", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + // Order is not semantically meaningful (pi-mono dedupes via Set); + // assert membership and exact size instead. + const tools = lastToolsPassed(); + expect(tools).toHaveLength(BUILTINS_7.length + 2); + expect(new Set(tools)).toEqual(new Set([...BUILTINS_7, "mcp", "mcp_call"])); + }); + + it("enumerates tools across multiple loaded extensions", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/a.ts": ["tool_a"], "/ext/b.ts": ["tool_b"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("tool_a"); + expect(tools).toContain("tool_b"); + }); + + it("disallowedTools removes both built-ins and extension tools", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, disallowedTools: ["bash", "mcp"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("bash"); + expect(tools).not.toContain("mcp"); + expect(tools).toContain("mcp_call"); + expect(tools).toContain("read"); + }); + + it("EXCLUDED_TOOL_NAMES never reach the allowlist even if an extension registers them", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ + "/ext/evil.ts": ["Agent", "get_subagent_result", "steer_subagent", "ok_ext"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("Agent"); + expect(tools).not.toContain("get_subagent_result"); + expect(tools).not.toContain("steer_subagent"); + expect(tools).toContain("ok_ext"); + }); + + it("keeps nested tools unavailable without explicit opt-in", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: false })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + expect(createNestedSubagentTools).not.toHaveBeenCalled(); + expect(lastToolsPassed()).not.toContain("Agent"); + expect(createAgentSession.mock.calls[0][0].customTools).toEqual([]); + }); + + it("injects scoped nested tools for an opted-in non-isolated agent", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, allowedSubagents: ["scout"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const manager = {} as any; + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, + }); + + expect(createNestedSubagentTools).toHaveBeenCalledWith(expect.objectContaining({ + manager, + parentAgentId: "parent", + depth: 1, + maxSubagentDepth: 3, + allowedSubagents: ["scout"], + configCwd: "/tmp", + })); + expect(lastToolsPassed()).toEqual(expect.arrayContaining([ + "Agent", "get_subagent_result", "steer_subagent", + ])); + expect(createAgentSession.mock.calls[0][0].customTools).toHaveLength(3); + }); + + it("keeps opt-in nested tools active UNDER EXTENSIONS despite the EXCLUDED-name collision", async () => { + // The nested tool names ARE EXCLUDED_TOOL_NAMES. Under the denylist mechanism + // they must (a) not be excluded from the registry, and (b) survive the live + // installExtensionToolScope renarrow that strips EXCLUDED_TOOL_NAMES. + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, allowedSubagents: "all" }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/ok.ts": ["ok_ext"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + const opts = createAgentSession.mock.calls[0][0]; + // (a) not denied at the registry gate, and passed as customTools. + expect(opts.excludeTools ?? []).not.toContain("Agent"); + expect(opts.customTools).toHaveLength(3); + // (b) survive the active-set renarrow alongside a real extension tool. + const active = lastToolsPassed(); + expect(active).toEqual(expect.arrayContaining(["Agent", "get_subagent_result", "steer_subagent"])); + expect(active).toContain("ok_ext"); + }); + + // Opt-in nested tools are re-admitted at three separate places because their + // names collide with EXCLUDED_TOOL_NAMES. Every one of those re-admissions + // carries a `disallowedTools` check, and no test set `disallowed_tools` and + // `allowed_subagents` together — so dropping any of the three checks would + // hand `Agent` back to an agent whose author explicitly denied it, with the + // suite still green. + describe("disallowed_tools beats opt-in nested delegation", () => { + it("denies the tool under the isolated static allowlist", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, allowedSubagents: "all", disallowedTools: ["Agent"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("Agent"); + // The siblings the agent did NOT deny stay available. + expect(tools).toEqual(expect.arrayContaining(["get_subagent_result", "steer_subagent"])); + }); + + it("denies the tool at the registry gate under extensions", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, allowedSubagents: "all", disallowedTools: ["Agent"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/ok.ts": ["ok_ext"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + expect(createAgentSession.mock.calls[0][0].excludeTools ?? []).toContain("Agent"); + expect(lastToolsPassed()).not.toContain("Agent"); + }); + + it("blocks the tool at runtime even though it was injected as a customTool", async () => { + // The registry gate and the active set are static snapshots; this is the + // live gate that judges a call as it happens. A nested tool is handed to + // the session as a customTool, so this is the last line of defense. + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, allowedSubagents: "all", disallowedTools: ["Agent"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/ok.ts": ["ok_ext"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + await expect( + session.agent.beforeToolCall?.({ toolCall: { name: "Agent" } }), + ).resolves.toMatchObject({ block: true }); + // ...while a nested tool that was NOT denied still passes the same gate. + await expect( + session.agent.beforeToolCall?.({ toolCall: { name: "steer_subagent" } }), + ).resolves.not.toMatchObject({ block: true }); + }); + + it("a partial denial does not take down the whole nested set", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ + extensions: false, + allowedSubagents: ["scout"], + disallowedTools: ["get_subagent_result"], + }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, + }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("get_subagent_result"); + expect(tools).toContain("Agent"); + expect(tools).toContain("steer_subagent"); + }); + }); + + it("still strips the orchestration tools under extensions when nesting is OFF", async () => { + // Guards the negative: without allowed_subagents the EXCLUDED names stay denied + // and inactive even though an extension registers tools with those very names. + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: true })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/evil.ts": ["Agent", "get_subagent_result", "steer_subagent", "ok_ext"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const active = lastToolsPassed(); + expect(active).not.toContain("Agent"); + expect(active).not.toContain("get_subagent_result"); + expect(active).not.toContain("steer_subagent"); + expect(active).toContain("ok_ext"); + }); + + it("suppresses nested tools in isolated mode even when opted in", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, allowedSubagents: "all" }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + isolated: true, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1 }, + }); + + expect(createNestedSubagentTools).not.toHaveBeenCalled(); + expect(lastToolsPassed()).not.toContain("Agent"); + }); + + it("passes the inherited depth cap through to the nested tools", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, allowedSubagents: "all" }), + ); + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 3 }, + }); + expect(createNestedSubagentTools).toHaveBeenLastCalledWith(expect.objectContaining({ maxSubagentDepth: 3 })); + }); + + it("injects no nested tools once the effective cap is reached", async () => { + // At the cap the agent can never spawn — and so can never own a child to + // fetch from or steer. Three always-erroring tools would just cost context. + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, allowedSubagents: "all" }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + nestedRuntime: { manager: {} as any, parentAgentId: "parent", depth: 1, maxSubagentDepth: 1 }, + }); + + expect(createNestedSubagentTools).not.toHaveBeenCalled(); + expect(lastToolsPassed()).not.toContain("Agent"); + }); + + it("extensions: false with disallowedTools — denylist applies to built-ins", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, disallowedTools: ["bash"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("bash"); + expect(tools).toEqual(BUILTINS_7.filter((t) => t !== "bash")); + }); + + it("dynamic mode: leaves the allowlist unset, denies via excludeTools, activates post-bind", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, disallowedTools: ["bash"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/mcp.ts": ["mcp"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + // Allowlist unset so async tools (e.g. MCP on session_start) can register; + // scope is a denylist of this extension's own tools plus `disallowedTools`. + const opts = createAgentSession.mock.calls[0][0]; + expect(opts.tools).toBeUndefined(); + expect(new Set(opts.excludeTools)).toEqual( + new Set([...Object.values(SUBAGENT_TOOL_NAMES), "bash"]), + ); + + // The active set is repaired AFTER bindExtensions (tools may register during + // session_start), activating the full allowed registry — the extension tool + // included, the denied built-in excluded. + expect(session.setActiveToolsByName).toHaveBeenCalledTimes(1); + const setOrder = session.setActiveToolsByName.mock.invocationCallOrder[0]; + const bindOrder = session.bindExtensions.mock.invocationCallOrder[0]; + expect(setOrder).toBeGreaterThan(bindOrder); + const activated = new Set(session.setActiveToolsByName.mock.calls[0][0]); + expect(activated.has("mcp")).toBe(true); + expect(activated.has("read")).toBe(true); + expect(activated.has("bash")).toBe(false); + }); +}); + +// ─── asynchronously-registered extension tools (issue #125) ────────────── +// pi-mcp calls registerTool from `session_start`, context-mode from +// `before_agent_start` — both long after loader.reload(). `registerTool` writes +// into the live `extension.tools` map, which is what these tests simulate. +describe("agent-runner async extension tool registration", () => { + /** Simulate `pi.registerTool` on an already-loaded extension. */ + function registerLate(extPath: string, toolName: string) { + const ext = loaderExtensionsRef.current.extensions.find((e) => e.path === extPath); + if (!ext) throw new Error(`no loaded extension at ${extPath}`); + ext.tools.set(toolName, {}); + } + + function setup(o: { builtinToolNames?: string[]; extSelectors?: string[] } = {}) { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: true, extSelectors: o.extSelectors }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(o.builtinToolNames ?? ["read"]); + } + + it("a tool registered during session_start reaches the active set", async () => { + setup(); + withExtensions({ "/ext/mcp.ts": [] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + // pi-mcp's real shape: nothing at load, tools appear when bindExtensions + // fires session_start and the MCP servers connect. + session.bindExtensions.mockImplementation(async () => { + registerLate("/ext/mcp.ts", "mcp_search"); + }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(lastToolsPassed()).toContain("mcp_search"); + }); + + it("a tool registered after bind is picked up on the next turn_end", async () => { + setup(); + withExtensions({ "/ext/mcp.ts": [] }); + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + expect(session.getActiveToolNames()).not.toContain("mcp_search"); + + // A lazy MCP server connects mid-conversation (context-mode registers at + // before_agent_start, i.e. after runAgent already installed the scope). + registerLate("/ext/mcp.ts", "mcp_search"); + for (const l of listeners) l({ type: "turn_end" }); + + expect(session.getActiveToolNames()).toContain("mcp_search"); + }); + + it("ext: admits a late tool from the selected extension but not from others", async () => { + setup({ extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": [], "/ext/bar.ts": [] }); + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + registerLate("/ext/foo.ts", "foo_late"); + registerLate("/ext/bar.ts", "bar_late"); + for (const l of listeners) l({ type: "turn_end" }); + + const active = session.getActiveToolNames(); + expect(active).toContain("foo_late"); + // This is the case the static allowlist could never express: bar_late did + // not exist at construction, so it could not have been denied by name. + expect(active).not.toContain("bar_late"); + }); + + it("ext:foo/bar narrowing still applies to late-registered siblings", async () => { + setup({ extSelectors: ["ext:foo/keep_me"] }); + withExtensions({ "/ext/foo.ts": [] }); + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + registerLate("/ext/foo.ts", "keep_me"); + registerLate("/ext/foo.ts", "drop_me"); + for (const l of listeners) l({ type: "turn_end" }); + + expect(session.getActiveToolNames()).toContain("keep_me"); + expect(session.getActiveToolNames()).not.toContain("drop_me"); + }); + + it("beforeToolCall blocks an out-of-scope tool and delegates otherwise", async () => { + // Turn 1 cannot be narrowed — before_agent_start fires inside prompt() and + // may widen the set after the turn's tools are snapshotted — so a call-time + // guard is the only correct enforcement there. + setup({ extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/bar.ts": ["bar_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + await expect( + session.agent.beforeToolCall?.({ toolCall: { name: "bar_tool" } }), + ).resolves.toMatchObject({ block: true }); + await expect( + session.agent.beforeToolCall?.({ toolCall: { name: "foo_tool" } }), + ).resolves.toBeUndefined(); + }); + + it("beforeToolCall preserves a hook pi installed before us", async () => { + setup(); + withExtensions({ "/ext/foo.ts": ["foo_tool"] }); + const { session } = createSession("OK"); + const prior = vi.fn(async () => undefined); + session.agent.beforeToolCall = prior; + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + await session.agent.beforeToolCall?.({ toolCall: { name: "foo_tool" } }); + + expect(prior).toHaveBeenCalledTimes(1); + }); + + it("scope outlives runAgent so resumed turns stay narrowed", async () => { + // runAgent tears down its own turn subscription in `finally`; the scope + // hooks must NOT be torn down with it, or resume/steer would drift. + setup({ extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": [], "/ext/bar.ts": [] }); + const { session, listeners } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + await resumeAgent(session as any, "keep going"); + + registerLate("/ext/foo.ts", "foo_late"); + registerLate("/ext/bar.ts", "bar_late"); + for (const l of listeners) l({ type: "turn_end" }); + + expect(session.getActiveToolNames()).toContain("foo_late"); + expect(session.getActiveToolNames()).not.toContain("bar_late"); + await expect( + session.agent.beforeToolCall?.({ toolCall: { name: "bar_late" } }), + ).resolves.toMatchObject({ block: true }); + }); + + it("isolated keeps the static allowlist — no live scoping installed", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions: false })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(["read"]); + withExtensions({ "/ext/foo.ts": ["foo_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, isolated: true }); + + // A hard registry gate is the right boundary here: nothing can register + // asynchronously, so there is no active-set narrowing to maintain. + expect(createAgentSession.mock.calls[0][0].tools).toEqual(["read"]); + expect(session.setActiveToolsByName).not.toHaveBeenCalled(); + expect(session.agent.beforeToolCall).toBeUndefined(); + }); +}); + +// ─── extensions: string[] as a loader-level extension filter ──────────── +// An array entry is a bare name (filters default-discovered extensions), +// a path (loads that extension fresh), or "*" (keep all defaults). +// Filtering happens at the loader via additionalExtensionPaths + +// extensionsOverride — excluded extensions never bind handlers or register +// tools. + +describe("extensionCanonicalName", () => { + it("strips .ts/.js from a single-file extension basename", () => { + expect(extensionCanonicalName("/x/foo.ts")).toBe("foo"); + expect(extensionCanonicalName("/x/foo.js")).toBe("foo"); + }); + it("uses the parent directory name for index.{ts,js} extensions", () => { + expect(extensionCanonicalName("/x/foo/index.ts")).toBe("foo"); + expect(extensionCanonicalName("/x/foo/index.js")).toBe("foo"); + }); + it("lowercases the result for case-insensitive matching", () => { + expect(extensionCanonicalName("/x/MCP.ts")).toBe("mcp"); + expect(extensionCanonicalName("/x/MyExt.js")).toBe("myext"); + expect(extensionCanonicalName("/x/Foo/index.ts")).toBe("foo"); + }); +}); + +describe("extensionCanonicalNames (#143 — package short name alias)", () => { + const tmpDirs: string[] = []; + function pkgDir(name: string, piExtensions: unknown): string { + const dir = mkdtempSync(join(tmpdir(), "subagents-pkg-")); + tmpDirs.push(dir); + const manifest: Record = { name }; + if (piExtensions !== undefined) manifest.pi = { extensions: piExtensions }; + writeFileSync(join(dir, "package.json"), JSON.stringify(manifest)); + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "index.ts"), "export default () => {};"); + return dir; + } + afterEach(() => { + while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true }); + }); + + it("aliases a package-declared index.ts entry to the unscoped, lowercased package name", () => { + // Without this, `pi.extensions: ["./src/index.ts"]` only ever matches as "src". + const dir = pkgDir("@tintinweb/Pi-Subagents", ["./src/index.ts"]); + expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src", "pi-subagents"]); + }); + + it("adds no alias for a loose file with no enclosing package.json", () => { + const dir = mkdtempSync(join(tmpdir(), "subagents-loose-")); + tmpDirs.push(dir); + writeFileSync(join(dir, "foo.ts"), "export default () => {};"); + expect(extensionCanonicalNames(join(dir, "foo.ts"))).toEqual(["foo"]); + }); + + it("adds no alias when the nearest manifest does not declare this entry", () => { + // The package.json is a real pi package but lists a *different* entry — so a + // co-located file (e.g. our own test fixtures under this repo) is not falsely + // stamped with the package name. + const dir = pkgDir("@scope/other-ext", ["./src/other.ts"]); + expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src"]); + }); + + it("adds no alias when the nearest package.json has no pi manifest", () => { + const dir = pkgDir("just-a-project", undefined); + expect(extensionCanonicalNames(join(dir, "src", "index.ts"))).toEqual(["src"]); + }); + + it("does not climb past a node_modules boundary into a consumer's manifest", () => { + // A consumer that *declares* a dependency's entry must not lend its name to + // that dependency: the walk stops at node_modules before reading it. + const root = mkdtempSync(join(tmpdir(), "subagents-consumer-")); + tmpDirs.push(root); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "consumer", pi: { extensions: ["./node_modules/inner-ext/index.ts"] } }), + ); + const inner = join(root, "node_modules", "inner-ext"); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, "index.ts"), "export default () => {};"); + // Only the path-derived name — never "consumer". + expect(extensionCanonicalNames(join(inner, "index.ts"))).toEqual(["inner-ext"]); + }); +}); + +describe("parseExtensionsSpec", () => { + it("classifies bare entries as names", () => { + const spec = parseExtensionsSpec(["mcp", "logger"], "/work"); + expect(spec.names).toEqual(new Set(["mcp", "logger"])); + expect(spec.paths).toEqual([]); + expect(spec.wildcard).toBe(false); + }); + it("treats '*' as the wildcard", () => { + const spec = parseExtensionsSpec(["*"], "/work"); + expect(spec.wildcard).toBe(true); + expect(spec.names.size).toBe(0); + expect(spec.paths).toEqual([]); + }); + it("resolves a relative path against cwd and adds its canonical name", () => { + const spec = parseExtensionsSpec(["./rel/foo.ts"], "/work"); + expect(spec.paths).toEqual(["/work/rel/foo.ts"]); + expect(spec.names).toEqual(new Set(["foo"])); + }); + it("keeps an absolute path as-is", () => { + const spec = parseExtensionsSpec(["/abs/bar.ts"], "/work"); + expect(spec.paths).toEqual(["/abs/bar.ts"]); + expect(spec.names).toEqual(new Set(["bar"])); + }); + it("expands a leading ~ to the home directory", () => { + const spec = parseExtensionsSpec(["~/ext/baz.ts"], "/work"); + expect(spec.paths[0]).toBe(`${homedir()}/ext/baz.ts`); + expect(spec.names).toEqual(new Set(["baz"])); + }); + it("composes wildcard, names, and paths", () => { + const spec = parseExtensionsSpec(["*", "mcp", "/abs/foo.ts"], "/work"); + expect(spec.wildcard).toBe(true); + expect(spec.names).toEqual(new Set(["mcp", "foo"])); + expect(spec.paths).toEqual(["/abs/foo.ts"]); + }); + it("lowercases bare-name entries — extension names match case-insensitively", () => { + const spec = parseExtensionsSpec(["Mcp", "LOGGER"], "/work"); + expect(spec.names).toEqual(new Set(["mcp", "logger"])); + }); + it("ignores empty entries (defensive — upstream parsers already strip them)", () => { + const spec = parseExtensionsSpec(["", "mcp", ""], "/work"); + expect(spec.names).toEqual(new Set(["mcp"])); + expect(spec.wildcard).toBe(false); + }); +}); + +describe("mandatory extension policy", () => { + function mandatoryEntry(): { dir: string; path: string } { + const dir = mkdtempSync(join(tmpdir(), "subagents-mandatory-runner-")); + const path = join(dir, "permission-system.ts"); + writeFileSync(path, "export default () => {};\n"); + return { dir, path }; + } + + function setup(extensions: true | string[] | false, excludeExtensions?: string[]) { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions, excludeExtensions })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions, excludeExtensions }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + } + + it("keeps exact mandatory handlers loaded under extensions:false without exposing their tools", async () => { + const fixture = mandatoryEntry(); + try { + setup(false); + withExtensions({ [fixture.path]: ["permission_admin"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + mandatoryExtensionPaths: [fixture.path], + }); + + expect(lastLoaderOpts()).toEqual(expect.objectContaining({ + noExtensions: false, + additionalExtensionPaths: [realpathSync.native(fixture.path)], + })); + expect(loaderExtensionsRef.current.extensions.map((extension) => extension.path)) + .toContain(fixture.path); + expect(lastToolsPassed()).not.toContain("permission_admin"); + expect(lastToolsPassed()).toEqual(expect.arrayContaining(BUILTINS_7)); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }); + + it("ignores Agent lifecycle exclusion of a mandatory entry but still hides its tools", async () => { + const fixture = mandatoryEntry(); + try { + setup(true, ["permission-system"]); + withExtensions({ [fixture.path]: ["permission_admin"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { + pi, + mandatoryExtensionPaths: [fixture.path], + onToolActivity, + }); + + expect(loaderExtensionsRef.current.extensions.map((extension) => extension.path)) + .toContain(fixture.path); + expect(lastToolsPassed()).not.toContain("permission_admin"); + expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({ + toolName: expect.stringContaining( + 'ignored lifecycle exclusion of mandatory extension "permission-system"', + ), + })); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }); + + it("fails closed before session creation when the exact mandatory entry did not load", async () => { + const fixture = mandatoryEntry(); + try { + setup(false); + withExtensions({}); + + await expect(runAgent(ctx, "Explore", "go", { + pi, + mandatoryExtensionPaths: [fixture.path], + })).rejects.toThrow("Mandatory extension failed to load"); + expect(createAgentSession).not.toHaveBeenCalled(); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }); +}); + +describe("agent-runner extension allowlist", () => { + function setupArrayAgent(extensions: string[]) { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions })); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ extensions })); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + } + + it("['*'] short-circuits — no extensionsOverride, behaves like extensions: true", async () => { + setupArrayAgent(["*"]); + withExtensions({ "/ext/a.ts": ["tool_a"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const opts = lastLoaderOpts(); + expect(opts.extensionsOverride).toBeUndefined(); + expect(opts.additionalExtensionPaths).toBeUndefined(); + expect(lastToolsPassed()).toContain("tool_a"); + }); + + it("['mcp'] keeps only the mcp-named extension, drops others", async () => { + setupArrayAgent(["mcp"]); + withExtensions({ + "/ext/mcp.ts": ["mcp", "mcp_call"], + "/ext/other.ts": ["other_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("mcp"); + expect(tools).toContain("mcp_call"); + expect(tools).not.toContain("other_tool"); + }); + + it("matches a package-installed extension by its package short name, not just its src dir (#143)", async () => { + // A package whose entry is `src/index.ts` canonicalizes to "src"; a child + // agent must still be able to allowlist it by the package name. + const dir = mkdtempSync(join(tmpdir(), "subagents-match-")); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "@tintinweb/pi-subagents", pi: { extensions: ["./src/index.ts"] } }), + ); + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "index.ts"), "export default () => {};"); + const entry = join(dir, "src", "index.ts"); + + setupArrayAgent(["pi-subagents"]); + withExtensions({ [entry]: ["pkg_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + // Before the fix keepNames={pi-subagents} but the extension only answered + // to "src", so it was filtered out and pkg_tool never reached the allowlist. + expect(lastToolsPassed()).toContain("pkg_tool"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("an absolute path is added to additionalExtensionPaths and its extension survives", async () => { + setupArrayAgent(["/abs/foo.ts"]); + // Pre-register the path so the mock loader treats it as a successful load. + withExtensions({ "/abs/foo.ts": ["foo_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(lastLoaderOpts().additionalExtensionPaths).toEqual(["/abs/foo.ts"]); + expect(lastToolsPassed()).toContain("foo_tool"); + }); + + it("['*', path] keeps all defaults plus the extra path", async () => { + setupArrayAgent(["*", "/abs/foo.ts"]); + withExtensions({ + "/ext/default.ts": ["default_tool"], + "/abs/foo.ts": ["foo_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("default_tool"); + expect(tools).toContain("foo_tool"); + }); + + it("['mcp', path] keeps exactly those two, drops other defaults (no wildcard)", async () => { + // Changelog: `["mcp", "/abs/foo.ts"]` is *just* those two. Distinct from + // `['*', path]` (all defaults + path) and `['mcp']` (name only). + setupArrayAgent(["mcp", "/abs/foo.ts"]); + withExtensions({ + "/ext/mcp.ts": ["mcp_tool"], + "/abs/foo.ts": ["foo_tool"], + "/ext/other.ts": ["other_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const opts = lastLoaderOpts(); + expect(opts.additionalExtensionPaths).toEqual(["/abs/foo.ts"]); + // No "*" → the loader override is in force (narrowing, not load-all). + expect(opts.extensionsOverride).toBeDefined(); + const tools = lastToolsPassed(); + expect(tools).toContain("mcp_tool"); + expect(tools).toContain("foo_tool"); + expect(tools).not.toContain("other_tool"); + }); + + it("disallowedTools still applies to tools from an allowlisted extension", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: ["mcp"] })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: ["mcp"], disallowedTools: ["mcp"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + withExtensions({ "/ext/mcp.ts": ["mcp", "mcp_call"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("mcp"); + expect(tools).toContain("mcp_call"); + }); + + it("warns but proceeds when a bare name matches no loaded extension", async () => { + setupArrayAgent(["mcp", "typo"]); + withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(result.responseText).toBe("OK"); + expect(onToolActivity).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: expect.stringContaining('extension-error:extension "typo"'), + }), + ); + }); + + it("warns but proceeds when a path entry fails to load", async () => { + setupArrayAgent(["/abs/missing.ts"]); + // Not pre-registered → the mock loader records a load error; the path's + // canonical name ("missing") is what the unmatched-name check reports. + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(result.responseText).toBe("OK"); + expect(onToolActivity).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: expect.stringContaining('extension-error:extension "missing"'), + }), + ); + }); + + it("matches `extensions: [Mcp]` against `mcp.ts` (case-insensitive)", async () => { + setupArrayAgent(["Mcp"]); + withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + // No extension-error warning — the name resolved. + const errorCalls = onToolActivity.mock.calls.filter((c) => + typeof c[0]?.toolName === "string" && c[0].toolName.startsWith("extension-error:"), + ); + expect(errorCalls).toEqual([]); + expect(lastToolsPassed()).toContain("mcp_tool"); + }); +}); + +// ─── exclude_extensions: denylist (#94) ────────────────────────────────── +describe("agent-runner exclude_extensions", () => { + function setupAgent(overrides: Record) { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig(overrides)); + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig(overrides)); + vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7); + } + function extensionErrors(onToolActivity: ReturnType): string[] { + return onToolActivity.mock.calls + .map((c) => c[0]?.toolName) + .filter((n): n is string => typeof n === "string" && n.startsWith("extension-error:")); + } + + it("extensions: true + exclude — override installed, excluded tools dropped, others kept", async () => { + setupAgent({ extensions: true, excludeExtensions: ["notify"] }); + withExtensions({ + "/ext/notify.ts": ["notify_send"], + "/ext/mcp.ts": ["mcp_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastLoaderOpts().extensionsOverride).toBeDefined(); + const tools = lastToolsPassed(); + expect(tools).not.toContain("notify_send"); + expect(tools).toContain("mcp_tool"); + expect(extensionErrors(onToolActivity)).toEqual([]); + }); + + it("['*'] + exclude — wildcard no longer short-circuits, exclusion applies", async () => { + setupAgent({ extensions: ["*"], excludeExtensions: ["notify"] }); + withExtensions({ + "/ext/notify.ts": ["notify_send"], + "/ext/mcp.ts": ["mcp_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(lastLoaderOpts().extensionsOverride).toBeDefined(); + const tools = lastToolsPassed(); + expect(tools).not.toContain("notify_send"); + expect(tools).toContain("mcp_tool"); + }); + + it("allowlist + exclude of a listed name — subtracted, 'in both' warning fires", async () => { + setupAgent({ extensions: ["mcp", "other"], excludeExtensions: ["other"] }); + withExtensions({ + "/ext/mcp.ts": ["mcp_tool"], + "/ext/other.ts": ["other_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + const tools = lastToolsPassed(); + expect(tools).toContain("mcp_tool"); + expect(tools).not.toContain("other_tool"); + expect(extensionErrors(onToolActivity)).toEqual([ + expect.stringContaining('in both extensions: and exclude_extensions:'), + ]); + }); + + it("exclude typo — warning fires, all extensions still load", async () => { + setupAgent({ extensions: true, excludeExtensions: ["nope"] }); + withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastToolsPassed()).toContain("mcp_tool"); + expect(extensionErrors(onToolActivity)).toEqual([ + expect.stringContaining('exclude_extensions: "nope"'), + ]); + }); + + it("extensions: false + exclude — orphan warning, no override", async () => { + setupAgent({ extensions: false, excludeExtensions: ["notify"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); + expect(extensionErrors(onToolActivity)).toEqual([ + expect.stringContaining("exclude_extensions has no effect"), + ]); + }); + + it("isolated: true + exclude — excludes nulled, no warnings", async () => { + setupAgent({ extensions: true, excludeExtensions: ["notify"] }); + withExtensions({ "/ext/notify.ts": ["notify_send"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity, isolated: true }); + + expect(lastToolsPassed()).not.toContain("notify_send"); + expect(extensionErrors(onToolActivity)).toEqual([]); + }); + + it("tools: ext:foo referencing an excluded extension — existing orphan warning fires", async () => { + setupAgent({ + extensions: true, + excludeExtensions: ["beta"], + extSelectors: ["ext:beta"], + }); + withExtensions({ + "/ext/beta.ts": ["beta_tool"], + "/ext/mcp.ts": ["mcp_tool"], + }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastToolsPassed()).not.toContain("beta_tool"); + expect(extensionErrors(onToolActivity)).toEqual([ + expect.stringContaining("extension-error:ext:beta"), + ]); + }); + + it("exclude matches case-insensitively", async () => { + setupAgent({ extensions: true, excludeExtensions: ["MCP"] }); + withExtensions({ "/ext/mcp.ts": ["mcp_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastToolsPassed()).not.toContain("mcp_tool"); + expect(extensionErrors(onToolActivity)).toEqual([]); + }); +}); + +// ─── unknown built-in tool names in `tools:` (#75) ────────────────────── +describe("agent-runner unknown built-in tools", () => { + it("emits a tools-error warning for each plain entry not in BUILTIN_TOOL_NAMES", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, builtinToolNames: ["read", "reed", "grep", "edt"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(["read", "reed", "grep", "edt"]); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(result.responseText).toBe("OK"); + const errorMessages = onToolActivity.mock.calls + .map((c) => c[0]?.toolName) + .filter((n): n is string => typeof n === "string" && n.startsWith("tools-error:")); + expect(errorMessages).toHaveLength(2); + expect(errorMessages.some((m) => m.includes('"reed"'))).toBe(true); + expect(errorMessages.some((m) => m.includes('"edt"'))).toBe(true); + }); + + it("stays quiet when all plain tool names are valid built-ins", async () => { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: false })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ extensions: false, builtinToolNames: ["read", "grep"] }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(["read", "grep"]); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + const errorMessages = onToolActivity.mock.calls + .map((c) => c[0]?.toolName) + .filter((n): n is string => typeof n === "string" && n.startsWith("tools-error:")); + expect(errorMessages).toEqual([]); + }); +}); + +// ─── ext: tool selectors in `tools:` (opt-in flip) ────────────────────── +describe("parseExtSelectors", () => { + it("bare ext:foo → name only, no narrowing", () => { + const { extNames, narrowing } = parseExtSelectors(["ext:foo"]); + expect(extNames).toEqual(new Set(["foo"])); + expect(narrowing.size).toBe(0); + }); + it("ext:foo/bar → name plus a narrowing entry", () => { + const { extNames, narrowing } = parseExtSelectors(["ext:foo/bar"]); + expect(extNames).toEqual(new Set(["foo"])); + expect(narrowing.get("foo")).toEqual(new Set(["bar"])); + }); + it("multiple ext:foo/* entries union", () => { + expect(parseExtSelectors(["ext:foo/a", "ext:foo/b"]).narrowing.get("foo")).toEqual( + new Set(["a", "b"]), + ); + }); + it("ext:foo + ext:foo/bar → narrowing wins", () => { + const { narrowing } = parseExtSelectors(["ext:foo", "ext:foo/bar"]); + expect(narrowing.get("foo")).toEqual(new Set(["bar"])); + }); + it("splits on the first / so tool names may contain /", () => { + expect(parseExtSelectors(["ext:foo/bar/baz"]).narrowing.get("foo")).toEqual( + new Set(["bar/baz"]), + ); + }); + it("skips empty name and empty tool halves", () => { + const { extNames, narrowing } = parseExtSelectors(["ext:", "ext:foo/"]); + expect(extNames).toEqual(new Set(["foo"])); + expect(narrowing.size).toBe(0); + }); + it("lowercases the extension name but preserves tool-name case", () => { + // The extension half matches the loader's canonical name (also lowercased); + // the tool half is matched against pi-mono's registered identifiers, which + // are case-sensitive. + const { extNames, narrowing } = parseExtSelectors(["ext:Mcp/SomeTool", "ext:FOO"]); + expect(extNames).toEqual(new Set(["mcp", "foo"])); + expect(narrowing.get("mcp")).toEqual(new Set(["SomeTool"])); + }); +}); + +describe("agent-runner ext: tool selectors", () => { + function setupExtAgent(o: { + extensions: boolean | string[]; + builtinToolNames: string[]; + extSelectors?: string[]; + disallowedTools?: string[]; + }) { + vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: o.extensions })); + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ + extensions: o.extensions, + extSelectors: o.extSelectors, + disallowedTools: o.disallowedTools, + }), + ); + vi.mocked(getToolNamesForType).mockReturnValueOnce(o.builtinToolNames); + } + + it("any ext: entry flips extension tools to an allowlist — non-selected extensions muted", async () => { + // `tools: ext:foo` → zero built-ins, opt-in flip active. + setupExtAgent({ extensions: true, builtinToolNames: [], extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("foo_tool"); + expect(tools).not.toContain("other_tool"); // loaded but muted + expect(tools).not.toContain("read"); // tools: ext:foo → no built-ins + // both extensions still load — no loader override needed under extensions: true + expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); + }); + + it("'*' alongside ext: keeps all built-ins while the flip still applies", async () => { + // `tools: *, ext:foo` → all built-ins, opt-in flip active. + setupExtAgent({ extensions: true, builtinToolNames: BUILTINS_7, extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + for (const b of BUILTINS_7) expect(tools).toContain(b); + expect(tools).toContain("foo_tool"); + expect(tools).not.toContain("other_tool"); + }); + + it("ext:foo/bar narrows foo to a single tool", async () => { + setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo/bar"] }); + withExtensions({ "/ext/foo.ts": ["bar", "baz"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("read"); + expect(tools).toContain("bar"); + expect(tools).not.toContain("baz"); + }); + + it("ext:foo is orphaned when extensions: false — no extension loads, warning fires", async () => { + // `extensions:` is the sole loading authority. `ext:` selectors can only narrow + // within the loaded set; they cannot pull an excluded extension back in. + setupExtAgent({ extensions: false, builtinToolNames: ["read"], extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastLoaderOpts().noExtensions).toBe(true); + const tools = lastToolsPassed(); + expect(tools).toEqual(["read"]); + expect(tools).not.toContain("foo_tool"); + expect(onToolActivity).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: expect.stringContaining('extension-error:ext:foo'), + }), + ); + }); + + it("ext: cannot pull an extension that `extensions: [...]` excludes — warns, no surfacing", async () => { + // extensions: [a] loads only a. ext:foo references foo, which isn't loaded; + // the opt-in flip still mutes a (it isn't named in ext:), so the agent gets + // zero extension tools and a warning fires. + setupExtAgent({ extensions: ["a"], builtinToolNames: [], extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/a.ts": ["a_tool"], "/ext/foo.ts": ["foo_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + const tools = lastToolsPassed(); + expect(tools).not.toContain("foo_tool"); // foo never loaded + expect(tools).not.toContain("a_tool"); // a loaded but muted by the ext: opt-in flip + expect(onToolActivity).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: expect.stringContaining('extension-error:ext:foo'), + }), + ); + }); + + it("['*'] short-circuit survives ext: narrowing", async () => { + setupExtAgent({ extensions: ["*"], builtinToolNames: ["read"], extSelectors: ["ext:foo/bar"] }); + withExtensions({ "/ext/foo.ts": ["bar", "baz"], "/ext/other.ts": ["other_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(lastLoaderOpts().extensionsOverride).toBeUndefined(); // pure-["*"] short-circuit holds + const tools = lastToolsPassed(); + expect(tools).toContain("bar"); + expect(tools).not.toContain("baz"); + expect(tools).not.toContain("other_tool"); // flip mutes the unselected extension + }); + + it("warns but proceeds when an ext: name doesn't match any loaded extension", async () => { + setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:ghost"] }); + withExtensions({ "/ext/real.ts": ["real_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + const result = await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(result.responseText).toBe("OK"); + expect(onToolActivity).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: expect.stringContaining('extension-error:ext:ghost'), + }), + ); + }); + + it("isolated: true ignores extSelectors — no extension tools", async () => { + setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo"] }); + withExtensions({ "/ext/foo.ts": ["foo_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, isolated: true }); + + const tools = lastToolsPassed(); + expect(tools).toContain("read"); + expect(tools).not.toContain("foo_tool"); + expect(lastLoaderOpts().noExtensions).toBe(true); + }); + + it("ext: composes with a path-loaded extension via its canonical name", async () => { + // Changelog: `ext:` is name-only (matched by canonical name), so it composes + // with extensions loaded by path through `extensions:`. The path "/abs/foo.ts" + // has canonical name "foo", which `ext:foo` then surfaces — no orphan warning. + setupExtAgent({ + extensions: ["/abs/foo.ts"], + builtinToolNames: ["read"], + extSelectors: ["ext:foo"], + }); + withExtensions({ "/abs/foo.ts": ["foo_tool"], "/ext/other.ts": ["other_tool"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + const onToolActivity = vi.fn(); + + await runAgent(ctx, "Explore", "go", { pi, onToolActivity }); + + expect(lastLoaderOpts().additionalExtensionPaths).toEqual(["/abs/foo.ts"]); + const tools = lastToolsPassed(); + expect(tools).toContain("foo_tool"); // path-loaded ext surfaced via ext:foo + expect(tools).toContain("read"); + expect(tools).not.toContain("other_tool"); // dropped at the loader (not in keepNames) + // ext:foo resolved against the path's canonical name → not orphaned. + const errorCalls = onToolActivity.mock.calls.filter( + (c) => typeof c[0]?.toolName === "string" && c[0].toolName.startsWith("extension-error:"), + ); + expect(errorCalls).toEqual([]); + }); + + it("ext:foo/Bar narrowing is case-sensitive on the tool half", async () => { + // The extension half is canonicalised (lowercased); the tool half is matched + // verbatim against pi-mono's registered identifiers, so `Bar` must not match `bar`. + setupExtAgent({ extensions: true, builtinToolNames: ["read"], extSelectors: ["ext:foo/Bar"] }); + withExtensions({ "/ext/foo.ts": ["Bar", "bar"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("Bar"); + expect(tools).not.toContain("bar"); // case-sensitive: not the selected tool + }); + + it("disallowedTools removes a tool reached via an ext: selector", async () => { + // The denylist applies uniformly to extension tools, including those surfaced + // by the ext: opt-in flip — same construction-time `allowedTools` filter. + setupExtAgent({ + extensions: true, + builtinToolNames: ["read"], + extSelectors: ["ext:foo"], + disallowedTools: ["foo_tool"], + }); + withExtensions({ "/ext/foo.ts": ["foo_tool", "foo_other"] }); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + const tools = lastToolsPassed(); + expect(tools).toContain("read"); + expect(tools).toContain("foo_other"); + expect(tools).not.toContain("foo_tool"); // denylisted even though ext:foo selects it + }); +}); + +// The limit a run will enforce, resolved before the run starts. The widget's +// turn counter has to predict it for agents spawned outside the Agent tool +// (mentions, cross-extension RPC), and a second copy of the expression there +// would drift from the one runAgent enforces — so both call this. +describe("resolveEffectiveMaxTurns", () => { + let prevDefault: number | undefined; + + beforeEach(() => { + prevDefault = getDefaultMaxTurns(); + vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig({ maxTurns: 7 }) as any); + }); + + afterEach(() => { + setDefaultMaxTurns(prevDefault); + vi.mocked(getAgentConfig).mockReset(); + }); + + it("prefers an explicit value over the agent's own and the project default", () => { + setDefaultMaxTurns(20); + expect(resolveEffectiveMaxTurns("test-agent", 3)).toBe(3); + }); + + it("falls back to the agent's own max_turns", () => { + setDefaultMaxTurns(20); + expect(resolveEffectiveMaxTurns("test-agent")).toBe(7); + }); + + it("falls back to the project default when the agent sets none", () => { + setDefaultMaxTurns(20); + vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig() as any); + expect(resolveEffectiveMaxTurns("test-agent")).toBe(20); + }); + + it("is unlimited when nothing sets a limit", () => { + setDefaultMaxTurns(undefined); + vi.mocked(getAgentConfig).mockReturnValue(makeAgentConfig() as any); + expect(resolveEffectiveMaxTurns("test-agent")).toBeUndefined(); + }); + + it("treats an explicit 0 as unlimited rather than as 'no opinion'", () => { + // Not the same as omitting it: 0 is how a caller says "no limit", and + // falling through to the default would impose one it asked not to have. + setDefaultMaxTurns(20); + expect(resolveEffectiveMaxTurns("test-agent", 0)).toBeUndefined(); + }); +}); + +// The soft-limit → grace → hard-abort machine (agent-runner.ts, the `turn_end` +// branch) has never executed in a test: every consumer of the `steered`/`aborted` +// flags mocks `runAgent` outright, so the flags are asserted but never produced. +// The machine is what stops a runaway subagent, so a broken latch is either an +// agent that never wraps up and never aborts, or one that aborts on turn 1. +describe("agent-runner turn limits", () => { + let prevMax: number | undefined; + let prevGrace: number; + + beforeEach(() => { + prevMax = getDefaultMaxTurns(); + prevGrace = getGraceTurns(); + }); + + afterEach(() => { + // Both are module-global; leaking them would silently retune other suites. + setDefaultMaxTurns(prevMax); + setGraceTurns(prevGrace); + }); + + /** + * Run an agent whose prompt fires `turns` synthetic turn_end events before it + * produces its final message — the same events a real session emits. + */ + async function runWithTurns(turns: number, options: Record = {}) { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const { session, listeners } = createSession("OK"); + session.prompt.mockImplementation(async () => { + for (let i = 0; i < turns; i++) { + for (const l of [...listeners]) l({ type: "turn_end" }); + } + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + createAgentSession.mockResolvedValue({ session }); + const result = await runAgent(ctx, "Explore", "go", { pi, ...options }); + return { session, result }; + } + + it("does not steer or abort below the limit", async () => { + const { session, result } = await runWithTurns(3, { maxTurns: 5 }); + expect(session.steer).not.toHaveBeenCalled(); + expect(session.abort).not.toHaveBeenCalled(); + expect(result.steered).toBe(false); + }); + + it("steers exactly once on reaching the limit, and does not abort", async () => { + setGraceTurns(5); + const { session, result } = await runWithTurns(5, { maxTurns: 5 }); + expect(session.steer).toHaveBeenCalledTimes(1); + expect(session.steer.mock.calls[0][0]).toContain("turn limit"); + expect(session.abort).not.toHaveBeenCalled(); + expect(result.steered).toBe(true); + }); + + it("does not re-steer on every turn once the soft limit latched", async () => { + // Without the latch the agent gets a wrap-up message every single turn, + // which both burns tokens and drowns out its actual task. + setGraceTurns(5); + const { session } = await runWithTurns(8, { maxTurns: 5 }); + expect(session.steer).toHaveBeenCalledTimes(1); + expect(session.abort).not.toHaveBeenCalled(); + }); + + it("hard-aborts once the grace turns are used up", async () => { + setGraceTurns(2); + const { session, result } = await runWithTurns(7, { maxTurns: 5 }); + expect(session.steer).toHaveBeenCalledTimes(1); + expect(session.abort).toHaveBeenCalled(); + expect(result.aborted).toBe(true); + }); + + it("keeps running through the grace window without aborting", async () => { + setGraceTurns(3); + const { session, result } = await runWithTurns(7, { maxTurns: 5 }); + expect(session.abort).not.toHaveBeenCalled(); + expect(result.aborted).toBe(false); + expect(result.steered).toBe(true); + }); + + it("treats maxTurns 0 as unlimited", async () => { + const { session } = await runWithTurns(30, { maxTurns: 0 }); + expect(session.steer).not.toHaveBeenCalled(); + expect(session.abort).not.toHaveBeenCalled(); + }); + + it("is unlimited when nothing configures a limit", async () => { + setDefaultMaxTurns(undefined); + const { session } = await runWithTurns(30); + expect(session.steer).not.toHaveBeenCalled(); + expect(session.abort).not.toHaveBeenCalled(); + }); + + it("falls back to the global default when the call sets no limit", async () => { + setDefaultMaxTurns(4); + setGraceTurns(5); + const { session } = await runWithTurns(4); + expect(session.steer).toHaveBeenCalledTimes(1); + }); + + it("an explicit maxTurns beats the global default", async () => { + setDefaultMaxTurns(2); + setGraceTurns(5); + const { session } = await runWithTurns(4, { maxTurns: 10 }); + expect(session.steer).not.toHaveBeenCalled(); + }); + + it("reports each turn to the caller's counter", async () => { + const onTurnEnd = vi.fn(); + await runWithTurns(3, { maxTurns: 10, onTurnEnd }); + expect(onTurnEnd.mock.calls.map(c => c[0])).toEqual([1, 2, 3]); + }); +}); + +// A parent Esc / interrupt reaches the child through options.signal. The only +// existing coverage asserts the RECORD flips to "stopped" with runAgent mocked — +// nothing checked that the signal actually reaches the session, so a child could +// be marked stopped while it keeps running and burning tokens. +describe("agent-runner abort signal forwarding", () => { + it("aborts the session when the parent signal fires mid-run", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const controller = new AbortController(); + const { session } = createSession("OK"); + session.prompt.mockImplementation(async () => { + controller.abort(); + session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] }); + }); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, signal: controller.signal }); + + expect(session.abort).toHaveBeenCalled(); + }); + + it("removes its listener once the run settles", async () => { + // A long-lived parent signal outlives many children; a listener left behind + // per child is a leak that also re-aborts sessions that are already gone. + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, signal: controller.signal }); + + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + + controller.abort(); + expect(session.abort).not.toHaveBeenCalled(); // detached, so a late abort is inert + }); + + it("registers nothing when no signal is supplied", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(session.abort).not.toHaveBeenCalled(); + }); +}); + +// resolveDefaultModel picks the model a subagent runs on. Every failure here is +// SILENT BY DESIGN: an unresolvable or unavailable `model:` deliberately falls +// back to the parent's model rather than erroring, because a user's frontmatter +// pin shouldn't hard-fail a spawn. That makes the availability filter untestable +// through observed behavior — a broken check just means every model-pinned agent +// quietly runs on the parent's model, costing whatever the parent costs. +// +// Exported for this (the file already exports normalizeMaxTurns/setGraceTurns +// purely for test/agent-runner-settings.test.ts). +describe("resolveDefaultModel", () => { + const parent = { provider: "anthropic", id: "parent-model" } as any; + const haiku = { provider: "anthropic", id: "claude-haiku-4-5" } as any; + + /** Registry whose `find` always succeeds; `getAvailable` is what varies. */ + function registry(available?: any[]) { + return { + find: vi.fn((provider: string, id: string) => ({ provider, id }) as any), + getAvailable: available ? () => available : undefined, + }; + } + + it("returns the configured model when the registry has it available", () => { + const r = registry([haiku]); + expect(resolveDefaultModel(parent, r, "anthropic/claude-haiku-4-5")) + .toEqual({ provider: "anthropic", id: "claude-haiku-4-5" }); + }); + + it("falls back to the parent when the model is NOT in the available set", () => { + // The branch with teeth: without this filter the subagent is handed a model + // the user has no credentials for, and the failure surfaces as a runtime + // auth error from deep inside createAgentSession instead of a clean fallback. + const r = registry([haiku]); + expect(resolveDefaultModel(parent, r, "openai/gpt-5")).toBe(parent); + }); + + it("trusts `find` when the registry cannot enumerate availability", () => { + // getAvailable absent → no filtering possible, so a found model is used. + const r = registry(undefined); + expect(resolveDefaultModel(parent, r, "anthropic/claude-haiku-4-5")) + .toEqual({ provider: "anthropic", id: "claude-haiku-4-5" }); + }); + + it("falls back to the parent when the registry cannot find the model", () => { + const r = { find: vi.fn(() => undefined), getAvailable: undefined }; + expect(resolveDefaultModel(parent, r as any, "anthropic/nope")).toBe(parent); + }); + + it("falls back to the parent for a model string with no provider prefix", () => { + const r = registry([haiku]); + expect(resolveDefaultModel(parent, r, "haiku")).toBe(parent); + expect(r.find).not.toHaveBeenCalled(); + }); + + it("returns the parent model when no model is configured", () => { + expect(resolveDefaultModel(parent, registry([haiku]), undefined)).toBe(parent); + }); + + it("returns undefined when neither a config model nor a parent model exists", () => { + expect(resolveDefaultModel(undefined, registry([haiku]), undefined)).toBeUndefined(); + }); +}); diff --git a/pi-subagents/test/agent-startup-error.test.ts b/pi-subagents/test/agent-startup-error.test.ts new file mode 100644 index 0000000..cef9a33 --- /dev/null +++ b/pi-subagents/test/agent-startup-error.test.ts @@ -0,0 +1,94 @@ +/** + * agent-startup-error.test.ts — a spawn that never starts must fail the tool + * call, not return a message (#179). + * + * The assertion is `rejects`, and that is the whole point: pi marks a tool + * result failed only when `execute` throws (`isError` on a returned result is + * discarded), so a returned diagnostic reaches the parent model as a subagent + * that ran and reported this — and the model retries the same doomed call. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/worktree.js", async () => { + const actual = await vi.importActual("../src/worktree.js"); + return { ...actual, createWorktree: vi.fn(() => undefined) }; +}); + +import subagentsExtension from "../src/index.js"; + +function boot() { + const tools = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn(), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + subagentsExtension(pi); + return tools; +} + +let cwd: string; +let originalCwd: string; +let originalAgentDir: string | undefined; +let originalHome: string | undefined; + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd, + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +describe("Agent startup failures fail the tool call (#179)", () => { + beforeEach(() => { + originalCwd = process.cwd(); + cwd = mkdtempSync(join(tmpdir(), "startup-error-")); + process.chdir(cwd); + originalAgentDir = process.env.PI_CODING_AGENT_DIR; + originalHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir"); + process.env.HOME = cwd; + }); + + afterEach(() => { + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + process.chdir(originalCwd); + if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDir; + if (originalHome == null) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(cwd, { recursive: true, force: true }); + }); + + for (const background of [false, true]) { + it(`rejects instead of returning the diagnostic (run_in_background: ${background})`, async () => { + const tools = boot(); + + await expect( + tools.get("Agent").execute( + "tc-1", + { + prompt: "do it", + description: "worktree probe", + subagent_type: "general-purpose", + isolation: "worktree", + run_in_background: background, + }, + undefined, undefined, ctx(), + ), + ).rejects.toThrow('Cannot run with isolation: "worktree"'); + }); + } +}); diff --git a/pi-subagents/test/agent-tool-error-rendering.test.ts b/pi-subagents/test/agent-tool-error-rendering.test.ts new file mode 100644 index 0000000..aeed9e2 --- /dev/null +++ b/pi-subagents/test/agent-tool-error-rendering.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import subagentsExtension from "../src/index.js"; + +function agentTool() { + const tools = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)), + registerCommand: vi.fn(), + on: vi.fn(), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + subagentsExtension(pi); + return tools.get("Agent"); +} + +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +} as any; + +function render(tool: any, result: any): string { + return tool.renderResult( + { content: result.content, details: result.details }, + { expanded: false, isPartial: false }, + theme, + { isError: result.isError }, + ).render(120).join("\n"); +} + +describe("Agent tool invocation error rendering", () => { + it("shows a Pi tool error instead of structured terminal status", () => { + const output = render(agentTool(), { + content: [{ type: "text", text: 'Cannot run with isolation: "worktree" — Git probe failed.' }], + isError: true, + details: { status: "aborted" }, + }); + + expect(output).toContain('Cannot run with isolation: "worktree" — Git probe failed.'); + expect(output).not.toContain("Aborted (max turns exceeded)"); + }); + + it.each([ + ["missing details", undefined], + ["empty details", {}], + ["unknown status", { status: "unknown" }], + ["a status with no rendering of its own", { status: "queued" }], + ])("shows the real result text for %s", (_name, details) => { + const output = render(agentTool(), { + content: [{ type: "text", text: "Unstructured Agent result." }], + isError: false, + details, + }); + + expect(output).toContain("Unstructured Agent result."); + expect(output).not.toContain("Aborted (max turns exceeded)"); + }); +}); diff --git a/pi-subagents/test/agent-types.test.ts b/pi-subagents/test/agent-types.test.ts new file mode 100644 index 0000000..f7ccf38 --- /dev/null +++ b/pi-subagents/test/agent-types.test.ts @@ -0,0 +1,493 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + BUILTIN_TOOL_NAMES, + getAgentConfig, + getAvailableTypes, + getConfig, + getDefaultAgentNames, + getMemoryToolNames, + getReadOnlyMemoryToolNames, + getToolNamesForType, + getUserAgentNames, + isDefaultsDisabled, + isValidType, + NO_FALLBACK, + registerAgents, + resolveEnabledTypeIn, + resolveSpawnType, + resolveSpawnTypeIn, + resolveType, + setDefaultsDisabled, + setFallbackSubagent, +} from "../src/agent-types.js"; +import { DEFAULT_AGENTS } from "../src/default-agents.js"; +import type { AgentConfig } from "../src/types.js"; + +function makeAgentConfig(overrides: Partial = {}): AgentConfig { + return { + name: "test-agent", + description: "Test agent", + builtinToolNames: ["read", "grep"], + extensions: false, + skills: false, + systemPrompt: "You are a test agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + ...overrides, + }; +} + +describe("agent type registry", () => { + beforeEach(() => { + registerAgents(new Map()); + }); + + describe("default agents", () => { + it("recognizes all default agent types", () => { + expect(isValidType("general-purpose")).toBe(true); + expect(isValidType("Explore")).toBe(true); + expect(isValidType("Plan")).toBe(true); + }); + + it("does not include removed agents", () => { + expect(isValidType("statusline-setup")).toBe(false); + expect(isValidType("claude-code-guide")).toBe(false); + }); + + it("rejects unknown types", () => { + expect(isValidType("nonexistent")).toBe(false); + expect(isValidType("")).toBe(false); + }); + + it("case-insensitive lookup works for isValidType", () => { + expect(isValidType("explore")).toBe(true); + expect(isValidType("EXPLORE")).toBe(true); + expect(isValidType("General-Purpose")).toBe(true); + expect(isValidType("plan")).toBe(true); + }); + + it("case-insensitive lookup works for getAgentConfig", () => { + const config = getAgentConfig("explore"); + expect(config?.name).toBe("Explore"); + expect(config?.model).toBe("anthropic/claude-haiku-4-5"); + }); + + it("resolveType returns canonical key or undefined", () => { + expect(resolveType("Explore")).toBe("Explore"); + expect(resolveType("explore")).toBe("Explore"); + expect(resolveType("GENERAL-PURPOSE")).toBe("general-purpose"); + expect(resolveType("nonexistent")).toBeUndefined(); + }); + + it("returns correct config for default types", () => { + const config = getConfig("general-purpose"); + expect(config.displayName).toBe("Agent"); + expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(config.extensions).toBe(true); + expect(config.skills).toBe(true); + }); + + it("Explore has read-only tools", () => { + const config = getConfig("Explore"); + expect(config.builtinToolNames).toEqual(["read", "bash", "grep", "find", "ls"]); + expect(config.builtinToolNames).not.toContain("edit"); + expect(config.builtinToolNames).not.toContain("write"); + }); + + it("Explore has haiku model in config", () => { + const cfg = getAgentConfig("Explore"); + expect(cfg?.model).toBe("anthropic/claude-haiku-4-5"); + }); + + it("default agents are marked isDefault", () => { + const cfg = getAgentConfig("general-purpose"); + expect(cfg?.isDefault).toBe(true); + }); + + // Regression guard for #37 — default agents must not bake in callsite-strategy fields. + // An explicit `false` here would silently win over the caller's `true` via `??` in + // resolveAgentInvocationConfig, breaking documented Agent tool params. + it("default agents do not lock strategy fields (run_in_background / inherit_context / isolated)", () => { + for (const name of ["general-purpose", "Explore", "Plan"]) { + const cfg = getAgentConfig(name); + expect(cfg?.runInBackground, `${name}.runInBackground`).toBeUndefined(); + expect(cfg?.inheritContext, `${name}.inheritContext`).toBeUndefined(); + expect(cfg?.isolated, `${name}.isolated`).toBeUndefined(); + } + }); + + it("getDefaultAgentNames returns default agent names", () => { + const names = getDefaultAgentNames(); + expect(names).toContain("general-purpose"); + expect(names).toContain("Explore"); + expect(names).toContain("Plan"); + }); + + it("BUILTIN_TOOL_NAMES includes all built-in tools", () => { + expect(BUILTIN_TOOL_NAMES).toContain("read"); + expect(BUILTIN_TOOL_NAMES).toContain("bash"); + expect(BUILTIN_TOOL_NAMES).toContain("edit"); + expect(BUILTIN_TOOL_NAMES).toContain("write"); + expect(BUILTIN_TOOL_NAMES).toContain("grep"); + expect(BUILTIN_TOOL_NAMES).toContain("find"); + expect(BUILTIN_TOOL_NAMES).toContain("ls"); + expect(BUILTIN_TOOL_NAMES.length).toBeGreaterThanOrEqual(7); + }); + }); + + describe("disable defaults", () => { + // Module-level flag — always reset so later describes see the default roster. + afterEach(() => { + setDefaultsDisabled(false); + registerAgents(new Map()); + }); + + it("defaults to enabled", () => { + expect(isDefaultsDisabled()).toBe(false); + }); + + it("registerAgents skips DEFAULT_AGENTS when disabled", () => { + setDefaultsDisabled(true); + registerAgents(new Map()); + + expect(getAvailableTypes()).toEqual([]); + expect(isValidType("general-purpose")).toBe(false); + expect(isValidType("Explore")).toBe(false); + expect(isValidType("Plan")).toBe(false); + }); + + it("user agents are unaffected when defaults are disabled", () => { + setDefaultsDisabled(true); + registerAgents(new Map([["auditor", makeAgentConfig({ name: "auditor" })]])); + + expect(getAvailableTypes()).toEqual(["auditor"]); + expect(isValidType("auditor")).toBe(true); + expect(getDefaultAgentNames()).toEqual([]); + }); + + it("re-enabling restores defaults on next registerAgents", () => { + setDefaultsDisabled(true); + registerAgents(new Map()); + expect(isValidType("general-purpose")).toBe(false); + + setDefaultsDisabled(false); + registerAgents(new Map()); + expect(isValidType("general-purpose")).toBe(true); + expect(isValidType("Explore")).toBe(true); + expect(isValidType("Plan")).toBe(true); + }); + + it("getConfig falls back to the hardcoded config when defaults are disabled and no user agents exist", () => { + setDefaultsDisabled(true); + registerAgents(new Map()); + + const config = getConfig("general-purpose"); + expect(config.displayName).toBe("Agent"); + expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(config.promptMode).toBe("append"); + }); + }); + + describe("user agents", () => { + it("registers and retrieves user agents", () => { + const agents = new Map([["auditor", makeAgentConfig({ name: "auditor", description: "Auditor" })]]); + registerAgents(agents); + + expect(isValidType("auditor")).toBe(true); + expect(getAgentConfig("auditor")?.description).toBe("Auditor"); + }); + + it("includes user agents in available types", () => { + const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]); + registerAgents(agents); + + const types = getAvailableTypes(); + expect(types).toContain("general-purpose"); + expect(types).toContain("Explore"); + expect(types).toContain("auditor"); + }); + + it("lists user agent names separately", () => { + const agents = new Map([ + ["auditor", makeAgentConfig({ name: "auditor" })], + ["reviewer", makeAgentConfig({ name: "reviewer" })], + ]); + registerAgents(agents); + + const names = getUserAgentNames(); + expect(names).toEqual(["auditor", "reviewer"]); + expect(names).not.toContain("general-purpose"); + }); + + it("getConfig returns config for user agents", () => { + const agents = new Map([["auditor", makeAgentConfig({ + name: "auditor", + description: "Security auditor", + builtinToolNames: ["read", "grep"], + extensions: false, + skills: true, + })]]); + registerAgents(agents); + + const config = getConfig("auditor"); + expect(config.displayName).toBe("auditor"); + expect(config.description).toBe("Security auditor"); + expect(config.builtinToolNames).toEqual(["read", "grep"]); + expect(config.extensions).toBe(false); + expect(config.skills).toBe(true); + }); + + it("getConfig returns extension allowlist for user agents", () => { + const agents = new Map([["partial", makeAgentConfig({ + name: "partial", + extensions: ["web-search"], + skills: ["planning"], + })]]); + registerAgents(agents); + + const config = getConfig("partial"); + expect(config.extensions).toEqual(["web-search"]); + expect(config.skills).toEqual(["planning"]); + }); + + it("getToolNamesForType works for user agents", () => { + const agents = new Map([["auditor", makeAgentConfig({ + name: "auditor", + builtinToolNames: ["read", "grep", "find"], + })]]); + registerAgents(agents); + + const names = getToolNamesForType("auditor"); + expect(names).toEqual(["read", "grep", "find"]); + }); + + it("getToolNamesForType honors an explicit empty builtinToolNames as zero built-ins", () => { + // `tools: none` and `tools:` with only `ext:` entries both produce `[]`. + const agents = new Map([["ext-only", makeAgentConfig({ + name: "ext-only", + builtinToolNames: [], + })]]); + registerAgents(agents); + + expect(getToolNamesForType("ext-only")).toEqual([]); + }); + + it("getConfig falls back to general-purpose for unknown types", () => { + const config = getConfig("nonexistent"); + expect(config.displayName).toBe("Agent"); + expect(config.description).toBe(DEFAULT_AGENTS.get("general-purpose")?.description); + }); + + it("clearing user agents works (defaults remain)", () => { + const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]); + registerAgents(agents); + expect(isValidType("auditor")).toBe(true); + + registerAgents(new Map()); + expect(isValidType("auditor")).toBe(false); + expect(isValidType("general-purpose")).toBe(true); + }); + + it("user agent overrides default with same name", () => { + const agents = new Map([["Explore", makeAgentConfig({ + name: "Explore", + description: "Custom Explore", + builtinToolNames: BUILTIN_TOOL_NAMES, + })]]); + registerAgents(agents); + + const config = getConfig("Explore"); + expect(config.description).toBe("Custom Explore"); + expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + }); + + it("disabled agent is excluded from available types", () => { + const agents = new Map([["Plan", makeAgentConfig({ + name: "Plan", + enabled: false, + })]]); + registerAgents(agents); + + expect(isValidType("Plan")).toBe(false); + expect(getAvailableTypes()).not.toContain("Plan"); + }); + + it("general-purpose can be disabled but fallback still works", () => { + const agents = new Map([["general-purpose", makeAgentConfig({ + name: "general-purpose", + enabled: false, + })]]); + registerAgents(agents); + + expect(isValidType("general-purpose")).toBe(false); + // getConfig fallback should still return something reasonable + const config = getConfig("general-purpose"); + expect(config.displayName).toBe("Agent"); + }); + }); + + describe("getMemoryToolNames", () => { + it("returns read, write, edit when none exist", () => { + const names = getMemoryToolNames(new Set()); + expect(names).toContain("read"); + expect(names).toContain("write"); + expect(names).toContain("edit"); + expect(names).toHaveLength(3); + }); + + it("skips tools that already exist", () => { + const names = getMemoryToolNames(new Set(["read", "edit"])); + expect(names).toEqual(["write"]); + }); + + it("returns empty when all memory tools already exist", () => { + const names = getMemoryToolNames(new Set(["read", "write", "edit"])); + expect(names).toHaveLength(0); + }); + }); + + describe("getReadOnlyMemoryToolNames", () => { + it("returns only read when missing", () => { + const names = getReadOnlyMemoryToolNames(new Set()); + expect(names).toEqual(["read"]); + }); + + it("returns empty when read already exists", () => { + const names = getReadOnlyMemoryToolNames(new Set(["read"])); + expect(names).toHaveLength(0); + }); + }); + + describe("BUILTIN_TOOL_NAMES", () => { + // BUILTIN_TOOL_NAMES is derived dynamically from pi's tool factories + // (createCodingTools + createReadOnlyTools). This guards against pi-mono + // dropping/renaming a built-in: the set must still contain at least these + // 7. It's a superset check ("at least") — pi adding a new built-in is fine + // and won't fail this test. + const EXPECTED = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + + it("contains at least the 7 known built-ins", () => { + for (const name of EXPECTED) { + expect(BUILTIN_TOOL_NAMES).toContain(name); + } + }); + + it("has no duplicate entries", () => { + expect(new Set(BUILTIN_TOOL_NAMES).size).toBe(BUILTIN_TOOL_NAMES.length); + }); + }); +}); + +describe("resolveSpawnType — fail-closed dispatch (#183)", () => { + afterEach(() => { + setFallbackSubagent(undefined); + setDefaultsDisabled(false); + registerAgents(new Map()); + }); + + const roster = () => new Map([ + ["scout", makeAgentConfig({ name: "scout" })], + ["retired", makeAgentConfig({ name: "retired", enabled: false })], + ["router", makeAgentConfig({ name: "router" })], + ]); + + it("resolves an enabled type case-insensitively", () => { + registerAgents(roster()); + expect(resolveSpawnType("SCOUT")).toEqual({ ok: true, type: "scout" }); + }); + + it("falls back to general-purpose when unset, reporting what was asked for", () => { + registerAgents(roster()); + expect(resolveSpawnType("typoo")).toEqual({ + ok: true, type: "general-purpose", fellBackFrom: "typoo", + }); + }); + + it("rejects unknown types under `none` and names what is available", () => { + registerAgents(roster()); + setFallbackSubagent(NO_FALLBACK); + const r = resolveSpawnType("typoo"); + expect(r.ok).toBe(false); + if (r.ok) throw new Error("expected rejection"); + expect(r.message).toContain('Unknown or disabled agent type: "typoo"'); + expect(r.message).toContain("scout"); + expect(r.message).not.toContain("retired"); // disabled agents aren't offered + }); + + it("treats a disabled type as unresolvable, not as a valid name", () => { + // Regression: the old path used resolveType(), which ignores `enabled`, so a + // disabled agent dispatched with its own prompt and general-purpose's tools. + registerAgents(roster()); + expect(resolveSpawnType("retired")).toEqual({ + ok: true, type: "general-purpose", fellBackFrom: "retired", + }); + setFallbackSubagent(NO_FALLBACK); + expect(resolveSpawnType("retired").ok).toBe(false); + }); + + it("refuses to guess between two types differing only by case", () => { + registerAgents(new Map([ + ["Scout", makeAgentConfig({ name: "Scout" })], + ["scout", makeAgentConfig({ name: "scout" })], + ])); + // An exact match is still unambiguous... + expect(resolveSpawnType("scout")).toEqual({ ok: true, type: "scout" }); + // ...but a differently-cased spelling matches both, so it must not pick one. + expect(resolveSpawnType("SCOUT").ok).toBe(true); + expect(resolveSpawnType("SCOUT")).toEqual({ + ok: true, type: "general-purpose", fellBackFrom: "SCOUT", + }); + }); + + it("routes unresolvable types to a named fallback agent", () => { + registerAgents(roster()); + setFallbackSubagent("router"); + expect(resolveSpawnType("typoo")).toEqual({ + ok: true, type: "router", fellBackFrom: "typoo", + }); + }); + + it("fails loudly when the configured fallback is itself unusable", () => { + // Explicit configuration that cannot work is a misconfiguration, not a + // second chance to guess — never a silent drop to general-purpose. + registerAgents(roster()); + setFallbackSubagent("retired"); + const r = resolveSpawnType("typoo"); + expect(r.ok).toBe(false); + if (r.ok) throw new Error("expected rejection"); + expect(r.message).toContain("fallbackSubagent"); + }); + + it("treats a missing type like any other unresolvable one", () => { + // Before this setting existed an empty type fell back like a typo; only + // opting in should change that, so the default must stay permissive. + registerAgents(roster()); + for (const empty of ["", " ", undefined]) { + expect(resolveSpawnType(empty)).toMatchObject({ ok: true, type: "general-purpose" }); + } + + setFallbackSubagent(NO_FALLBACK); + for (const empty of ["", " ", undefined]) { + const r = resolveSpawnType(empty); + expect(r.ok).toBe(false); + if (r.ok) throw new Error("expected rejection"); + expect(r.message).toContain("No agent type given"); + } + }); + + it("resolves strictly regardless of the setting, for nested delegation", () => { + // Nested delegation uses this seam so a project-level fallback can't hand a + // nested caller an agent its allowlist never named. + const registry = roster(); + setFallbackSubagent("router"); + expect(resolveEnabledTypeIn(registry, "typoo")).toBeUndefined(); + expect(resolveEnabledTypeIn(registry, "retired")).toBeUndefined(); + expect(resolveEnabledTypeIn(registry, " SCOUT ")).toBe("scout"); + // ...while the policy layer still honors it. + expect(resolveSpawnTypeIn(registry, "typoo")).toEqual({ + ok: true, type: "router", fellBackFrom: "typoo", + }); + }); +}); diff --git a/pi-subagents/test/agent-widget.test.ts b/pi-subagents/test/agent-widget.test.ts new file mode 100644 index 0000000..b1bc1c5 --- /dev/null +++ b/pi-subagents/test/agent-widget.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from "vitest"; +import { renderRunningAgentStatus } from "../src/index.js"; +import type { WidgetMode } from "../src/types.js"; +import { type AgentActivity, AgentWidget, fgPreservingNestedStyles, formatCost, formatSessionTokens } from "../src/ui/agent-widget.js"; + +describe("formatSessionTokens", () => { + const theme = { fg: (c: string, s: string) => `<${c}>${s}`, bold: (s: string) => s }; + const ansiTheme = { + fg: (c: string, s: string) => { + const codes: Record = { dim: "2", warning: "33", accent: "35" }; + return `\u001b[${codes[c] ?? "31"}m${s}\u001b[39m`; + }, + bold: (s: string) => s, + }; + + it("applies threshold colors (<70 dim, 70–85 warning, ≥85 error)", () => { + expect(formatSessionTokens(1234, null, theme)).toBe("1.2k token"); + expect(formatSessionTokens(1234, 50, theme)).toBe("1.2k token (50%)"); + expect(formatSessionTokens(1234, 70, theme)).toBe("1.2k token (70%)"); + expect(formatSessionTokens(1234, 84, theme)).toBe("1.2k token (84%)"); + expect(formatSessionTokens(1234, 85, theme)).toBe("1.2k token (85%)"); + expect(formatSessionTokens(1234, 99, theme)).toBe("1.2k token (99%)"); + }); + + it("annotates compaction count alongside percent", () => { + // compactions only (e.g. immediately post-compaction, percent null) + expect(formatSessionTokens(1234, null, theme, 1)).toBe("1.2k token (⇊1)"); + expect(formatSessionTokens(1234, null, theme, 3)).toBe("1.2k token (⇊3)"); + // percent + compactions, joined with ` · ` + expect(formatSessionTokens(1234, 45, theme, 2)).toBe("1.2k token (45% · ⇊2)"); + expect(formatSessionTokens(1234, 88, theme, 4)).toBe("1.2k token (88% · ⇊4)"); + // compactions=0 omitted + expect(formatSessionTokens(1234, 45, theme, 0)).toBe("1.2k token (45%)"); + }); + + it("preserves the outer style after nested annotation styles reset", () => { + const tokenText = formatSessionTokens(1234, 70, ansiTheme); + + expect(fgPreservingNestedStyles(ansiTheme, "accent", tokenText)).toBe( + "\u001b[35m1.2k token (\u001b[33m70%\u001b[39m\u001b[35m)\u001b[39m", + ); + }); +}); + +describe("renderRunningAgentStatus", () => { + it("renders running status as separate component lines", () => { + const theme = { fg: (_c: string, s: string) => s }; + const component = renderRunningAgentStatus("⠋", "thinking: xhigh · 4 tool uses", "thinking…", theme); + + expect(component.render(120).map((line) => line.trimEnd())).toEqual([ + "⠋ thinking: xhigh · 4 tool uses", + " ⎿ thinking…", + ]); + }); +}); + +describe("AgentWidget", () => { + const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + + function makeActivity(): AgentActivity { + return { + activeTools: new Map(), + toolUses: 0, + responseText: "", + turnCount: 1, + }; + } + + function makeRecord(id: string, opts: { isBackground?: boolean; parentAgentId?: string } = {}) { + return { + id, + type: "general-purpose", + description: `${id} description`, + status: "running", + toolUses: 0, + startedAt: Date.now(), + lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compactionCount: 0, + isBackground: opts.isBackground, + parentAgentId: opts.parentAgentId, + }; + } + + /** Render the widget for a manager and return the produced lines ("" if nothing rendered). */ + function renderLines(manager: unknown, activityId: string, mode?: () => WidgetMode): string { + const widget = new AgentWidget( + manager as any, + new Map([[activityId, makeActivity()]]), + mode, + ); + let factory: any; + widget.setUICtx({ + setStatus: () => {}, + setWidget: (_key, content) => { factory = content; }, + }); + widget.update(); + if (!factory) return ""; + return factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme) + .render() + .join("\n"); + } + + // "all" (and the no-policy constructor default) shows every agent. + it("shows foreground agents in 'all' mode (and by default)", () => { + const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] }; + expect(renderLines(manager, "foreground")).toContain("foreground description"); + expect(renderLines(manager, "foreground", () => "all")).toContain("foreground description"); + }); + + it("hides nested children in every coordinator widget mode", () => { + const manager = { + listAgents: () => [makeRecord("nested", { isBackground: true, parentAgentId: "parent" })], + }; + expect(renderLines(manager, "nested", () => "all")).toBe(""); + expect(renderLines(manager, "nested", () => "background")).toBe(""); + }); + + it("excludes foreground agents in 'background' mode", () => { + const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] }; + expect(renderLines(manager, "foreground", () => "background")).toBe(""); + }); + + // Also covers scheduler-spawned agents (isBackground=true, no `invocation` + // snapshot): if the filter still keyed off `invocation.runInBackground` — + // #118's original approach — this would wrongly vanish. + it("renders background agents in 'background' mode", () => { + const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] }; + const lines = renderLines(manager, "background", () => "background"); + expect(lines).toContain("Agents"); + expect(lines).toContain("background description"); + }); + + // 'background' excludes only agents *known* to be foreground; one with no + // isBackground flag (e.g. a cross-extension RPC spawn) is kept, not hidden. + it("keeps agents with no isBackground flag in 'background' mode", () => { + const manager = { listAgents: () => [makeRecord("unflagged", {})] }; + expect(renderLines(manager, "unflagged", () => "background")).toContain("unflagged description"); + }); + + // "off" hides the widget entirely — even a background agent renders nothing. + it("renders nothing in 'off' mode", () => { + const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] }; + expect(renderLines(manager, "background", () => "off")).toBe(""); + }); +}); + +// The widget caps itself at MAX_WIDGET_LINES (12) and, past that, hands out a +// line budget in priority order: running pairs, then the queued summary, then +// finished lines. Running and finished increment `hiddenRunning`/`hiddenFinished` +// when they don't fit; the queued line is dropped with NO counter at all, so the +// footer under-reports and — worse — the queue vanishes from the UI entirely. +// That happens exactly when the concurrency limit is saturated, i.e. when the +// queue is the thing the user most needs to see. +describe("formatCost", () => { + it("keeps the precision that distinguishes one run from another", () => { + // Rounding to cents would print the same figure for a run that cost four + // times another — the band most single subagent runs fall in. + expect(formatCost(0.0042)).toBe("~$0.0042"); + expect(formatCost(0.0123)).toBe("~$0.0123"); + expect(formatCost(1.239)).toBe("~$1.24"); + }); + + it("never pads a round figure with noise, nor cuts it below cents", () => { + expect(formatCost(0.05)).toBe("~$0.05"); // not ~$0.0500 + expect(formatCost(0.4)).toBe("~$0.40"); // not ~$0.4 + expect(formatCost(12)).toBe("~$12.00"); + }); + + it("shows nothing when there is nothing to show", () => { + // Zero is what a model with no pricing data reports, so `$0.00` would claim + // a measurement that was never made. + expect(formatCost(0)).toBe(""); + expect(formatCost(Number.NaN)).toBe(""); + expect(formatCost(-1)).toBe(""); + }); + + it("says a real but tiny cost is tiny, not zero", () => { + // The distinction the whole helper turns on: "measured, below what four + // decimals can show" must not render the same as "never measured". + expect(formatCost(0.00002)).toBe("<$0.0001"); + expect(formatCost(0)).toBe(""); + }); + + it("marks the figure as an estimate", () => { + // The tilde is the whole disclaimer — it sits beside exact token counts. + expect(formatCost(0.5).startsWith("~")).toBe(true); + }); +}); + +describe("AgentWidget cost display", () => { + const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + + function render(showCost: boolean, cost: number): string { + const agent = { + id: "a1", + type: "general-purpose", + description: "spending agent", + status: "running", + toolUses: 1, + startedAt: Date.now(), + lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost }, + compactionCount: 0, + }; + // Carries figures of its own, in the shape the tracker used to have: spend + // is read from the record now, so these must not reach the line. Only the + // record accumulates a nested child's spend, and only it outlives the run. + const activity = new Map([["a1", { + activeTools: new Map(), + toolUses: 1, + responseText: "", + turnCount: 1, + lifetimeUsage: { input: 9, output: 9, cacheWrite: 0, cost: 0.9 }, + } as unknown as AgentActivity]]); + const widget = new AgentWidget( + { listAgents: () => [agent] } as any, + activity, + () => "all", + () => showCost, + ); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any); + widget.update(); + return factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n"); + } + + it("shows the cost beside the token count when enabled", () => { + const line = render(true, 0.0042); + expect(line).toContain("1.2k token"); + expect(line).toContain("~$0.0042"); + }); + + it("shows no cost when disabled", () => { + const line = render(false, 0.0042); + expect(line).toContain("1.2k token"); + expect(line).not.toContain("$"); + }); + + it("shows no cost for an unpriced model, even when enabled", () => { + const line = render(true, 0); + expect(line).toContain("1.2k token"); + expect(line).not.toContain("$"); + }); + + it("keeps the cost visible after the agent finishes", () => { + // The activity entry is deleted the moment an agent finishes, so a finished + // line reading from it would drop the number precisely when the question + // "what did that cost" gets asked. + const finished = { + id: "a1", type: "general-purpose", description: "done agent", status: "completed", + toolUses: 2, startedAt: Date.now() - 1000, completedAt: Date.now(), + lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.0042 }, + compactionCount: 0, + }; + const widget = new AgentWidget( + { listAgents: () => [finished] } as any, new Map(), () => "all", () => true, + ); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any); + widget.update(); + const out = factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n"); + + expect(out).toContain("done agent"); + expect(out).toContain("~$0.0042"); + }); + + it("shows stats for an agent nobody is tracking live", () => { + // A scheduled agent has no activity entry — it spawns through the manager + // directly — and used to render with no tokens and no cost at all. + const running = { + id: "sched", type: "general-purpose", description: "scheduled agent", status: "running", + toolUses: 1, startedAt: Date.now(), + lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.0042 }, + compactionCount: 0, + }; + const widget = new AgentWidget( + { listAgents: () => [running] } as any, new Map(), () => "all", () => true, + ); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any); + widget.update(); + const out = factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n"); + + expect(out).toContain("1.2k token"); + expect(out).toContain("~$0.0042"); + }); + + it("defaults to hiding it", () => { + const agent = { + id: "a1", type: "general-purpose", description: "d", status: "running", + toolUses: 0, startedAt: Date.now(), + lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost: 0.5 }, compactionCount: 0, + }; + const activity = new Map([["a1", { + activeTools: new Map(), toolUses: 0, responseText: "", turnCount: 1, + } as AgentActivity]]); + const widget = new AgentWidget({ listAgents: () => [agent] } as any, activity, () => "all"); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any); + widget.update(); + expect(factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render().join("\n")) + .not.toContain("$"); + }); +}); + +describe("AgentWidget overflow accounting", () => { + const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + + function record(id: string, status: string) { + return { + id, + type: "general-purpose", + description: `${id} description`, + status, + toolUses: 0, + startedAt: Date.now(), + completedAt: status === "completed" ? Date.now() : undefined, + lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compactionCount: 0, + isBackground: true, + }; + } + + /** Render a whole fleet (mixed statuses) and return the produced lines. */ + function renderFleet(counts: { running: number; queued: number; finished: number }): string[] { + const agents = [ + ...Array.from({ length: counts.running }, (_, i) => record(`run${i}`, "running")), + ...Array.from({ length: counts.queued }, (_, i) => record(`q${i}`, "queued")), + ...Array.from({ length: counts.finished }, (_, i) => record(`fin${i}`, "completed")), + ]; + const activity = new Map(agents.map(a => [a.id, { + activeTools: new Map(), + toolUses: 0, + responseText: "", + turnCount: 1, + } as AgentActivity])); + const widget = new AgentWidget({ listAgents: () => agents } as any, activity, () => "all"); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k, c) => { factory = c; } } as any); + widget.update(); + if (!factory) return []; + return factory({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render(); + } + + /** The `+N more (…)` footer, if the widget overflowed. */ + const footer = (lines: string[]) => lines.find(l => l.includes("more (")); + + /** Every fleet shape worth rendering — swept, not sampled. */ + const SHAPES: { running: number; queued: number; finished: number }[] = []; + for (let running = 0; running <= 8; running++) + for (let queued = 0; queued <= 8; queued++) + for (let finished = 0; finished <= 8; finished++) SHAPES.push({ running, queued, finished }); + + // Swept rather than sampled: reserving the queued row moves `budget` around by + // hand, and an off-by-one there overflows the cap only for specific shapes. + it("never exceeds the line cap, for any fleet shape", () => { + for (const counts of SHAPES) { + expect(renderFleet(counts).length, JSON.stringify(counts)).toBeLessThanOrEqual(12); + } + }); + + it("never prints a footer that miscounts what it hid, for any fleet shape", () => { + for (const counts of SHAPES) { + const f = footer(renderFleet(counts)); + if (!f) continue; + const total = Number(/\+(\d+) more/.exec(f)?.[1]); + const where = `${JSON.stringify(counts)} → ${f}`; + // A visible footer means something was dropped, so "+0 more ()" is a lie... + expect(total, where).toBeGreaterThan(0); + // ...and it counts agents that have their own row, so it can never exceed + // them — in particular the queued summary must not be counted as an agent. + expect(total, where).toBeLessThanOrEqual(counts.running + counts.finished); + } + }); + + it("keeps the queued summary visible when the running agents fill the widget", () => { + // 5 running (10 lines) consume the entire budget, so the queued line is + // dropped — and with it, any sign that 3 agents are waiting to start. + const lines = renderFleet({ running: 5, queued: 3, finished: 1 }); + expect(lines.join("\n")).toContain("3 queued"); + }); + + it("counts everything it hid — the footer total matches what is missing", () => { + // Computed rather than hardcoded, so this survives a scenario change but not + // a change to what the footer counts. + const counts = { running: 5, queued: 3, finished: 1 }; + const lines = renderFleet(counts); + const body = lines.join("\n"); + + const shownRunning = counts.running - [...Array(counts.running).keys()] + .filter(i => !body.includes(`run${i} description`)).length; + const shownFinished = counts.finished - [...Array(counts.finished).keys()] + .filter(i => !body.includes(`fin${i} description`)).length; + const actuallyHidden = (counts.running - shownRunning) + (counts.finished - shownFinished); + + const reported = Number(/\+(\d+) more/.exec(footer(lines) ?? "")?.[1] ?? -1); + expect(reported).toBe(actuallyHidden); + }); + + it("gives the queued summary priority over finished lines", () => { + const lines = renderFleet({ running: 4, queued: 2, finished: 3 }); + expect(lines.join("\n")).toContain("2 queued"); + }); + + it("renders everything with no footer when the fleet fits", () => { + const lines = renderFleet({ running: 2, queued: 1, finished: 1 }); + expect(lines.join("\n")).toContain("1 queued"); + expect(footer(lines)).toBeUndefined(); + }); + + // A background resume runs an agent that already finished once. markFinished + // only seeds an age it has not seen before, so without markRunning the agent + // carries its previous run's age — already past the linger limit — and the + // resumed run's ✓ line never renders: the agent just disappears. + it("shows the completion line again after a finished agent is resumed", () => { + const agent = record("resumed", "completed"); + const activity = new Map([[agent.id, { + activeTools: new Map(), + toolUses: 0, + responseText: "", + turnCount: 1, + } as AgentActivity]]); + const widget = new AgentWidget({ listAgents: () => [agent] } as any, activity, () => "all"); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_k: any, c: any) => { factory = c; } } as any); + const render = () => { + widget.update(); + return (factory?.({ terminal: { columns: 200 }, requestRender: () => {} }, theme).render() ?? []).join("\n"); + }; + + // First run finishes and ages out of the widget. + widget.markFinished(agent.id); + widget.onTurnStart(); + widget.onTurnStart(); + expect(render()).not.toContain("resumed description"); + + // Background resume puts it back on the running list. + agent.status = "running"; + widget.markRunning(agent.id); + expect(render()).toContain("resumed description"); + + // ...and its completion is visible when the resumed run settles. + agent.status = "completed"; + widget.markFinished(agent.id); + expect(render()).toContain("resumed description"); + }); +}); diff --git a/pi-subagents/test/background-by-default.test.ts b/pi-subagents/test/background-by-default.test.ts new file mode 100644 index 0000000..2d37733 --- /dev/null +++ b/pi-subagents/test/background-by-default.test.ts @@ -0,0 +1,114 @@ +/** + * background-by-default.test.ts — the `backgroundByDefault` flip, asserted at + * the tool boundary rather than at the resolver. + * + * `documented-defaults.test.ts` pins `resolveAgentInvocationConfig`'s arguments; + * this pins what the orchestrator actually receives back from a real `Agent` + * call, which is the part the tool description makes promises about: + * + * - an unqualified spawn hands back an ID instead of the agent's output, + * - `run_in_background: false` still blocks and returns the output inline, + * - a fan-out sized like the ones the description tells the model to send + * runs concurrently instead of queueing behind `maxConcurrent`. + * + * That last one is the reason the concurrency default moved 4 → 10: foreground + * agents bypass the pool, so the limit only started applying to ordinary + * parallel work once background became the default. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; + +const settled = (text: string) => + vi.mocked(runAgent).mockResolvedValue({ + responseText: text, + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + } as any); + +function spawn(tools: Map, params: Record = {}) { + return tools.get("Agent").execute( + "tc", + { prompt: "go", description: "d", subagent_type: "general-purpose", ...params }, + undefined, + undefined, + ctx(), + ); +} + +describe("backgroundByDefault", () => { + it("returns an agent ID, not the result, when the call doesn't specify", async () => { + const { pi, tools } = makePi(); + subagentsExtension(pi); + settled("THE-PAYLOAD"); + + const out = textOf(await spawn(tools)); + + expect(out).toContain("Agent ID:"); + // The whole point of backgrounding: the orchestrator does NOT get the + // output here — it arrives later as a notification preview. + expect(out).not.toContain("THE-PAYLOAD"); + }); + + it("still blocks and returns the output inline when run_in_background is false", async () => { + const { pi, tools } = makePi(); + subagentsExtension(pi); + settled("THE-PAYLOAD"); + + const out = textOf(await spawn(tools, { run_in_background: false })); + + expect(out).toContain("THE-PAYLOAD"); + expect(out).not.toContain("started in background"); + }); + + it("starts a six-way fan-out concurrently instead of queueing the tail", async () => { + // Six is the shape the Agent tool description tells the model to send. + // With maxConcurrent at its old 4 this queued two of them. + const { pi, tools } = makePi(); + subagentsExtension(pi); + // Never settles — every agent stays occupying its slot for the whole test. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); + + const outs: string[] = []; + for (let i = 0; i < 6; i++) outs.push(textOf(await spawn(tools))); + + expect(outs).toHaveLength(6); + for (const out of outs) expect(out).not.toContain("queued"); + }); +}); diff --git a/pi-subagents/test/background-resume-wiring.test.ts b/pi-subagents/test/background-resume-wiring.test.ts new file mode 100644 index 0000000..151a49c --- /dev/null +++ b/pi-subagents/test/background-resume-wiring.test.ts @@ -0,0 +1,287 @@ +/** + * Agent tool wiring for `resume` + `run_in_background` (#214). + * + * The manager-level mechanics live in agent-manager.test.ts; what this file + * pins down is what the TOOL hands the manager, which is where a detached + * resume can quietly diverge from a detached spawn. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() }; +}); + +vi.mock("../src/output-file.js", async () => { + const actual = await vi.importActual("../src/output-file.js"); + return { + ...actual, + createOutputFilePath: vi.fn(() => "/tmp/fake-subagent.output"), + writeInitialEntry: vi.fn(), + ensureOutputFile: vi.fn(), + streamToOutputFile: vi.fn(() => vi.fn()), + }; +}); + +import { resumeAgent, runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { ensureOutputFile, streamToOutputFile, writeInitialEntry } from "../src/output-file.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const emitted: { event: string; payload: any }[] = []; + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn((event: string, payload: any) => { emitted.push({ event, payload }); }), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle, emitted }; +} + +function makeCtx(cwd: string) { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd, + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "session-1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +/** Flatten a tool result's content blocks to plain text. */ +function resultText(result: any): string { + const content = result?.content; + if (Array.isArray(content)) return content.map((c: any) => c?.text ?? "").join("\n"); + return String(content ?? result?.text ?? JSON.stringify(result)); +} + +/** Pull the agent id out of a background spawn/resume tool result. */ +function agentIdOf(result: any): string { + const id = /Agent ID: (\S+)/.exec(resultText(result))?.[1]; + if (!id) throw new Error(`no agent id in result: ${JSON.stringify(result)}`); + return id; +} + +describe("Agent tool — background resume wiring", () => { + let cwd: string; + let agentDir: string; + let previousCwd: string; + let previousAgentDir: string | undefined; + let previousHome: string | undefined; + /** The session the spawned agent ends up holding — prior turns already in it. */ + let session: any; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "pi-bg-resume-cwd-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-bg-resume-agent-")); + previousCwd = process.cwd(); + previousAgentDir = process.env.PI_CODING_AGENT_DIR; + previousHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + mkdirSync(join(agentDir, "agents"), { recursive: true }); + process.chdir(cwd); + + session = { + messages: [ + { role: "user", content: "first task" }, + { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + { role: "user", content: "tool result" }, + ], + subscribe: vi.fn(() => vi.fn()), + steer: vi.fn(async () => {}), + dispose: vi.fn(), + }; + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options: any) => { + await Promise.resolve(); + options.onSessionCreated?.(session); + return { responseText: "done", session, aborted: false, steered: false }; + }); + vi.mocked(resumeAgent).mockResolvedValue({ text: "resumed answer" } as any); + }); + + afterEach(() => { + process.chdir(previousCwd); + if (previousAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousHome == null) delete process.env.HOME; + else process.env.HOME = previousHome; + rmSync(cwd, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + /** Spawn a background agent and let it settle, so it holds a resumable session. */ + async function spawnSettled(tools: Map, ctx: any, type = "general-purpose") { + const res = await tools.get("Agent").execute( + "spawn-call", + { prompt: "first task", description: "First task", subagent_type: type, run_in_background: true }, + undefined, + undefined, + ctx, + ); + const id = agentIdOf(res); + await new Promise((r) => setTimeout(r, 0)); + return id; + } + + // A background spawn deliberately omits the tool-call signal — that signal + // aborts when the parent turn is interrupted (user Esc), which must not reach + // an agent the caller has already detached from. A detached resume that + // forwarded it would die on Esc while background spawns from the same turn + // kept running. + it("does not tie the detached run to the tool-call signal", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const ctx = makeCtx(cwd); + const id = await spawnSettled(tools, ctx); + + let runSignal: AbortSignal | undefined; + vi.mocked(resumeAgent).mockImplementation((_s: any, _p: any, opts: any) => { + runSignal = opts.signal; + return new Promise(() => {}); // never settles — the run is still in flight + }); + + const toolAbort = new AbortController(); + await tools.get("Agent").execute( + "resume-call", + { prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true }, + toolAbort.signal, + undefined, + ctx, + ); + + expect(runSignal).toBeDefined(); + expect(runSignal!.aborted).toBe(false); + + // The parent turn is interrupted. The detached run must survive it. + toolAbort.abort(); + await new Promise((r) => setTimeout(r, 0)); + expect(runSignal!.aborted).toBe(false); + + await lifecycle.get("session_shutdown")?.({}, ctx); + }); + + // The transcript path is deterministic per agent+session, so the resume lands + // on the file the previous run wrote. writeInitialEntry truncates it. + it("appends to the agent's existing transcript instead of truncating it", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const ctx = makeCtx(cwd); + const id = await spawnSettled(tools, ctx); + + vi.mocked(writeInitialEntry).mockClear(); + vi.mocked(streamToOutputFile).mockClear(); + + await tools.get("Agent").execute( + "resume-call", + { prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true }, + undefined, + undefined, + ctx, + ); + + // Never truncate; ensure the file exists without disturbing its contents. + expect(writeInitialEntry).not.toHaveBeenCalled(); + expect(ensureOutputFile).toHaveBeenCalled(); + + // Streaming is anchored past the turns the session already carries, so the + // prior conversation is not re-emitted behind the resumed run. + expect(streamToOutputFile).toHaveBeenCalledTimes(1); + expect(vi.mocked(streamToOutputFile).mock.calls[0][4]).toBe(3); + + await lifecycle.get("session_shutdown")?.({}, ctx); + }); + + // Resume ignores subagent_type — the record keeps the type it was spawned + // with — so a "created" event carrying the caller's type would re-register the + // agent under the wrong one in cross-extension mirrors keyed by id. + it("reports the record's own type, not the caller's ignored subagent_type", async () => { + writeFileSync(join(agentDir, "agents", "explorer.md"), `---\ndescription: Explorer agent\n---\n\nExplore.`); + const { pi, tools, lifecycle, emitted } = makePi(); + subagentsExtension(pi); + const ctx = makeCtx(cwd); + const id = await spawnSettled(tools, ctx, "general-purpose"); + + emitted.length = 0; + const res = await tools.get("Agent").execute( + "resume-call", + { prompt: "keep going", description: "Different description", subagent_type: "explorer", resume: id, run_in_background: true }, + undefined, + undefined, + ctx, + ); + + const created = emitted.find(e => e.event === "subagents:created"); + expect(created).toBeDefined(); + expect(created!.payload.type).toBe("general-purpose"); + expect(created!.payload.id).toBe(id); + expect(resultText(res)).toContain("Type: general-purpose"); + + await lifecycle.get("session_shutdown")?.({}, ctx); + }); + + // Detaching hands control back while the record is still running, so nothing + // stops the model from resuming the same agent again. The second resume must + // be refused with a message it can act on — not started, and not reported as + // a generic failure. + it("refuses a second background resume while the first run is in flight", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const ctx = makeCtx(cwd); + const id = await spawnSettled(tools, ctx); + + vi.mocked(resumeAgent).mockImplementation(() => new Promise(() => {})); + vi.mocked(resumeAgent).mockClear(); + + const params = { prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: true }; + await tools.get("Agent").execute("resume-1", params, undefined, undefined, ctx); + const second = await tools.get("Agent").execute("resume-2", params, undefined, undefined, ctx); + + expect(resumeAgent).toHaveBeenCalledTimes(1); + const text = resultText(second); + expect(text).toContain("still running"); + expect(text).toContain("steer_subagent"); + + await lifecycle.get("session_shutdown")?.({}, ctx); + }); + + // Resume follows the same default as a fresh spawn — background — so + // foreground is now the explicit case rather than the implicit one. + it("still resumes in the foreground when run_in_background is false", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const ctx = makeCtx(cwd); + const id = await spawnSettled(tools, ctx); + + vi.mocked(resumeAgent).mockResolvedValue({ text: "inline answer" } as any); + const res = await tools.get("Agent").execute( + "resume-call", + { prompt: "keep going", description: "Keep going", subagent_type: "general-purpose", resume: id, run_in_background: false }, + undefined, + undefined, + ctx, + ); + + // Foreground resume returns the answer inline — no background handoff text. + expect(resultText(res)).toContain("inline answer"); + expect(resultText(res)).not.toContain("You will be notified"); + + await lifecycle.get("session_shutdown")?.({}, ctx); + }); +}); diff --git a/pi-subagents/test/child-context.test.ts b/pi-subagents/test/child-context.test.ts new file mode 100644 index 0000000..5166b0f --- /dev/null +++ b/pi-subagents/test/child-context.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { inChildSessionContext, runInChildSessionContext } from "../src/child-context.js"; +import subagentsExtension from "../src/index.js"; + +describe("child session async context", () => { + it("is scoped to the child async branch", async () => { + expect(inChildSessionContext()).toBe(false); + await runInChildSessionContext(async () => { + expect(inChildSessionContext()).toBe(true); + await Promise.resolve(); + expect(inChildSessionContext()).toBe(true); + }); + expect(inChildSessionContext()).toBe(false); + }); + + it("prevents a child resource load from creating another extension manager", async () => { + const pi = new Proxy({}, { + get: vi.fn(() => { + throw new Error("child extension factory must be a no-op"); + }), + }); + + await runInChildSessionContext(async () => { + expect(() => subagentsExtension(pi as any)).not.toThrow(); + }); + }); +}); diff --git a/pi-subagents/test/child-session-shutdown.test.ts b/pi-subagents/test/child-session-shutdown.test.ts new file mode 100644 index 0000000..9046bb9 --- /dev/null +++ b/pi-subagents/test/child-session-shutdown.test.ts @@ -0,0 +1,187 @@ +/** + * #242 — `runAgent` calls `session.bindExtensions()` so `session_start` fires and + * extensions can set up per-session state, but nothing ever closed that lifecycle: + * both eviction (`removeRecord`) and quit (`dispose`) called `session.dispose()`, + * which in pi only calls `ExtensionRunner.invalidate()` — it does NOT emit + * `session_shutdown`. So anything an extension armed in `session_start` (timers, fs + * watchers, sockets) leaked once per spawn, and its next tick threw `assertActive()` + * from a bare `Timeout._onTimeout` — an uncaughtException that killed interactive pi. + * + * pi emits the event itself in `AgentSessionRuntime.dispose()` before disposing; these + * tests pin that we do the same, that quit actually waits for the handlers, and that a + * hung handler can't strand the user at a dead terminal. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentManager } from "../src/agent-manager.js"; +import { registerPermissionChildSession } from "../src/permission-system-bridge.js"; + +vi.mock("../src/agent-runner.js", () => ({ + runAgent: vi.fn(), + resumeAgent: vi.fn(), +})); + +vi.mock("../src/worktree.js", () => ({ + createWorktree: vi.fn(), + cleanupWorktree: vi.fn(() => ({ hasChanges: false })), + pruneWorktrees: vi.fn(), + isWorktreeIsolationEnabled: vi.fn(() => true), +})); + +import { runAgent } from "../src/agent-runner.js"; +import { pruneWorktrees } from "../src/worktree.js"; + +const mockPi = {} as any; +const mockCtx = { cwd: "/tmp" } as any; + +/** A child session as `runAgent` leaves it: extensions bound, so a runner with handlers. */ +function boundSession(emit: (...args: any[]) => any = vi.fn(async () => {})) { + return { + dispose: vi.fn(), + extensionRunner: { + hasHandlers: vi.fn((event: string) => event === "session_shutdown"), + emit: vi.fn(emit), + }, + } as any; +} + +/** Spawn one background agent that resolves with `session`, and wait for it to complete. */ +async function spawnCompleted(manager: AgentManager, session: any) { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session, + aborted: false, + steered: false, + } as any); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + return id; +} + +describe("child session shutdown (#242)", () => { + let manager: AgentManager; + + afterEach(async () => { + await manager?.dispose(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("emits session_shutdown before disposing an evicted session", async () => { + manager = new AgentManager(); + const session = boundSession(); + await spawnCompleted(manager, session); + + manager.clearCompleted(); + await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled()); + + expect(session.extensionRunner.emit).toHaveBeenCalledWith({ + type: "session_shutdown", + reason: "quit", + }); + // Order is the whole point: after `dispose()` the runner is invalidated and + // every `ctx` getter throws, so a handler emitted afterwards is useless. + expect(session.extensionRunner.emit.mock.invocationCallOrder[0]) + .toBeLessThan(session.dispose.mock.invocationCallOrder[0]); + }); + + it("publishes child disposal only after the session is closed", async () => { + manager = new AgentManager(); + const order: string[] = []; + const pi = { + events: { emit: vi.fn((channel: string) => { order.push(channel); }) }, + } as any; + const session = boundSession(); + session.sessionManager = { getSessionId: () => "child-session" }; + session.dispose.mockImplementation(() => { order.push("session-disposed"); }); + registerPermissionChildSession(pi, session, "root-session"); + await spawnCompleted(manager, session); + + manager.clearCompleted(); + await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled()); + + expect(order).toEqual([ + "subagents:child:session-created", + "session-disposed", + "subagents:child:disposed", + ]); + }); + + it("quit waits for the child's shutdown handlers to finish", async () => { + manager = new AgentManager(); + let releaseHandler!: () => void; + const session = boundSession(() => new Promise(r => { releaseHandler = r; })); + await spawnCompleted(manager, session); + + const disposed = manager.dispose(); + let settled = false; + void disposed.then(() => { settled = true; }); + // Several microtask turns: enough for a fire-and-forget implementation to have + // resolved, not enough for a correctly awaited one. + for (let i = 0; i < 5; i++) await Promise.resolve(); + + expect(settled).toBe(false); + expect(session.dispose).not.toHaveBeenCalled(); + + releaseHandler(); + await disposed; + expect(settled).toBe(true); + expect(session.dispose).toHaveBeenCalledOnce(); + }); + + it("quit is not hostage to a handler that never resolves", async () => { + manager = new AgentManager(); + const session = boundSession(() => new Promise(() => {})); + await spawnCompleted(manager, session); + + vi.useFakeTimers(); + const disposed = manager.dispose(); + // Past the internal ceiling. Without it the TUI is already torn down and the + // user is left at a dead terminal with only Ctrl-C. + await vi.advanceTimersByTimeAsync(5_000); + await disposed; + + expect(session.dispose).toHaveBeenCalledOnce(); + // Teardown continues past the timeout rather than unwinding. + expect(pruneWorktrees).toHaveBeenCalled(); + }); + + it("skips the emit when no extension handles session_shutdown", async () => { + manager = new AgentManager(); + const session = boundSession(); + session.extensionRunner.hasHandlers = vi.fn(() => false); + await spawnCompleted(manager, session); + + manager.clearCompleted(); + await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled()); + + expect(session.extensionRunner.emit).not.toHaveBeenCalled(); + }); + + it("degrades on a stubbed session instead of throwing", async () => { + const rejections: unknown[] = []; + const onRejection = (err: unknown) => rejections.push(err); + process.on("unhandledRejection", onRejection); + try { + manager = new AgentManager(); + // No extensionRunner at all — an older pi, or a partial `onSessionCreated` stub. + const noRunner = { dispose: vi.fn() } as any; + await spawnCompleted(manager, noRunner); + manager.clearCompleted(); + await vi.waitFor(() => expect(noRunner.dispose).toHaveBeenCalled()); + + // Nothing at all — not even dispose. + const empty = {} as any; + await spawnCompleted(manager, empty); + manager.clearCompleted(); + await expect(manager.dispose()).resolves.toBeUndefined(); + + await new Promise(r => setImmediate(r)); + expect(rejections).toEqual([]); + } finally { + process.off("unhandledRejection", onRejection); + } + }); +}); diff --git a/pi-subagents/test/clear-completed-wiring.test.ts b/pi-subagents/test/clear-completed-wiring.test.ts new file mode 100644 index 0000000..047e6cf --- /dev/null +++ b/pi-subagents/test/clear-completed-wiring.test.ts @@ -0,0 +1,172 @@ +/** + * clear-completed-wiring.test.ts — reproduces issue #108 end-to-end through the + * REAL session lifecycle handlers + the REAL get_subagent_result tool. + * + * Bug: a background agent that has COMPLETED but whose result the LLM hasn't read + * yet (resultConsumed=false) was wiped by clearCompleted() on session_start / + * session_before_switch, so the next get_subagent_result returned "Agent not + * found". The fix makes both handlers call clearCompleted(true), preserving + * unread records (the 10-minute timer evicts them later). + * + * These tests exercise the wiring, not the manager method in isolation: spawn a + * real background agent, let it complete, fire the real session event, then read + * it back through the real tool — the exact path the reporter hit. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); // pi.on(...) — session_start, session_before_switch, session_shutdown + const events = new Map(); // pi.events.on(...) — subagents:rpc:*, etc. + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + events.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle, events }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; +// Let runAgent's resolved .then() chain settle so the record reaches "completed". +const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +// Spawn a real background agent and drive it to status "completed" with +// resultConsumed=false (only get_subagent_result sets that flag for background). +async function spawnCompletedBackgroundAgent(tools: Map): Promise { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "THE-RESULT-PAYLOAD", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + const spawn = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "Review monero_en.rs in depth", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctx(), + ); + const id = textOf(spawn).match(/Agent ID: (\S+)/)?.[1]; + expect(id, "background spawn should surface an agent id").toBeTruthy(); + await flush(); + return id as string; +} + +describe("issue #108: unread completed background agents survive session events", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + // Hermetic cwd + global dir, scheduling off, so session_start doesn't spin a + // scheduler or touch the dev's filesystem — isolates the clearCompleted path. + tmpDir = mkdtempSync(join(tmpdir(), "pi-108-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-108-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("session_before_switch (user switches sessions) does NOT wipe the unread result", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + // The exact #108 trigger: a session switch fires before the LLM read the result. + await lifecycle.get("session_before_switch")?.(); + + const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(res); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("session_start (/resume) does NOT wipe the unread result", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + await lifecycle.get("session_start")?.({}, ctx()); + + const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(res); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("once read, a session switch DOES evict it — the fix stays surgical, no leak", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + // LLM reads the result → resultConsumed=true. + const first = await tools.get("get_subagent_result").execute("tc-read1", { agent_id: id }, undefined, undefined, ctx()); + expect(textOf(first)).toContain("THE-RESULT-PAYLOAD"); + + // Now a session switch SHOULD clean it up (consumed records are not preserved). + await lifecycle.get("session_before_switch")?.(); + + const second = await tools.get("get_subagent_result").execute("tc-read2", { agent_id: id }, undefined, undefined, ctx()); + expect(textOf(second)).toContain("Agent not found"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); +}); diff --git a/pi-subagents/test/context.test.ts b/pi-subagents/test/context.test.ts new file mode 100644 index 0000000..0b25ba2 --- /dev/null +++ b/pi-subagents/test/context.test.ts @@ -0,0 +1,129 @@ +/** + * context.test.ts — Parent conversation context extraction for inherit_context spawns. + * + * buildParentContext shapes what a subagent sees from its parent; silent bugs + * here would feed wrong context into spawns. Tests use realistic SessionEntry + * shapes and a minimal ExtensionContext stub — no mocking beyond the one + * getBranch() call the function actually reads. + */ + +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { buildParentContext, extractText } from "../src/context.js"; + +function makeCtx(entries: unknown[]): ExtensionContext { + return { sessionManager: { getBranch: () => entries } } as unknown as ExtensionContext; +} + +function userMsg(content: string | unknown[]) { + return { type: "message", message: { role: "user", content } }; +} + +function assistantMsg(blocks: unknown[]) { + return { type: "message", message: { role: "assistant", content: blocks } }; +} + +describe("extractText", () => { + it("joins multiple text blocks with newlines", () => { + expect(extractText([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toBe("a\nb"); + }); + + it("filters out non-text blocks (tool_use, etc.)", () => { + expect( + extractText([ + { type: "text", text: "keep" }, + { type: "tool_use", name: "x", input: {} }, + { type: "text", text: "also keep" }, + ]), + ).toBe("keep\nalso keep"); + }); + + it("treats a text block with missing text field as empty", () => { + expect(extractText([{ type: "text" }, { type: "text", text: "x" }])).toBe("\nx"); + }); + + it("returns empty string for an empty content array", () => { + expect(extractText([])).toBe(""); + }); +}); + +describe("buildParentContext", () => { + it("returns empty string for an empty branch", () => { + expect(buildParentContext(makeCtx([]))).toBe(""); + }); + + it("returns empty string when no entries produce extractable content", () => { + // toolResult is skipped, empty-summary compaction is skipped + const out = buildParentContext( + makeCtx([ + { type: "message", message: { role: "tool_result", content: "..." } }, + { type: "compaction", summary: "" }, + ]), + ); + expect(out).toBe(""); + }); + + it("wraps a user+assistant exchange with the parent-context header and task footer", () => { + const out = buildParentContext( + makeCtx([userMsg("hello"), assistantMsg([{ type: "text", text: "hi back" }])]), + ); + expect(out).toContain("# Parent Conversation Context"); + expect(out).toContain("[User]: hello"); + expect(out).toContain("[Assistant]: hi back"); + expect(out).toMatch(/# Your Task \(below\)\n$/); + // Entries are joined with a blank line, preserving conversation order + expect(out).toContain("[User]: hello\n\n[Assistant]: hi back"); + }); + + it("accepts user messages whose content is content-blocks (not just a string)", () => { + const out = buildParentContext(makeCtx([userMsg([{ type: "text", text: "from blocks" }])])); + expect(out).toContain("[User]: from blocks"); + }); + + it("includes compaction summaries inline in their original position", () => { + const out = buildParentContext( + makeCtx([ + userMsg("orig question"), + { type: "compaction", summary: "we discussed X" }, + assistantMsg([{ type: "text", text: "follow-up" }]), + ]), + ); + expect(out.indexOf("[User]: orig question")) + .toBeLessThan(out.indexOf("[Summary]: we discussed X")); + expect(out.indexOf("[Summary]: we discussed X")) + .toBeLessThan(out.indexOf("[Assistant]: follow-up")); + }); + + it("skips tool_result messages — they're too verbose for inherited context", () => { + const out = buildParentContext( + makeCtx([ + userMsg("real user"), + { type: "message", message: { role: "tool_result", content: "noisy tool output" } }, + assistantMsg([{ type: "text", text: "real assistant" }]), + ]), + ); + expect(out).not.toContain("noisy tool output"); + expect(out).toContain("[User]: real user"); + expect(out).toContain("[Assistant]: real assistant"); + }); + + it("trims and skips whitespace-only messages", () => { + const out = buildParentContext( + makeCtx([userMsg(" \n "), assistantMsg([{ type: "text", text: "non-empty" }])]), + ); + expect(out).not.toMatch(/\[User\]:/); + expect(out).toContain("[Assistant]: non-empty"); + }); + + it("ignores assistant messages whose only content is non-text blocks", () => { + // Assistant emitted only a tool_use — nothing extractable, so it shouldn't appear + const out = buildParentContext( + makeCtx([ + userMsg("question"), + assistantMsg([{ type: "tool_use", name: "x", input: {} }]), + ]), + ); + expect(out).toContain("[User]: question"); + expect(out).not.toContain("[Assistant]:"); + }); +}); diff --git a/pi-subagents/test/conversation-viewer-keybindings.test.ts b/pi-subagents/test/conversation-viewer-keybindings.test.ts new file mode 100644 index 0000000..51914ea --- /dev/null +++ b/pi-subagents/test/conversation-viewer-keybindings.test.ts @@ -0,0 +1,140 @@ +import { KeybindingsManager, TUI_KEYBINDINGS } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import type { AgentRecord } from "../src/types.js"; +import { ConversationViewer } from "../src/ui/conversation-viewer.js"; +import type { ViewerKeybindings } from "../src/ui/viewer-keys.js"; +import { createViewerKeys } from "../src/ui/viewer-keys.js"; + +const CTRL_P = "\x10"; +const CTRL_N = "\x0e"; +const UP = "\x1b[A"; +const DOWN = "\x1b[B"; +const SHIFT_UP = "\x1b[1;2A"; +const SHIFT_DOWN = "\x1b[1;2B"; +const PAGE_UP = "\x1b[5~"; +const PAGE_DOWN = "\x1b[6~"; + +function createEmacsKeybindings(): KeybindingsManager { + return new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.select.up": ["up", "ctrl+p"], + "tui.select.down": ["down", "ctrl+n"], + }); +} + +function createViewer(keybindings?: ViewerKeybindings) { + const tui = { + terminal: { rows: 20, columns: 80 }, + requestRender: vi.fn(), + } as any; + const messages = Array.from({ length: 60 }, (_, i) => ({ + role: "user", + content: `message ${i}`, + })); + const session = { + messages, + subscribe: vi.fn(() => vi.fn()), + } as any; + const record = { + id: "test-1", + type: "general-purpose", + description: "test agent", + status: "completed", + toolUses: 0, + startedAt: Date.now(), + } as AgentRecord; + const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as any; + const viewer = new ConversationViewer(tui, session, record, undefined, theme, vi.fn(), undefined, keybindings); + viewer.render(80); // sets lastInnerW and scrolls to bottom (autoScroll) + return viewer; +} + +function scrollOffset(viewer: ConversationViewer): number { + return (viewer as any).scrollOffset; +} + +describe("viewer-keys", () => { + it("honors user keybindings when a manager is provided", () => { + const keys = createViewerKeys(createEmacsKeybindings()); + expect(keys.scrollUp(CTRL_P)).toBe(true); + expect(keys.scrollUp(UP)).toBe(true); + expect(keys.scrollDown(CTRL_N)).toBe(true); + expect(keys.scrollDown(DOWN)).toBe(true); + }); + + it("falls back to hardcoded defaults without a manager", () => { + const keys = createViewerKeys(); + expect(keys.scrollUp(UP)).toBe(true); + expect(keys.scrollUp(CTRL_P)).toBe(false); + expect(keys.scrollDown(DOWN)).toBe(true); + expect(keys.scrollDown(CTRL_N)).toBe(false); + expect(keys.pageUp(PAGE_UP)).toBe(true); + expect(keys.pageDown(PAGE_DOWN)).toBe(true); + }); + + it("keeps the k/j and shift+arrow aliases with and without a manager", () => { + for (const keys of [createViewerKeys(), createViewerKeys(createEmacsKeybindings())]) { + expect(keys.scrollUp("k")).toBe(true); + expect(keys.scrollDown("j")).toBe(true); + expect(keys.pageUp(SHIFT_UP)).toBe(true); + expect(keys.pageDown(SHIFT_DOWN)).toBe(true); + } + }); + + it("manager with no user overrides behaves like the hardcoded defaults", () => { + const keys = createViewerKeys(new KeybindingsManager(TUI_KEYBINDINGS, {})); + expect(keys.scrollUp(UP)).toBe(true); + expect(keys.scrollDown(DOWN)).toBe(true); + expect(keys.pageUp(PAGE_UP)).toBe(true); + expect(keys.pageDown(PAGE_DOWN)).toBe(true); + expect(keys.scrollUp(CTRL_P)).toBe(false); + expect(keys.scrollDown(CTRL_N)).toBe(false); + }); + + it("respects rebinding that removes a default key", () => { + const manager = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.select.up": "ctrl+p", + }); + const keys = createViewerKeys(manager); + expect(keys.scrollUp(CTRL_P)).toBe(true); + expect(keys.scrollUp(UP)).toBe(false); + }); +}); + +describe("ConversationViewer custom keybindings", () => { + it("scrolls with ctrl+p/ctrl+n when bound to tui.select.up/down", () => { + const viewer = createViewer(createEmacsKeybindings()); + const bottom = scrollOffset(viewer); + expect(bottom).toBeGreaterThan(0); + + viewer.handleInput(CTRL_P); + expect(scrollOffset(viewer)).toBe(bottom - 1); + viewer.handleInput(CTRL_N); + expect(scrollOffset(viewer)).toBe(bottom); + }); + + it("keeps arrows and k/j working alongside custom bindings", () => { + const viewer = createViewer(createEmacsKeybindings()); + const bottom = scrollOffset(viewer); + + viewer.handleInput(UP); + viewer.handleInput("k"); + expect(scrollOffset(viewer)).toBe(bottom - 2); + viewer.handleInput(DOWN); + viewer.handleInput("j"); + expect(scrollOffset(viewer)).toBe(bottom); + }); + + it("treats ctrl+p/ctrl+n as unbound without a keybindings manager", () => { + const viewer = createViewer(); + const bottom = scrollOffset(viewer); + + viewer.handleInput(CTRL_P); + viewer.handleInput(CTRL_N); + expect(scrollOffset(viewer)).toBe(bottom); + viewer.handleInput(UP); + expect(scrollOffset(viewer)).toBe(bottom - 1); + }); +}); diff --git a/pi-subagents/test/conversation-viewer.test.ts b/pi-subagents/test/conversation-viewer.test.ts new file mode 100644 index 0000000..950818c --- /dev/null +++ b/pi-subagents/test/conversation-viewer.test.ts @@ -0,0 +1,527 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentRecord } from "../src/types.js"; + +// ── Mock wrapTextWithAnsi ────────────────────────────────────────────── +// We need to control what wrapTextWithAnsi returns to simulate the +// upstream bug (returning lines wider than requested width). +// vi.mock is hoisted and intercepts before conversation-viewer.ts binds +// its import. + +let wrapOverride: ((text: string, width: number) => string[]) | null = null; + +vi.mock("@earendil-works/pi-tui", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + wrapTextWithAnsi: (...args: [string, number]) => { + if (wrapOverride) return wrapOverride(...args); + return original.wrapTextWithAnsi(...args); + }, + }; +}); + +// Must import AFTER vi.mock declaration (vitest hoists vi.mock but the +// dynamic import of the test subject must happen after) +const { visibleWidth } = await import("@earendil-works/pi-tui"); +const { ConversationViewer } = await import("../src/ui/conversation-viewer.js"); + +// ── Helpers ──────────────────────────────────────────────────────────── + +function mockTui(rows = 40, columns = 80) { + return { + terminal: { rows, columns }, + requestRender: vi.fn(), + } as any; +} + +function mockSession(messages: any[] = []) { + return { + messages, + subscribe: vi.fn(() => vi.fn()), + dispose: vi.fn(), + getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }), + } as any; +} + +function mockRecord(overrides: Partial = {}): AgentRecord { + return { + id: "test-1", + type: "general-purpose", + description: "test agent", + status: "running", + toolUses: 0, + startedAt: Date.now(), + ...overrides, + } as AgentRecord; +} + +function ansiTheme() { + return { + fg: (_color: string, text: string) => `\x1b[38;5;240m${text}\x1b[0m`, + bold: (text: string) => `\x1b[1m${text}\x1b[22m`, + } as any; +} + +function assertAllLinesFit(lines: string[], width: number) { + for (let i = 0; i < lines.length; i++) { + const vw = visibleWidth(lines[i]); + expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width); + } +} + +// ── Tests ────────────────────────────────────────────────────────────── + +beforeEach(() => { + wrapOverride = null; +}); + +describe("ConversationViewer cost display", () => { + /** The header line, with a cost of `cost` on the record and showCost `on`. */ + function header(on: boolean, cost: number): string { + const record = mockRecord({ + lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost }, + } as Partial); + const viewer = new ConversationViewer( + mockTui(30, 200), mockSession([]), record, undefined, + { fg: (_c: string, t: string) => t, bold: (t: string) => t } as any, + vi.fn(), undefined, undefined, undefined, on, + ); + return viewer.render(200).join("\n"); + } + + it("shows the cost beside the token count when enabled", () => { + // The viewer opens on finished agents, whose live activity entry is gone — + // so this reads the record, and would show nothing if it did not. + const out = header(true, 0.0042); + expect(out).toContain("1.2k token"); + expect(out).toContain("~$0.0042"); + }); + + it("shows no cost when disabled", () => { + const out = header(false, 0.0042); + expect(out).toContain("1.2k token"); + expect(out).not.toContain("$"); + }); + + it("shows no cost for a model with no pricing data", () => { + expect(header(true, 0)).not.toContain("$"); + }); +}); + +describe("ConversationViewer", () => { + describe("render width safety", () => { + const widths = [40, 80, 120, 216]; + + it("no line exceeds width with empty messages", () => { + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with plain text messages", () => { + const messages = [ + { role: "user", content: "Hello, how are you?" }, + { role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("keeps bordered rows exact-width at a double-width truncation boundary", () => { + const width = 40; + for (let prefixLength = 0; prefixLength < width; prefixLength++) { + const viewer = new ConversationViewer( + mockTui(30, width), + mockSession([]), + mockRecord({ description: `${"a".repeat(prefixLength)}界more` }), + undefined, + ansiTheme(), + vi.fn(), + ); + + for (const line of viewer.render(width)) { + expect( + visibleWidth(line), + `prefix ${prefixLength} produced an under-width bordered row: ${JSON.stringify(line)}`, + ).toBe(width); + } + } + }); + + it("no line exceeds width when text is longer than viewport", () => { + const longLine = "A".repeat(500); + const messages = [ + { role: "user", content: longLine }, + { role: "assistant", content: [{ type: "text", text: longLine }] }, + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with embedded ANSI escape codes in content", () => { + const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`; + const messages = [ + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with long URLs", () => { + const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100); + const messages = [ + { role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with wide table-like content", () => { + const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |"; + const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |"; + const table = [header, dataRow, dataRow, dataRow].join("\n"); + const messages = [ + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with bashExecution messages", () => { + const messages = [ + { + role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt", + output: "O".repeat(600), + exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(), + }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with running activity indicator", () => { + const activity = { + activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]), + toolUses: 5, tokens: "10k", responseText: "R".repeat(400), + session: { getSessionStats: () => ({ tokens: { total: 50000 } }) }, + }; + const messages = [ + { role: "user", content: "do the thing" }, + { role: "assistant", content: [{ type: "text", text: "working on it" }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity as any, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with tool calls", () => { + const messages = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that." }, + { type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} }, + ], + }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width at narrow terminal", () => { + const messages = [ + { role: "user", content: "Hello world, this is a normal sentence." }, + { role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] }, + ]; + for (const w of [8, 10, 15, 20]) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + + it("no line exceeds width with mixed ANSI + unicode content", () => { + const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`; + const messages = [ + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] }, + ]; + for (const w of widths) { + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(viewer.render(w), w); + } + }); + }); + + describe("safety net against upstream wrapTextWithAnsi bugs", () => { + // These tests call buildContentLines() directly (via the private method) + // because render() has its own truncation via row(). The safety net in + // buildContentLines is what prevents the TUI crash — it must clamp + // independently of render(). + + /** Call the private buildContentLines method directly. */ + function callBuildContentLines(viewer: InstanceType, width: number): string[] { + return (viewer as any).buildContentLines(width); + } + + it("mock is intercepting wrapTextWithAnsi", async () => { + const { wrapTextWithAnsi } = await import("@earendil-works/pi-tui"); + wrapOverride = () => ["MOCK_SENTINEL"]; + expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]); + wrapOverride = null; + }); + + it("clamps overwidth lines from toolResult content", () => { + const w = 80; + wrapOverride = () => ["X".repeat(w + 50)]; + + const messages = [ + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] }, + ]; + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(callBuildContentLines(viewer, w), w); + }); + + it("clamps overwidth lines from user message content", () => { + const w = 80; + wrapOverride = () => ["Y".repeat(w + 100)]; + + const messages = [{ role: "user", content: "hello" }]; + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(callBuildContentLines(viewer, w), w); + }); + + it("clamps overwidth lines from assistant message content", () => { + const w = 80; + wrapOverride = () => ["Z".repeat(w + 100)]; + + const messages = [ + { role: "assistant", content: [{ type: "text", text: "response" }] }, + ]; + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(callBuildContentLines(viewer, w), w); + }); + + it("clamps overwidth lines from bashExecution output", () => { + const w = 80; + wrapOverride = () => ["B".repeat(w + 100)]; + + const messages = [ + { + role: "bashExecution", command: "ls", output: "out", + exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(), + }, + ]; + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(callBuildContentLines(viewer, w), w); + }); + + it("clamps overwidth lines that also contain ANSI codes", () => { + const w = 80; + wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`]; + + const messages = [ + { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] }, + ]; + const viewer = new ConversationViewer( + mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(), + ); + assertAllLinesFit(callBuildContentLines(viewer, w), w); + }); + }); + + describe("stop key", () => { + const W = 80; + + it("two-press x stops a running agent (first arms, second aborts)", () => { + const onStop = vi.fn(); + const tui = mockTui(30, W); + const viewer = new ConversationViewer( + tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop, + ); + + // Idle footer offers the stop affordance. + expect(viewer.render(W).join("\n")).toContain("x stop"); + + // First press arms (no abort yet) and re-renders. + viewer.handleInput("x"); + expect(onStop).not.toHaveBeenCalled(); + expect(tui.requestRender).toHaveBeenCalled(); + expect(viewer.render(W).join("\n")).toContain("x again to STOP"); + + // Second press aborts. + viewer.handleInput("x"); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it("any other key disarms the confirm", () => { + const onStop = vi.fn(); + const viewer = new ConversationViewer( + mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop, + ); + + viewer.handleInput("x"); // arm + viewer.handleInput("j"); // scroll → disarm + expect(viewer.render(W).join("\n")).toContain("x stop"); + expect(viewer.render(W).join("\n")).not.toContain("x again to STOP"); + + viewer.handleInput("x"); // arms again, does NOT stop + expect(onStop).not.toHaveBeenCalled(); + }); + + it("does not offer or perform stop once the agent is no longer running", () => { + const onStop = vi.fn(); + const viewer = new ConversationViewer( + mockTui(30, W), mockSession(), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(), onStop, + ); + + expect(viewer.render(W).join("\n")).not.toContain("x stop"); + viewer.handleInput("x"); + viewer.handleInput("x"); + expect(onStop).not.toHaveBeenCalled(); + }); + + it("no stop affordance when no onStop handler is provided (read-only history)", () => { + const viewer = new ConversationViewer( + mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), + ); + expect(viewer.render(W).join("\n")).not.toContain("x stop"); + expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow(); + }); + }); + + describe("steer composer", () => { + const W = 80; + + function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) { + const onSteer = opts.onSteer ?? vi.fn(); + const tui = mockTui(30, W); + const viewer = new ConversationViewer( + tui, mockSession(), mockRecord({ status: opts.status ?? "running" }), + undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer, + ); + return { viewer, tui, onSteer }; + } + + it("offers the steer affordance for a running agent and opens on Enter", () => { + const { viewer } = makeViewer(); + expect(viewer.render(W).join("\n")).toContain("Enter steer"); + + viewer.handleInput("\r"); // Enter + // Composer is shown (its prompt + send/cancel hint), idle footer is gone. + const out = viewer.render(W).join("\n"); + expect(out).toContain("Enter send · Esc cancel"); + expect(out).not.toContain("Enter steer"); + }); + + it("typing then Enter sends the trimmed message and closes the composer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + for (const ch of " hello ") viewer.handleInput(ch); + viewer.handleInput("\r"); // send + + expect(onSteer).toHaveBeenCalledWith("hello"); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed + }); + + it("Esc cancels the composer without sending", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + for (const ch of "draft") viewer.handleInput(ch); + viewer.handleInput("\x1b"); // Esc + + expect(onSteer).not.toHaveBeenCalled(); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); + }); + + it("an empty submit just returns (like Esc), without calling onSteer", () => { + const { viewer, onSteer } = makeViewer(); + viewer.handleInput("\r"); // open composer + viewer.handleInput("\r"); // empty submit + expect(onSteer).not.toHaveBeenCalled(); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed + }); + + it("scroll keys are inert while composing (input owns them)", () => { + const { viewer } = makeViewer(); + viewer.handleInput("\r"); // open composer + // 'j' would normally scroll, but here it types into the composer. + viewer.handleInput("j"); + expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel"); + }); + + it("no steer affordance once the agent is no longer running", () => { + const { viewer, onSteer } = makeViewer({ status: "completed" }); + expect(viewer.render(W).join("\n")).not.toContain("Enter steer"); + viewer.handleInput("\r"); + expect(viewer.render(W).join("\n")).not.toContain("Enter send"); + expect(onSteer).not.toHaveBeenCalled(); + }); + + it("no steer affordance when no onSteer handler is provided", () => { + const viewer = new ConversationViewer( + mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), + ); + expect(viewer.render(W).join("\n")).not.toContain("Enter steer"); + expect(() => viewer.handleInput("\r")).not.toThrow(); + }); + + it("composer rows never exceed width", () => { + for (const w of [40, 80, 120]) { + const tui = mockTui(30, w); + const viewer = new ConversationViewer( + tui, mockSession(), mockRecord({ status: "running" }), + undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(), + ); + viewer.handleInput("\r"); // open composer + for (const ch of "x".repeat(200)) viewer.handleInput(ch); + assertAllLinesFit(viewer.render(w), w); + } + }); + }); +}); diff --git a/pi-subagents/test/cost-display.test.ts b/pi-subagents/test/cost-display.test.ts new file mode 100644 index 0000000..b1df738 --- /dev/null +++ b/pi-subagents/test/cost-display.test.ts @@ -0,0 +1,217 @@ +/** + * cost-display.test.ts — what `showCost` puts on each surface, and what it must + * never put there. + * + * Two rules run through all of it. A cost is shown only when there is one to + * show: a model pi has no rates for reports 0, and printing `$0.00` beside its + * tokens would claim the run was measured and free. And each surface punctuates + * its own — the stats line joins with `·`, the foreground result with `,`, the + * `get_subagent_result` header with `|` — which is why the cost travels as a + * number and is formatted at the end, not baked into the token string. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import { registerAgents } from "../src/agent-types.js"; +import subagentsExtension from "../src/index.js"; +import { ctx, flush, type Hermetic, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js"; + +const COST = 0.0123; + +/** One foreground run that spends `cost` on a single assistant message. */ +function runSpending(cost: number) { + vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => { + opts.onAssistantUsage?.({ input: 1000, output: 200, cacheWrite: 0, cost }); + return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false }; + }); +} + +const spawn = (tools: Map) => + tools.get("Agent").execute( + "tc-1", + { prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: false }, + undefined, undefined, ctx(), + ); + +describe("cost display", () => { + let hermetic: Hermetic; + + function boot(settings: Record) { + hermetic = hermeticDir({ settings }); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + return { pi, tools, lifecycle }; + } + + beforeEach(() => { + vi.mocked(runAgent).mockReset(); + }); + + afterEach(() => { + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + registerAgents(new Map()); + hermetic?.restore(); + }); + + describe("the foreground result the orchestrator reads", () => { + it("names the cost in the stats it already reports", async () => { + const { tools } = boot({ showCost: true }); + runSpending(COST); + + const text = textOf(await spawn(tools)); + + expect(text).toContain("~$0.0123"); + // Comma-joined with the rest, not glued to the token count with the "·" + // that the widget uses — the separator belongs to the surface. + expect(text).toMatch(/1\.2k token, ~\$0\.0123/); + }); + + it("says nothing when the setting is off", async () => { + const { tools } = boot({ showCost: false }); + runSpending(COST); + + expect(textOf(await spawn(tools))).not.toContain("$"); + }); + + it("says nothing for a model with no pricing data", async () => { + const { tools } = boot({ showCost: true }); + runSpending(0); + + const text = textOf(await spawn(tools)); + expect(text).toContain("1.2k token"); // tokens are still exact + expect(text).not.toContain("$"); + }); + }); + + describe("get_subagent_result", () => { + it("reports the cost as its own labelled field", async () => { + const { tools } = boot({ showCost: true }); + runSpending(COST); + await spawn(tools); + await flush(); + + // The agent above ran in the foreground; look it up by the handle its + // type gets, which is how the orchestrator would reach it. + const text = textOf(await tools.get("get_subagent_result").execute( + "tc-2", { agent_id: "general-purpose" }, undefined, undefined, ctx(), + )); + + // Pipe-separated `Label: value` fields, matching its neighbours. + expect(text).toContain("Cost: ~$0.0123"); + }); + + it("omits the field entirely when unpriced", async () => { + const { tools } = boot({ showCost: true }); + runSpending(0); + await spawn(tools); + await flush(); + + const text = textOf(await tools.get("get_subagent_result").execute( + "tc-2", { agent_id: "general-purpose" }, undefined, undefined, ctx(), + )); + + expect(text).not.toContain("Cost:"); + }); + }); + + describe("the background completion notification the model reads", () => { + /** + * The text sent into the parent conversation. Held + * behind a 200ms nudge debounce (plus batch finalization), so this polls + * rather than waiting a fixed beat. + */ + async function notificationText(pi: any): Promise { + for (let i = 0; i < 60 && pi.sendMessage.mock.calls.length === 0; i++) { + await new Promise((r) => setTimeout(r, 25)); + } + return pi.sendMessage.mock.calls.at(-1)?.[0]?.content ?? ""; + } + + const spawnBackground = (tools: Map) => + tools.get("Agent").execute( + "tc-1", + { prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: true }, + undefined, undefined, ctx(), + ); + + it("includes the cost in the usage block when enabled", async () => { + const { pi, tools } = boot({ showCost: true, defaultJoinMode: "async" }); + runSpending(COST); + + await spawnBackground(tools); + + expect(await notificationText(pi)).toContain("0.0123"); + }); + + it("omits it when disabled — this is LLM context, not a display", async () => { + // A figure the orchestrator was not asked to track is one it may start + // reporting unprompted, so the setting gates the context too, not just + // what a human sees. + const { pi, tools } = boot({ showCost: false, defaultJoinMode: "async" }); + runSpending(COST); + + await spawnBackground(tools); + + const text = await notificationText(pi); + expect(text).toContain(""); + expect(text).not.toContain("estimated_cost_usd"); + }); + + it("omits it for a model with no pricing data", async () => { + const { pi, tools } = boot({ showCost: true, defaultJoinMode: "async" }); + runSpending(0); + + await spawnBackground(tools); + + expect(await notificationText(pi)).not.toContain("estimated_cost_usd"); + }); + }); + + describe("the completion notification", () => { + /** Render a notification through the extension's registered renderer. */ + function render(pi: any, details: any): string { + const [, renderer] = pi.registerMessageRenderer.mock.calls[0]; + const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + return renderer({ details }, { expanded: false }, theme).render().join("\n"); + } + + const agent = (description: string, totalTokens: number, totalCost: number) => ({ + id: description, description, status: "completed", toolUses: 1, turnCount: 1, + totalTokens, totalCost, durationMs: 1000, resultPreview: "done", + }); + + it("totals a group, so nobody adds four figures by hand", () => { + const { pi } = boot({ showCost: true }); + const out = render(pi, { ...agent("first", 1000, 0.01), others: [agent("second", 3000, 0.02)] }); + + expect(out).toContain("2 agents · 4.0k token · ~$0.03"); + }); + + it("does not total a single agent — the line above already says it", () => { + const { pi } = boot({ showCost: true }); + const out = render(pi, agent("only", 1000, 0.01)); + + expect(out).toContain("~$0.01"); + expect(out).not.toContain("1 agents"); + }); + + it("shows no total, and no per-agent cost, when unpriced", () => { + const { pi } = boot({ showCost: true }); + const out = render(pi, { ...agent("first", 1000, 0), others: [agent("second", 3000, 0)] }); + + expect(out).not.toContain("$"); + }); + + it("shows nothing when the setting is off", () => { + const { pi } = boot({ showCost: false }); + const out = render(pi, { ...agent("first", 1000, 0.01), others: [agent("second", 3000, 0.02)] }); + + expect(out).not.toContain("$"); + }); + }); +}); diff --git a/pi-subagents/test/cross-extension-rpc.test.ts b/pi-subagents/test/cross-extension-rpc.test.ts new file mode 100644 index 0000000..a95bd03 --- /dev/null +++ b/pi-subagents/test/cross-extension-rpc.test.ts @@ -0,0 +1,320 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type EventBus, PROTOCOL_VERSION, type RpcDeps, registerRpcHandlers, type SpawnCapable } from "../src/cross-extension-rpc.js"; + +/** Simple in-process event bus for testing. */ +function createEventBus(): EventBus { + const listeners = new Map void>>(); + return { + on(event, handler) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(handler); + return () => { listeners.get(event)?.delete(handler); }; + }, + emit(event, data) { + for (const handler of listeners.get(event) ?? []) handler(data); + }, + }; +} + +describe("cross-extension RPC", () => { + let events: EventBus; + let manager: SpawnCapable; + let ctx: object | undefined; + let deps: RpcDeps; + + beforeEach(() => { + events = createEventBus(); + manager = { spawn: vi.fn().mockReturnValue("agent-42"), abort: vi.fn().mockReturnValue(true) }; + ctx = { session: true }; + deps = { events, pi: { events }, getCtx: () => ctx, manager }; + }); + + // --- ping --- + + describe("ping RPC", () => { + it("replies with protocol version", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:ping:reply:req-1", reply); + events.emit("subagents:rpc:ping", { requestId: "req-1" }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: true, data: { version: PROTOCOL_VERSION } }); + }); + + it("scopes replies — other requestIds do not receive it", async () => { + registerRpcHandlers(deps); + const wrongReply = vi.fn(); + events.on("subagents:rpc:ping:reply:req-other", wrongReply); + events.emit("subagents:rpc:ping", { requestId: "req-1" }); + + await new Promise((r) => setTimeout(r, 20)); + expect(wrongReply).not.toHaveBeenCalled(); + }); + + it("unsub stops responding to pings", async () => { + const { unsubPing } = registerRpcHandlers(deps); + unsubPing(); + + const reply = vi.fn(); + events.on("subagents:rpc:ping:reply:req-1", reply); + events.emit("subagents:rpc:ping", { requestId: "req-1" }); + + await new Promise((r) => setTimeout(r, 20)); + expect(reply).not.toHaveBeenCalled(); + }); + }); + + // --- spawn --- + + describe("spawn RPC", () => { + it("returns agent id on success", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-s1", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s1", type: "general-purpose", prompt: "do stuff", + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: true, data: { id: "agent-42" } }); + expect(manager.spawn).toHaveBeenCalledWith( + deps.pi, ctx, "general-purpose", "do stuff", {}, + ); + }); + + it("passes options through to manager.spawn", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-s2", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s2", type: "Explore", prompt: "find it", + options: { description: "search", isBackground: true }, + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(manager.spawn).toHaveBeenCalledWith( + deps.pi, ctx, "Explore", "find it", + { description: "search", isBackground: true }, + ); + }); + + it("returns error when no active session", async () => { + ctx = undefined; + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-s3", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s3", type: "general-purpose", prompt: "x", + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: false, error: "No active session" }); + expect(manager.spawn).not.toHaveBeenCalled(); + }); + + it("returns error when manager.spawn throws", async () => { + (manager.spawn as ReturnType).mockImplementation(() => { + throw new Error("unknown agent type"); + }); + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-s4", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s4", type: "bad-type", prompt: "x", + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: false, error: "unknown agent type" }); + }); + + it("scopes replies — other requestIds do not receive it", async () => { + registerRpcHandlers(deps); + const wrongReply = vi.fn(); + const rightReply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-other", wrongReply); + events.on("subagents:rpc:spawn:reply:req-s5", rightReply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s5", type: "general-purpose", prompt: "x", + }); + + await vi.waitFor(() => expect(rightReply).toHaveBeenCalled()); + expect(wrongReply).not.toHaveBeenCalled(); + }); + + it("unsub stops responding to spawns", async () => { + const { unsubSpawn } = registerRpcHandlers(deps); + unsubSpawn(); + + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-s6", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-s6", type: "general-purpose", prompt: "x", + }); + + // Give any potential async handler time to fire + await new Promise((r) => setTimeout(r, 20)); + expect(reply).not.toHaveBeenCalled(); + }); + }); + + // --- stop --- + + describe("stop RPC", () => { + it("returns success when agent is aborted", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:stop:reply:req-st1", reply); + events.emit("subagents:rpc:stop", { requestId: "req-st1", agentId: "agent-42" }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: true }); + expect(manager.abort).toHaveBeenCalledWith("agent-42"); + }); + + it("returns error when agent not found", async () => { + (manager.abort as ReturnType).mockReturnValue(false); + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:stop:reply:req-st2", reply); + events.emit("subagents:rpc:stop", { requestId: "req-st2", agentId: "nonexistent" }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: false, error: "Agent not found" }); + }); + + it("scopes replies — other requestIds do not receive it", async () => { + registerRpcHandlers(deps); + const wrongReply = vi.fn(); + const rightReply = vi.fn(); + events.on("subagents:rpc:stop:reply:req-other", wrongReply); + events.on("subagents:rpc:stop:reply:req-st3", rightReply); + events.emit("subagents:rpc:stop", { requestId: "req-st3", agentId: "agent-42" }); + + await vi.waitFor(() => expect(rightReply).toHaveBeenCalled()); + expect(wrongReply).not.toHaveBeenCalled(); + }); + + it("unsub stops responding to stop requests", async () => { + const { unsubStop } = registerRpcHandlers(deps); + unsubStop(); + + const reply = vi.fn(); + events.on("subagents:rpc:stop:reply:req-st4", reply); + events.emit("subagents:rpc:stop", { requestId: "req-st4", agentId: "agent-42" }); + + await new Promise((r) => setTimeout(r, 20)); + expect(reply).not.toHaveBeenCalled(); + }); + }); + + // --- concurrent requests --- + + describe("concurrent requests", () => { + it("handles multiple simultaneous spawn requests independently", async () => { + let callCount = 0; + (manager.spawn as ReturnType).mockImplementation(() => `agent-${++callCount}`); + registerRpcHandlers(deps); + + const reply1 = vi.fn(); + const reply2 = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-a", reply1); + events.on("subagents:rpc:spawn:reply:req-b", reply2); + + events.emit("subagents:rpc:spawn", { requestId: "req-a", type: "Explore", prompt: "first" }); + events.emit("subagents:rpc:spawn", { requestId: "req-b", type: "Plan", prompt: "second" }); + + await vi.waitFor(() => { + expect(reply1).toHaveBeenCalled(); + expect(reply2).toHaveBeenCalled(); + }); + + expect(reply1).toHaveBeenCalledWith({ success: true, data: { id: "agent-1" } }); + expect(reply2).toHaveBeenCalledWith({ success: true, data: { id: "agent-2" } }); + }); + }); + + // --- model override resolution (regression for cross-extension callers + // that forward a serializable string instead of a Model object) --- + + describe("spawn RPC model override", () => { + const fakeModel = { id: "gpt-5.5", provider: "openai-codex", name: "GPT 5.5" }; + const registry = { + find: (provider: string, id: string) => + provider === fakeModel.provider && id === fakeModel.id ? fakeModel : null, + getAll: () => [fakeModel], + getAvailable: () => [fakeModel], + }; + + beforeEach(() => { + ctx = { session: true, modelRegistry: registry }; + deps = { events, pi: { events }, getCtx: () => ctx, manager }; + }); + + it("resolves a string model to a Model instance before manager.spawn", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-m1", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-m1", type: "general-purpose", prompt: "x", + options: { model: "openai-codex/gpt-5.5" }, + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(reply).toHaveBeenCalledWith({ success: true, data: { id: "agent-42" } }); + expect(manager.spawn).toHaveBeenCalledWith( + deps.pi, ctx, "general-purpose", "x", + { model: fakeModel }, + ); + }); + + it("passes a Model object through unchanged", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-m2", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-m2", type: "general-purpose", prompt: "x", + options: { model: fakeModel }, + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + expect(manager.spawn).toHaveBeenCalledWith( + deps.pi, ctx, "general-purpose", "x", + { model: fakeModel }, + ); + }); + + it("surfaces a clear error when the model string can't be resolved", async () => { + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-m3", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-m3", type: "general-purpose", prompt: "x", + options: { model: "nope/does-not-exist" }, + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + const call = (reply as ReturnType).mock.calls[0][0]; + expect(call.success).toBe(false); + expect(call.error).toMatch(/Model not found/); + expect(manager.spawn).not.toHaveBeenCalled(); + }); + + it("errors when ctx has no modelRegistry but a string model is given", async () => { + ctx = { session: true }; // no modelRegistry + registerRpcHandlers(deps); + const reply = vi.fn(); + events.on("subagents:rpc:spawn:reply:req-m4", reply); + events.emit("subagents:rpc:spawn", { + requestId: "req-m4", type: "general-purpose", prompt: "x", + options: { model: "openai-codex/gpt-5.5" }, + }); + + await vi.waitFor(() => expect(reply).toHaveBeenCalled()); + const call = (reply as ReturnType).mock.calls[0][0]; + expect(call.success).toBe(false); + expect(call.error).toMatch(/modelRegistry is unavailable/); + expect(manager.spawn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/pi-subagents/test/custom-agents.test.ts b/pi-subagents/test/custom-agents.test.ts new file mode 100644 index 0000000..b5c2756 --- /dev/null +++ b/pi-subagents/test/custom-agents.test.ts @@ -0,0 +1,1166 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { serializeAgentFile } from "../src/agent-file-toggle.js"; +import { BUILTIN_TOOL_NAMES } from "../src/agent-types.js"; +import { loadCustomAgents } from "../src/custom-agents.js"; +import type { AgentConfig } from "../src/types.js"; + +describe("loadCustomAgents", () => { + let tmpDir: string; + let originalHome: string | undefined; + let originalAgentDir: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-test-")); + originalHome = process.env.HOME; + originalAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.HOME = tmpDir; + delete process.env.PI_CODING_AGENT_DIR; + }); + + afterEach(() => { + if (originalHome == null) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDir; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeAgentIn(projectDir: ".agents" | ".pi", name: string, content: string) { + const dir = join(tmpDir, projectDir, "agents"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.md`), content); + } + + function writeAgent(name: string, content: string) { + writeAgentIn(".pi", name, content); + } + + function writeWorkspaceAgent(name: string, content: string) { + writeAgentIn(".agents", name, content); + } + + it("returns empty map when custom agent dirs do not exist", () => { + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(0); + }); + + it("loads a workspace project agent from .agents/agents", () => { + writeWorkspaceAgent("reviewer", `--- +description: Workspace Reviewer +--- + +Workspace prompt.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(1); + expect(result.get("reviewer")?.description).toBe("Workspace Reviewer"); + expect(result.get("reviewer")?.systemPrompt).toBe("Workspace prompt."); + expect(result.get("reviewer")?.source).toBe("project"); + }); + + it(".pi/agents overrides .agents/agents on a name clash", () => { + writeWorkspaceAgent("dupe", `--- +description: Workspace Project +--- + +Workspace prompt.`); + writeAgent("dupe", `--- +description: Pi Project +--- + +Pi prompt.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(1); + expect(result.get("dupe")?.description).toBe("Pi Project"); + expect(result.get("dupe")?.systemPrompt).toBe("Pi prompt."); + }); + + it("workspace project agents override global agents", () => { + const globalAgentDir = join(tmpDir, "global-agent-dir"); + process.env.PI_CODING_AGENT_DIR = globalAgentDir; + const globalAgents = join(globalAgentDir, "agents"); + mkdirSync(globalAgents, { recursive: true }); + writeFileSync(join(globalAgents, "dupe.md"), `--- +description: Global +--- + +Global prompt.`); + writeWorkspaceAgent("dupe", `--- +description: Workspace Project +--- + +Workspace prompt.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(1); + expect(result.get("dupe")?.description).toBe("Workspace Project"); + expect(result.get("dupe")?.systemPrompt).toBe("Workspace prompt."); + }); + + it("loads a basic agent with all frontmatter fields", () => { + writeAgent("auditor", `--- +description: Security Auditor +tools: read, grep, find +model: anthropic/claude-opus-4-6 +thinking: high +max_turns: 30 +persist_session: true +output_transcript: false +session_dir: .seams/pi-sessions/seam-plan-reviewer +allowed_subagents: scout, reviewer +prompt_mode: replace +inherit_context: true +run_in_background: true +isolated: true +--- + +You are a security auditor.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(1); + + const agent = result.get("auditor")!; + expect(agent.name).toBe("auditor"); + expect(agent.description).toBe("Security Auditor"); + expect(agent.builtinToolNames).toEqual(["read", "grep", "find"]); + expect(agent.model).toBe("anthropic/claude-opus-4-6"); + expect(agent.thinking).toBe("high"); + expect(agent.maxTurns).toBe(30); + expect(agent.persistSession).toBe(true); + expect(agent.outputTranscript).toBe(false); + expect(agent.sessionDir).toBe(".seams/pi-sessions/seam-plan-reviewer"); + expect(agent.allowedSubagents).toEqual(["scout", "reviewer"]); + expect(agent.promptMode).toBe("replace"); + expect(agent.inheritContext).toBe(true); + expect(agent.runInBackground).toBe(true); + expect(agent.isolated).toBe(true); + expect(agent.systemPrompt).toBe("You are a security auditor."); + }); + + it("uses sensible defaults when frontmatter is empty", () => { + writeAgent("minimal", `--- +--- + +Just a prompt.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("minimal")!; + + expect(agent.name).toBe("minimal"); + expect(agent.displayName).toBeUndefined(); + expect(agent.color).toBeUndefined(); + expect(agent.description).toBe("minimal"); // defaults to filename + expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); // all tools + expect(agent.extensions).toBe(true); // inherit all + expect(agent.skills).toBe(true); // inherit all + expect(agent.model).toBeUndefined(); + expect(agent.thinking).toBeUndefined(); + expect(agent.maxTurns).toBeUndefined(); + expect(agent.persistSession).toBeUndefined(); + expect(agent.outputTranscript).toBeUndefined(); + expect(agent.sessionDir).toBeUndefined(); + expect(agent.allowedSubagents).toBeUndefined(); + expect(agent.promptMode).toBe("replace"); + expect(agent.inheritContext).toBeUndefined(); + expect(agent.runInBackground).toBeUndefined(); + expect(agent.isolated).toBeUndefined(); + expect(agent.systemPrompt).toBe("Just a prompt."); + }); + + it("uses sensible defaults when no frontmatter at all", () => { + writeAgent("bare", "Just a system prompt, no frontmatter."); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("bare")!; + + expect(agent.name).toBe("bare"); + expect(agent.description).toBe("bare"); + expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(agent.systemPrompt).toBe("Just a system prompt, no frontmatter."); + }); + + it("parses allowed_subagents: off by default, `all` wildcard, csv restriction", () => { + writeAgent("omitted", `--- +--- +Off.`); + writeAgent("unrestricted", `--- +allowed_subagents: all +--- +Unrestricted.`); + writeAgent("wildcard", `--- +allowed_subagents: "*" +--- +Unrestricted.`); + writeAgent("mixed-case", `--- +allowed_subagents: scout, ALL +--- +Unrestricted.`); + writeAgent("none", `--- +allowed_subagents: none +--- +Off.`); + writeAgent("blank", `--- +allowed_subagents: +--- +Off.`); + writeAgent("restricted", `--- +allowed_subagents: scout, reviewer +--- +Restricted.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("omitted")!.allowedSubagents).toBeUndefined(); + expect(result.get("unrestricted")!.allowedSubagents).toBe("all"); + expect(result.get("wildcard")!.allowedSubagents).toBe("all"); + expect(result.get("mixed-case")!.allowedSubagents).toBe("all"); + expect(result.get("none")!.allowedSubagents).toBeUndefined(); + expect(result.get("blank")!.allowedSubagents).toBeUndefined(); + expect(result.get("restricted")!.allowedSubagents).toEqual(["scout", "reviewer"]); + }); + + it("accepts booleans like extensions:/skills: do, instead of a type named \"true\"", () => { + writeAgent("bool-on", `--- +allowed_subagents: true +--- +On.`); + writeAgent("bool-off", `--- +allowed_subagents: false +--- +Off.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("bool-on")!.allowedSubagents).toBe("all"); + expect(result.get("bool-off")!.allowedSubagents).toBeUndefined(); + }); + + it("handles tools: none → empty array", () => { + writeAgent("notool", `--- +tools: none +--- + +No tools.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("notool")!.builtinToolNames).toEqual([]); + }); + + it("handles extensions: false → no extensions", () => { + writeAgent("noext", `--- +extensions: false +skills: false +--- + +No extensions.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("noext")!; + expect(agent.extensions).toBe(false); + expect(agent.skills).toBe(false); + }); + + it("handles extension allowlist", () => { + writeAgent("partial", `--- +extensions: web-search, mcp-server +skills: planning, review +--- + +Partial access.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("partial")!; + expect(agent.extensions).toEqual(["web-search", "mcp-server"]); + expect(agent.skills).toEqual(["planning", "review"]); + }); + + it("parses exclude_extensions CSV", () => { + writeAgent("no-notify", `--- +extensions: true +exclude_extensions: pi-notify, telemetry +--- + +No notifications.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("no-notify")!; + expect(agent.extensions).toBe(true); + expect(agent.excludeExtensions).toEqual(["pi-notify", "telemetry"]); + }); + + it("parses exclude_extensions YAML list", () => { + writeAgent("no-notify-yaml", `--- +exclude_extensions: + - pi-notify +--- + +No notifications.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("no-notify-yaml")!.excludeExtensions).toEqual(["pi-notify"]); + }); + + it("exclude_extensions omitted or none → undefined", () => { + writeAgent("plain", `--- +description: plain +--- + +Plain.`); + writeAgent("explicit-none", `--- +exclude_extensions: none +--- + +None.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("plain")!.excludeExtensions).toBeUndefined(); + expect(result.get("explicit-none")!.excludeExtensions).toBeUndefined(); + }); + + it("passes through unknown tool names (not filtered)", () => { + writeAgent("custom-tools", `--- +tools: read, my_custom_tool, grep +--- + +Custom tools.`); + + const result = loadCustomAgents(tmpDir); + // Unknown tool names are passed through — filtering happens at tool creation time + expect(result.get("custom-tools")!.builtinToolNames).toEqual(["read", "my_custom_tool", "grep"]); + }); + + it("partitions tools: ext: entries out of builtinToolNames into extSelectors", () => { + writeAgent("ext-agent", `--- +tools: read, ext:foo, ext:bar/x +--- + +Extension selectors.`); + + const agent = loadCustomAgents(tmpDir).get("ext-agent")!; + expect(agent.builtinToolNames).toEqual(["read"]); + expect(agent.extSelectors).toEqual(["ext:foo", "ext:bar/x"]); + }); + + it("tools: with only ext: entries yields zero built-ins", () => { + writeAgent("ext-only", `--- +tools: ext:foo/bar +--- + +Ext only.`); + + const agent = loadCustomAgents(tmpDir).get("ext-only")!; + expect(agent.builtinToolNames).toEqual([]); + expect(agent.extSelectors).toEqual(["ext:foo/bar"]); + }); + + it("tools: '*' expands to all built-ins and composes with ext: selectors", () => { + writeAgent("wild", `--- +tools: "*, ext:foo" +--- + +Wildcard plus ext.`); + + const agent = loadCustomAgents(tmpDir).get("wild")!; + expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(agent.extSelectors).toEqual(["ext:foo"]); + }); + + it("tools: 'all' is a case-insensitive alias for '*' (closes #75)", () => { + // `tools: all` previously parsed "all" as a single tool name → allowlist + // containing the non-existent tool "all" → silent zero-tool agent. + for (const [name, value] of [["all-lower", "all"], ["all-upper", "ALL"], ["all-mixed", "All"]]) { + writeAgent(name, `---\ntools: ${value}\n---\n\nAlias.`); + const agent = loadCustomAgents(tmpDir).get(name)!; + expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(agent.extSelectors).toBeUndefined(); + } + }); + + it("tools: 'all' composes with ext: selectors like '*'", () => { + writeAgent("all-plus-ext", `--- +tools: "all, ext:foo" +--- + +All plus ext.`); + + const agent = loadCustomAgents(tmpDir).get("all-plus-ext")!; + expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); + expect(agent.extSelectors).toEqual(["ext:foo"]); + }); + + it("leaves extSelectors undefined when tools: has no ext: entries", () => { + writeAgent("plain", `--- +tools: read, bash +--- + +Plain tools.`); + + const agent = loadCustomAgents(tmpDir).get("plain")!; + expect(agent.builtinToolNames).toEqual(["read", "bash"]); + expect(agent.extSelectors).toBeUndefined(); + }); + + it("passes through thinking level as-is (no validation)", () => { + writeAgent("anythink", `--- +thinking: turbo +--- + +Any thinking.`); + + const result = loadCustomAgents(tmpDir); + // Pi validates at session creation — we just pass through + expect(result.get("anythink")!.thinking).toBe("turbo"); + }); + + it("loads thinking: max (pi 0.80's top level) unchanged (#147)", () => { + writeAgent("deepthink", `--- +thinking: max +--- + +Think hard.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("deepthink")!.thinking).toBe("max"); + }); + + it("accepts max_turns: 0 as unlimited", () => { + writeAgent("unlimited", `--- +max_turns: 0 +--- + +Unlimited turns.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("unlimited")!.maxTurns).toBe(0); + }); + + it("rejects negative max_turns", () => { + writeAgent("negturns", `--- +max_turns: -5 +--- + +Negative turns.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("negturns")!.maxTurns).toBeUndefined(); + }); + + it("handles prompt_mode: append", () => { + writeAgent("appender", `--- +prompt_mode: append +--- + +Extra instructions.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("appender")!.promptMode).toBe("append"); + }); + + it("defaults unknown prompt_mode to replace", () => { + writeAgent("badmode", `--- +prompt_mode: merge +--- + +Unknown mode.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("badmode")!.promptMode).toBe("replace"); + }); + + it("loads multiple agents", () => { + writeAgent("agent1", `--- +description: First +--- + +First agent.`); + writeAgent("agent2", `--- +description: Second +--- + +Second agent.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(2); + expect(result.has("agent1")).toBe(true); + expect(result.has("agent2")).toBe(true); + }); + + it("skips non-.md files", () => { + const dir = join(tmpDir, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "notes.txt"), "not an agent"); + writeFileSync(join(dir, "real.md"), `--- +description: Real Agent +--- + +Real.`); + + const result = loadCustomAgents(tmpDir); + expect(result.size).toBe(1); + expect(result.has("real")).toBe(true); + }); + + it("allows agents with names matching defaults (overrides them)", () => { + writeAgent("Explore", `--- +description: Custom Explore +--- + +Custom explore agent.`); + writeAgent("custom", `--- +description: Custom Agent +--- + +Should be loaded.`); + + const result = loadCustomAgents(tmpDir); + expect(result.has("Explore")).toBe(true); + expect(result.get("Explore")!.description).toBe("Custom Explore"); + expect(result.has("custom")).toBe(true); + }); + + it("handles empty body with frontmatter", () => { + writeAgent("nobody", `--- +description: No body +tools: read +--- +`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("nobody")!.systemPrompt).toBe(""); + }); + + it("supports inherit_extensions as alternative to extensions", () => { + writeAgent("altkey", `--- +inherit_extensions: false +inherit_skills: false +--- + +Alt keys.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("altkey")!; + expect(agent.extensions).toBe(false); + expect(agent.skills).toBe(false); + }); + + it("extensions: none → false", () => { + writeAgent("extnone", `--- +extensions: none +skills: none +--- + +None.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("extnone")!; + expect(agent.extensions).toBe(false); + expect(agent.skills).toBe(false); + }); + + it("extensions: true → true (inherit all)", () => { + writeAgent("exttrue", `--- +extensions: true +skills: true +--- + +All.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("exttrue")!; + expect(agent.extensions).toBe(true); + expect(agent.skills).toBe(true); + }); + + it("handles enabled: false frontmatter", () => { + writeAgent("disabled", `--- +enabled: false +--- +`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("disabled")!; + expect(agent.enabled).toBe(false); + }); + + it("takes the agent type from frontmatter name, not the filename", () => { + // Claude Code's rule: "the filename doesn't have to match". The same file + // dropped into either tool must dispatch under the same type. + writeAgent("blubb", `--- +name: code-review +description: Reviews code +--- + +Agent prompt.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("code-review")!.name).toBe("code-review"); + expect(result.get("blubb")).toBeUndefined(); + }); + + it("records the file it was read from, not the one its type would name", () => { + // `/agents` edits `sourcePath`: probing for `.md` finds nothing here, + // and its no-file branch writes a stub that loses to this file on load. + writeAgent("blubb", `--- +name: code-review +description: Reviews code +--- + +Agent prompt.`); + + expect(loadCustomAgents(tmpDir).get("code-review")!.sourcePath) + .toBe(join(tmpDir, ".pi", "agents", "blubb.md")); + }); + + it("falls back to the filename for an empty or blank declared name", () => { + // A quoted empty `name:` would otherwise register the agent under the empty + // type — unspawnable, and it takes the filename-derived one down with it. + writeAgent("myagent", "---\nname: \"\"\ndescription: My Agent\n---\n\nPrompt."); + writeAgent("other", "---\nname: \" \"\ndescription: Other\n---\n\nPrompt."); + + const result = loadCustomAgents(tmpDir); + expect(result.get("myagent")!.name).toBe("myagent"); + expect(result.get("other")!.name).toBe("other"); + expect(result.has("")).toBe(false); + }); + + it("trims a declared name so it matches what the user meant to type", () => { + writeAgent("blubb", "---\nname: \" code-review \"\ndescription: Reviews code\n---\n\nPrompt."); + + expect(loadCustomAgents(tmpDir).get("code-review")!.name).toBe("code-review"); + }); + + it("falls back to the filename when no name is declared", () => { + // Claude Code requires `name`; most existing files here predate it and + // must keep loading under the identity they already dispatch by. + writeAgent("myagent", `--- +description: My Agent +--- + +Agent prompt.`); + + expect(loadCustomAgents(tmpDir).get("myagent")!.name).toBe("myagent"); + }); + + it("keeps display_name as a label only, independent of the type", () => { + writeAgent("blubb", `--- +name: code-review +description: My Agent +display_name: MyAgent +--- + +Agent prompt.`); + + const agent = loadCustomAgents(tmpDir).get("code-review")!; + expect(agent.name).toBe("code-review"); + expect(agent.displayName).toBe("MyAgent"); + }); + + it("leaves displayName unset so the badge falls back to the type", () => { + // A Claude Code file has no display_name; `getConfig` resolves the label + // to the type, so it still badges as "code-reviewer" as it did before. + writeAgent("whatever", `--- +name: code-reviewer +description: Reviews code +color: "#8B5CF6" +--- + +Agent prompt.`); + + const agent = loadCustomAgents(tmpDir).get("code-reviewer")!; + expect(agent.name).toBe("code-reviewer"); + expect(agent.displayName).toBeUndefined(); + expect(agent.color).toBe("#8B5CF6"); + }); + + it("accepts a name Claude Code accepts, however unlike a type it looks", () => { + // Its docs describe names as "lowercase letters and hyphens", but the only + // load failure they state is the colon — so this must still load. + writeAgent("reviewer", `--- +name: Code Reviewer +description: Reviews code +--- + +Agent prompt.`); + + expect(loadCustomAgents(tmpDir).get("Code Reviewer")!.name).toBe("Code Reviewer"); + }); + + it("refuses a name containing the plugin-scope separator", () => { + // Claude Code doesn't load these. Skipping beats loading it under the + // filename, which would dispatch an agent whose declared identity nothing + // honoured. + writeAgent("scoped", `--- +name: my-plugin:reviewer +description: Reviews code +--- + +Agent prompt.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("my-plugin:reviewer")).toBeUndefined(); + expect(result.get("scoped")).toBeUndefined(); + }); + + it("does not claim the rejected file was overriding its filename's agent", () => { + // It would have registered under its *declared* name, which a colon keeps + // out of the registry entirely — so it shadowed nothing. Reporting a + // substitution of the same-named file from another directory describes a + // swap that never happened, and points at an agent that is unchanged. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeWorkspaceAgent("scoped", "---\ndescription: An unrelated agent\n---\n\nBody."); + writeAgent("scoped", "---\nname: my-plugin:reviewer\ndescription: Reviews code\n---\n\nBody."); + + const result = loadCustomAgents(tmpDir); + + expect(result.get("scoped")?.description).toBe("An unrelated agent"); + const message = warn.mock.calls.map(args => String(args[0])).join("\n"); + expect(message).toContain("reserved for plugin-scoped identifiers"); + expect(message).not.toContain("now loads from"); + } finally { + warn.mockRestore(); + } + }); + + it("lets a later file win a declared-name clash, as a filename clash always did", () => { + // Filenames were unique per directory by construction; declared names are + // not, so two files in one directory can now claim the same type. + writeAgent("a-first", `--- +name: shared +description: first +--- + +First.`); + writeAgent("b-second", `--- +name: shared +description: second +--- + +Second.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("shared")!.description).toBe("second"); + expect([...result.keys()].filter(k => k === "shared")).toHaveLength(1); + }); + + it("parses disallowed_tools as csv list", () => { + writeAgent("restricted", `--- +description: Restricted Agent +disallowed_tools: bash, write +--- + +No bash or write.`); + + const result = loadCustomAgents(tmpDir); + const agent = result.get("restricted")!; + expect(agent.disallowedTools).toEqual(["bash", "write"]); + }); + + it("disallowed_tools defaults to undefined when omitted", () => { + writeAgent("unrestricted", `--- +description: Unrestricted +--- + +All tools.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("unrestricted")!.disallowedTools).toBeUndefined(); + }); + + it("parses memory scope", () => { + writeAgent("rememberer", `--- +description: Agent with memory +memory: project +--- + +Remember things.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("rememberer")!.memory).toBe("project"); + }); + + it("parses memory: user scope", () => { + writeAgent("global-mem", `--- +memory: user +--- + +User memory.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("global-mem")!.memory).toBe("user"); + }); + + it("memory defaults to undefined when omitted", () => { + writeAgent("no-mem", `--- +description: No memory +--- + +Stateless.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("no-mem")!.memory).toBeUndefined(); + }); + + it("rejects invalid memory scope", () => { + writeAgent("bad-mem", `--- +memory: invalid +--- + +Bad memory.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("bad-mem")!.memory).toBeUndefined(); + }); + + it("parses isolation: worktree", () => { + writeAgent("isolated-wt", `--- +description: Worktree agent +isolation: worktree +--- + +Isolated.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("isolated-wt")!.isolation).toBe("worktree"); + }); + + it("isolation defaults to undefined when omitted", () => { + writeAgent("no-isolation", `--- +description: Normal +--- + +Normal.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("no-isolation")!.isolation).toBeUndefined(); + }); + + it("rejects invalid isolation mode", () => { + writeAgent("bad-isolation", `--- +isolation: docker +--- + +Bad isolation.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("bad-isolation")!.isolation).toBeUndefined(); + }); + + // `isolation: off` is a veto, not a synonym for omitting the field: agent + // config outranks tool-call params, so it turns a caller's "worktree" back + // off. That is why it must survive parsing as "off" rather than undefined. + it("parses isolation: off", () => { + writeAgent("no-wt", `--- +description: Never worktree +isolation: off +--- + +No worktree.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get("no-wt")!.isolation).toBe("off"); + }); + + // pi's frontmatter parser is not YAML 1.1, so bare `off`/`no` stay strings + // and only `false` becomes a boolean — accept the spellings an author is + // likely to reach for rather than silently dropping them. + it.each([ + ["false", "isolation: false"], + ["none", "isolation: none"], + ["no", "isolation: no"], + ])("accepts %s as a spelling of off", (name, line) => { + writeAgent(`off-${name}`, `--- +${line} +--- + +Off.`); + + const result = loadCustomAgents(tmpDir); + expect(result.get(`off-${name}`)!.isolation).toBe("off"); + }); + + // A YAML error in one file used to escape loadFromDir and abort the whole + // extension load — pi exited 1 before the TUI. Regression for #212. + it("skips a file with malformed frontmatter and still loads the others", () => { + // Unquoted `description` containing ": " — the shape Claude Code tolerates. + writeAgent("broken", `--- +name: broken +description: Use this: that +--- + +Broken body.`); + writeAgent("good", `--- +description: Still loads +--- + +Good body.`); + + const result = loadCustomAgents(tmpDir); + + expect(result.has("broken")).toBe(false); + expect(result.get("good")?.description).toBe("Still loads"); + }); + + it("names the offending file and the reason when skipping it", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken body."); + + loadCustomAgents(tmpDir); + + const message = warn.mock.calls.map(args => String(args[0])).join("\n"); + expect(message).toContain(join(tmpDir, ".pi", "agents", "broken.md")); + expect(message).toContain("Nested mappings are not allowed"); + } finally { + warn.mockRestore(); + } + }); + + // Skipping an override is not the same as skipping an agent: the name still + // resolves, to a different prompt, model and tool policy. Nothing downstream + // can flag that, because the Agent call succeeds. + it("warns when a skipped file was overriding an agent that stays resolvable", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\n---\n\nEarlier body."); + writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body."); + + const result = loadCustomAgents(tmpDir); + + expect(result.get("dup")?.description).toBe("Earlier definition"); + const message = warn.mock.calls.map(args => String(args[0])).join("\n"); + expect(message).toContain(`Agent "dup" now loads from ${join(tmpDir, ".agents", "agents", "dup.md")} instead`); + } finally { + warn.mockRestore(); + } + }); + + // A disabled agent does not dispatch (resolveEnabledTypeIn), so claiming the + // name "still resolves" to it would send the user chasing the wrong file. + it("does not claim a fallback when the shadowed definition is disabled", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\nenabled: false\n---\n\nEarlier body."); + writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body."); + + loadCustomAgents(tmpDir); + + const message = warn.mock.calls.map(args => String(args[0])).join("\n"); + expect(message).toContain("Skipping agent file"); + expect(message).not.toContain("now loads from"); + } finally { + warn.mockRestore(); + } + }); + + it("does not claim a fallback when the skipped file overrode nothing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeAgent("lonely", "---\nname: lonely\ndescription: Use this: that\n---\n\nBroken body."); + + loadCustomAgents(tmpDir); + + const message = warn.mock.calls.map(args => String(args[0])).join("\n"); + expect(message).toContain("Skipping agent file"); + expect(message).not.toContain("now loads from"); + } finally { + warn.mockRestore(); + } + }); + + // strictAgentFiles: opt in to failing closed rather than running a substitute. + it("throws naming the file when strict, and skips it when not", () => { + writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken."); + writeAgent("healthy", "---\ndescription: Fine\n---\n\nFine."); + const brokenPath = join(tmpDir, ".pi", "agents", "broken.md"); + + expect(() => loadCustomAgents(tmpDir, true)).toThrow(brokenPath); + expect(() => loadCustomAgents(tmpDir, true)).toThrow("Nested mappings are not allowed"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = loadCustomAgents(tmpDir); + expect(result.has("broken")).toBe(false); + expect(result.has("healthy")).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + // The rule is "warn when it breaks, stay quiet while it stays broken". + // Suppressing an unchanged problem must not suppress it forever. + it("warns when a file breaks, not while it stays broken", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // Two loads while broken: the second must be suppressed as unchanged. + writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken."); + loadCustomAgents(tmpDir); + loadCustomAgents(tmpDir); + expect(warn).toHaveBeenCalledTimes(1); + + writeAgent("flip", "---\ndescription: Fixed\n---\n\nFixed."); + expect(loadCustomAgents(tmpDir).get("flip")?.description).toBe("Fixed"); + + // Same breakage again — a new problem, not the one already reported. + writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken."); + loadCustomAgents(tmpDir); + + expect(warn).toHaveBeenCalledTimes(2); + } finally { + warn.mockRestore(); + } + }); + + // Agents reload on every Agent call, so repeating would scribble a live TUI. + it("warns once per message, not on every reload", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + writeAgent("noisy", "---\nname: noisy\ndescription: Use this: that\n---\n\nBroken body."); + + loadCustomAgents(tmpDir); + loadCustomAgents(tmpDir); + loadCustomAgents(tmpDir); + + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + it("honors PI_CODING_AGENT_DIR for global custom agent discovery", () => { + const altAgentDir = mkdtempSync(join(tmpdir(), "pi-alt-agent-")); + const originalEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = altAgentDir; + try { + const globalAgentsDir = join(altAgentDir, "agents"); + mkdirSync(globalAgentsDir, { recursive: true }); + writeFileSync( + join(globalAgentsDir, "via-env.md"), + "---\ndescription: Discovered via env var\n---\n\nTest body.", + ); + + const result = loadCustomAgents(tmpDir); + + // Agent is found at $PI_CODING_AGENT_DIR/agents, not at $HOME/.pi/agent/agents + expect(result.has("via-env")).toBe(true); + expect(result.get("via-env")!.description).toBe("Discovered via env var"); + } finally { + if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalEnv; + rmSync(altAgentDir, { recursive: true, force: true }); + } + }); + + // `/agents → Eject` writes an AgentConfig back out as frontmatter. That writer + // and this loader are the two halves of one format, but nothing pinned them + // together — so a field can serialize to something the loader reads back + // differently, and the agent silently changes shape on eject. + describe("eject round-trip", () => { + function roundTrip(cfg: Partial) { + const full: AgentConfig = { + description: "Round trip agent", + systemPrompt: "Body prompt.", + promptMode: "append", + ...cfg, + } as AgentConfig; + writeAgent("rt", serializeAgentFile(full)); + const loaded = loadCustomAgents(tmpDir).get("rt"); + expect(loaded).toBeDefined(); + return loaded!; + } + + it("preserves an explicitly narrowed tool list", () => { + expect(roundTrip({ builtinToolNames: ["read", "grep"] }).builtinToolNames).toEqual(["read", "grep"]); + }); + + it("preserves the full built-in set", () => { + expect(roundTrip({ builtinToolNames: [...BUILTIN_TOOL_NAMES] }).builtinToolNames) + .toEqual([...BUILTIN_TOOL_NAMES]); + }); + + it("preserves an empty tool list instead of widening it to every built-in", () => { + // `tools: none` parses to [] on load, so ejecting an agent with zero + // built-ins must not write `tools: all` — that hands it the whole toolbox. + expect(roundTrip({ builtinToolNames: [] }).builtinToolNames).toEqual([]); + }); + + it("preserves the scalar and list fields it writes", () => { + const loaded = roundTrip({ + displayName: "RT", + model: "anthropic/claude-haiku-4-5", + thinking: "low", + maxTurns: 7, + allowedSubagents: ["Explore"], + excludeExtensions: ["ext-beta"], + disallowedTools: ["write"], + inheritContext: true, + runInBackground: true, + outputTranscript: false, + isolated: true, + memory: "project", + isolation: "worktree", + }); + expect(loaded.displayName).toBe("RT"); + expect(loaded.model).toBe("anthropic/claude-haiku-4-5"); + expect(loaded.thinking).toBe("low"); + expect(loaded.maxTurns).toBe(7); + expect(loaded.allowedSubagents).toEqual(["Explore"]); + expect(loaded.excludeExtensions).toEqual(["ext-beta"]); + expect(loaded.disallowedTools).toEqual(["write"]); + expect(loaded.inheritContext).toBe(true); + expect(loaded.runInBackground).toBe(true); + expect(loaded.outputTranscript).toBe(false); + expect(loaded.isolated).toBe(true); + expect(loaded.memory).toBe("project"); + expect(loaded.isolation).toBe("worktree"); + }); + + // The writer used to emit `run_in_background` only when truthy, so an + // explicit `false` was dropped. Harmless while foreground was the default + // and omission meant the same thing — but with `backgroundByDefault` on, + // dropping it flips the ejected agent to background. + it("preserves an explicit run_in_background: false instead of dropping it", () => { + expect(roundTrip({ runInBackground: false }).runInBackground).toBe(false); + }); + + it("leaves run_in_background unset when the config doesn't pin it", () => { + // Absent must stay absent — writing a value would freeze the agent + // against the setting rather than letting it follow the default. + expect(roundTrip({}).runInBackground).toBeUndefined(); + }); + + it("preserves the extension and skill list fields", () => { + // These serialize as bare CSV and are re-parsed by parseExtensionsSpec / + // the skills field. A generate/parse mismatch here is silent: the ejected + // agent loads fine but with a different extension or skill scope than the + // one that was ejected. + const loaded = roundTrip({ + extensions: ["mcp", "pi-notify"], + skills: ["planning", "review"], + disallowedTools: ["write", "edit"], + }); + expect(loaded.extensions).toEqual(["mcp", "pi-notify"]); + expect(loaded.skills).toEqual(["planning", "review"]); + expect(loaded.disallowedTools).toEqual(["write", "edit"]); + }); + + it("preserves the boolean forms of extensions and skills", () => { + const off = roundTrip({ extensions: false, skills: false }); + expect(off.extensions).toBe(false); + expect(off.skills).toBe(false); + }); + + it("preserves allowed_subagents in both its list and `all` forms", () => { + expect(roundTrip({ allowedSubagents: "all" }).allowedSubagents).toBe("all"); + expect(roundTrip({ allowedSubagents: ["Explore", "Plan"] }).allowedSubagents) + .toEqual(["Explore", "Plan"]); + }); + + it("preserves a description containing a colon", () => { + // Serialized via JSON.stringify precisely so YAML doesn't split on the colon. + expect(roundTrip({ description: "Scout: find things" }).description).toBe("Scout: find things"); + }); + }); +}); diff --git a/pi-subagents/test/documented-defaults.test.ts b/pi-subagents/test/documented-defaults.test.ts new file mode 100644 index 0000000..225527e --- /dev/null +++ b/pi-subagents/test/documented-defaults.test.ts @@ -0,0 +1,71 @@ +// README publishes concrete default values (Persistent settings, README:441). +// Every existing test that looked like it checked one actually SET the value +// first — `test/agent-runner-settings.test.ts` had a `beforeEach(setGraceTurns(5))` +// followed by `it("defaults to 5")`, which asserts the setter, not the default. +// +// The defaults live in module-level `let`s that the settings appliers overwrite +// at boot, so reading them after any other suite has run tells you nothing. +// `vi.resetModules()` + a dynamic import gives a genuinely fresh module, which +// is why this lives in its own file: resetModules is file-wide and hostile to +// suites that hold module references across tests. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("documented defaults (README:441)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + // src/agent-runner.js pulls in the whole pi-coding-agent graph, and + // `server.deps.inline` means resetModules re-transforms all of it — several + // seconds under a loaded full run, versus instant in isolation. The default + // 5s timeout makes these two flaky, so they get an explicit generous one + // rather than a retry. + const HEAVY_REIMPORT_MS = 60_000; + + it("grace turns after the soft limit default to 5", async () => { + const { getGraceTurns } = await import("../src/agent-runner.js"); + expect(getGraceTurns()).toBe(5); + }, HEAVY_REIMPORT_MS); + + it("max turns is unlimited by default", async () => { + const { getDefaultMaxTurns } = await import("../src/agent-runner.js"); + expect(getDefaultMaxTurns()).toBeUndefined(); + }, HEAVY_REIMPORT_MS); + + it("nested subagent depth defaults to 2", async () => { + const { getMaxSubagentDepth } = await import("../src/nested-tools.js"); + expect(getMaxSubagentDepth()).toBe(2); + }); + + // Raised from 4 when top-level spawns started defaulting to background: + // foreground bypasses the pool entirely, so a limit tuned for opt-in + // background would now queue the tail of ordinary parallel fan-outs. + it("background concurrency defaults to 10", async () => { + const { AgentManager } = await import("../src/agent-manager.js"); + const manager = new AgentManager(); + try { + expect(manager.getMaxConcurrent()).toBe(10); + } finally { + manager.dispose(); + } + }); + + it("top-level spawns default to background, nested spawns to foreground", async () => { + const { resolveAgentInvocationConfig } = await import("../src/invocation-config.js"); + // The setting's default (true) is what index.ts passes for top-level calls. + expect(resolveAgentInvocationConfig(undefined, {}, { defaultRunInBackground: true }).runInBackground).toBe(true); + // nested-tools.ts passes false unconditionally. + expect(resolveAgentInvocationConfig(undefined, {}, { defaultRunInBackground: false }).runInBackground).toBe(false); + // An explicit param still wins over either default. + expect(resolveAgentInvocationConfig(undefined, { run_in_background: false }, { defaultRunInBackground: true }).runInBackground).toBe(false); + expect(resolveAgentInvocationConfig(undefined, { run_in_background: true }, { defaultRunInBackground: false }).runInBackground).toBe(true); + }); + + it("model scope is off by default", async () => { + // Off is the safe default: on, an unconfigured enabledModels would start + // refusing spawns. README:428 documents it as opt-in. + const { isScopeModelsEnabled } = await import("../src/model-scope.js"); + expect(isScopeModelsEnabled()).toBe(false); + }); +}); diff --git a/pi-subagents/test/e2e/isolated-provider.e2e.test.ts b/pi-subagents/test/e2e/isolated-provider.e2e.test.ts new file mode 100644 index 0000000..a59245c --- /dev/null +++ b/pi-subagents/test/e2e/isolated-provider.e2e.test.ts @@ -0,0 +1,81 @@ +/** + * isolated-provider.e2e.test.ts — reachability guard for PR #152 (issue #151). + * + * PR #152 fixes isolated subagents dropping extension-registered custom providers + * on Pi >= 0.80.8 (the `modelRegistry` → `modelRuntime` migration). agent-runner + * forwards the parent's runtime, read off the ModelRegistry facade as + * `ctx.modelRegistry.runtime` via `as unknown as { runtime }`. + * + * That forwarding is already guarded by the unit test in test/agent-runner.test.ts + * ("passes the parent model runtime …") — but against a MOCK whose `.runtime` is + * hand-set. The mock cannot catch the one thing that would silently break the fix: + * `.runtime` is a `private readonly` field on the real ModelRegistry, absent from + * the public type AND the package exports. If a future Pi renames it, makes it a + * true #private, or moves the module, the cast quietly yields `undefined`, the fix + * omits `modelRuntime`, and the bug returns with no failing test. + * + * This test closes exactly that gap and nothing else: it asserts the real facade + * exposes a runtime-reachable `.runtime` that IS the runtime it wraps. It is not a + * guard for the forwarding itself (that's the unit test's job) — a fuller e2e that + * drives real `runAgent` end-to-end is tracked as a follow-up. + * + * VERSION GATE: `.runtime` only exists in the post-migration facade world (Pi >= + * 0.80.8, where `ModelRuntime` is first exported). The repo's dev dependency is + * pinned post-migration, so this runs by default — but it stays gated because CI + * also runs the suite against the peer-range floor (see .github/workflows/ci.yml), + * where Pi predates the migration. There we DYNAMICALLY import Pi and skip + * cleanly; a static `import { ModelRuntime }` would be a link-time error. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; + +// Dynamic, so this file LOADS on pre-migration Pi (a static `import { ModelRuntime }` +// would be a link-time error there — 0.80.6 doesn't export it). +const pi = (await import("@earendil-works/pi-coding-agent")) as Record; +const ModelRuntime = pi.ModelRuntime as + | { create(opts?: Record): Promise } + | undefined; + +// The migration is exactly "ModelRuntime now exists". Absent ⇒ pre-0.80.8 ⇒ there +// is no `.runtime` facade to guard. +const MIGRATED = typeof ModelRuntime?.create === "function"; +const RT = ModelRuntime as { create(opts?: Record): Promise }; + +// The one method the reach scenario needs; `.runtime` itself is private (reached below). +interface ModelRuntimeLike { + registerProvider(id: string, config: Record): void; +} + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe.skipIf(!MIGRATED)("PR #152 reach: real ModelRegistry exposes .runtime (Pi >= 0.80.8)", () => { + it("ctx.modelRegistry.runtime is reachable and IS the runtime it wraps", async () => { + // A real, configured runtime — as an extension leaves it after registerProvider. + const dir = mkdtempSync(join(tmpdir(), "iso-prov-")); + tmpDirs.push(dir); + const runtime = await RT.create({ + authPath: join(dir, "auth.json"), + modelsPath: join(dir, "models.json"), + allowModelNetwork: false, + }); + + // `.runtime` is private and not in the package exports — reach the compiled + // class by file path, exactly the field the patch's cast depends on. If Pi + // moves/renames/#privates it, THIS line fails loudly instead of the fix + // silently no-op'ing back to the #151 bug. + const indexUrl = import.meta.resolve("@earendil-works/pi-coding-agent"); + const mrUrl = indexUrl.replace(/index\.js$/, "core/model-registry.js"); + const { ModelRegistry } = (await import(mrUrl)) as { + ModelRegistry: new (rt: ModelRuntimeLike) => { runtime?: unknown }; + }; + + const facade = new ModelRegistry(runtime); + // This is the exact expression agent-runner reads (`ctx.modelRegistry.runtime`). + expect((facade as { runtime?: unknown }).runtime).toBe(runtime); + }); +}); diff --git a/pi-subagents/test/e2e/mention-clone-tool-reachability.e2e.test.ts b/pi-subagents/test/e2e/mention-clone-tool-reachability.e2e.test.ts new file mode 100644 index 0000000..6d4918e --- /dev/null +++ b/pi-subagents/test/e2e/mention-clone-tool-reachability.e2e.test.ts @@ -0,0 +1,97 @@ +/** + * mention-clone-tool-reachability.e2e.test.ts — reachability guard for the one + * tool the mention clone is built around. + * + * `runMentionClone` hands a session ONE tool and expects the model to call it. + * Whether that tool ever reaches the model is decided entirely inside Pi, by + * `createAgentSession`'s allowlist plumbing — and the unit tests cannot see it: + * their `createAgentSession` is a mock that hands `customTools[0]` straight to + * the model turn, so a session option that silently strips the tool passes + * every one of them. + * + * That is not hypothetical. The clone shipped with `noTools: "all"` on the + * reading its doc comment invites ("start with no tools enabled" — no + * built-ins, keep mine). Pi turns that flag into an EMPTY allowlist, and an + * empty array is truthy, so `AgentSession` builds an empty `Set` and + * `isAllowedTool` rejects every name — custom tools are filtered by the same + * predicate as built-ins. Every mention was prompted with no tools, answered in + * prose, and fell back to a direct start with a warning. The unit suite stayed + * green throughout. + * + * So this asserts against a REAL session, on the two things a mock cannot + * establish: + * 1. the clone's `Agent` tool is actually active on it, and + * 2. nothing else is — the invisible turn cannot read, write or run anything. + * + * No network/LLM: a faux provider satisfies session construction, and the + * assertion is on the constructed tool set rather than on a model turn. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Real pi-mono session construction; a cold first run under full-suite CPU +// contention can exceed vitest's 5s default. +vi.setConfig({ testTimeout: 30_000 }); + +// Hoisted so the (lifted) mock factory can reach it. Everything except the +// capture is the real module — the point is to construct a REAL session. +const { sessions } = vi.hoisted(() => ({ sessions: [] as any[] })); + +vi.mock("@earendil-works/pi-coding-agent", async () => { + const actual = await vi.importActual("@earendil-works/pi-coding-agent"); + return { + ...actual, + createAgentSession: async (opts: any) => { + const created = await actual.createAgentSession(opts); + sessions.push(created.session); + return created; + }, + }; +}); + +import { runMentionClone } from "../../src/mention-clone.js"; +import { fauxModelBackend } from "../helpers/faux-model-backend.js"; +import { registerFauxProvider } from "../helpers/pi-ai.js"; + +describe("mention clone tool reachability against real pi-mono", () => { + let cwd: string; + let faux: ReturnType; + + beforeEach(() => { + sessions.length = 0; + cwd = mkdtempSync(join(tmpdir(), "subagents-mention-clone-")); + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] }); + }); + afterEach(() => { + faux.unregister(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it("the clone's Agent tool is live on the real session, and it is the only one", async () => { + const model = faux.getModel(); + const backend = fauxModelBackend(model); + const ctx: any = { + cwd, + model, + getSystemPrompt: () => "PARENT", + // mention-clone reads the runtime off the registry facade, the same shim + // agent-runner carries for Pi >= 0.80.8. + modelRegistry: { ...backend.modelRegistry, runtime: backend.modelRuntime }, + sessionManager: { getEntries: () => [], getLeafId: () => undefined }, + }; + + // Never called: the assertion is on what the session exposes, not on the + // faux model deciding to use it. + const agentTool = { name: "Agent", execute: vi.fn() } as any; + + // Never rejects by contract; a faux turn that cannot complete is fine, + // because the tool set is fixed at construction. + await runMentionClone({ ctx, type: "Explore", message: "go", agentTool }); + + expect(sessions).toHaveLength(1); + // The bug this file exists for: with an empty allowlist this is `[]`. + expect(sessions[0].getActiveToolNames()).toEqual(["Agent"]); + }); +}); diff --git a/pi-subagents/test/e2e/tool-veto-reachability.e2e.test.ts b/pi-subagents/test/e2e/tool-veto-reachability.e2e.test.ts new file mode 100644 index 0000000..77fe8a8 --- /dev/null +++ b/pi-subagents/test/e2e/tool-veto-reachability.e2e.test.ts @@ -0,0 +1,145 @@ +/** + * tool-veto-reachability.e2e.test.ts — reachability guard for the `ext:` turn-1 + * tool veto (issue #125). + * + * `installExtensionToolScope` enforces `ext:` narrowing two ways. Re-narrowing the + * ACTIVE set on `turn_end` is built entirely on public API (`getAllTools`, + * `getActiveToolNames`, `setActiveToolsByName`) and is covered by the unit tests. + * The second half is not: turn 1 cannot be narrowed at all — `before_agent_start` + * fires INSIDE `prompt()` and may widen the tool set, but `createContextSnapshot()` + * freezes that turn's tools immediately after, leaving no window — so out-of-scope + * calls are vetoed at call time by wrapping `session.agent.beforeToolCall`. + * + * That wrap is the one place this extension reaches past the documented surface: + * - `ExtensionBindings` has no tool_call hook, so there is no SDK-level way to + * inject a veto into a session we construct. Pi exposes the veto to EXTENSIONS + * as `pi.on("tool_call") -> { block, reason }`, but we are the SDK caller here, + * not an extension bound to the child session. + * - So we wrap the property Pi itself installs in the AgentSession constructor + * (`_installAgentToolHooks`), chaining to the prior hook so Pi's own `tool_call` + * dispatch still runs. + * + * The unit tests assert our wrapper's behavior against a MOCK session whose `agent` + * is a hand-written `{ beforeToolCall: undefined }`. That mock cannot catch the one + * thing that would silently break the veto: if a future Pi renames `beforeToolCall`, + * stops installing it, makes `agent` non-enumerable/private, or moves the veto + * elsewhere, our assignment lands on a property nothing reads. Every test still + * passes, and out-of-scope tools become callable on turn 1 with no failing test. + * + * This guard closes exactly that gap and nothing else. It asserts against a REAL + * session that: + * 1. Pi installs its own `beforeToolCall` (so there IS a prior hook to chain), and + * 2. after `runAgent`, ours is installed and vetoes an out-of-scope tool in the + * `{ block, reason }` shape Pi honors. + * + * No network/LLM: a faux Model satisfies `createAgentSession`, and the veto is + * invoked directly rather than through a model turn. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAgent } from "../../src/agent-runner.js"; +import { registerAgents } from "../../src/agent-types.js"; +import type { AgentConfig } from "../../src/types.js"; +import { registerFauxProvider } from "../helpers/pi-ai.js"; + +// Real pi-mono (loader + dynamic extension import + session construction). +vi.setConfig({ testTimeout: 30_000 }); + +/** Registers `alpha_read` / `alpha_write`; reused so no new fixture is needed. */ +const ALPHA = resolve(fileURLToPath(new URL("../fixtures/ext-alpha.mjs", import.meta.url))); +/** Registers `beta_tool` — loaded but NOT selected by the `ext:` selector below. */ +const BETA = resolve(fileURLToPath(new URL("../fixtures/ext-beta.mjs", import.meta.url))); + +function makePi() { + return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any; +} + +describe("tool veto reachability against real pi-mono", () => { + let cwd: string; + let faux: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "subagents-veto-")); + faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-1", contextWindow: 200_000 }], + }); + }); + afterEach(() => { + faux.unregister(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it("pi installs a chainable beforeToolCall, and runAgent's veto blocks out-of-scope tools", async () => { + registerAgents( + new Map([ + [ + "veto", + { + name: "veto", + description: "veto guard", + builtinToolNames: ["read"], + // Select alpha only — beta loads (its handlers run) but is muted. + extensions: [ALPHA, BETA], + extSelectors: ["ext:ext-alpha.mjs"], + skills: false, + systemPrompt: "You are veto.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + } as AgentConfig, + ], + ]), + ); + + const model = faux.getModel(); + const modelRegistry: any = { + find: () => model, + getAll: () => [model], + getAvailable: () => [model], + hasConfiguredAuth: () => true, + isUsingOAuth: () => false, + getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }), + registerProvider: () => {}, + unregisterProvider: () => {}, + }; + const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry }; + + let priorIsFunction: boolean | undefined; + let session: any; + try { + await runAgent(ctx, "veto", "go", { + pi: makePi(), + model, + onSessionCreated: (s: any) => { + session = s; + // By onSessionCreated our wrapper is already installed, so this being a + // function proves the property is reachable and writable. Pi installing + // its own in the constructor is what gives us something to chain to — + // asserted below via the in-scope path returning undefined rather than + // throwing on a missing prior hook. + priorIsFunction = typeof s.agent?.beforeToolCall === "function"; + }, + }); + } catch { + // A faux-model turn may not complete; the veto is fixed at construction. + } + + expect(priorIsFunction).toBe(true); + + // Out of scope: beta loaded but the ext: flip did not select it. + await expect( + session.agent.beforeToolCall({ toolCall: { name: "beta_tool" }, args: {} }), + ).resolves.toMatchObject({ block: true, reason: expect.any(String) }); + + // In scope: must NOT be blocked. Reaching Pi's own prior hook without throwing + // also proves the chain is intact (a clobbered/absent prior would surface here). + await expect( + session.agent.beforeToolCall({ toolCall: { name: "alpha_read" }, args: {} }), + ).resolves.toSatisfy((r: any) => !r?.block); + }); +}); diff --git a/pi-subagents/test/e2e/usage-reaches-session-stats.e2e.test.ts b/pi-subagents/test/e2e/usage-reaches-session-stats.e2e.test.ts new file mode 100644 index 0000000..0452791 --- /dev/null +++ b/pi-subagents/test/e2e/usage-reaches-session-stats.e2e.test.ts @@ -0,0 +1,148 @@ +/** + * usage-reaches-session-stats.e2e.test.ts — the premise #193 rests on, checked + * against the REAL pi runtime. + * + * Every unit test for usage reporting asserts that our tool results carry a + * `usage` field. None of them can establish the thing that makes carrying it + * worth doing: that pi picks it up. That happens entirely inside pi — + * `createToolResultMessage` copies `AgentToolResult.usage` onto the persisted + * message, and `getSessionStats()` folds `toolResult.usage` into the tokens and + * cost the footer, the statusline and `/cost` read. Mock pi, and a release that + * stopped doing either would leave the whole feature reporting into a void with + * a green suite. + * + * So this drives a real `AgentSession` and reads its real `getSessionStats()`, + * with the exact object `PendingUsagePool.drain()` produces — including the + * `cacheRead` our own display total drops but this report must carry, and the + * cost breakdown whose `total` pi reads with no guard at all. + * + * No network/LLM and no model turn: the message is appended through pi's own + * `sessionManager.appendMessage`, because what is under test is the accounting, + * not the streaming that would normally produce the message. + * + * This test is also what set the peer floor. Pi began folding `toolResult.usage` + * into `getSessionStats()` in 0.81.0, when the computation moved to walking + * session entries through `addUsageToTotals`; every 0.80.x sums assistant + * messages alone and drops the field. Running unconditionally is the point — + * against a Pi that does not aggregate, this fails rather than skipping, which + * is how the range stays honest. `peerDependencies` moved to `>=0.81.0` for + * exactly this reason, so the CI floor job runs it too. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PendingUsagePool } from "../../src/usage.js"; +import { fauxModelBackend } from "../helpers/faux-model-backend.js"; +import { registerFauxProvider } from "../helpers/pi-ai.js"; + +// Real pi session construction; a cold first run under full-suite CPU +// contention can exceed vitest's 5s default. +vi.setConfig({ testTimeout: 30_000 }); + +describe("subagent usage reaches the parent session's stats (real pi)", () => { + let cwd: string; + let faux: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "subagents-usage-e2e-")); + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] }); + }); + afterEach(() => { + faux.unregister(); + rmSync(cwd, { recursive: true, force: true }); + }); + + /** A real session, in memory, on a faux model. */ + async function realSession() { + const model = faux.getModel(); + const backend = fauxModelBackend(model); + const { session } = await createAgentSession({ + cwd, + sessionManager: SessionManager.inMemory(cwd), + model: model as any, + modelRegistry: backend.modelRegistry, + modelRuntime: backend.modelRuntime, + tools: [], + } as any); + return session; + } + + /** The tool result our `Agent` tool returns, as pi would persist it. */ + function toolResultCarrying(usage: unknown) { + return { + role: "toolResult" as const, + toolCallId: "tc-1", + toolName: "Agent", + content: [{ type: "text" as const, text: "Agent completed." }], + isError: false, + timestamp: 1, + usage, + }; + } + + it("pi adds our reported tokens and cost to getSessionStats()", async () => { + const session = await realSession(); + try { + const before = session.getSessionStats(); + + const pool = new PendingUsagePool(); + pool.add({ input: 1000, output: 400, cacheWrite: 100, cacheRead: 9000, cost: 0.0123 }); + pool.add({ input: 2000, output: 600, cacheWrite: 200, cacheRead: 18_000, cost: 0.0077 }); + const usage = pool.drain(); + + session.sessionManager.appendMessage(toolResultCarrying(usage) as any); + const after = session.getSessionStats(); + + // Exactly what we reported, on every component pi tracks — cacheRead + // included, which is the one pi counts for its own messages and our own + // display total leaves out. + expect(after.tokens.input - before.tokens.input).toBe(3000); + expect(after.tokens.output - before.tokens.output).toBe(1000); + expect(after.tokens.cacheWrite - before.tokens.cacheWrite).toBe(300); + expect(after.tokens.cacheRead - before.tokens.cacheRead).toBe(27_000); + + // The cost: the whole point of the feature for anyone watching a + // statusline. `addUsageToTotals` reads `usage.cost.total` with no guard, + // so an incomplete object would have thrown before reaching here. + expect(after.cost - before.cost).toBeCloseTo(0.02, 10); + } finally { + session.dispose?.(); + } + }); + + it("leaves the context-window percentage alone", async () => { + // pi derives context usage from assistant messages only. If that ever + // changed, a delegating session would look like it was filling its context + // with work that happened somewhere else entirely — and users would compact + // for no reason. + const session = await realSession(); + try { + const before = session.getSessionStats().contextUsage?.percent ?? null; + + const pool = new PendingUsagePool(); + pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 }); + session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any); + + expect(session.getSessionStats().contextUsage?.percent ?? null).toBe(before); + } finally { + session.dispose?.(); + } + }); + + it("counts nothing for a tool result that carries no usage", async () => { + // The `reportUsage: false` shape, and every other tool in the session. + const session = await realSession(); + try { + const before = session.getSessionStats(); + session.sessionManager.appendMessage(toolResultCarrying(undefined) as any); + const after = session.getSessionStats(); + + expect(after.tokens.input).toBe(before.tokens.input); + expect(after.cost).toBe(before.cost); + } finally { + session.dispose?.(); + } + }); +}); diff --git a/pi-subagents/test/enabled-models.test.ts b/pi-subagents/test/enabled-models.test.ts new file mode 100644 index 0000000..a5e3652 --- /dev/null +++ b/pi-subagents/test/enabled-models.test.ts @@ -0,0 +1,206 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "../src/enabled-models.js"; + +/** Mock models matching typical registry shape. */ +const MODELS = [ + { id: "gemma-4-31b-it", name: "Gemma 4 31B", provider: "google" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, +]; + +function makeRegistry(models = MODELS, available?: typeof MODELS): ModelRegistryRef { + return { + getAll() { return models; }, + getAvailable: available ? () => available : undefined, + }; +} + +describe("readEnabledModels", () => { + let agentDir: string; + let projectDir: string; + let originalEnv: string | undefined; + + const projectFile = () => join(projectDir, ".pi", "settings.json"); + const globalFile = () => join(agentDir, "settings.json"); + + beforeEach(() => { + agentDir = mkdtempSync(join(tmpdir(), "pi-em-global-")); + projectDir = mkdtempSync(join(tmpdir(), "pi-em-project-")); + originalEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + }); + + afterEach(() => { + if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalEnv; + rmSync(agentDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeProject(obj: unknown) { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), JSON.stringify(obj)); + } + + it("returns undefined when both settings files are missing", () => { + expect(readEnabledModels(projectDir)).toBeUndefined(); + }); + + it("returns undefined when field absent from both files", () => { + writeFileSync(globalFile(), JSON.stringify({ defaultProvider: "openai" })); + expect(readEnabledModels(projectDir)).toBeUndefined(); + }); + + it("returns enabledModels from global when project file absent", () => { + writeFileSync(globalFile(), JSON.stringify({ + enabledModels: ["anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"], + })); + expect(readEnabledModels(projectDir)).toEqual([ + "anthropic/claude-sonnet-4-6", + "google/gemma-4-31b-it", + ]); + }); + + it("returns enabledModels from project when global file absent", () => { + writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] }); + expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]); + }); + + it("project overrides global (array replaces wholly, mirrors pi's deep-merge)", () => { + writeFileSync(globalFile(), JSON.stringify({ + enabledModels: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"], + })); + writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] }); + // Project replaces wholly — globals NOT merged in + expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]); + }); + + it("falls back to global when project file has no enabledModels field", () => { + writeFileSync(globalFile(), JSON.stringify({ + enabledModels: ["anthropic/claude-sonnet-4-6"], + })); + writeProject({ defaultProvider: "anthropic" }); // project exists but no enabledModels + expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-sonnet-4-6"]); + }); + + it("returns undefined when global JSON is corrupt (try/catch swallow)", () => { + writeFileSync(globalFile(), "not json {{{"); + expect(readEnabledModels(projectDir)).toBeUndefined(); + }); + + it("returns undefined when enabledModels is not an array (global)", () => { + writeFileSync(globalFile(), JSON.stringify({ enabledModels: "anthropic/claude-sonnet-4-6" })); + expect(readEnabledModels(projectDir)).toBeUndefined(); + }); + + it("returns undefined when enabledModels is not an array (project)", () => { + writeProject({ enabledModels: "anthropic/claude-haiku-4-5" }); + // Project's non-array enabledModels is invalid → falls back to global; global empty → undefined + expect(readEnabledModels(projectDir)).toBeUndefined(); + }); +}); + +describe("resolveEnabledModels", () => { + it("returns undefined for empty patterns", () => { + expect(resolveEnabledModels([], makeRegistry())).toBeUndefined(); + expect(resolveEnabledModels(undefined, makeRegistry())).toBeUndefined(); + }); + + it("returns undefined when no matches", () => { + expect(resolveEnabledModels(["nonexistent/foo"], makeRegistry())).toBeUndefined(); + }); + + it("skips empty string patterns", () => { + const result = resolveEnabledModels(["", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"], makeRegistry()); + // Empty string should not match — only exact patterns should match + expect(result!.size).toBe(2); + }); + + it("skips whitespace-only patterns", () => { + const result = resolveEnabledModels([" ", "google/gemma-4-31b-it"], makeRegistry()); + expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); + }); + + it("returns undefined when getAvailable returns empty array", () => { + const result = resolveEnabledModels( + ["anthropic/claude-haiku-4-5"], + makeRegistry(MODELS, []), + ); + expect(result).toBeUndefined(); + }); + + it("deduplicates duplicate patterns", () => { + const result = resolveEnabledModels( + ["anthropic/claude-haiku-4-5", "anthropic/claude-haiku-4-5"], + makeRegistry(), + ); + expect(result!.size).toBe(1); // duplicate resolves to one entry + }); + + describe("exact provider/modelId", () => { + it("resolves exact match (key stored lowercase)", () => { + const result = resolveEnabledModels(["google/gemma-4-31b-it"], makeRegistry()); + expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); + }); + + it("resolves model id with colon (part of id, not split)", () => { + const result = resolveEnabledModels( + ["anthropic/claude-opus-4-6"], + makeRegistry(), + ); + expect(result).toEqual(new Set(["anthropic/claude-opus-4-6"])); + }); + + it("is case-insensitive", () => { + const result = resolveEnabledModels(["GOOGLE/GEMMA-4-31B-IT"], makeRegistry()); + expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); + }); + }); + + describe("no bare modelId or fuzzy matching", () => { + it("returns undefined for bare id (pi always writes provider/modelId)", () => { + const result = resolveEnabledModels(["gemma-4-31b-it"], makeRegistry()); + expect(result).toBeUndefined(); + }); + + it("returns undefined for bare substring patterns", () => { + const result = resolveEnabledModels(["Opus"], makeRegistry()); + expect(result).toBeUndefined(); + }); + }); + + + + describe("mixed patterns", () => { + it("combines multiple exact provider/modelId in one call", () => { + const result = resolveEnabledModels( + ["google/gemma-4-31b-it", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"], + makeRegistry(), + ); + expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true); + expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true); + expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(true); + expect(result!.has("google/gemini-2.5-pro".toLowerCase())).toBe(false); + expect(result!.has("anthropic/claude-opus-4-6".toLowerCase())).toBe(false); + }); + }); + + describe("getAvailable filtering", () => { + it("resolves only against available models when getAvailable present", () => { + const available = [MODELS[0], MODELS[3]]; // google + haiku only + const result = resolveEnabledModels( + ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"], + makeRegistry(MODELS, available), + ); + // haiku and google are available; sonnet is not + expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true); + expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(false); // not available + expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true); + }); + }); +}); diff --git a/pi-subagents/test/env.test.ts b/pi-subagents/test/env.test.ts new file mode 100644 index 0000000..72cd9d7 --- /dev/null +++ b/pi-subagents/test/env.test.ts @@ -0,0 +1,61 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { detectEnv } from "../src/env.js"; + +/** Minimal mock of pi.exec() that shells out via child_process. */ +function mockPi(): ExtensionAPI { + return { + exec: async (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => { + try { + const stdout = execSync(`${command} ${args.join(" ")}`, { + cwd: options?.cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: options?.timeout, + }); + return { stdout, stderr: "", code: 0, killed: false }; + } catch (err: any) { + return { stdout: "", stderr: err.stderr ?? "", code: err.status ?? 1, killed: false }; + } + }, + } as unknown as ExtensionAPI; +} + +describe("detectEnv", () => { + it("detects git repo in current project", async () => { + const env = await detectEnv(mockPi(), process.cwd()); + expect(env.isGitRepo).toBe(true); + expect(env.platform).toBe(process.platform); + }); + + it("returns branch name when on a branch", async () => { + // Create a temp repo on a known branch to test branch detection + const tmpDir = mkdtempSync(join(tmpdir(), "pi-env-branch-")); + try { + execSync("git init && git config user.email test@test.com && git config user.name Test && git checkout -b test-branch && git commit --allow-empty -m init", { + cwd: tmpDir, stdio: "pipe", + }); + const env = await detectEnv(mockPi(), tmpDir); + expect(env.isGitRepo).toBe(true); + expect(env.branch).toBe("test-branch"); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("detects non-git directory", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "pi-env-test-")); + try { + const env = await detectEnv(mockPi(), tmpDir); + expect(env.isGitRepo).toBe(false); + expect(env.branch).toBe(""); + expect(env.platform).toBe(process.platform); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/pi-subagents/test/ext-templates-e2e.test.ts b/pi-subagents/test/ext-templates-e2e.test.ts new file mode 100644 index 0000000..1815463 --- /dev/null +++ b/pi-subagents/test/ext-templates-e2e.test.ts @@ -0,0 +1,173 @@ +/** + * ext-templates-e2e.test.ts — Data-driven, headless end-to-end runner for + * `tools:`/`ext:`/`extensions:` scoping against the REAL pi-mono runtime. + * + * Unlike agent-runner-e2e.test.ts (which builds AgentConfig objects in code), + * this exercises the FULL chain from frontmatter onward: + * + * test/fixtures/.pi/agents/*.md (pre-configured agent templates) + * → real loadCustomAgents() (frontmatter → parseToolsField/ext:) + * → registerAgents() (real registry) + * → real runAgent() [headless] (real DefaultResourceLoader loads the + * real .mjs extension fixtures) + * → real createAgentSession() (real pi-mono tool gating) + * → session.getActiveToolNames() (what the LLM could actually call) + * + * Each template is self-describing: its `expect_present` / `expect_absent` + * frontmatter declares the tools that must / must not be active. Adding a + * scenario = adding a .md file; no test code changes needed. + * + * Headless: a faux Model object satisfies createAgentSession; we assert on the + * pre-prompt gated tool set captured at onSessionCreated, so no LLM/network is + * involved. cwd is the fixtures dir so the templates' relative `extensions:` + * paths resolve and the .mjs fixtures can import `@sinclair/typebox` from the + * repo's node_modules. + */ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { runAgent } from "../src/agent-runner.js"; +import { getAgentConfig, registerAgents } from "../src/agent-types.js"; +import { loadCustomAgents } from "../src/custom-agents.js"; +import { resolveAgentInvocationConfig } from "../src/invocation-config.js"; +import { registerFauxProvider } from "./helpers/pi-ai.js"; + +// Real pi-mono (loader + dynamic extension import + session construction) — a +// cold run under full-suite contention can exceed vitest's 5s default. +vi.setConfig({ testTimeout: 30_000 }); + +const FIXTURES_DIR = resolve(fileURLToPath(new URL("./fixtures", import.meta.url))); +const TEMPLATES_DIR = join(FIXTURES_DIR, ".pi", "agents"); + +function csv(val: unknown): string[] { + return typeof val === "string" ? val.split(",").map((s) => s.trim()).filter(Boolean) : []; +} + +/** Discover scenarios from the template files at collection time. */ +const SCENARIOS = readdirSync(TEMPLATES_DIR) + .filter((f) => f.endsWith(".md")) + .map((file) => { + const fm = parseFrontmatter>( + readFileSync(join(TEMPLATES_DIR, file), "utf8"), + ).frontmatter; + return { + name: file.replace(/\.md$/, ""), // loadCustomAgents keys agents by filename + present: csv(fm.expect_tools_present), + absent: csv(fm.expect_tools_absent), + promptContains: csv(fm.expect_prompt_contains), + promptAbsent: csv(fm.expect_prompt_absent), + }; + }); + +/** Distinctive marker for the parent system prompt (prompt_mode: append asserts it leaks in). */ +const PARENT_PROMPT = "PARENT_PROMPT_MARKER"; + +describe("ext: / tools: scoping — template-driven e2e (real pi-mono, headless)", () => { + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + let hermeticDir: string; + let faux: ReturnType; + + beforeAll(() => { + // Isolate global discovery (getAgentDir / ~/.pi) so the dev's real agents + // and extensions can't bleed into the run. + hermeticDir = mkdtempSync(join(tmpdir(), "subagents-tmpl-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = hermeticDir; + process.env.HOME = hermeticDir; + + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] }); + + // Load the templates through the REAL loader (project agents come from + // /.pi/agents → FIXTURES_DIR/.pi/agents) and install them in the + // registry runAgent reads from. + registerAgents(loadCustomAgents(FIXTURES_DIR)); + }); + + afterAll(() => { + faux.unregister(); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(hermeticDir, { recursive: true, force: true }); + }); + + async function runScenario(agentName: string): Promise<{ active: string[]; prompt: string }> { + const model = faux.getModel(); + const modelRegistry: any = { + find: () => model, + getAll: () => [model], + getAvailable: () => [model], + hasConfiguredAuth: () => true, + isUsingOAuth: () => false, + getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }), + registerProvider: () => {}, + unregisterProvider: () => {}, + }; + // cwd = fixtures dir so the templates' relative extensions: paths resolve. + // getSystemPrompt returns a distinctive marker so prompt_mode: append can be + // proven to inherit the parent prompt. + const ctx: any = { cwd: FIXTURES_DIR, getSystemPrompt: () => PARENT_PROMPT, model, modelRegistry }; + const pi: any = { exec: async () => ({ code: 1, stdout: "", stderr: "" }) }; + + // Mirror production: the caller resolves frontmatter-locked fields (isolated, + // inherit_context, …) into runAgent options via resolveAgentInvocationConfig. + // isolated is the one that affects tool gating (forces extensions:false + drops ext:). + const resolved = resolveAgentInvocationConfig(getAgentConfig(agentName), { modelFromParams: false } as any); + + let active: string[] = []; + let prompt = ""; + try { + await runAgent(ctx, agentName, "go", { + pi, + model, + cwd: FIXTURES_DIR, + isolated: resolved.isolated, + inheritContext: resolved.inheritContext, + onSessionCreated: (s) => { + // Both fixed at construction (before any prompt turn): the gated tool + // set and the effective system prompt (built from prompt_mode + skills). + active = s.getActiveToolNames(); + prompt = s.systemPrompt; + }, + }); + } catch { + // Prompt may error (no live provider) — both observables are captured at + // onSessionCreated, before the turn. + } + return { active, prompt }; + } + + it("every template on disk is discovered, registered, and self-describing", () => { + // 1:1 with the .md files in the templates dir — nothing silently dropped. + const onDisk = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".md")).length; + expect(SCENARIOS.length).toBe(onDisk); + expect(SCENARIOS.length).toBeGreaterThanOrEqual(6); + + for (const s of SCENARIOS) { + // Each declares at least one expectation, so no scenario is a no-op. + expect(s.present.length + s.promptContains.length + s.promptAbsent.length).toBeGreaterThan(0); + // Each loaded as ITS OWN agent — guards against runAgent silently falling + // back to general-purpose when a template fails to parse/register. + const cfg = getAgentConfig(s.name); + expect(cfg, `template "${s.name}" did not register (parse error or name mismatch?)`).toBeDefined(); + expect(cfg?.name).toBe(s.name); + } + }); + + it.each(SCENARIOS)( + "$name → active tools and system prompt match the template", + async ({ name, present, absent, promptContains, promptAbsent }) => { + const { active, prompt } = await runScenario(name); + for (const tool of present) expect(active, `${name}: expected "${tool}" active`).toContain(tool); + for (const tool of absent) expect(active, `${name}: expected "${tool}" NOT active`).not.toContain(tool); + for (const s of promptContains) expect(prompt, `${name}: prompt should contain "${s}"`).toContain(s); + for (const s of promptAbsent) expect(prompt, `${name}: prompt should NOT contain "${s}"`).not.toContain(s); + }, + ); +}); diff --git a/pi-subagents/test/fallback-subagent-wiring.test.ts b/pi-subagents/test/fallback-subagent-wiring.test.ts new file mode 100644 index 0000000..3d93790 --- /dev/null +++ b/pi-subagents/test/fallback-subagent-wiring.test.ts @@ -0,0 +1,246 @@ +/** + * fallback-subagent-wiring.test.ts — proves `fallbackSubagent` actually gates + * dispatch through the real registered tools (#183), not just that the resolver + * returns the right verdict. + * + * The load-bearing assertion in the rejection tests is that `runAgent` was NEVER + * called: the complaint in #183 is that a background call starts executing the + * wrong agent before the caller learns anything, so a rejection that still + * spawns would be no fix at all. The fallback tests assert the opposite — that + * it ran, and which agent it ran. + */ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import { getAllTypes, getAvailableTypes, NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +let cwd: string; +let originalCwd: string; +let originalAgentDir: string | undefined; +let originalHome: string | undefined; + +/** Real agent files on disk. The Agent tool reloads the registry from + * `process.cwd()` on every call, so an in-memory registry would be wiped before + * dispatch is resolved — and a "disabled" fixture that never loads would make + * the disabled-type test pass merely because the name was unknown. */ +function writeAgents(): void { + const dir = join(cwd, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "scout.md"), "---\ndescription: Scout\ntools: read\n---\nScout.\n"); + writeFileSync(join(dir, "retired.md"), "---\ndescription: Retired\ntools: read\nenabled: false\n---\nRetired.\n"); +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd, + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; + +describe("fallbackSubagent gates dispatch through the real Agent tool", () => { + beforeEach(() => { + originalCwd = process.cwd(); + cwd = mkdtempSync(join(tmpdir(), "fallback-wiring-")); + writeAgents(); + process.chdir(cwd); + // A developer's real ~/.pi/subagents.json would otherwise set this very + // setting under the tests, and their global agents would pollute the roster. + originalAgentDir = process.env.PI_CODING_AGENT_DIR; + originalHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir"); + process.env.HOME = cwd; + vi.mocked(runAgent).mockReset(); + }); + + afterEach(() => { + setFallbackSubagent(undefined); + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + process.chdir(originalCwd); + if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDir; + if (originalHome == null) delete process.env.HOME; + else process.env.HOME = originalHome; + registerAgents(new Map()); + rmSync(cwd, { recursive: true, force: true }); + }); + + function boot() { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + return { pi, tools, lifecycle }; + } + + for (const background of [false, true]) { + it(`refuses an unknown type without spawning (run_in_background: ${background})`, async () => { + const { tools } = boot(); + setFallbackSubagent(NO_FALLBACK); + + const result = await tools.get("Agent").execute( + "tc-1", + { + prompt: "do it", + description: "typo dispatch", + subagent_type: "definitely-missing", + run_in_background: background, + }, + undefined, undefined, ctx(), + ); + + expect(textOf(result)).toContain('Unknown or disabled agent type: "definitely-missing"'); + expect(textOf(result)).toContain("scout"); + // The whole point: nothing ran. + expect(runAgent).not.toHaveBeenCalled(); + }); + } + + it("still falls back — and says so — when the setting is unset", async () => { + const { tools } = boot(); + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false, + }); + + const result = await tools.get("Agent").execute( + "tc-2", + { prompt: "do it", description: "typo dispatch", subagent_type: "definitely-missing" }, + undefined, undefined, ctx(), + ); + + expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"'); + // Not merely "something ran" — a fallback that routed anywhere else would pass that. + expect(runAgent).toHaveBeenCalledWith( + expect.anything(), "general-purpose", "do it", expect.anything(), + ); + }); + + it("carries the fallback note on the background branch too", async () => { + // Previously the note was computed after spawnAndWait returned, so only a + // foreground caller ever saw it (#183). + const { tools } = boot(); + vi.mocked(runAgent).mockReturnValue(new Promise(() => {}) as any); + + const result = await tools.get("Agent").execute( + "tc-3", + { + prompt: "do it", + description: "typo dispatch", + subagent_type: "definitely-missing", + run_in_background: true, + }, + undefined, undefined, ctx(), + ); + + expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"'); + expect(textOf(result)).toContain("started in background"); + }); + + it("refuses a disabled type, which used to dispatch with a mixed identity", async () => { + const { tools } = boot(); + setFallbackSubagent(NO_FALLBACK); + // Pin the fixture: without this the test passes identically if retired.md + // stopped loading, since "unknown" and "disabled" share one message. + expect(getAllTypes()).toContain("retired"); + expect(getAvailableTypes()).not.toContain("retired"); + + const result = await tools.get("Agent").execute( + "tc-4", + { prompt: "do it", description: "disabled dispatch", subagent_type: "retired" }, + undefined, undefined, ctx(), + ); + + expect(textOf(result)).toContain("Unknown or disabled agent type"); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it("never persists a blank type into a scheduled job", async () => { + // `fellBackFrom` is "" for a blank request, and `??` does not treat "" as + // nullish — the job would be stored with an empty type and re-fail forever. + const { tools, lifecycle } = boot(); + await lifecycle.get("session_start")({}, ctx()); + + const result = await tools.get("Agent").execute( + "tc-5", + { prompt: "later", description: "blank type", subagent_type: " ", schedule: "+1h" }, + undefined, undefined, ctx(), + ); + expect(textOf(result)).toContain("Scheduled"); + + const storeDir = join(cwd, ".pi", "subagent-schedules"); + const jobs = readdirSync(storeDir).flatMap((f) => + JSON.parse(readFileSync(join(storeDir, f), "utf-8")).jobs ?? [], + ); + expect(jobs).toHaveLength(1); + expect(jobs[0].subagent_type).toBe("general-purpose"); + }); + + it("never blocks resume, which ignores subagent_type entirely", async () => { + // resume replays a stored session; the type is required by the schema but + // unused. Gating it would make a live agent unresumable the moment its type + // is deleted or disabled — the opposite of what strict dispatch is for. + const { tools } = boot(); + vi.mocked(runAgent).mockResolvedValue({ + // `messages` is not optional on a real AgentSession, and a background + // resume reads it to anchor transcript streaming. + responseText: "first", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false, + }); + const spawned = await tools.get("Agent").execute( + "tc-6", + { prompt: "start", description: "live agent", subagent_type: "scout", run_in_background: false }, + undefined, undefined, ctx(), + ); + const id = /Agent ID: (\S+)/.exec(textOf(spawned))?.[1] + ?? (spawned as any).details?.agentId; + expect(id).toBeTruthy(); + + setFallbackSubagent(NO_FALLBACK); + const resumed = await tools.get("Agent").execute( + "tc-7", + { resume: id, prompt: "keep going", description: "resume", subagent_type: "deleted-since" }, + undefined, undefined, ctx(), + ); + + expect(textOf(resumed)).not.toContain("Unknown or disabled agent type"); + }); + + it("applies the same contract to cross-extension spawns", async () => { + // The registry entry is what RPC callers reach; it must not be a way around + // the setting. A throw here becomes an error envelope at the RPC boundary. + boot(); + setFallbackSubagent(NO_FALLBACK); + const registry = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + + expect(() => registry.spawn({}, ctx(), "definitely-missing", "do it", { description: "rpc" })) + .toThrow(/Unknown or disabled agent type/); + expect(runAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/pi-subagents/test/fixtures/.pi/agents/all-and-alpha-selected.md b/pi-subagents/test/fixtures/.pi/agents/all-and-alpha-selected.md new file mode 100644 index 0000000..88a7cba --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/all-and-alpha-selected.md @@ -0,0 +1,9 @@ +--- +description: "Loads alpha+beta, selects only alpha via ext:. Flip mutes beta." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*, ext:ext-alpha.mjs" +expect_tools_present: "read, bash, alpha_read, alpha_write" +expect_tools_absent: "beta_tool" +--- +e2e template: a single ext: selector flips extension tools to an allowlist; +alpha is selected (all its tools surface), beta is loaded but muted. diff --git a/pi-subagents/test/fixtures/.pi/agents/beta-selected-mutes-alpha.md b/pi-subagents/test/fixtures/.pi/agents/beta-selected-mutes-alpha.md new file mode 100644 index 0000000..acb5272 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/beta-selected-mutes-alpha.md @@ -0,0 +1,9 @@ +--- +description: "Selects beta via ext:; the loaded alpha extension is muted." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*, ext:ext-beta.mjs" +expect_tools_present: "read, beta_tool" +expect_tools_absent: "alpha_read, alpha_write" +--- +e2e template: mirror of all-and-alpha-selected — selecting beta proves the flip +mutes the *other* loaded extension (alpha) regardless of which one is named. diff --git a/pi-subagents/test/fixtures/.pi/agents/disallow-alpha-write.md b/pi-subagents/test/fixtures/.pi/agents/disallow-alpha-write.md new file mode 100644 index 0000000..e86a14f --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/disallow-alpha-write.md @@ -0,0 +1,10 @@ +--- +description: "Selects alpha but denylists alpha_write via disallowed_tools." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*, ext:ext-alpha.mjs" +disallowed_tools: "alpha_write" +expect_tools_present: "read, alpha_read" +expect_tools_absent: "alpha_write, beta_tool" +--- +e2e template: disallowed_tools removes an extension tool even when the ext: +selector would otherwise surface it. diff --git a/pi-subagents/test/fixtures/.pi/agents/exclude-beats-ext-selector.md b/pi-subagents/test/fixtures/.pi/agents/exclude-beats-ext-selector.md new file mode 100644 index 0000000..ac7edb5 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/exclude-beats-ext-selector.md @@ -0,0 +1,12 @@ +--- +description: "An ext: selector cannot resurrect an excluded extension." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +exclude_extensions: ext-beta.mjs +tools: "*, ext:ext-beta.mjs" +expect_tools_present: "read" +expect_tools_absent: "beta_tool, alpha_read, alpha_write" +--- +e2e template: exclude_extensions beats a tools: ext: selector — beta never +loads, so ext:ext-beta.mjs is an orphan (warns, does not pull beta back in). +Alpha tools are also absent because any ext: entry flips extension tools to an +explicit allowlist and alpha is not selected. diff --git a/pi-subagents/test/fixtures/.pi/agents/exclude-beta.md b/pi-subagents/test/fixtures/.pi/agents/exclude-beta.md new file mode 100644 index 0000000..c913e12 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/exclude-beta.md @@ -0,0 +1,12 @@ +--- +description: "Loads alpha+beta, excludes beta via exclude_extensions." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +exclude_extensions: ext-beta.mjs +tools: "*" +expect_tools_present: "read, alpha_read, alpha_write" +expect_tools_absent: "beta_tool" +--- +e2e template: exclude_extensions removes an extension after the include set is +computed — alpha surfaces normally, beta's tools never register. (Excluding a +name that the extensions: list also loads warns "in both — exclude wins"; the +exclusion still applies.) diff --git a/pi-subagents/test/fixtures/.pi/agents/extensions-disabled.md b/pi-subagents/test/fixtures/.pi/agents/extensions-disabled.md new file mode 100644 index 0000000..c75fbdb --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/extensions-disabled.md @@ -0,0 +1,8 @@ +--- +description: "extensions:false — no extension tools at all." +extensions: false +tools: "*" +expect_tools_present: "read, bash, edit, write, grep, find, ls" +expect_tools_absent: "alpha_read, alpha_write, beta_tool" +--- +e2e template: extensions are disabled, so only the built-in tools are active. diff --git a/pi-subagents/test/fixtures/.pi/agents/fmt-array.md b/pi-subagents/test/fixtures/.pi/agents/fmt-array.md new file mode 100644 index 0000000..7c3c997 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/fmt-array.md @@ -0,0 +1,8 @@ +--- +description: "tools value format: YAML flow array." +tools: [read, grep, find] +expect_tools_present: "read, grep, find" +expect_tools_absent: "bash, edit, write, ls" +--- +e2e template (format check 3/3): YAML array. Per the README, `[a, b]` == `"a, b"`, +so this must yield the same active tool set as the CSV forms. diff --git a/pi-subagents/test/fixtures/.pi/agents/fmt-quoted-csv.md b/pi-subagents/test/fixtures/.pi/agents/fmt-quoted-csv.md new file mode 100644 index 0000000..54610ab --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/fmt-quoted-csv.md @@ -0,0 +1,8 @@ +--- +description: "tools value format: quoted CSV string." +tools: "read, grep, find" +expect_tools_present: "read, grep, find" +expect_tools_absent: "bash, edit, write, ls" +--- +e2e template (format check 2/3): quoted CSV string. Same result as the unquoted +and array forms. diff --git a/pi-subagents/test/fixtures/.pi/agents/fmt-unquoted-csv.md b/pi-subagents/test/fixtures/.pi/agents/fmt-unquoted-csv.md new file mode 100644 index 0000000..dee330b --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/fmt-unquoted-csv.md @@ -0,0 +1,8 @@ +--- +description: "tools value format: unquoted CSV." +tools: read, grep, find +expect_tools_present: "read, grep, find" +expect_tools_absent: "bash, edit, write, ls" +--- +e2e template (format check 1/3): unquoted CSV. Must be equivalent to the quoted +and array forms — see fmt-quoted-csv.md and fmt-array.md. diff --git a/pi-subagents/test/fixtures/.pi/agents/isolated-overrides-all.md b/pi-subagents/test/fixtures/.pi/agents/isolated-overrides-all.md new file mode 100644 index 0000000..95d12ac --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/isolated-overrides-all.md @@ -0,0 +1,11 @@ +--- +description: "isolated:true forces built-ins only, overriding extensions and ext:." +isolated: true +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*, ext:ext-alpha.mjs" +expect_tools_present: "read, bash, edit, write, grep, find, ls" +expect_tools_absent: "alpha_read, alpha_write, beta_tool" +--- +e2e template: per the README, isolated:true is hermetic — it forces +extensions:false + skills:false and drops ext: selectors, leaving only built-ins, +even though this template also sets extensions and an ext: selector. diff --git a/pi-subagents/test/fixtures/.pi/agents/lazy-ext-selected.md b/pi-subagents/test/fixtures/.pi/agents/lazy-ext-selected.md new file mode 100644 index 0000000..625480e --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/lazy-ext-selected.md @@ -0,0 +1,10 @@ +--- +description: "ext: selecting the lazy extension surfaces its session_start tool." +extensions: "./ext-lazy.mjs, ./ext-alpha.mjs" +tools: "*, ext:ext-lazy.mjs" +expect_tools_present: "read, bash, lazy_tool" +expect_tools_absent: "alpha_read, alpha_write" +--- +e2e template: the case a static allowlist can never express. `lazy_tool` does not +exist when the session is constructed, so it cannot be listed up front — scope +has to be re-derived once the extension registers it. diff --git a/pi-subagents/test/fixtures/.pi/agents/lazy-ext-unselected.md b/pi-subagents/test/fixtures/.pi/agents/lazy-ext-unselected.md new file mode 100644 index 0000000..e65192b --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/lazy-ext-unselected.md @@ -0,0 +1,10 @@ +--- +description: "A lazy extension left out of the ext: flip stays muted." +extensions: "./ext-lazy.mjs, ./ext-alpha.mjs" +tools: "*, ext:ext-alpha.mjs" +expect_tools_present: "read, bash, alpha_read, alpha_write" +expect_tools_absent: "lazy_tool" +--- +e2e template: the mirror of lazy-ext-selected — admitting late tools must not +mean admitting ALL late tools. ext-lazy loads and its session_start handler runs, +but the `ext:` flip did not select it, so `lazy_tool` never becomes active. diff --git a/pi-subagents/test/fixtures/.pi/agents/lazy-no-selector.md b/pi-subagents/test/fixtures/.pi/agents/lazy-no-selector.md new file mode 100644 index 0000000..63155a1 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/lazy-no-selector.md @@ -0,0 +1,9 @@ +--- +description: "A tool registered at session_start reaches the subagent (#125)." +extensions: "./ext-lazy.mjs" +tools: "*" +expect_tools_present: "read, bash, lazy_tool" +--- +e2e template: ext-lazy registers `lazy_tool` from `session_start`, i.e. AFTER +loader.reload() has already run. Any scoping that snapshots the tool set at +construction drops it permanently — this asserts it survives. diff --git a/pi-subagents/test/fixtures/.pi/agents/memory-denied-write.md b/pi-subagents/test/fixtures/.pi/agents/memory-denied-write.md new file mode 100644 index 0000000..622dcf5 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/memory-denied-write.md @@ -0,0 +1,25 @@ +--- +description: "memory + disallowed_tools — a denied write tool must not count as write capability." +memory: project +tools: read, write +disallowed_tools: write +expect_tools_present: "read" +expect_tools_absent: "write, edit, bash, grep" +expect_prompt_contains: "Agent Memory (read-only), Memory scope: project" +expect_prompt_absent: "persistent memory directory" +--- +README: "The `disallowed_tools` field is respected when determining write +capability — an agent with `tools: write` + `disallowed_tools: write` correctly +gets read-only memory." + +This is the combination neither `memory-readonly.md` (no write tool at all) nor +`memory-readwrite.md` (write tool, nothing denied) exercises: the write tool IS +in the `tools:` set, so a naive capability check sees it and hands the agent the +read-write memory prompt — which also appends `write`/`edit` to the tool names, +widening the set the agent asked for. The denylist still filters the registry, so +the visible symptom is an agent instructed to write memory files with no tool to +do it; the invisible one is the tool-name widening. + +Scope `project` is safe here precisely because the read-only branch creates no +memory directory — if this fixture ever flips to the read-write branch, it would +also start writing into the repo, which is the loudest possible signal. diff --git a/pi-subagents/test/fixtures/.pi/agents/memory-readonly.md b/pi-subagents/test/fixtures/.pi/agents/memory-readonly.md new file mode 100644 index 0000000..d119bee --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/memory-readonly.md @@ -0,0 +1,13 @@ +--- +description: "memory read-only — an agent without write tools gets a read-only memory block." +memory: project +tools: read, grep +expect_tools_present: "read, grep" +expect_tools_absent: "write, edit" +expect_prompt_contains: "Agent Memory (read-only), Memory scope: project" +expect_prompt_absent: "persistent memory directory" +--- +A read-only memory agent. Per the README, agents without write/edit tools +auto-get a read-only memory fallback: existing memory is injected, no write +access is granted, and no memory directory is created (so `project` scope is +safe here — nothing is written into the repo). diff --git a/pi-subagents/test/fixtures/.pi/agents/memory-readwrite.md b/pi-subagents/test/fixtures/.pi/agents/memory-readwrite.md new file mode 100644 index 0000000..91032f6 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/memory-readwrite.md @@ -0,0 +1,12 @@ +--- +description: "memory read-write — an agent with write tools gets a writable memory block." +memory: user +tools: read, write +expect_tools_present: "read, write, edit" +expect_prompt_contains: "persistent memory directory, Memory scope: user" +expect_prompt_absent: "(read-only)" +--- +A write-capable memory agent. Per the README, agents with write/edit tools get +full read-write memory; the memory tool set is completed (edit is auto-added) and +a writable memory block is injected into the system prompt. Scope `user` so the +memory dir is created under the hermetic HOME, never in the repo. diff --git a/pi-subagents/test/fixtures/.pi/agents/minimal.md b/pi-subagents/test/fixtures/.pi/agents/minimal.md new file mode 100644 index 0000000..c15685b --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/minimal.md @@ -0,0 +1,9 @@ +--- +description: "Minimal agent — only description and body; every other field omitted." +expect_tools_present: "read, bash, edit, write, grep, find, ls" +expect_tools_absent: "alpha_read, alpha_write, beta_tool" +--- +A minimal agent. Per the README defaults: omitted `tools` => all 7 built-ins; +omitted `extensions` => true (all *discovered* extensions load — none exist in +this hermetic fixture, so no extension tools surface). expect_* are test-harness +annotations and are ignored by the agent loader. diff --git a/pi-subagents/test/fixtures/.pi/agents/narrow-alpha-read.md b/pi-subagents/test/fixtures/.pi/agents/narrow-alpha-read.md new file mode 100644 index 0000000..ff1387f --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/narrow-alpha-read.md @@ -0,0 +1,9 @@ +--- +description: "Narrows alpha to a single tool via ext:ext-alpha.mjs/alpha_read." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*, ext:ext-alpha.mjs/alpha_read" +expect_tools_present: "read, alpha_read" +expect_tools_absent: "alpha_write, beta_tool" +--- +e2e template: ext:/ narrows alpha to just alpha_read; alpha_write +and the unselected beta extension are both muted. diff --git a/pi-subagents/test/fixtures/.pi/agents/no-selector-all-surface.md b/pi-subagents/test/fixtures/.pi/agents/no-selector-all-surface.md new file mode 100644 index 0000000..8b265f4 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/no-selector-all-surface.md @@ -0,0 +1,8 @@ +--- +description: "No ext: selector, so all loaded extensions' tools surface." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: "*" +expect_tools_present: "read, alpha_read, alpha_write, beta_tool" +--- +e2e template: with no ext: entry there is no flip, so every loaded extension's +tools surface alongside the built-ins. diff --git a/pi-subagents/test/fixtures/.pi/agents/prompt-mode-append.md b/pi-subagents/test/fixtures/.pi/agents/prompt-mode-append.md new file mode 100644 index 0000000..1455137 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/prompt-mode-append.md @@ -0,0 +1,8 @@ +--- +description: "prompt_mode append — body appended to the parent's prompt." +prompt_mode: append +expect_tools_present: "read" +expect_prompt_contains: "PARENT_PROMPT_MARKER, APPEND_BODY_MARKER" +--- +APPEND_BODY_MARKER — in append mode the parent prompt flows in verbatim, so the +real session's system prompt contains BOTH the parent marker and this body. diff --git a/pi-subagents/test/fixtures/.pi/agents/prompt-mode-replace.md b/pi-subagents/test/fixtures/.pi/agents/prompt-mode-replace.md new file mode 100644 index 0000000..45a762b --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/prompt-mode-replace.md @@ -0,0 +1,9 @@ +--- +description: "prompt_mode replace (default) — body is the full system prompt." +prompt_mode: replace +expect_tools_present: "read" +expect_prompt_contains: "REPLACE_BODY_MARKER" +expect_prompt_absent: "PARENT_PROMPT_MARKER" +--- +REPLACE_BODY_MARKER — in replace mode the parent prompt is NOT inherited, so the +real session's system prompt contains this body but not the parent's marker. diff --git a/pi-subagents/test/fixtures/.pi/agents/skills-preload.md b/pi-subagents/test/fixtures/.pi/agents/skills-preload.md new file mode 100644 index 0000000..edb1483 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/skills-preload.md @@ -0,0 +1,8 @@ +--- +description: "skills: preloads a named skill into the system prompt." +skills: probe-skill +expect_tools_present: "read" +expect_prompt_contains: "Preloaded Skill: probe-skill, SKILL_BODY_MARKER" +--- +A skill-preloading agent. The `skills: probe-skill` entry must inject +test/fixtures/.pi/skills/probe-skill.md into the real session's system prompt. diff --git a/pi-subagents/test/fixtures/.pi/agents/tools-narrow.md b/pi-subagents/test/fixtures/.pi/agents/tools-narrow.md new file mode 100644 index 0000000..acbb447 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/tools-narrow.md @@ -0,0 +1,9 @@ +--- +description: "tools narrowed to two built-ins; extensions omitted (=> true)." +tools: read, grep +expect_tools_present: "read, grep" +expect_tools_absent: "bash, edit, write, find, ls, alpha_read, alpha_write, beta_tool" +--- +e2e template: a plain built-in allowlist narrows to exactly the listed tools. +extensions is omitted (defaults to true); none are discovered here, so no +extension tools surface. diff --git a/pi-subagents/test/fixtures/.pi/agents/tools-none.md b/pi-subagents/test/fixtures/.pi/agents/tools-none.md new file mode 100644 index 0000000..1ca6128 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/tools-none.md @@ -0,0 +1,9 @@ +--- +description: "tools:none => zero built-ins; loaded extension tools still surface." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +tools: none +expect_tools_present: "alpha_read, alpha_write, beta_tool" +expect_tools_absent: "read, bash, edit, write, grep, find, ls" +--- +e2e template: `tools: none` yields zero built-ins. With extensions loaded and no +ext: selector, all extension tools still surface — only the built-ins are dropped. diff --git a/pi-subagents/test/fixtures/.pi/agents/tools-omitted-ext-loaded.md b/pi-subagents/test/fixtures/.pi/agents/tools-omitted-ext-loaded.md new file mode 100644 index 0000000..5b77cb2 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/agents/tools-omitted-ext-loaded.md @@ -0,0 +1,7 @@ +--- +description: "tools omitted (=> all built-ins) with extensions explicitly loaded." +extensions: "./ext-alpha.mjs, ./ext-beta.mjs" +expect_tools_present: "read, bash, edit, write, grep, find, ls, alpha_read, alpha_write, beta_tool" +--- +e2e template: omitting `tools` yields all 7 built-ins; with extensions loaded and +no ext: selector there is no flip, so every loaded extension tool also surfaces. diff --git a/pi-subagents/test/fixtures/.pi/skills/probe-skill.md b/pi-subagents/test/fixtures/.pi/skills/probe-skill.md new file mode 100644 index 0000000..2642542 --- /dev/null +++ b/pi-subagents/test/fixtures/.pi/skills/probe-skill.md @@ -0,0 +1,5 @@ +# Probe Skill + +SKILL_BODY_MARKER — distinctive content used by the template-driven e2e to +prove that a `skills:` frontmatter entry is preloaded into the real subagent +system prompt. diff --git a/pi-subagents/test/fixtures/e2e-probe-ext.mjs b/pi-subagents/test/fixtures/e2e-probe-ext.mjs new file mode 100644 index 0000000..2c4f48c --- /dev/null +++ b/pi-subagents/test/fixtures/e2e-probe-ext.mjs @@ -0,0 +1,25 @@ +/** + * Real extension fixture for the end-to-end test. Loaded by pi-mono's actual + * DefaultResourceLoader via `additionalExtensionPaths`. Registers one tool, + * `e2e_probe`, that writes a marker file when executed so the test can prove + * the model was actually able to call it (not just that it appeared in a list). + * + * Plain ESM (.mjs) so node imports it without any TS transform step. + */ +import { writeFileSync } from "node:fs"; +import { Type } from "@sinclair/typebox"; + +export default function (pi) { + pi.registerTool({ + name: "e2e_probe", + label: "E2E Probe", + description: "Writes a marker file. Used only by the end-to-end test.", + parameters: Type.Object({ + marker: Type.String({ description: "Absolute path of the marker file to write." }), + }), + async execute(_id, params) { + writeFileSync(params.marker, "probed"); + return { content: [{ type: "text", text: `wrote ${params.marker}` }] }; + }, + }); +} diff --git a/pi-subagents/test/fixtures/ext-alpha.mjs b/pi-subagents/test/fixtures/ext-alpha.mjs new file mode 100644 index 0000000..7bbcfe6 --- /dev/null +++ b/pi-subagents/test/fixtures/ext-alpha.mjs @@ -0,0 +1,23 @@ +/** + * Real extension fixture "alpha" for the template-driven e2e runner. + * Registers two tools so narrowing (ext:ext-alpha.mjs/alpha_read) can be + * distinguished from exposing the whole extension. Plain ESM so node imports + * it without a TS transform; lives inside the repo tree so `@sinclair/typebox` + * resolves. Tools are never invoked by the runner — it only inspects the + * session's active tool set — so execute() is a trivial stub. + */ +import { Type } from "@sinclair/typebox"; + +export default function (pi) { + for (const name of ["alpha_read", "alpha_write"]) { + pi.registerTool({ + name, + label: name, + description: `Alpha extension tool ${name} (e2e fixture).`, + parameters: Type.Object({}), + async execute() { + return { content: [{ type: "text", text: name }] }; + }, + }); + } +} diff --git a/pi-subagents/test/fixtures/ext-beta.mjs b/pi-subagents/test/fixtures/ext-beta.mjs new file mode 100644 index 0000000..87242da --- /dev/null +++ b/pi-subagents/test/fixtures/ext-beta.mjs @@ -0,0 +1,18 @@ +/** + * Real extension fixture "beta" for the template-driven e2e runner. + * Registers a single tool, used to prove that the `ext:` allowlist flip mutes + * a loaded-but-unselected extension. See ext-alpha.mjs for the conventions. + */ +import { Type } from "@sinclair/typebox"; + +export default function (pi) { + pi.registerTool({ + name: "beta_tool", + label: "beta_tool", + description: "Beta extension tool (e2e fixture).", + parameters: Type.Object({}), + async execute() { + return { content: [{ type: "text", text: "beta_tool" }] }; + }, + }); +} diff --git a/pi-subagents/test/fixtures/ext-lazy.mjs b/pi-subagents/test/fixtures/ext-lazy.mjs new file mode 100644 index 0000000..7cbc5c0 --- /dev/null +++ b/pi-subagents/test/fixtures/ext-lazy.mjs @@ -0,0 +1,27 @@ +/** + * Real extension fixture "lazy" for the template-driven e2e runner. + * + * Registers its tool from `session_start` rather than at load — the shape that + * broke subagents in issue #125. pi-mcp does exactly this (it can only enumerate + * tools once its MCP servers connect), and eagerly connecting at load time would + * orphan child processes on pi's non-agent code paths. + * + * The point of the fixture is the TIMING: at `loader.reload()` this extension + * contributes no tools at all, so any scoping that snapshots the tool set then + * will drop `lazy_tool` permanently. See ext-alpha.mjs for the conventions. + */ +import { Type } from "@sinclair/typebox"; + +export default function (pi) { + pi.on("session_start", () => { + pi.registerTool({ + name: "lazy_tool", + label: "lazy_tool", + description: "Lazily-registered extension tool (e2e fixture).", + parameters: Type.Object({}), + async execute() { + return { content: [{ type: "text", text: "lazy_tool" }] }; + }, + }); + }); +} diff --git a/pi-subagents/test/fleet-list.test.ts b/pi-subagents/test/fleet-list.test.ts new file mode 100644 index 0000000..5f93655 --- /dev/null +++ b/pi-subagents/test/fleet-list.test.ts @@ -0,0 +1,553 @@ +import { Editor, visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import type { AgentManager } from "../src/agent-manager.js"; +import { registerAgents } from "../src/agent-types.js"; +import type { AgentConfig, AgentRecord } from "../src/types.js"; +import { type AgentActivity, getDisplayName } from "../src/ui/agent-widget.js"; +import { FleetList, type FleetUICtx, formatFleetElapsed, formatFleetTokens } from "../src/ui/fleet-list.js"; + +// ---- Key sequences (see node_modules/@earendil-works/pi-tui/dist/keys.js) ---- +const DOWN = "\x1b[B"; +const UP = "\x1b[A"; +const LEFT = "\x1b[D"; +const RIGHT = "\x1b[C"; +const ESC = "\x1b"; +const ENTER = "\r"; +// Kitty-protocol key-RELEASE for ↓ (event type 3) — listeners receive these too. +const DOWN_RELEASE = "\x1b[1;1:3B"; + +const theme = { fg: (c: string, s: string) => `<${c}>${s}`, bold: (s: string) => `*${s}*` }; + +/** An agent that renders as a badge — no default agent configures a color. */ +const BADGED_TYPE = "colored-reviewer"; +const PURPLE_BACKGROUND = "\u001b[48;2;130;125;189m"; +const BADGED_CONFIG: AgentConfig = { + name: BADGED_TYPE, + displayName: "Code Reviewer", + color: "purple", + description: "Reviews code", + extensions: false, + skills: false, + systemPrompt: "Review code.", + promptMode: "replace", +}; + +/** + * Visible text of a rendered row: ANSI stripped, along with this theme's fake + * `` / `*bold*` markers — all three stand in for zero-width escapes. + */ +function plain(row: string): string { + return row.replace(/\u001b\[[0-9;]*m/g, "").replace(/<\/?[a-zA-Z]+>|\*/g, ""); +} + +/** A no-op session so a record is "openable" by default (the list hides session-less agents). */ +const FAKE_SESSION = { subscribe: () => () => {}, messages: [] }; + +function makeRecord(over: Partial = {}): AgentRecord { + return { + id: "a1", + type: "general-purpose", + description: "Sleep then report 1", + status: "running", + toolUses: 0, + startedAt: Date.now(), + session: FAKE_SESSION as any, + lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0 }, + compactionCount: 0, + ...over, + } as AgentRecord; +} + +/** Fake manager exposing only what FleetList touches. */ +function fakeManager(agents: AgentRecord[]): AgentManager { + return { + listAgents: () => agents, + abort: () => true, + steer: vi.fn(() => true), + } as unknown as AgentManager; +} + +interface Harness { + fleet: FleetList; + ui: FleetUICtx; + manager: AgentManager; + /** The overlay component (a real ConversationViewer) once one is opened. */ + overlayComponent: () => { handleInput(data: string): void } | undefined; + /** Feed a key to the registered input handler; returns the consume result. */ + press: (data: string) => { consume?: boolean } | undefined; + /** Render the currently-registered below-editor widget at the given width. */ + render: (width?: number) => string[]; + setEditorText: (t: string) => void; + /** Whether an overlay has been opened. */ + overlayOpened: () => boolean; + /** Whether the most recently opened overlay's `done` was invoked (closed). */ + overlayClosed: () => boolean; + /** Simulate the viewer closing itself (Esc → done); flushes the close microtask. */ + closeOverlay: () => Promise; + /** The fake `tui` handed to the widget factory; tests set `focusedComponent` on it. */ + widgetTui: { requestRender(): void; focusedComponent?: unknown }; +} + +function harness(agents: AgentRecord[]): Harness { + let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined; + let widgetFactory: ((tui: any, theme: any) => { render(w: number): string[] }) | undefined; + let editorText = ""; + let opened = false; + let closed = false; + let overlayDone: ((r: undefined) => void) | undefined; + let overlayComponent: { handleInput(data: string): void } | undefined; + const fakeTui = { requestRender: () => {}, terminal: { columns: 120, rows: 40 } }; + + const ui: FleetUICtx = { + setWidget: (_key, content) => { widgetFactory = content as any; }, + onTerminalInput: (h) => { inputHandler = h; return () => { inputHandler = undefined; }; }, + getEditorText: () => editorText, + notify: () => {}, + custom: ((factory: any) => { + opened = true; + return new Promise((resolve) => { + const done = (r: undefined) => { closed = true; overlayDone = undefined; resolve(r); }; + overlayDone = done; + // Construct the overlay component so the controller wires viewerClose, + // and keep it so tests can drive the real ConversationViewer's input. + overlayComponent = factory(fakeTui, theme, undefined, done); + }); + }) as FleetUICtx["custom"], + }; + + const manager = fakeManager(agents); + const fleet = new FleetList(manager, new Map()); + fleet.setUICtx(ui); + fleet.update(); + + return { + fleet, + ui, + manager, + overlayComponent: () => overlayComponent, + press: (data) => inputHandler?.(data), + render: (width = 120) => (widgetFactory ? widgetFactory(fakeTui, theme).render(width) : []), + setEditorText: (t) => { editorText = t; }, + overlayOpened: () => opened, + overlayClosed: () => closed, + closeOverlay: async () => { overlayDone?.(undefined); await Promise.resolve(); }, + widgetTui: fakeTui, + }; +} + +describe("formatFleetElapsed", () => { + it("renders integer seconds (no decimal, no suffix)", () => { + expect(formatFleetElapsed(0)).toBe("0s"); + expect(formatFleetElapsed(11_000)).toBe("11s"); + expect(formatFleetElapsed(11_400)).toBe("11s"); + expect(formatFleetElapsed(11_600)).toBe("12s"); + }); + it("floors negatives to 0s", () => { + expect(formatFleetElapsed(-500)).toBe("0s"); + }); +}); + +describe("formatFleetTokens", () => { + it("prefixes a down-arrow and uses plural 'tokens'", () => { + expect(formatFleetTokens(13_100)).toBe("↓ 13.1k tokens"); + expect(formatFleetTokens(950)).toBe("↓ 950 tokens"); + expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens"); + }); +}); + +describe("FleetList navigation", () => { + it("does not register a widget when there are no agents", () => { + const h = harness([]); + expect(h.render()).toEqual([]); + }); + + it("hides nested child records from the coordinator fleet", () => { + const h = harness([ + makeRecord({ id: "top", description: "top-level" }), + makeRecord({ id: "nested", description: "nested-child", parentAgentId: "top" }), + ]); + const output = h.render().join("\n"); + expect(output).toContain("top-level"); + expect(output).not.toContain("nested-child"); + }); + + it("activates on ↓ at an empty prompt, consuming the key", () => { + const h = harness([makeRecord()]); + const res = h.press(DOWN); + expect(res).toEqual({ consume: true }); + // main selected, list active → nav hint shown + expect(h.render().some(l => l.includes("enter view"))).toBe(true); + }); + + it("also activates on ← (matches the '← for agents' hint)", () => { + const h = harness([makeRecord()]); + expect(h.press(LEFT)).toEqual({ consume: true }); + }); + + it("does NOT activate when the prompt is non-empty (typing is preserved)", () => { + const h = harness([makeRecord()]); + h.setEditorText("hello"); + expect(h.press(DOWN)).toBeUndefined(); + }); + + it("ignores key-release events so one tap moves exactly one row", () => { + const h = harness([ + makeRecord({ id: "a1", description: "one" }), + makeRecord({ id: "a2", description: "two" }), + ]); + h.press(DOWN); // activate → selection on main (idx 0) + h.press(DOWN_RELEASE); // release half of the SAME tap — must be a no-op + expect(h.render().find(l => l.includes("main"))).toContain("●"); + h.press(DOWN); // a real second tap → first agent + h.press(DOWN_RELEASE); + expect(h.render().find(l => l.includes("one"))).toContain("●"); + expect(h.render().find(l => l.includes("two"))).toContain("○"); + }); + + it("renders the whole selected row in the theme's primary text color (#230)", () => { + const h = harness([ + makeRecord({ id: "a1", description: "one" }), + makeRecord({ id: "a2", description: "two" }), + ]); + h.press(DOWN); // activate → main + h.press(DOWN); // → a1 + const selected = h.render().find(l => l.includes("one"))!; + // Selection marker keeps accent color; row content uses primary text color. + expect(selected).toContain(""); + expect(selected).toContain("one"); + expect(selected).toMatch(/\d+s · ↓ [\d.]+k? tokens<\/text>/); + // Agent display name rendered with the text token too (this type has no badge). + expect(selected).toContain(`${getDisplayName("general-purpose")}`); + // Inactive rows keep the muted/dim treatment. + const unselected = h.render().find(l => l.includes("two"))!; + expect(unselected).toContain(""); + expect(unselected).toMatch(/\d+s · ↓ [\d.]+k? tokens<\/dim>/); + expect(unselected).not.toContain(""); + }); + + it("keeps a color badge on the selected row, bolded, without shifting it (#230)", () => { + registerAgents(new Map([[BADGED_TYPE, BADGED_CONFIG]])); + try { + const h = harness([ + makeRecord({ id: "a1", type: BADGED_TYPE, description: "one" }), + makeRecord({ id: "a2", type: BADGED_TYPE, description: "two" }), + ]); + h.press(DOWN); // activate → main + const before = h.render().find(l => l.includes("one"))!; + expect(before).toContain(`${PURPLE_BACKGROUND}`); + expect(before).toContain(` ${BADGED_CONFIG.displayName} `); + + h.press(DOWN); // → a1 + const selected = h.render().find(l => l.includes("one"))!; + // Selection bolds the badge rather than repainting it (Claude Code's FleetView) … + expect(selected).toContain(PURPLE_BACKGROUND); + expect(selected).toContain(`* ${BADGED_CONFIG.displayName} *`); + expect(selected).not.toContain(`${BADGED_CONFIG.displayName}`); + // … so the description stays in the same column as when unselected. + expect(plain(selected).indexOf("one")).toBe(plain(before).indexOf("one")); + } finally { + registerAgents(new Map()); + } + }); + + it("moves selection down/up and clamps at the ends", () => { + const agents = [ + makeRecord({ id: "a1", description: "one" }), + makeRecord({ id: "a2", description: "two" }), + ]; + const h = harness(agents); + h.press(DOWN); // activate → index 0 (main) + h.press(DOWN); // → 1 (a1) + expect(h.render().find(l => l.includes("one"))).toContain("●"); + h.press(DOWN); // → 2 (a2) + h.press(DOWN); // clamp at 2 + expect(h.render().find(l => l.includes("two"))).toContain("●"); + expect(h.render().find(l => l.includes("one"))).toContain("○"); + }); + + it("↑ above 'main' deactivates (returns to the prompt)", () => { + const h = harness([makeRecord()]); + h.press(DOWN); // activate, index 0 + expect(h.press(UP)).toEqual({ consume: true }); + // back to inactive hint + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("Esc deactivates", () => { + const h = harness([makeRecord()]); + h.press(DOWN); + expect(h.press(ESC)).toEqual({ consume: true }); + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("passes non-nav keys through and cancels navigation", () => { + const h = harness([makeRecord()]); + h.press(DOWN); + expect(h.press(RIGHT)).toBeUndefined(); + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("ignores all input while disabled and hides the widget", () => { + const h = harness([makeRecord()]); + h.fleet.setEnabled(false); + expect(h.press(DOWN)).toBeUndefined(); + expect(h.render()).toEqual([]); + }); + + it("re-arms the refresh timer when the list is re-shown (toggle off→on)", () => { + vi.useFakeTimers(); + try { + const agents = [makeRecord({ id: "a1" })]; + const listAgents = vi.fn(() => agents); + const manager = { listAgents, abort: () => true } as unknown as AgentManager; + const fleet = new FleetList(manager, new Map()); + fleet.setUICtx({ + setWidget: () => {}, onTerminalInput: () => () => {}, getEditorText: () => "", + notify: () => {}, custom: (() => new Promise(() => {})) as FleetUICtx["custom"], + }); + fleet.update(); // shows list, arms the timer + fleet.setEnabled(false); // hides, clears the timer + fleet.setEnabled(true); // re-shows — must re-arm the timer + const before = listAgents.mock.calls.length; + vi.advanceTimersByTime(250); // a tick should fire and re-read the roster + expect(listAgents.mock.calls.length).toBeGreaterThan(before); + fleet.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("FleetList vs other focused components (#123)", () => { + // pi dispatches terminal input to extension listeners BEFORE the focused + // component (pi-tui TUI.handleInput), and ctx.ui.select/confirm/input swap + // the prompt editor out of the editor container while getEditorText() still + // reads the detached (empty) editor. So while another component owns the + // keyboard — another extension's selector (rpiv-ask-user-question), pi's own + // menus, our /agents settings — the list must not consume its keys. + + /** A minimal real Editor — what pi focuses at the prompt (CustomEditor extends it). */ + function realEditor(): Editor { + const fakeTui = { requestRender: () => {} }; + const theme = { borderColor: (s: string) => s, selectList: {} }; + return new Editor(fakeTui as any, theme as any); + } + + /** Hand the fleet list its `tui` (happens on first widget render in pi) with the given focus. */ + function focusInHarness(h: Harness, focused: unknown): void { + h.widgetTui.focusedComponent = focused; + h.render(); + } + + it("does not steal ↓ from a focused selector (activation)", () => { + const h = harness([makeRecord()]); + focusInHarness(h, { kind: "selector" }); // e.g. ExtensionSelectorComponent + expect(h.press(DOWN)).toBeUndefined(); // must flow through to the selector + }); + + it("does not steal navigation keys from a selector opened while the list was active", () => { + const h = harness([makeRecord()]); + focusInHarness(h, realEditor()); + expect(h.press(DOWN)).toEqual({ consume: true }); // activate at the prompt + focusInHarness(h, { kind: "selector" }); // a dialog takes focus + expect(h.press(DOWN)).toBeUndefined(); + expect(h.press(ENTER)).toBeUndefined(); + expect(h.press(ESC)).toBeUndefined(); + // and the list dropped back to its inactive hint + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("still activates when the prompt editor has focus", () => { + const h = harness([makeRecord()]); + focusInHarness(h, realEditor()); + expect(h.press(DOWN)).toEqual({ consume: true }); + }); + + it("assumes the editor when focus is unknowable (no tui yet / nothing focused)", () => { + const h = harness([makeRecord()]); + // No render yet → the list has never seen a tui: activation must still work. + expect(h.press(DOWN)).toEqual({ consume: true }); + }); +}); + +describe("FleetList rendering", () => { + it("renders main + agent rows with markers, type, description and right-aligned stats", () => { + const h = harness([makeRecord({ description: "Sleep then report 1" })]); + const lines = h.render(120); + // hint + blank + main + one agent + expect(lines[0]).toContain("← for agents"); + expect(lines.find(l => l.includes("main"))).toContain("●"); // main selected by default + const agentLine = lines.find(l => l.includes("Sleep then report 1"))!; + expect(agentLine).toContain("○"); + expect(agentLine).toContain(getDisplayName("general-purpose")); + expect(agentLine).toContain("↓ 13.1k tokens"); + expect(agentLine).toMatch(/\d+s · ↓/); // "s · ↓ ..." (timing-agnostic) + }); + + it("orders agents earliest-launched first (top)", () => { + const agents = [ + makeRecord({ id: "new", description: "newest", startedAt: 2000 }), + makeRecord({ id: "old", description: "oldest", startedAt: 1000 }), + ]; + const lines = harness(agents).render(); + const oldIdx = lines.findIndex(l => l.includes("oldest")); + const newIdx = lines.findIndex(l => l.includes("newest")); + expect(oldIdx).toBeGreaterThanOrEqual(0); + expect(oldIdx).toBeLessThan(newIdx); // earliest sits above the later one + }); + + it("hides agents that have no session yet (pending)", () => { + const agents = [ + makeRecord({ id: "live", description: "running one" }), + makeRecord({ id: "pending", description: "queued one", status: "queued", session: undefined }), + ]; + const lines = harness(agents).render(); + expect(lines.some(l => l.includes("running one"))).toBe(true); + expect(lines.some(l => l.includes("queued one"))).toBe(false); + }); + + it("collapses overflow into a '↓ N more' indicator", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `report ${i}` })); + const h = harness(agents); + const lines = h.render(120); + // 8 agents, cap 5 visible → "↓ 3 more" + expect(lines.some(l => l.includes("↓ 3 more"))).toBe(true); + }); + + it("never emits a line wider than the terminal (guards wrap-induced flicker)", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `a very long agent description number ${i} that keeps going` })); + const h = harness(agents); + for (const w of [4, 8, 12, 20, 40, 80, 200]) { + for (const line of h.render(w)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(w); + } + } + }); + + it("windows the visible agents so the selection stays on screen", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `report ${i}` })); + const h = harness(agents); + h.press(DOWN); // activate (main) + // step down to the last agent (8 agents → roster index 8) + for (let i = 0; i < 8; i++) h.press(DOWN); + const lines = h.render(120); + expect(lines.find(l => l.includes("report 7"))).toContain("●"); + expect(lines.some(l => l.includes("↑"))).toBe(true); // hidden-above indicator + }); +}); + +describe("FleetList overlay lifecycle", () => { + it("Enter on 'main' just deactivates (no overlay)", () => { + const h = harness([makeRecord()]); + h.press(DOWN); // active, index 0 (main) + h.press(ENTER); + expect(h.overlayOpened()).toBe(false); // never opened an overlay + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("keeps the cursor on the viewed agent after closing, even if the list reordered", async () => { + const fakeSession = { subscribe: () => () => {}, messages: [] }; + const agents = [ + makeRecord({ id: "a1", description: "one", session: fakeSession as any }), + makeRecord({ id: "a2", description: "two", session: fakeSession as any }), + makeRecord({ id: "a3", description: "three", session: fakeSession as any }), + ]; + const h = harness(agents); + h.press(DOWN); // activate (main, idx 0) + h.press(DOWN); // a1 (idx 1) + h.press(DOWN); // a2 (idx 2) + h.press(ENTER); // open a2 + // a1 finishes and drops out while viewing → a2 shifts from idx 2 to idx 1. + agents.splice(0, 1); + await h.closeOverlay(); + // Selection follows a2 ("two") to its new position, not whatever is at idx 2 now. + expect(h.render().find(l => l.includes("two"))).toContain("●"); + expect(h.render().find(l => l.includes("three"))).toContain("○"); + }); + + it("wires the viewer's steer composer to manager.steer with the agent id", () => { + const agents = [makeRecord({ id: "live", description: "the one" })]; + const h = harness(agents); + h.press(DOWN); // activate (main) + h.press(DOWN); // → the agent + h.press(ENTER); // open the conversation viewer + + const viewer = h.overlayComponent(); + expect(viewer).toBeDefined(); + viewer!.handleInput("\r"); // Enter → open composer + for (const ch of "go left") viewer!.handleInput(ch); + viewer!.handleInput("\r"); // Enter → send + + expect(h.manager.steer).toHaveBeenCalledWith("live", "go left"); + }); + + it("does NOT auto-close when the viewed agent finishes (final output stays readable)", () => { + const agents = [makeRecord({ id: "live", description: "the one" })]; + const h = harness(agents); + h.press(DOWN); // active (main) + h.press(DOWN); // → the agent + h.press(ENTER); // opens overlay + expect(h.overlayOpened()).toBe(true); + // The agent finishes, well past the linger window... + agents[0] = makeRecord({ id: "live", description: "the one", status: "completed", completedAt: Date.now() - 60_000 }); + h.fleet.onAgentFinished("live"); + expect(h.overlayClosed()).toBe(false); // viewer stays open + expect(h.render().some(l => l.includes("the one"))).toBe(true); // and stays listed while viewed + }); + + it("lingers a finished agent in the list, then drops it after the window", () => { + const recent = makeRecord({ id: "r", description: "recent done", status: "completed", completedAt: Date.now() }); + expect(harness([recent]).render().some(l => l.includes("recent done"))).toBe(true); + const old = makeRecord({ id: "o", description: "old done", status: "completed", completedAt: Date.now() - 60_000 }); + expect(harness([old]).render().some(l => l.includes("old done"))).toBe(false); + }); +}); + +describe("FleetList cost display", () => { + const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + + function row(showCost: boolean, cost: number, activity?: Map): string { + const record = makeRecord({ lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0, cost } }); + const fleet = new FleetList(fakeManager([record]), activity ?? new Map(), () => showCost); + let factory: any; + fleet.setUICtx({ + setWidget: (_k: string, c: any) => { factory = c; }, + onTerminalInput: () => () => {}, + getEditorText: () => "", + notify: () => {}, + custom: (() => new Promise(() => {})) as any, + } as any); + fleet.update(); + return factory({ requestRender: () => {}, terminal: { columns: 120, rows: 40 } }, theme).render(120).join("\n"); + } + + it("appends the cost after the token count when enabled", () => { + const out = row(true, 0.0042); + expect(out).toContain("13.1k tokens"); + expect(out).toContain("~$0.0042"); + }); + + it("shows no cost when disabled, and none for an unpriced model", () => { + expect(row(false, 0.0042)).not.toContain("$"); + expect(row(true, 0)).not.toContain("$"); + }); + + it("reads the record, so the figures do not change when the agent finishes", () => { + // Spend used to come from the live activity tracker while an agent ran and + // from its record once the tracker was deleted. The two disagree: only the + // record carries a nested child's spend (nested-tools folds it into every + // ancestor), so the number jumped upward at completion. + // The stale shape on purpose: an activity entry carrying figures of its own + // is what the old fallback preferred, so a row that still renders the + // record's numbers proves the tracker is no longer consulted for spend. + const tracked = new Map([["a1", { + activeTools: new Map(), toolUses: 0, responseText: "", turnCount: 1, + lifetimeUsage: { input: 1, output: 1, cacheWrite: 0, cost: 0.9 }, + } as unknown as AgentActivity]]); + + expect(row(true, 0.0042, tracked)).toBe(row(true, 0.0042)); + }); +}); diff --git a/pi-subagents/test/fleet-wiring.test.ts b/pi-subagents/test/fleet-wiring.test.ts new file mode 100644 index 0000000..ff7bcd0 --- /dev/null +++ b/pi-subagents/test/fleet-wiring.test.ts @@ -0,0 +1,141 @@ +/** + * fleet-wiring.test.ts — end-to-end wiring of the FleetView through the REAL + * extension (src/index.ts), not the FleetList class in isolation. + * + * The unit tests in fleet-list.test.ts drive FleetList with a fake ui/manager. + * These prove the bits only the extension can: that `tool_execution_start` + * hands the fleet the live UI (so it captures input), that spawning a background + * agent actually registers the `belowEditor` widget once the agent has a session, + * and that `session_shutdown` tears it down. runAgent is mocked (no LLM); the + * manager, settings load, completion routing, and lifecycle handlers are real. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +/** A UI context with the surfaces the widget + fleet touch; setWidget is spied. */ +function uiCtx() { + return { + setStatus: vi.fn(), + setWidget: vi.fn(), + notify: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), + getEditorText: vi.fn(() => ""), + custom: vi.fn(), + }; +} + +function ctxWith(ui: ReturnType) { + return { + hasUI: true, + ui, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: () => "s1", getBranch: () => [] }, + getSystemPrompt: () => "parent", + } as any; +} + +const textOf = (r: any): string => r.content[0].text; +const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +describe("FleetView wiring (real extension lifecycle)", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-fleet-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-fleet-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + // async join → completion routes straight to sendIndividualNudge (no batch + // debounce), so fleet.onAgentFinished fires synchronously on the result. + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, defaultJoinMode: "async" })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("captures terminal input on tool_execution_start (fleet hooked into the UI)", async () => { + const { pi, lifecycle } = makePi(); + subagentsExtension(pi); + const ui = uiCtx(); + await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui)); + expect(ui.onTerminalInput).toHaveBeenCalled(); + }); + + it("registers the belowEditor widget once a spawned agent has a session, then clears it on shutdown", async () => { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const ui = uiCtx(); + await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui)); // fleet captures THIS ui + + const spawn = await tools.get("Agent").execute( + "tc", + { prompt: "go", description: "live one", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctxWith(uiCtx()), + ); + expect(textOf(spawn)).toMatch(/Agent ID:/); + await flush(); // completion → fleet.onAgentFinished → update → widget registers + + const fleetRegs = ui.setWidget.mock.calls.filter(c => c[0] === "fleet" && typeof c[1] === "function"); + expect(fleetRegs.length, "fleet widget should register with a render factory").toBeGreaterThan(0); + + await lifecycle.get("session_shutdown")?.({}, ctxWith(uiCtx())); + expect(ui.setWidget).toHaveBeenCalledWith("fleet", undefined); // dispose cleared it + }); +}); diff --git a/pi-subagents/test/foreground-result-retrieval.test.ts b/pi-subagents/test/foreground-result-retrieval.test.ts new file mode 100644 index 0000000..40f9b52 --- /dev/null +++ b/pi-subagents/test/foreground-result-retrieval.test.ts @@ -0,0 +1,220 @@ +/** + * foreground-result-retrieval.test.ts — issue #174, via the REAL Agent tool + + * the REAL get_subagent_result tool. + * + * Report: a FOREGROUND agent that wraps up at max_turns returns its partial + * result inline, but a get_subagent_result for "that agent ID" immediately + * afterwards answers `Agent not found ... It may have been cleaned up.` — with + * no /new, /resume or session switch in between. + * + * Tracing says the premise can't hold, and these tests pin both halves of why: + * + * 1. The record is NOT cleaned up. Foreground completion mutates the record + * in place (agent-manager.ts startAgent's .then) — nothing deletes it. So + * a lookup with the REAL id succeeds. + * 2. The model never HAD the real id. Foreground returns the result text + * only; the id travels in `details`, which is renderer metadata and never + * reaches the API (only `content` is serialized). The background path is + * the one that puts `Agent ID: ...` in the text. + * + * Together: whatever id the reporter's model passed, it wasn't one we issued, + * and "not found" was the correct answer. Test 3 pins the eviction rule that + * DOES apply, so the two are not confused again. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; + +/** + * Run a FOREGROUND agent that wraps up at the turn limit — the exact #174 + * shape. `steered: true` is what agent-manager turns into status "steered", + * which is what produces the reporter's "(wrapped up at the turn limit — + * output may be partial)" note. + * + * Returns the tool result plus the id read out of `details` — the only place + * a foreground id exists, which is the point of test 2. + */ +async function runForegroundSteeredAgent(tools: Map) { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "THE-RESULT-PAYLOAD", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: true, + }); + const res = await tools.get("Agent").execute( + "tc-fg", + { + prompt: "Perform a very thorough read-only codebase exploration.", + description: "Locate organization-scope changes", + subagent_type: "Explore", + max_turns: 20, + run_in_background: false, + }, + undefined, + undefined, + ctx(), + ); + const id = (res as any).details?.agentId as string | undefined; + expect(id, "foreground spawn should have produced a record id in details").toBeTruthy(); + return { res, id: id as string }; +} + +describe("issue #174: foreground agent that hits max_turns", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + // Hermetic cwd + global dir, scheduling off — same isolation as + // clear-completed-wiring.test.ts, so session_start doesn't spin a + // scheduler or touch the dev's filesystem. + tmpDir = mkdtempSync(join(tmpdir(), "pi-174-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-174-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("is NOT cleaned up — get_subagent_result with the real id still resolves it", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const { res, id } = await runForegroundSteeredAgent(tools); + + // The inline result is the turn-limit wrap-up the reporter described. + expect(textOf(res)).toContain("wrapped up at the turn limit"); + + // No /new, no /resume, no session switch — exactly the reporter's sequence. + const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(read); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("never hands the model an agent id — the id lives only in renderer details", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const { res, id } = await runForegroundSteeredAgent(tools); + + // `content` is the only thing serialized to the API. If the id isn't here, + // the model cannot have obtained it — any id it passes is invented. + expect(textOf(res)).not.toContain(id); + expect(textOf(res)).not.toMatch(/Agent ID:/); + + // An invented id is correctly rejected — this is the reporter's error, + // reproduced WITHOUT any record having been cleaned up. + const bogus = await tools.get("get_subagent_result").execute( + "tc-bogus", + { agent_id: "3f1320a7-74ec-422" }, + undefined, + undefined, + ctx(), + ); + expect(textOf(bogus)).toContain("Agent not found"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("survives a subagent session's OWN activation lifecycle (adversarial: cross-activation eviction)", async () => { + // The one mechanism that could produce the reported symptom with no + // user-visible session change: a second activation of this extension in the + // same process. Child sessions no longer reach it — activation returns early + // under `inChildSessionContext()` — but any other in-process activation still + // can, and if its session_start / session_shutdown reached the PARENT's + // manager, that activation ending would wipe the parent's records. + const parent = makePi(); + subagentsExtension(parent.pi); + await parent.lifecycle.get("session_start")?.({}, ctx()); + const { id } = await runForegroundSteeredAgent(parent.tools); + + // A child activation runs its full lifecycle, as a subagent session does. + const child = makePi(); + subagentsExtension(child.pi); + await child.lifecycle.get("session_start")?.({}, ctx()); + await child.lifecycle.get("session_shutdown")?.({}, ctx()); + + // The parent's record must be untouched — separate manager per activation. + const read = await parent.tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(read); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await parent.lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("IS evicted by a session switch — its result was already delivered inline", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const { id } = await runForegroundSteeredAgent(tools); + + // Foreground results count as consumed the moment they're returned inline, + // so clearCompleted(true)'s #108 preservation deliberately does not cover + // them. This is the ONLY path that makes a foreground id stop resolving. + await lifecycle.get("session_before_switch")?.(); + + const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + expect(textOf(read)).toContain("Agent not found"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); +}); diff --git a/pi-subagents/test/group-join.test.ts b/pi-subagents/test/group-join.test.ts new file mode 100644 index 0000000..ccca666 --- /dev/null +++ b/pi-subagents/test/group-join.test.ts @@ -0,0 +1,153 @@ +/** + * group-join.test.ts — Behavior of GroupJoinManager's state machine and timers. + * + * Uses fake timers to assert deterministic timeout behavior without flakiness. + * The class itself is exercised directly with real records — no mocks beyond + * a spy on the delivery callback. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GroupJoinManager } from "../src/group-join.js"; +import type { AgentRecord } from "../src/types.js"; + +function makeRecord(id: string, overrides: Partial = {}): AgentRecord { + return { + id, + type: "general-purpose", + description: "test", + status: "completed", + toolUses: 0, + startedAt: 0, + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, + ...overrides, + }; +} + +describe("GroupJoinManager", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns 'pass' for unregistered agents and never invokes the callback", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver); + expect(mgr.onAgentComplete(makeRecord("a"))).toBe("pass"); + expect(deliver).not.toHaveBeenCalled(); + expect(mgr.isGrouped("a")).toBe(false); + }); + + it("holds the first completion and arms the join timeout", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver, 30_000); + mgr.registerGroup("g", ["a", "b"]); + + expect(mgr.isGrouped("a")).toBe(true); + expect(mgr.onAgentComplete(makeRecord("a"))).toBe("held"); + + vi.advanceTimersByTime(29_999); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("delivers all records (partial=false) when the final completion arrives in time", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver); + mgr.registerGroup("g", ["a", "b"]); + + mgr.onAgentComplete(makeRecord("a", { result: "A" })); + expect(mgr.onAgentComplete(makeRecord("b", { result: "B" }))).toBe("delivered"); + + expect(deliver).toHaveBeenCalledTimes(1); + const [records, partial] = deliver.mock.calls[0]; + expect(records.map((r: AgentRecord) => r.id).sort()).toEqual(["a", "b"]); + expect(partial).toBe(false); + + // Group is cleaned up — no future deliveries can fire from these ids + expect(mgr.isGrouped("a")).toBe(false); + expect(mgr.isGrouped("b")).toBe(false); + }); + + it("delivers partial=true on timeout and re-arms the group for stragglers", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver, 30_000); + mgr.registerGroup("g", ["a", "b", "c"]); + + mgr.onAgentComplete(makeRecord("a")); + vi.advanceTimersByTime(30_000); + + expect(deliver).toHaveBeenCalledTimes(1); + const [records, partial] = deliver.mock.calls[0]; + expect(records.map((r: AgentRecord) => r.id)).toEqual(["a"]); + expect(partial).toBe(true); + + // 'a' was delivered and is dropped from the group; 'b' and 'c' remain as stragglers + expect(mgr.isGrouped("a")).toBe(false); + expect(mgr.isGrouped("b")).toBe(true); + expect(mgr.isGrouped("c")).toBe(true); + }); + + it("uses the shorter straggler timeout (15s) regardless of the configured group timeout", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver, 30_000); + mgr.registerGroup("g", ["a", "b", "c"]); + + // First batch: 'a' alone, partial-delivered after 30s + mgr.onAgentComplete(makeRecord("a")); + vi.advanceTimersByTime(30_000); + expect(deliver).toHaveBeenCalledTimes(1); + + // Straggler 'b' arrives — fires at 15s, not 30s + mgr.onAgentComplete(makeRecord("b")); + vi.advanceTimersByTime(14_999); + expect(deliver).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(deliver).toHaveBeenCalledTimes(2); + + expect(deliver.mock.calls[1][0].map((r: AgentRecord) => r.id)).toEqual(["b"]); + expect(deliver.mock.calls[1][1]).toBe(true); + expect(mgr.isGrouped("c")).toBe(true); // 'c' is the remaining straggler now + }); + + it("delivers stragglers as a complete batch (partial=false) when all complete before their timeout", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver, 30_000); + mgr.registerGroup("g", ["a", "b", "c"]); + + mgr.onAgentComplete(makeRecord("a")); + vi.advanceTimersByTime(30_000); // partial: 'a' + expect(deliver).toHaveBeenCalledTimes(1); + + mgr.onAgentComplete(makeRecord("b")); + expect(mgr.onAgentComplete(makeRecord("c"))).toBe("delivered"); + + expect(deliver).toHaveBeenCalledTimes(2); + expect(deliver.mock.calls[1][0].map((r: AgentRecord) => r.id).sort()).toEqual(["b", "c"]); + expect(deliver.mock.calls[1][1]).toBe(false); + }); + + it("returns 'pass' for late completions arriving after a group is already delivered", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver); + mgr.registerGroup("g", ["a", "b"]); + + mgr.onAgentComplete(makeRecord("a")); + mgr.onAgentComplete(makeRecord("b")); // full delivery + expect(deliver).toHaveBeenCalledTimes(1); + + // A duplicate/late completion must not trigger a second delivery + expect(mgr.onAgentComplete(makeRecord("a"))).toBe("pass"); + expect(deliver).toHaveBeenCalledTimes(1); + }); + + it("dispose() clears pending timers so a partial delivery never fires post-dispose", () => { + const deliver = vi.fn(); + const mgr = new GroupJoinManager(deliver, 30_000); + mgr.registerGroup("g", ["a", "b"]); + + mgr.onAgentComplete(makeRecord("a")); // arms 30s timeout + mgr.dispose(); + + vi.advanceTimersByTime(60_000); + expect(deliver).not.toHaveBeenCalled(); + expect(mgr.isGrouped("a")).toBe(false); + expect(mgr.isGrouped("b")).toBe(false); + }); +}); diff --git a/pi-subagents/test/helpers/boot-extension.ts b/pi-subagents/test/helpers/boot-extension.ts new file mode 100644 index 0000000..912294b --- /dev/null +++ b/pi-subagents/test/helpers/boot-extension.ts @@ -0,0 +1,115 @@ +/** + * boot-extension.ts — the mock `pi` / `ctx` pair the wiring tests use to boot the + * REAL extension (src/index.ts) and drive its registered tools directly. + * + * The 14 pre-existing wiring tests each inline their own near-identical copy of + * this. They are deliberately NOT migrated: `test/` is outside the tsconfig + * `include`, so that churn would be unchecked by `tsc`, and those copies have + * small divergences that would have to be reconciled blind. New files use this. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { vi } from "vitest"; + +export interface BootedPi { + pi: any; + tools: Map; + lifecycle: Map; +} + +/** A mock ExtensionAPI that records every tool and lifecycle handler registered. */ +export function makePi(): BootedPi { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +/** A mock ExtensionContext — the second half of what a tool's `execute` receives. */ +export function ctx(overrides: Record = {}) { + return { + // The interactive mode extensions normally run in. Set explicitly because + // the `@handle` input hook is TUI-only, so an absent mode would make every + // mention test exercise the headless fall-through instead. + mode: "tui", + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + ...overrides, + } as any; +} + +/** Text of a tool result. */ +export const textOf = (r: any): string => r.content[0].text; + +/** Let queued microtasks run — enough for the manager's internal chaining. */ +export const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +export interface Hermetic { + dir: string; + restore: () => void; +} + +/** + * Redirect cwd, `PI_CODING_AGENT_DIR` and `HOME` into a fresh temp dir, so the + * developer's real settings and agent files can't reach the extension under + * test. Call BEFORE instantiating the extension — settings are read at boot. + */ +export function hermeticDir(opts: { + settings?: Record; + agentFiles?: Record; +} = {}): Hermetic { + const dir = mkdtempSync(join(tmpdir(), "pi-boot-")); + const agentDir = mkdtempSync(join(tmpdir(), "pi-boot-agentdir-")); + const prevCwd = process.cwd(); + const prevAgentDir = process.env.PI_CODING_AGENT_DIR; + const prevHome = process.env.HOME; + + mkdirSync(join(dir, ".pi"), { recursive: true }); + if (opts.settings) { + writeFileSync(join(dir, ".pi", "subagents.json"), JSON.stringify(opts.settings)); + } + if (opts.agentFiles) { + mkdirSync(join(dir, ".pi", "agents"), { recursive: true }); + for (const [name, content] of Object.entries(opts.agentFiles)) { + writeFileSync(join(dir, ".pi", "agents", `${name}.md`), content); + } + } + + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + process.chdir(dir); + + return { + dir, + restore() { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(dir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + }, + }; +} diff --git a/pi-subagents/test/helpers/faux-model-backend.ts b/pi-subagents/test/helpers/faux-model-backend.ts new file mode 100644 index 0000000..bddb3d5 --- /dev/null +++ b/pi-subagents/test/helpers/faux-model-backend.ts @@ -0,0 +1,70 @@ +/** + * faux-model-backend.ts — the model/auth plumbing a faux-provider session needs, + * in one place. + * + * `registerFauxProvider` scripts the *responses*, but a session still has to get + * past model lookup and auth before it streams anything, and where that check + * lives moved with Pi 0.80.8: + * - Pi < 0.80.8: `createAgentSession({ modelRegistry })`, auth via + * `getApiKeyAndHeaders()`. + * - Pi >= 0.80.8: `createAgentSession({ modelRuntime })` — the registry option + * is gone entirely — auth via `getAuth()`/`hasConfiguredAuth()`, and the + * turn itself streams through `modelRuntime.streamSimple`. + * + * Passing BOTH spans the supported range: each Pi ignores the option it no + * longer knows. Structural fakes (not real instances) keep the suites hermetic — + * no auth.json, no network, no local login state. + */ +import type { Model } from "@earendil-works/pi-ai"; +import { streamSimple } from "./pi-ai.js"; + +/** Both option shapes for `createAgentSession`, for the given faux model. */ +export function fauxModelBackend(model: Model): { + modelRegistry: any; + modelRuntime: any; +} { + return { + modelRegistry: { + find: () => model, + getAll: () => [model], + getAvailable: () => [model], + hasConfiguredAuth: () => true, + isUsingOAuth: () => false, + // createAgentSession's injected streamFn checks `auth.ok` and throws + // Error(auth.error) otherwise — so the `ok: true` flag is mandatory, not + // cosmetic. Without it the turn dies before streaming (empty error message). + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux", headers: {} }), + registerProvider: () => {}, + unregisterProvider: () => {}, + }, + modelRuntime: { + getModel: () => model, + getModels: () => [model], + getProvider: () => undefined, + getProviders: () => [], + getAvailable: async () => [model], + getAvailableSnapshot: () => [model], + getError: () => undefined, + hasConfiguredAuth: () => true, + checkAuth: async () => ({ ok: true }), + isUsingOAuth: () => false, + isUsingSubscription: () => false, + // Shape mirrors ModelRuntime.getAuth: the session reads `auth.apiKey` / + // `auth.headers` and throws "No API key found" when both are absent. + getAuth: async () => ({ auth: { apiKey: "faux", headers: {} } }), + getProviderAuthStatus: () => "configured", + getCompatibilityRequestConfig: () => ({}), + getRegisteredProviderIds: () => [], + getRegisteredProviderConfig: () => undefined, + getRegisteredNativeProvider: () => undefined, + registerProvider: () => {}, + registerNativeProvider: () => {}, + unregisterProvider: () => {}, + refresh: async () => ({}), + // The faux provider registers itself in pi-ai's global api-provider + // registry, so compat's dispatcher reaches it by `model.api`. + stream: streamSimple, + streamSimple, + }, + }; +} diff --git a/pi-subagents/test/helpers/pi-ai.ts b/pi-subagents/test/helpers/pi-ai.ts new file mode 100644 index 0000000..5d67b41 --- /dev/null +++ b/pi-subagents/test/helpers/pi-ai.ts @@ -0,0 +1,7 @@ +/** + * pi-ai.ts — single import point for the two test helpers that pi-ai ≥0.80 + * exports only from the `/compat` subpath (all lived on the package root in + * ≤0.75.x). Upstream deletes `/compat` with its coding-agent ModelManager + * migration; the replacement then is `fauxProvider()` + `createModels()`. + */ +export { getModel, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat"; diff --git a/pi-subagents/test/helpers/print-mode-runner.ts b/pi-subagents/test/helpers/print-mode-runner.ts new file mode 100644 index 0000000..924c051 --- /dev/null +++ b/pi-subagents/test/helpers/print-mode-runner.ts @@ -0,0 +1,616 @@ +/** + * print-mode-runner.ts — a headless ("print mode") host runner for driving the + * pi-subagents extension through REAL end-to-end subagent runs. + * + * WHY THIS EXISTS + * --------------- + * The other e2e suites (agent-runner-e2e, ext-templates-e2e) assert on the + * *gated tool set captured at construction* — they never drive a turn, so they + * never actually spawn a subagent or exercise the background hold condition. + * This runner closes that gap: it boots a real headless pi session with the + * pi-subagents extension loaded, drives a real assistant turn that calls the + * `Agent` tool, and lets the extension spawn a real child session through the + * real `runAgent` path — then waits for it to finish exactly like a production + * print-mode host does. + * + * It is the pi-subagents analogue of pi-chonky-step's `src/agent.ts` headless + * runner: same shape (DefaultResourceLoader → createAgentSession → prompt loop), + * and crucially it replicates pi-chonky-step's SUBAGENT HOLD CONDITION — the + * `dequeueFollowUpMessages` monkey-patch that blocks the parent agent loop until + * background subagents complete (via the `Symbol.for("pi-subagents:manager")` + * global the extension publishes). Without that patch, `session.prompt()` + * resolves and the parent finishes before background children report back. + * + * MODEL BACKEND (faux default, real opt-in) + * ----------------------------------------- + * - Faux (default): a scripted `registerFauxProvider` model drives both the + * parent and the spawned child deterministically — no network, CI-safe. You + * supply a `respond(context)` function (or raw `steps`) that emits the + * `Agent` tool call on the parent and a reply on the child. `routeBySession` + * does the parent/child branching for the common single-spawn case. + * - Live (opt-in): set `PI_E2E_LIVE=1` or pass `live: {provider, model}`. A real + * model drives the turn; `respond`/`steps` are ignored. Non-deterministic, + * needs creds. With no explicit model pin, it resolves the model from your + * local `pi` config (settings default → first authed model), so a logged-in + * `pi` is picked up automatically — no PI_PROVIDER/PI_MODEL needed. + * + * ONE PARAMETERIZED RUNNER + * ------------------------ + * The same `runPrintMode()` covers built-in agent types, `.pi/agents/*.md` / + * `.agents/agents/*.md` frontmatter agents, and inline-instruction agents — the difference is purely + * what you register in `beforeRun` and which `subagent_type` the `Agent` call + * names. See `test/subagents-print-mode-e2e.test.ts` for usage. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type AssistantMessage, + type Context, + type FauxContentBlock, + type FauxResponseStep, + fauxAssistantMessage, + fauxText, + fauxToolCall, + type Model, + type ToolCall, +} from "@earendil-works/pi-ai"; +import { + type AgentSession, + type AgentSessionEvent, + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { fauxModelBackend } from "./faux-model-backend.js"; +import { getModel, registerFauxProvider } from "./pi-ai.js"; + +/** Path to the pi-subagents extension entrypoint (repo `src/index.ts`). */ +const EXTENSION_PATH = fileURLToPath(new URL("../../src/index.ts", import.meta.url)); + +/** The cross-package handle the extension publishes on a global Symbol. */ +const MANAGER_KEY = Symbol.for("pi-subagents:manager"); + +export interface ManagerHandle { + waitForAll(): Promise; + hasRunning(): boolean; + getRecord(id: string): unknown; +} + +/** A faux reply in any convenient shape; normalized to an AssistantMessage. */ +export type FauxReply = string | FauxContentBlock | FauxContentBlock[] | AssistantMessage; + +/** + * A context-branching responder. Invoked once per model call (parent OR child) + * with that call's own `Context`, so it can decide what to emit from the prompt + * it sees — order-independent, unlike a flat FIFO `steps` list. + */ +export type FauxResponder = ( + context: Context, + state: { callCount: number }, +) => FauxReply | Promise; + +export interface RunPrintModeOptions { + /** The user prompt that kicks off the parent turn. */ + prompt: string; + /** + * Working directory for the run. Defaults to a fresh temp dir that `dispose()` + * removes. Pass a fixtures dir to make project custom agents discoverable. + */ + cwd?: string; + /** Parent host system prompt. Default: a minimal orchestrator prompt. */ + systemPrompt?: string; + /** + * Faux mode: context-branching responder (padded to `maxModelCalls` calls). + * Ignored in live mode. Mutually exclusive with `steps` (steps wins). + */ + respond?: FauxResponder; + /** Faux mode: explicit FIFO response steps. Overrides `respond`. */ + steps?: FauxResponseStep[]; + /** Faux mode: how many model calls to pad the queue for. Default 16. */ + maxModelCalls?: number; + /** + * Honor the subagent hold condition — block the parent agent loop until + * background subagents finish (the pi-chonky-step monkey-patch). Default true. + */ + hold?: boolean; + /** + * Run before the parent turn, after globals are isolated — e.g. + * `registerAgents(loadCustomAgents(cwd))` to install frontmatter agents. + */ + beforeRun?: () => void | Promise; + /** + * Isolate global discovery (PI_CODING_AGENT_DIR + HOME → temp) so the dev's + * real agents/extensions can't bleed into the run. Default true in faux mode, + * false in live mode (so real auth/config resolve). Restored on `dispose()`. + */ + isolateGlobals?: boolean; + /** Wall-clock guard for the whole run. Default 30_000ms. */ + timeoutMs?: number; + /** Abort the parent (and forwarded children) externally. */ + signal?: AbortSignal; + /** + * Force live mode against a specific provider/model (overrides PI_E2E_LIVE). + * When omitted, live mode is on iff `PI_E2E_LIVE` is truthy. In live mode, if + * neither this nor `PI_PROVIDER`+`PI_MODEL` is set, the model is left for pi to + * resolve from your local config (settings default → first authed model) — i.e. + * it picks up whatever your `pi` install is logged into, no env required. + * + * `false` pins the run faux even under `PI_E2E_LIVE=1`. A suite whose whole + * point is a scripted response — a provider error with no content, a + * three-level delegation chain — has nothing to gain from a real model and + * cannot assert anything once one answers instead. Without this, running the + * documented pre-publish smoke turns those suites red on a healthy tree, + * which is worse than not running them: it hides a real regression in noise. + */ + live?: { provider: string; model: string } | false; +} + +export interface PrintModeRun { + /** Last assistant text the parent produced (the "printed" answer). */ + responseText: string; + /** The live parent session (history, tool calls, etc.). */ + parentSession: AgentSession; + /** The extension's manager handle (undefined if the extension didn't load). */ + manager: ManagerHandle | undefined; + /** Snapshot of all subagent records the manager knew about at the end. */ + subagents: Array>; + /** Faux model call count (0 in live mode). */ + modelCalls: number; + /** + * Tear down: emit session_shutdown (so extensions clear timers), dispose the + * session, unregister faux, restore cwd/env, rm temp dir. Async — await it. + */ + dispose: () => Promise; +} + +// -------------------------------------------------------------------------- +// Faux scripting helpers +// -------------------------------------------------------------------------- + +/** + * Build an `Agent` tool call for a faux assistant turn. `subagent_type` defaults + * to "general-purpose"; everything else is passed straight through as tool args. + */ +export function agentCall( + args: { + prompt: string; + description: string; + subagent_type?: string; + run_in_background?: boolean; + [k: string]: unknown; + }, + opts?: { id?: string }, +): ToolCall { + return fauxToolCall("Agent", { subagent_type: "general-purpose", ...args }, opts); +} + +function resolveReply( + reply: FauxReply | ((ctx: Context) => FauxReply), + ctx: Context, +): FauxReply { + return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply; +} + +/** + * The common single-spawn flow as a responder. Routes by inspecting the calling + * session's own context: + * - PARENT (its tool set includes `Agent`): + * · `parentInitial` until an `Agent` tool result is in history (the spawn), + * · then `parentFinal` (the answer after the child reports back). + * - SUBAGENT (no `Agent` tool): `subagent`. + * Each route may be a value or a `(ctx) => value` function. + */ +export function routeBySession(routes: { + parentInitial: FauxReply | ((ctx: Context) => FauxReply); + parentFinal?: FauxReply | ((ctx: Context) => FauxReply); + subagent: FauxReply | ((ctx: Context) => FauxReply); +}): FauxResponder { + return (context) => { + const isParent = (context.tools ?? []).some((t) => t.name === "Agent"); + if (!isParent) return resolveReply(routes.subagent, context); + const spawned = context.messages.some( + (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", + ); + if (spawned) { + return routes.parentFinal != null + ? resolveReply(routes.parentFinal, context) + : "Done."; + } + return resolveReply(routes.parentInitial, context); + }; +} + +/** Normalize any FauxReply into a faux AssistantMessage (tool calls ⇒ stopReason "toolUse"). */ +function toAssistantMessage(reply: FauxReply): AssistantMessage { + if (reply && typeof reply === "object" && "role" in reply) { + return reply as AssistantMessage; + } + const content: FauxContentBlock[] = + typeof reply === "string" ? [fauxText(reply)] : Array.isArray(reply) ? reply : [reply]; + const hasToolCall = content.some((b) => (b as { type?: string }).type === "toolCall"); + return fauxAssistantMessage(content, { stopReason: hasToolCall ? "toolUse" : "stop" }); +} + +// -------------------------------------------------------------------------- +// The runner +// -------------------------------------------------------------------------- + +const DEFAULT_SYSTEM_PROMPT = + "You are a headless orchestrator. Use the Agent tool to delegate, then report the result."; + +function isLive(options: RunPrintModeOptions): boolean { + // An explicit `false` wins over the env var — the env var is a blanket switch, + // and a suite that pins itself faux is stating something the switch can't know. + if (options.live === false) return false; + return Boolean(options.live) || /^(1|true|yes)$/i.test(process.env.PI_E2E_LIVE ?? ""); +} + +export async function runPrintMode(options: RunPrintModeOptions): Promise { + const live = isLive(options); + const isolateGlobals = options.isolateGlobals ?? !live; + const timeoutMs = options.timeoutMs ?? 30_000; + + // --- working dir (own it only if we created it) --- + const ownsCwd = options.cwd == null; + const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "subagents-print-")); + + // chdir into cwd: the extension discovers project custom agents from process.cwd() + // (not ctx.cwd), and re-reads them on every Agent invocation — so a custom agent + // is only spawnable if process.cwd() points at the dir holding it. Restored on + // dispose. (Vitest isolates test files per process, so this doesn't race.) + const prevCwd = process.cwd(); + process.chdir(cwd); + + // --- isolate global discovery so the dev env can't bleed in --- + const prevAgentDir = process.env.PI_CODING_AGENT_DIR; + const prevHome = process.env.HOME; + let hermeticDir: string | undefined; + if (isolateGlobals) { + hermeticDir = mkdtempSync(join(tmpdir(), "subagents-print-home-")); + process.env.PI_CODING_AGENT_DIR = hermeticDir; + process.env.HOME = hermeticDir; + } + + // --- model backend --- + let faux: ReturnType | undefined; + let model: Model | undefined; + let modelRegistry: unknown; + let modelRuntime: unknown; + if (live) { + // Explicit pin wins (options.live or PI_PROVIDER + PI_MODEL). Otherwise leave + // `model` undefined: createAgentSession then calls findInitialModel() against + // the real, auth-backed registry + your local settings default — i.e. it + // picks up whatever your `pi` install is logged into, no env needed. + // `live: false` never reaches here (isLive returned false), but narrow it + // away rather than asserting: the pin is a plain option, not a type-level fact. + const pin = options.live || undefined; + const provider = pin?.provider ?? process.env.PI_PROVIDER; + const modelId = pin?.model ?? process.env.PI_MODEL; + if (provider && modelId) { + // getModel's overloads need the concrete provider literal; cast through. + // Since pi-ai 0.80 it is a static builtin-catalog lookup that returns + // undefined for unknown models — fail fast instead of letting + // createAgentSession silently substitute another model. + model = (getModel as (p: string, m: string) => Model | undefined)(provider, modelId); + if (!model) { + throw new Error( + `runPrintMode (live mode): model "${provider}/${modelId}" not found in the builtin catalog`, + ); + } + } + // Let createAgentSession build the real, auth-backed registry/runtime. + modelRegistry = undefined; + modelRuntime = undefined; + } else { + if (!options.steps && !options.respond) { + throw new Error("runPrintMode (faux mode): provide `respond` or `steps`"); + } + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] }); + model = faux.getModel(); + // Structural faux registry + runtime (see faux-model-backend.ts): the parent + // session uses `model` directly; subagents inherit it via ctx.model since + // resolveDefaultModel falls back to the parent model when no model is pinned. + ({ modelRegistry, modelRuntime } = fauxModelBackend(model)); + + // Pad the response queue: one context-branching responder per expected model + // call. The queue is a single FIFO shared by parent + child, but every entry + // is the same responder that decides from its own context, so interleaving + // order doesn't matter. + if (options.steps) { + faux.setResponses(options.steps); + } else { + const respond = options.respond; + if (!respond) { + throw new Error("runPrintMode (faux mode): provide `respond` or `steps`"); + } + const max = options.maxModelCalls ?? 16; + const factory: FauxResponseStep = async (context, _opts, state) => + toAssistantMessage(await respond(context, state)); + faux.setResponses(Array.from({ length: max }, () => factory)); + } + } + + // --- build the parent host session with the extension loaded --- + // Resolved after globals are isolated, so it honors the hermetic dir. + const agentDir = getAgentDir(); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + additionalExtensionPaths: [EXTENSION_PATH], + systemPromptOverride: () => options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + appendSystemPromptOverride: () => [], + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); + await loader.reload(); + + // Run any test-supplied registration (e.g. loadCustomAgents) now that globals + // are isolated but before the parent turn spawns anything. + await options.beforeRun?.(); + + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + // Structural faux registry/runtime in faux mode; undefined in live mode (defaults). + modelRegistry: modelRegistry as any, + modelRuntime: modelRuntime as any, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(cwd), + // Live: real settings so an omitted model resolves to your local default + // (settingsManager.getDefaultModel) and retries/compaction match your config. + // Faux: in-memory, deterministic, no disk. + settingsManager: live + ? SettingsManager.create(cwd, agentDir) + : SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }), + }); + session.setSessionName("print-mode-host"); + + // Binding fires session_start so the extension initializes and publishes its + // manager on the global Symbol. + await session.bindExtensions({}); + + const manager = (globalThis as Record)[MANAGER_KEY] as + | ManagerHandle + | undefined; + + // --- subagent hold condition (the pi-chonky-step monkey-patch) --- + // Block the parent agent loop while background subagents are still running, so + // their completion nudges land before the parent's final turn. + const hold = options.hold ?? true; + if (hold && manager) { + // dequeueFollowUpMessages is internal — reach through with a cast. + const agent = (session as any).agent; + if (agent?.dequeueFollowUpMessages) { + const original = agent.dequeueFollowUpMessages.bind(agent); + agent.dequeueFollowUpMessages = function patched() { + const messages = original(); + if (messages.length > 0) return messages; + if (manager.hasRunning()) { + // Returning a Promise is auto-unwrapped by the async loop config — + // the loop blocks here until all subagents finish and queue nudges. + return manager.waitForAll().then(() => original()); + } + return messages; + }; + } + } + + // --- collect the parent's last assistant text --- + let responseText = ""; + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (event.type === "message_start") responseText = ""; + if ( + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + responseText += event.assistantMessageEvent.delta; + } + }); + + // --- forward external abort --- + const onAbort = () => session.abort(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + const dispose = async () => { + // Emit session_shutdown FIRST so extensions tear down cleanly — in live mode + // the real env loads global extensions (e.g. a status-bar) whose background + // timers would otherwise fire after dispose() invalidates the ctx and surface + // as unhandled "stale ctx" rejections. dispose() itself does the invalidation, + // so shutdown has to happen before it. + try { + await session.extensionRunner?.emit({ type: "session_shutdown", reason: "quit" }); + } catch { + /* ignore */ + } + try { + session.dispose?.(); + } catch { + /* ignore */ + } + faux?.unregister(); + delete (globalThis as Record)[MANAGER_KEY]; + // Restore cwd before removing the temp dir (can't rm the dir you're in). + try { + process.chdir(prevCwd); + } catch { + /* ignore */ + } + if (isolateGlobals) { + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + if (hermeticDir) rmSync(hermeticDir, { recursive: true, force: true }); + } + if (ownsCwd) rmSync(cwd, { recursive: true, force: true }); + }; + + // --- drive the turn under a wall-clock guard --- + let timer: ReturnType | undefined; + let failed = false; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const stillRunning = manager?.hasRunning() ? " (background subagents still running)" : ""; + reject(new Error(`print-mode runner timed out after ${timeoutMs}ms${stillRunning}`)); + }, timeoutMs); + }); + try { + await Promise.race([ + (async () => { + await session.prompt(options.prompt); + // Fallback for when the hold patch is unavailable: catch any subagents + // still running after prompt() returns and process their results. + if (hold) { + while (!failed && manager?.hasRunning()) { + await manager.waitForAll(); + // prompt() resolves (not rejects) on abort, so after a timeout this + // orphaned race arm keeps running — never re-prompt a torn-down session. + if (failed) break; + await session.prompt("Background agents have completed. Process their results."); + } + } + })(), + timeout, + ]); + } catch (err) { + // On timeout (or any turn failure) we throw, so the caller never receives + // the dispose handle — without this, a live session and its background + // subagents would keep streaming after the test already failed. Subagents + // are aborted by dispose()'s session_shutdown emit (the extension's + // shutdown handler calls manager.abortAll()). + failed = true; + try { + session.abort(); + } catch { + /* ignore */ + } + try { + await dispose(); + } catch { + /* ignore — the turn error below is the diagnostic that matters */ + } + throw err; + } finally { + clearTimeout(timer); + unsubscribe(); + options.signal?.removeEventListener("abort", onAbort); + } + + if (!responseText.trim()) { + responseText = lastAssistantText(session); + } + + // Snapshot subagent records (manager exposes them via the extension session, + // but the cross-package handle only exposes getRecord — read listAgents off + // the underlying manager if reachable, else fall back to an empty list). + const subagents = snapshotSubagents(manager); + + return { + responseText: responseText.trim(), + parentSession: session, + manager, + subagents, + modelCalls: faux?.state.callCount ?? 0, + dispose, + }; +} + +/** + * Extract the text of every `Agent` tool result in a session's history. This is + * the real end-to-end observable: for a foreground spawn it contains the child's + * own output; for a background spawn it's the "started in background" envelope. + */ +export function agentToolResults(session: AgentSession): string[] { + const out: string[] = []; + for (const msg of session.messages) { + if (msg.role !== "toolResult") continue; + if ((msg as { toolName?: string }).toolName !== "Agent") continue; + const text = (msg.content as Array<{ type?: string; text?: string }>) + .map((b) => (b.type === "text" ? (b.text ?? "") : "")) + .join(""); + out.push(text); + } + return out; +} + +/** + * All text across the whole conversation — assistant turns, user/nudge messages, + * and every tool result. Use this to assert a child's output *materialized + * somewhere* (a foreground tool result, a get_subagent_result result, a held + * nudge), rather than only in the parent's final message which may summarize it. + */ +export function conversationText(session: AgentSession): string { + const parts: string[] = []; + for (const msg of session.messages) { + const content = (msg as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const block of content as Array<{ type?: string; text?: string }>) { + if (block.type === "text" && block.text) parts.push(block.text); + } + } + return parts.join("\n"); +} + +/** Names of every tool the assistant actually invoked (in order). */ +export function invokedToolNames(session: AgentSession): string[] { + const out: string[] = []; + for (const msg of session.messages) { + if (msg.role !== "assistant") continue; + for (const block of msg.content as Array<{ type?: string; name?: string }>) { + if (block.type === "toolCall" && block.name) out.push(block.name); + } + } + return out; +} + +/** + * The arguments of every `Agent` tool call the model actually made — lets a live + * smoke assert which feature was exercised (e.g. `run_in_background`, + * `subagent_type`) rather than just that *some* spawn happened. + */ +export function agentToolCalls(session: AgentSession): Array> { + const out: Array> = []; + for (const msg of session.messages) { + if (msg.role !== "assistant") continue; + for (const block of msg.content as Array<{ type?: string; name?: string; arguments?: unknown }>) { + if (block.type === "toolCall" && block.name === "Agent") { + out.push((block.arguments ?? {}) as Record); + } + } + } + return out; +} + +/** Walk session history backward for the last non-empty assistant text. */ +function lastAssistantText(session: AgentSession): string { + const messages = session.messages; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "assistant") continue; + const text = msg.content + .map((b) => ((b as { type?: string; text?: string }).type === "text" ? (b as { text?: string }).text ?? "" : "")) + .join("") + .trim(); + if (text) return text; + } + return ""; +} + +/** Best-effort snapshot of the manager's agent records for assertions. */ +function snapshotSubagents(manager: ManagerHandle | undefined): Array> { + if (!manager) return []; + // The published handle is minimal; the real manager (with listAgents) is the + // same object the extension constructed. Try listAgents if present. + const m = manager as unknown as { listAgents?: () => Array> }; + try { + return m.listAgents ? m.listAgents() : []; + } catch { + return []; + } +} diff --git a/pi-subagents/test/invocation-config.test.ts b/pi-subagents/test/invocation-config.test.ts new file mode 100644 index 0000000..ce6519f --- /dev/null +++ b/pi-subagents/test/invocation-config.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentInvocationConfig, resolveJoinMode } from "../src/invocation-config.js"; +import type { AgentConfig } from "../src/types.js"; + +function makeConfig(overrides: Partial = {}): AgentConfig { + return { + name: "Explore", + description: "Explore", + builtinToolNames: ["read"], + extensions: false, + skills: false, + systemPrompt: "Test agent", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + ...overrides, + }; +} + +describe("resolveAgentInvocationConfig", () => { + it("prefers agent config over tool-call params for locked fields", () => { + const resolved = resolveAgentInvocationConfig( + makeConfig({ + model: "provider/config-model", + thinking: "high", + maxTurns: 42, + inheritContext: false, + runInBackground: false, + isolated: false, + isolation: "worktree", + }), + { + model: "provider/param-model", + thinking: "minimal", + max_turns: 1, + inherit_context: true, + run_in_background: true, + isolated: true, + isolation: "worktree", + }, + ); + + expect(resolved.modelInput).toBe("provider/config-model"); + expect(resolved.modelFromParams).toBe(false); + expect(resolved.thinking).toBe("high"); + expect(resolved.maxTurns).toBe(42); + expect(resolved.inheritContext).toBe(false); + expect(resolved.runInBackground).toBe(false); + expect(resolved.isolated).toBe(false); + expect(resolved.isolation).toBe("worktree"); + }); + + it("uses tool-call params when no agent config is available", () => { + const resolved = resolveAgentInvocationConfig(undefined, { + model: "provider/param-model", + thinking: "minimal", + max_turns: 3, + inherit_context: true, + run_in_background: true, + isolated: true, + isolation: "worktree", + }); + + expect(resolved.modelInput).toBe("provider/param-model"); + expect(resolved.modelFromParams).toBe(true); + expect(resolved.thinking).toBe("minimal"); + expect(resolved.maxTurns).toBe(3); + expect(resolved.inheritContext).toBe(true); + expect(resolved.runInBackground).toBe(true); + expect(resolved.isolated).toBe(true); + expect(resolved.isolation).toBe("worktree"); + }); + + it("lets parent fill in booleans when config leaves them undefined", () => { + const resolved = resolveAgentInvocationConfig( + makeConfig({ + inheritContext: undefined, + runInBackground: undefined, + isolated: undefined, + }), + { + inherit_context: true, + run_in_background: true, + isolated: true, + }, + ); + + expect(resolved.inheritContext).toBe(true); + expect(resolved.runInBackground).toBe(true); + expect(resolved.isolated).toBe(true); + }); + + it("defaults booleans to false when neither config nor params set them", () => { + const resolved = resolveAgentInvocationConfig( + makeConfig({ + inheritContext: undefined, + runInBackground: undefined, + isolated: undefined, + }), + {}, + ); + + expect(resolved.inheritContext).toBe(false); + expect(resolved.runInBackground).toBe(false); + expect(resolved.isolated).toBe(false); + }); + + // "off" exists so a model that cannot bring itself to omit an optional field + // has a legal way to say no (#231). It is an input spelling only — the + // resolver collapses it to undefined so no consumer downstream grows a branch. + it('collapses a param isolation of "off" to undefined', () => { + const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: undefined }), { isolation: "off" }); + expect(resolved.isolation).toBeUndefined(); + }); + + // Agent config outranks tool-call params, so "off" in frontmatter is the only + // way to veto a caller's worktree — before #231 no value could do this. + it('lets a config isolation of "off" veto a param "worktree"', () => { + const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "off" }), { isolation: "worktree" }); + expect(resolved.isolation).toBeUndefined(); + }); + + it('still honours a param "worktree" when the config leaves isolation unset', () => { + const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: undefined }), { isolation: "worktree" }); + expect(resolved.isolation).toBe("worktree"); + }); + + it("drops worktree isolation when the project disallows it", () => { + const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "worktree" }), { isolation: "worktree" }, { worktreeAllowed: false }); + expect(resolved.isolation).toBeUndefined(); + }); + + it("keeps worktree isolation when the project allows it", () => { + const resolved = resolveAgentInvocationConfig(makeConfig({ isolation: "worktree" }), {}, { worktreeAllowed: true }); + expect(resolved.isolation).toBe("worktree"); + }); +}); + +describe("resolveJoinMode", () => { + it("returns the global default for background agents", () => { + expect(resolveJoinMode("smart", true)).toBe("smart"); + expect(resolveJoinMode("async", true)).toBe("async"); + }); + + it("ignores join mode for foreground agents", () => { + expect(resolveJoinMode("smart", false)).toBeUndefined(); + expect(resolveJoinMode("group", false)).toBeUndefined(); + }); +}); diff --git a/pi-subagents/test/isolation-param.test.ts b/pi-subagents/test/isolation-param.test.ts new file mode 100644 index 0000000..f95095d --- /dev/null +++ b/pi-subagents/test/isolation-param.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isolationParam } from "../src/invocation-config.js"; + +/** + * The `isolation` parameter's *shape* is the fix for #231, so it is worth + * asserting directly rather than only through behaviour. + * + * As a single-value optional literal it gave models that fill every optional + * parameter nothing harmless to fill it with: the session log on #231 shows one + * emitting `isolation: "worktree"` on three consecutive calls — alongside + * `resume: ""` and `schedule: ""` — while its own reasoning, its message to the + * user, and two explicit user instructions all said to omit the field. A second + * legal value is what lets it comply. + */ +describe("isolationParam", () => { + function schema(enabled: boolean) { + const built = isolationParam(enabled); + return built.isolation as { anyOf?: { const?: string }[] } | undefined; + } + + it("offers a value meaning 'no isolation', not just 'worktree'", () => { + const values = schema(true)?.anyOf?.map(v => v.const); + expect(values).toContain("off"); + expect(values).toContain("worktree"); + }); + + // A model that fills optional fields tends to reach for what it reads first, + // so the inert value leads. This is the whole mitigation — if the order flips, + // the schema stops steering and the bug is back in practice. + it("lists the inert value first", () => { + expect(schema(true)?.anyOf?.[0]?.const).toBe("off"); + }); + + it("warns that a worktree cannot see uncommitted work", () => { + // The specific trap in #231: the subagent reviewed an empty `git diff + // --cached` in a fresh copy and returned nothing, three times. + const described = JSON.stringify(schema(true)); + expect(described).toMatch(/uncommitted or staged/); + }); + + it("omits the parameter entirely when the project disabled worktrees", () => { + // Nothing to pass beats accepting it and quietly downgrading — and it costs + // the model no context in disabled mode, as `scheduleParam` already does. + expect(isolationParam(false)).toEqual({}); + }); +}); diff --git a/pi-subagents/test/manager-registry-guard.test.ts b/pi-subagents/test/manager-registry-guard.test.ts new file mode 100644 index 0000000..6e9d358 --- /dev/null +++ b/pi-subagents/test/manager-registry-guard.test.ts @@ -0,0 +1,164 @@ +/** + * manager-registry-guard.test.ts — the Symbol.for("pi-subagents:manager") + * global registry across multiple activations in one process. + * + * Subagent sessions re-activate this extension in the same process + * (session.bindExtensions in agent-runner.ts). The old code let every + * activation overwrite the global slot — pointing cross-package consumers at + * a short-lived child manager — and every child's session_shutdown DELETED + * the slot, so the root session's entry was lost as soon as any subagent ran. + * + * The fix: the first activation claims the slot, later activations leave it + * alone, and only the owner's shutdown releases it. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +const MANAGER_KEY = Symbol.for("pi-subagents:manager"); + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; + +async function spawnBackground(tools: Map): Promise { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // never resolves + const r = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "registry test agent", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctx(), + ); + return /Agent ID: (\S+)/.exec(textOf(r))![1]; +} + +// Restore the global slot around every test. +const priorGlobal = (globalThis as any)[MANAGER_KEY]; +afterEach(() => { + if (priorGlobal === undefined) delete (globalThis as any)[MANAGER_KEY]; + else (globalThis as any)[MANAGER_KEY] = priorGlobal; + vi.mocked(runAgent).mockReset(); +}); + +describe("Symbol.for manager registry across activations", () => { + it("child activation does not overwrite the root entry; child shutdown does not delete it", async () => { + delete (globalThis as any)[MANAGER_KEY]; + + // Root session activates first and owns the registry. + const root = makePi(); + subagentsExtension(root.pi); + const rootEntry = (globalThis as any)[MANAGER_KEY]; + expect(rootEntry).toBeDefined(); + + // Spawn a background agent through the ROOT so its record is findable. + const id = await spawnBackground(root.tools); + expect(rootEntry.getRecord(id)).toBeDefined(); + + // A child agent session re-activates the extension in-process. + const child = makePi(); + subagentsExtension(child.pi); + + // Registry still points at the root's entry (child did not clobber it) … + expect((globalThis as any)[MANAGER_KEY]).toBe(rootEntry); + expect((globalThis as any)[MANAGER_KEY].getRecord(id)).toBeDefined(); + + // … and the child's shutdown does not delete the root's entry. + await child.lifecycle.get("session_shutdown")?.(); + expect((globalThis as any)[MANAGER_KEY]).toBe(rootEntry); + + // The root's own shutdown releases the slot. + await root.lifecycle.get("session_shutdown")?.(); + expect((globalThis as any)[MANAGER_KEY]).toBeUndefined(); + }); +}); + +// The registry's `spawn` is reachable from any package in the process, so its +// options are attacker-controlled in the only sense that matters here: nothing +// downstream re-checks them. Four are internal capabilities the extension +// issues to itself, and a forged value for each buys something real. +describe("the registry spawn strips internal capabilities", () => { + /** Boot a fresh owner and spawn through the registry with forged options. */ + function forge(options: Record) { + delete (globalThis as any)[MANAGER_KEY]; + const root = makePi(); + subagentsExtension(root.pi); + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); + const entry = (globalThis as any)[MANAGER_KEY]; + const id = entry.spawn(root.pi, ctx(), "general-purpose", "go", { + description: "forged", isBackground: true, ...options, + }); + return { entry, id, root, runOpts: () => vi.mocked(runAgent).mock.calls[0][3] as any }; + } + + it("refuses a forged nesting, so the agent cannot hide under someone else's id", async () => { + // A nested record is filtered out of every top-level surface and inherits + // its parent's delegation budget. + const { entry, id, root } = forge({ parentAgentId: "victim-agent-id", depth: 9, maxSubagentDepth: 99 }); + + expect(entry.getRecord(id)).toMatchObject({ + parentAgentId: undefined, depth: 1, maxSubagentDepth: undefined, + }); + await root.lifecycle.get("session_shutdown")?.(); + }); + + it("refuses a forged transcript directory and config root", async () => { + // rootSessionId names a directory the transcript is written into, and + // configCwd names where agent files and memory are resolved from. + const { entry, id, root, runOpts } = forge({ rootSessionId: "../../elsewhere", configCwd: "/etc" }); + + expect(entry.getRecord(id).rootSessionId).toBeUndefined(); + expect(runOpts().configCwd).toBeUndefined(); + await root.lifecycle.get("session_shutdown")?.(); + }); + + it("refuses a forged session file, which would replay someone else's conversation", async () => { + const { root, runOpts } = forge({ resumeSessionFile: "/home/victim/.pi/agent/sessions/private.jsonl" }); + + expect(runOpts().resumeSessionFile).toBeUndefined(); + await root.lifecycle.get("session_shutdown")?.(); + }); + + it("refuses a forged reclaim and allocates a handle the ordinary way", async () => { + // reclaim bypasses assignHandle, so a forged value could duplicate a live + // agent's name and make `@handle` resolve to either of two records. + const { entry, id, root } = forge({ reclaim: { handle: "explore", alias: "auth-audit" } }); + + expect(entry.getRecord(id)).toMatchObject({ handle: "general-purpose", alias: undefined }); + await root.lifecycle.get("session_shutdown")?.(); + }); +}); diff --git a/pi-subagents/test/mandatory-extensions.test.ts b/pi-subagents/test/mandatory-extensions.test.ts new file mode 100644 index 0000000..76489d8 --- /dev/null +++ b/pi-subagents/test/mandatory-extensions.test.ts @@ -0,0 +1,55 @@ +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + assertMandatoryExtensionsLoaded, + extensionPathMatches, + normalizeMandatoryExtensionPaths, +} from "../src/mandatory-extensions.js"; + +function fixturePath(name = "permission-system.ts"): string { + const dir = mkdtempSync(join(tmpdir(), "pi-subagents-mandatory-")); + const path = join(dir, name); + writeFileSync(path, "export default () => {};\n"); + return path; +} + +describe("mandatory extension paths", () => { + it("requires absolute existing extension entries and canonicalizes duplicates", () => { + const path = fixturePath(); + expect(normalizeMandatoryExtensionPaths([path, path])).toEqual([ + realpathSync.native(path), + ]); + expect(() => normalizeMandatoryExtensionPaths(["relative.ts"])) + .toThrow("must be absolute"); + expect(() => normalizeMandatoryExtensionPaths([join(tmpdir(), "missing-extension.ts")])) + .toThrow("is unavailable"); + }); + + it("accepts extension directories", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-subagents-mandatory-dir-")); + const extensionDir = join(dir, "permission-system"); + mkdirSync(extensionDir); + expect(normalizeMandatoryExtensionPaths([extensionDir])).toEqual([ + realpathSync.native(extensionDir), + ]); + }); + + it("matches the exact canonical entry rather than a same-named impostor", () => { + const trusted = fixturePath(); + const impostor = fixturePath(); + const [canonical] = normalizeMandatoryExtensionPaths([trusted]); + expect(extensionPathMatches(trusted, canonical)).toBe(true); + expect(extensionPathMatches(impostor, canonical)).toBe(false); + }); + + it("fails closed when the exact mandatory entry did not survive reload", () => { + const trusted = fixturePath(); + const impostor = fixturePath(); + const mandatory = normalizeMandatoryExtensionPaths([trusted]); + expect(() => assertMandatoryExtensionsLoaded([impostor], mandatory)) + .toThrow(`Mandatory extension failed to load: ${mandatory[0]}`); + expect(() => assertMandatoryExtensionsLoaded([trusted], mandatory)).not.toThrow(); + }); +}); diff --git a/pi-subagents/test/memory-legacy-fallback.test.ts b/pi-subagents/test/memory-legacy-fallback.test.ts new file mode 100644 index 0000000..6a3541d --- /dev/null +++ b/pi-subagents/test/memory-legacy-fallback.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock homedir so the legacy ~/.pi/agent-memory fallback can be exercised +// against a temp directory instead of the real home. The default return must +// be a valid string: pi-coding-agent evaluates getAgentDir() at module load +// (before beforeEach runs), so an undefined homedir would throw at import. +const mockHomedir = vi.hoisted(() => vi.fn(() => "/tmp")); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: mockHomedir }; +}); + +import { resolveMemoryDir } from "../src/memory.js"; + +describe("resolveMemoryDir user-scope legacy fallback", () => { + let tmpDir: string; + let fakeHome: string; + let agentDir: string; + let originalEnv: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-mem-legacy-test-")); + fakeHome = join(tmpDir, "home"); + agentDir = join(tmpDir, "agent-dir"); + mkdirSync(fakeHome, { recursive: true }); + mockHomedir.mockReturnValue(fakeHome); + originalEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + }); + + afterEach(() => { + if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("uses the agent dir location when no legacy memory exists", () => { + const dir = resolveMemoryDir("chronicler", "user", "/workspace"); + expect(dir).toBe(join(agentDir, "agent-memory", "chronicler")); + }); + + it("falls back to legacy ~/.pi/agent-memory when it exists and the new location doesn't", () => { + const legacy = join(fakeHome, ".pi", "agent-memory", "chronicler"); + mkdirSync(legacy, { recursive: true }); + + const dir = resolveMemoryDir("chronicler", "user", "/workspace"); + expect(dir).toBe(legacy); + }); + + it("prefers the new location once it exists, even if legacy also exists", () => { + const legacy = join(fakeHome, ".pi", "agent-memory", "chronicler"); + const current = join(agentDir, "agent-memory", "chronicler"); + mkdirSync(legacy, { recursive: true }); + mkdirSync(current, { recursive: true }); + + const dir = resolveMemoryDir("chronicler", "user", "/workspace"); + expect(dir).toBe(current); + }); + + it("ignores a symlinked legacy directory", () => { + const target = join(tmpDir, "elsewhere"); + mkdirSync(target, { recursive: true }); + mkdirSync(join(fakeHome, ".pi", "agent-memory"), { recursive: true }); + symlinkSync(target, join(fakeHome, ".pi", "agent-memory", "chronicler")); + + const dir = resolveMemoryDir("chronicler", "user", "/workspace"); + expect(dir).toBe(join(agentDir, "agent-memory", "chronicler")); + }); + + it("scopes the fallback per agent name", () => { + const legacyOther = join(fakeHome, ".pi", "agent-memory", "other-agent"); + mkdirSync(legacyOther, { recursive: true }); + + // "other-agent" has legacy memory; "chronicler" doesn't — only the former falls back. + expect(resolveMemoryDir("other-agent", "user", "/workspace")).toBe(legacyOther); + expect(resolveMemoryDir("chronicler", "user", "/workspace")).toBe(join(agentDir, "agent-memory", "chronicler")); + }); +}); diff --git a/pi-subagents/test/memory.test.ts b/pi-subagents/test/memory.test.ts new file mode 100644 index 0000000..527a79e --- /dev/null +++ b/pi-subagents/test/memory.test.ts @@ -0,0 +1,343 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock homedir so the user-scope legacy fallback check (~/.pi/agent-memory) +// resolves against a controlled temp home rather than the developer's real +// ~/.pi state. The default return must be a valid string: pi-coding-agent +// evaluates getAgentDir() at module load, so an undefined homedir throws at import. +const mockHomedir = vi.hoisted(() => vi.fn(() => "/tmp")); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: mockHomedir }; +}); + +import { buildMemoryBlock, buildReadOnlyMemoryBlock, ensureMemoryDir, isSymlink, isUnsafeName, readMemoryIndex, resolveMemoryDir, safeReadFile } from "../src/memory.js"; + +describe("memory", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-mem-test-")); + // Point homedir at a clean temp home with no legacy agent-memory dirs, so + // user-scope resolution deterministically returns the agent-dir location. + const fakeHome = join(tmpDir, "home"); + mkdirSync(fakeHome, { recursive: true }); + mockHomedir.mockReturnValue(fakeHome); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("resolveMemoryDir", () => { + it("resolves project scope to .pi/agent-memory/", () => { + const dir = resolveMemoryDir("auditor", "project", "/workspace"); + expect(dir).toBe("/workspace/.pi/agent-memory/auditor"); + }); + + it("resolves local scope to .pi/agent-memory-local/", () => { + const dir = resolveMemoryDir("auditor", "local", "/workspace"); + expect(dir).toBe("/workspace/.pi/agent-memory-local/auditor"); + }); + + it("resolves user scope under the agent dir (honors PI_CODING_AGENT_DIR)", () => { + const originalEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(tmpDir, "custom-agent-dir"); + try { + const dir = resolveMemoryDir("auditor", "user", "/workspace"); + expect(dir).toBe(join(tmpDir, "custom-agent-dir", "agent-memory", "auditor")); + expect(dir).not.toContain("/workspace"); + } finally { + if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalEnv; + } + }); + + it("throws on names with path traversal (..)", () => { + expect(() => resolveMemoryDir("../../etc/evil", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on names with forward slash", () => { + expect(() => resolveMemoryDir("foo/bar", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on names with backslash", () => { + expect(() => resolveMemoryDir("foo\\bar", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on names with null byte", () => { + expect(() => resolveMemoryDir("foo\0bar", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on empty name", () => { + expect(() => resolveMemoryDir("", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on names starting with dot", () => { + expect(() => resolveMemoryDir(".hidden", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("throws on names with spaces", () => { + expect(() => resolveMemoryDir("foo bar", "project", "/workspace")).toThrow("Unsafe agent name"); + }); + + it("allows hyphens, underscores, and dots in names", () => { + expect(() => resolveMemoryDir("my-agent_v2.1", "project", "/workspace")).not.toThrow(); + }); + }); + + describe("isUnsafeName (whitelist validation)", () => { + it("rejects empty string", () => { + expect(isUnsafeName("")).toBe(true); + }); + + it("rejects names longer than 128 chars", () => { + expect(isUnsafeName("a".repeat(129))).toBe(true); + }); + + it("rejects path traversal", () => { + expect(isUnsafeName("../../etc")).toBe(true); + }); + + it("rejects names starting with dot", () => { + expect(isUnsafeName(".hidden")).toBe(true); + }); + + it("rejects names with spaces", () => { + expect(isUnsafeName("foo bar")).toBe(true); + }); + + it("rejects names with special characters", () => { + expect(isUnsafeName("foo;bar")).toBe(true); + expect(isUnsafeName("foo|bar")).toBe(true); + expect(isUnsafeName("foo`bar")).toBe(true); + }); + + it("allows valid names", () => { + expect(isUnsafeName("my-agent")).toBe(false); + expect(isUnsafeName("agent_v2")).toBe(false); + expect(isUnsafeName("Agent123")).toBe(false); + expect(isUnsafeName("my-agent.v2")).toBe(false); + }); + }); + + describe("ensureMemoryDir", () => { + it("creates directory if it doesn't exist", () => { + const dir = join(tmpDir, "agent-memory", "test"); + expect(existsSync(dir)).toBe(false); + ensureMemoryDir(dir); + expect(existsSync(dir)).toBe(true); + }); + + it("no-ops if directory already exists", () => { + const dir = join(tmpDir, "agent-memory", "test"); + mkdirSync(dir, { recursive: true }); + ensureMemoryDir(dir); // should not throw + expect(existsSync(dir)).toBe(true); + }); + + it("throws on symlinked directory", () => { + const realDir = join(tmpDir, "real-dir"); + const linkDir = join(tmpDir, "symlink-dir"); + mkdirSync(realDir, { recursive: true }); + symlinkSync(realDir, linkDir); + expect(() => ensureMemoryDir(linkDir)).toThrow("symlinked memory directory"); + }); + }); + + describe("isSymlink", () => { + it("returns false for regular file", () => { + const file = join(tmpDir, "regular.txt"); + writeFileSync(file, "content"); + expect(isSymlink(file)).toBe(false); + }); + + it("returns true for symlink", () => { + const file = join(tmpDir, "real.txt"); + const link = join(tmpDir, "link.txt"); + writeFileSync(file, "content"); + symlinkSync(file, link); + expect(isSymlink(link)).toBe(true); + }); + + it("returns false for nonexistent path", () => { + expect(isSymlink(join(tmpDir, "nope"))).toBe(false); + }); + }); + + describe("safeReadFile", () => { + it("reads regular files", () => { + const file = join(tmpDir, "regular.txt"); + writeFileSync(file, "hello"); + expect(safeReadFile(file)).toBe("hello"); + }); + + it("rejects symlinked files", () => { + const file = join(tmpDir, "real.txt"); + const link = join(tmpDir, "link.txt"); + writeFileSync(file, "secret"); + symlinkSync(file, link); + expect(safeReadFile(link)).toBeUndefined(); + }); + + it("returns undefined for nonexistent files", () => { + expect(safeReadFile(join(tmpDir, "nope.txt"))).toBeUndefined(); + }); + }); + + describe("readMemoryIndex", () => { + it("returns undefined when MEMORY.md doesn't exist", () => { + const result = readMemoryIndex(tmpDir); + expect(result).toBeUndefined(); + }); + + it("reads MEMORY.md content", () => { + writeFileSync(join(tmpDir, "MEMORY.md"), "# Memories\n- Item 1\n- Item 2"); + const result = readMemoryIndex(tmpDir); + expect(result).toBe("# Memories\n- Item 1\n- Item 2"); + }); + + it("rejects symlinked memory directory", () => { + const realDir = join(tmpDir, "real-mem"); + const linkDir = join(tmpDir, "link-mem"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "MEMORY.md"), "# Secret"); + symlinkSync(realDir, linkDir); + expect(readMemoryIndex(linkDir)).toBeUndefined(); + }); + + it("rejects symlinked MEMORY.md file", () => { + const realFile = join(tmpDir, "secret.md"); + writeFileSync(realFile, "# Secret"); + const memDir = join(tmpDir, "mem-dir"); + mkdirSync(memDir); + symlinkSync(realFile, join(memDir, "MEMORY.md")); + expect(readMemoryIndex(memDir)).toBeUndefined(); + }); + + it("truncates content beyond 200 lines", () => { + const lines = Array.from({ length: 250 }, (_, i) => `Line ${i + 1}`); + writeFileSync(join(tmpDir, "MEMORY.md"), lines.join("\n")); + const result = readMemoryIndex(tmpDir)!; + expect(result).toContain("Line 200"); + expect(result).not.toContain("Line 201"); + expect(result).toContain("truncated at 200 lines"); + }); + }); + + describe("buildMemoryBlock", () => { + it("builds memory block with no existing MEMORY.md", () => { + const block = buildMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("Agent Memory"); + expect(block).toContain("agent-memory/test-agent"); + expect(block).toContain("No MEMORY.md exists yet"); + expect(block).toContain("Memory Instructions"); + }); + + it("builds memory block with existing MEMORY.md", () => { + const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); + mkdirSync(memDir, { recursive: true }); + writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this"); + const block = buildMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("Existing"); + expect(block).toContain("recall this"); + expect(block).not.toContain("No MEMORY.md exists yet"); + }); + + it("creates memory directory if it doesn't exist", () => { + const memDir = join(tmpDir, ".pi", "agent-memory", "new-agent"); + expect(existsSync(memDir)).toBe(false); + buildMemoryBlock("new-agent", "project", tmpDir); + expect(existsSync(memDir)).toBe(true); + }); + + it("includes Read/Write/Edit instructions", () => { + const block = buildMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("Read, Write, and Edit tools"); + }); + + it("uses correct directory for local scope", () => { + const block = buildMemoryBlock("test-agent", "local", tmpDir); + expect(block).toContain("agent-memory-local/test-agent"); + }); + + it("uses correct directory for user scope", () => { + // Pin the agent dir to a temp location so the test doesn't create + // directories in the real home / agent dir (buildMemoryBlock mkdirs). + const originalEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(tmpDir, "agent-dir"); + try { + const block = buildMemoryBlock("test-agent", "user", join(tmpDir, "workspace")); + expect(block).toContain(join(tmpDir, "agent-dir", "agent-memory", "test-agent")); + expect(block).not.toContain(join(tmpDir, "workspace")); + } finally { + if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalEnv; + } + }); + + it("includes scope label in header", () => { + expect(buildMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project"); + expect(buildMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local"); + expect(buildMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user"); + }); + }); + + describe("buildReadOnlyMemoryBlock", () => { + it("returns read-only instructions without write/edit mention", () => { + const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("read-only"); + expect(block).not.toContain("Write"); + expect(block).not.toContain("Edit"); + expect(block).not.toContain("Memory Instructions"); + }); + + it("does NOT create the memory directory", () => { + const memDir = join(tmpDir, ".pi", "agent-memory", "ro-agent"); + expect(existsSync(memDir)).toBe(false); + buildReadOnlyMemoryBlock("ro-agent", "project", tmpDir); + expect(existsSync(memDir)).toBe(false); + }); + + it("includes existing MEMORY.md content", () => { + const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); + mkdirSync(memDir, { recursive: true }); + writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this"); + const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("Existing"); + expect(block).toContain("recall this"); + }); + + it("returns 'no memory available' when no MEMORY.md exists", () => { + const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); + expect(block).toContain("No memory is available yet"); + expect(block).not.toContain("Create one"); + }); + + it("includes scope label in header", () => { + expect(buildReadOnlyMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project"); + expect(buildReadOnlyMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local"); + expect(buildReadOnlyMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user"); + }); + + it("does not mention memory directory path for write access", () => { + const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); + expect(block).not.toContain("persistent memory directory at:"); + expect(block).not.toContain("Create one at"); + }); + + it("rejects symlinked memory directory in read-only mode", () => { + const realDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "MEMORY.md"), "# Secret"); + const linkDir = join(tmpDir, ".pi", "agent-memory", "linked-agent"); + mkdirSync(join(tmpDir, ".pi", "agent-memory"), { recursive: true }); + symlinkSync(realDir, linkDir); + // Should not read through the symlink + const block = buildReadOnlyMemoryBlock("linked-agent", "project", tmpDir); + expect(block).toContain("No memory is available yet"); + }); + }); +}); diff --git a/pi-subagents/test/mention-clone.test.ts b/pi-subagents/test/mention-clone.test.ts new file mode 100644 index 0000000..a0a17df --- /dev/null +++ b/pi-subagents/test/mention-clone.test.ts @@ -0,0 +1,401 @@ +/** + * mention-clone.test.ts — the off-screen conversation clone that starts a + * mentioned agent. + * + * Everything here guards one property: the clone is a throwaway, but the agent + * it starts must be indistinguishable from one the main model launched. That + * breaks in quiet ways — a spawn attributed to the fork's session id files its + * transcript in the wrong place, a tool-call id the real session never issued + * puts a dangling `` in the completion notification. Neither + * surfaces as an error, so each is pinned below. + * + * The other half is the fallback contract: `runMentionClone` never rejects, + * because the caller starts the agent directly on `spawned: false` and a + * rejection would instead lose the mention entirely. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close +// over ordinary top-level consts. +const { buildSessionContext, createAgentSession, inMemory } = vi.hoisted(() => ({ + buildSessionContext: vi.fn(), + createAgentSession: vi.fn(), + inMemory: vi.fn(), +})); + +vi.mock("@earendil-works/pi-coding-agent", async () => { + const actual = await vi.importActual("@earendil-works/pi-coding-agent"); + return { + ...actual, + buildSessionContext, + createAgentSession, + SessionManager: { ...actual.SessionManager, inMemory }, + }; +}); + +import { agentMentionReminder } from "../src/mention.js"; +import { runMentionClone } from "../src/mention-clone.js"; + +/** One user turn and its reply, as buildSessionContext resolves them. */ +const CONVERSATION = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, +] as any[]; + +beforeEach(() => { + createAgentSession.mockReset(); + inMemory.mockReset(); + inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any); + buildSessionContext.mockReset(); + buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "high", model: null } as any); +}); + +/** The main session's context — the one the spawn must be attributed to. */ +function mainCtx(overrides: Record = {}) { + return { + cwd: "/repo", + model: { id: "main-model" }, + thinkingLevel: "high", + modelRegistry: { runtime: { kind: "runtime" } }, + getSystemPrompt: vi.fn(() => "the live system prompt"), + sessionManager: { + getEntries: vi.fn(() => [{ type: "message" }] as any[]), + getLeafId: vi.fn(() => "leaf-1"), + }, + ...overrides, + } as any; +} + +/** The registered Agent tool, whose handler the clone is supposed to reuse. */ +function agentTool() { + return { + name: "Agent", + execute: vi.fn(async () => ({ content: [{ type: "text", text: "Agent ID: a1" }], details: {} })), + } as any; +} + +/** + * Pi's own tool-visibility rule, reproduced from `sdk.js` + `agent-session.js`: + * an allowlist is derived once (`tools`, or the empty list when `noTools: + * "all"`), and EVERY tool — built-in, extension and custom alike — is dropped + * from the registry unless the allowlist names it. So `noTools: "all"` does not + * mean "no built-ins, keep my custom tool": it means the clone is handed + * nothing, answers in prose, and the mention falls back to a direct start. + */ +function visibleTools(opts: any): any[] { + const allowed = opts.tools ?? (opts.noTools === "all" ? [] : undefined); + const allowedSet = allowed ? new Set(allowed) : undefined; + const excluded = new Set(opts.excludeTools ?? []); + return (opts.customTools ?? []).filter( + (tool: any) => (!allowedSet || allowedSet.has(tool.name)) && !excluded.has(tool.name), + ); +} + +/** + * Stand in for `createAgentSession`. `turn` receives the clone's single custom + * tool and plays the part of the model deciding what to do with it — and only + * the tools Pi would really expose reach it, so a clone built with an allowlist + * that hides its own tool prompts a model with nothing to call. + */ +function cloneSession(turn?: (tool: any) => Promise | void) { + const session = { + agent: { state: { systemPrompt: "rebuilt-from-cwd", messages: [] as any[] } }, + prompt: vi.fn(async () => {}), + dispose: vi.fn(), + } as any; + createAgentSession.mockImplementation(async (opts: any) => { + const tools = visibleTools(opts); + session.prompt.mockImplementation(async () => { + // No tool, no tool call: the model can only answer in prose. + if (tools.length === 0) return; + await turn?.(tools[0]); + }); + session.createdWith = opts; + return { session }; + }); + return session; +} + +/** What the model does when it plays along: one Agent call. */ +const callsAgent = (params: Record = { subagent_type: "Explore", prompt: "go" }) => + async (tool: any) => { + await tool.execute("clone-tool-call-1", params, undefined, undefined, { cwd: "/fork" }); + }; + +const opts = (over: Record = {}) => ({ + ctx: mainCtx(), + type: "Explore", + message: "find the flaky test", + agentTool: agentTool(), + ...over, +}) as any; + +describe("cloning the conversation", () => { + it("carries the conversation's own messages, not a rendering of them", async () => { + // The whole point: the copy reasons over what the main model can see. + const session = cloneSession(callsAgent()); + + await runMentionClone(opts()); + + expect(session.agent.state.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + }); + + it("takes the conversation from memory, never from the session file", async () => { + // SessionManager withholds every write until the first assistant message, + // so a file-based copy is empty for the whole of the first turn — which is + // exactly when someone types their first mention. + const o = opts(); + cloneSession(callsAgent()); + + await runMentionClone(o); + + expect(buildSessionContext).toHaveBeenCalledWith([{ type: "message" }], "leaf-1"); + expect(createAgentSession.mock.calls[0][0].sessionManager).toEqual({ + kind: "in-memory-session-manager", + }); + }); + + it("thinks at the level the session is really on", async () => { + cloneSession(callsAgent()); + + await runMentionClone(opts()); + + expect(createAgentSession.mock.calls[0][0].thinkingLevel).toBe("high"); + }); + + it("omits the level rather than taking buildSessionContext's, which lies", async () => { + // getSessionContextSettings starts at "off" and moves only on an explicit + // thinking_level_change entry, so a session where nobody ran /think reports + // "off". Passing that would silently think less than the user asked for; + // omitting it lets createAgentSession resolve the real level from settings. + // Also the Pi <0.82.0 path, where ctx has no thinkingLevel at all. + buildSessionContext.mockReturnValue({ messages: CONVERSATION, thinkingLevel: "off", model: null } as any); + const o = opts({ ctx: mainCtx({ thinkingLevel: undefined }) }); + cloneSession(callsAgent()); + + await runMentionClone(o); + + expect(createAgentSession.mock.calls[0][0]).not.toHaveProperty("thinkingLevel"); + }); + + it("clones a conversation that has not started yet", async () => { + // First input of a fresh session. There is no history to carry, which is an + // answer and not a failure — the copy still runs on the main model and + // system prompt, and still makes the call. + buildSessionContext.mockReturnValue({ messages: [], thinkingLevel: "medium", model: null } as any); + const o = opts(); + const session = cloneSession(callsAgent()); + + const result = await runMentionClone(o); + + expect(result).toEqual({ spawned: true }); + expect(session.agent.state.messages).toEqual([]); + expect(session.agent.state.systemPrompt).toBe("the live system prompt"); + }); + + it("carries the live system prompt rather than the one it rebuilt", async () => { + // createAgentSession derives a prompt from cwd and agentDir. Close, but not + // what the user's model is working under — extensions add to it per turn. + const session = cloneSession(callsAgent()); + + await runMentionClone(opts()); + + expect(session.agent.state.systemPrompt).toBe("the live system prompt"); + }); + + it("inherits the parent's model, thinking level and providers", async () => { + cloneSession(callsAgent()); + + await runMentionClone(opts()); + + const built = createAgentSession.mock.calls[0][0]; + expect(built.model).toEqual({ id: "main-model" }); + expect(built.thinkingLevel).toBe("high"); + expect(built.modelRuntime).toEqual({ kind: "runtime" }); + }); + + it("gives the clone the Agent tool and nothing else", async () => { + // It runs where nobody is watching. A full toolset would let an invisible + // turn read, write or run things on the user's behalf. + cloneSession(callsAgent()); + + await runMentionClone(opts()); + + const built = createAgentSession.mock.calls[0][0]; + expect(built.customTools).toHaveLength(1); + expect(built.customTools[0].name).toBe("Agent"); + expect(visibleTools(built).map((tool: any) => tool.name)).toEqual(["Agent"]); + }); + + it("names its own tool in the allowlist, or Pi hands it nothing", async () => { + // `noTools: "all"` reads like "no built-ins, keep my custom tool" and is + // not: it sets an EMPTY allowlist, which strips the custom tool from the + // registry too (agent-session.js `isAllowedTool`). The clone then has + // nothing to call, every mention falls through to the direct start, and the + // user sees "Started @x directly — the conversation clone did not start it" + // on every single one. Naming the tool is what makes it reachable. + cloneSession(callsAgent()); + + const result = await runMentionClone(opts()); + + const built = createAgentSession.mock.calls[0][0]; + expect(built.tools).toEqual(["Agent"]); + expect(built.noTools).toBeUndefined(); + expect(result).toEqual({ spawned: true }); + }); + + it("prompts it with the message, then the reminder", async () => { + const session = cloneSession(callsAgent()); + + await runMentionClone(opts()); + + expect(session.prompt).toHaveBeenCalledWith( + `find the flaky test\n\n${agentMentionReminder("Explore")}`, + ); + }); +}); + +describe("attributing the spawn to the real session", () => { + it("runs the real Agent handler with the MAIN context, not the fork's", async () => { + // The handler reads cwd, model and sessionManager.getSessionId() off this + // to place the .output transcript and rootSessionId. The clone's own + // context would file both under a session that is about to be discarded. + const tool = agentTool(); + const o = opts({ agentTool: tool }); + cloneSession(callsAgent()); + + await runMentionClone(o); + + expect(tool.execute).toHaveBeenCalledTimes(1); + expect(tool.execute.mock.calls[0][4]).toBe(o.ctx); + }); + + it("passes no tool-call id, since the real session issued none", async () => { + // Left on the record it becomes a in the completion + // notification pointing at a call the main conversation never made. + const tool = agentTool(); + cloneSession(callsAgent()); + + await runMentionClone(opts({ agentTool: tool })); + + expect(tool.execute.mock.calls[0][0]).toBeUndefined(); + }); + + it("forwards the parameters the clone chose", async () => { + const tool = agentTool(); + cloneSession(callsAgent({ subagent_type: "Plan", prompt: "sketch the migration" })); + + await runMentionClone(opts({ agentTool: tool })); + + expect(tool.execute.mock.calls[0][1]).toEqual({ + subagent_type: "Plan", + prompt: "sketch the migration", + run_in_background: true, + }); + }); + + it("forces the spawn into the background — a foreground result goes nowhere", async () => { + // `run_in_background` defaults to false, and a foreground agent returns its + // answer as the TOOL RESULT: AgentManager marks the record `resultConsumed` + // precisely so the completion notification is skipped as redundant. Here + // that tool result lands in the throwaway clone, which is disposed moments + // later — so the agent runs to completion, shows up in the widget and the + // fleet, and its answer reaches nobody. The main conversation is not part + // of the clone's turn, so background delivery is the only way back. + const tool = agentTool(); + cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go" })); + + await runMentionClone(opts({ agentTool: tool })); + + expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true }); + }); + + it("overrides a clone that explicitly asked for a foreground run", async () => { + // Nothing tells the clone's model that its own turn is discarded, so an + // explicit `false` is a reasonable thing for it to emit. It must not decide + // this one. + const tool = agentTool(); + cloneSession(callsAgent({ subagent_type: "Explore", prompt: "go", run_in_background: false })); + + await runMentionClone(opts({ agentTool: tool })); + + expect(tool.execute.mock.calls[0][1]).toMatchObject({ run_in_background: true }); + }); + + it("refuses a second spawn from the same mention", async () => { + // One handle, one agent. A clone that decides to also launch something else + // would do it unseen and unasked. + const tool = agentTool(); + cloneSession(async (t) => { + await callsAgent()(t); + await callsAgent()(t); + }); + + const result = await runMentionClone(opts({ agentTool: tool })); + + expect(tool.execute).toHaveBeenCalledTimes(1); + expect(result).toEqual({ spawned: true }); + }); + + it("tells the clone why the second call was refused", async () => { + const captured: any[] = []; + cloneSession(async (t) => { + await callsAgent()(t); + captured.push(await t.execute("c2", { subagent_type: "Explore", prompt: "again" }, undefined, undefined, {})); + }); + + await runMentionClone(opts()); + + expect(captured[0].isError).toBe(true); + expect(captured[0].content[0].text).toContain("Already started an agent"); + }); +}); + +describe("when the clone cannot deliver", () => { + it("reports a turn that never called the tool", async () => { + // The model answering in prose is a real outcome, and a silent one — the + // caller needs it to fall back rather than leave the mention unanswered. + cloneSession(); + + const result = await runMentionClone(opts()); + + expect(result.spawned).toBe(false); + expect(result.error).toContain("did not start it"); + }); + + it("returns a thrown error rather than rejecting", async () => { + // The caller's fallback runs off the resolved value. A rejection here would + // land in an unhandled promise and lose the mention. + createAgentSession.mockRejectedValue(new Error("no provider configured")); + + await expect(runMentionClone(opts())).resolves.toEqual({ + spawned: false, + error: "no provider configured", + }); + }); + + it("keeps a spawn that already happened when the turn then fails", async () => { + // The agent is running. Reporting spawned:false would start a second one. + cloneSession(async (tool) => { + await callsAgent()(tool); + throw new Error("turn aborted"); + }); + + const result = await runMentionClone(opts()); + + expect(result).toEqual({ spawned: true, error: "turn aborted" }); + }); + + it("disposes the clone even when the turn throws", async () => { + const session = cloneSession(() => { + throw new Error("turn aborted"); + }); + + await runMentionClone(opts()); + + expect(session.dispose).toHaveBeenCalled(); + }); +}); diff --git a/pi-subagents/test/mention-start-notification.test.ts b/pi-subagents/test/mention-start-notification.test.ts new file mode 100644 index 0000000..6abc3e5 --- /dev/null +++ b/pi-subagents/test/mention-start-notification.test.ts @@ -0,0 +1,108 @@ +/** + * mention-start-notification.test.ts — does an agent STARTED by a mention + * report back to the main conversation? + * + * The resume path is pinned in agent-mention-wiring.test.ts ("relays the + * resumed answer through the ordinary completion notification"). The start path + * — `@handle msg` naming a type with no live instance — has no equivalent, and + * it is the path every first mention takes. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() }; +}); +vi.mock("../src/mention-clone.js", () => ({ runMentionClone: vi.fn() })); + +import { resumeAgent, runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { runMentionClone } from "../src/mention-clone.js"; +import { ctx, type Hermetic, hermeticDir, makePi } from "./helpers/boot-extension.js"; + +let hermetic: Hermetic | undefined; +let booted: Map | undefined; + +beforeEach(() => { + vi.mocked(runAgent).mockReset(); + vi.mocked(resumeAgent).mockReset(); + vi.mocked(runMentionClone).mockReset(); +}); + +afterEach(async () => { + await booted?.get("session_shutdown")?.(); + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + booted = undefined; + hermetic?.restore(); + hermetic = undefined; +}); + +function fakeSession() { + return { + steer: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + messages: [], + getActiveToolNames: vi.fn(() => []), + } as any; +} + +function boot(settings: Record = {}) { + hermetic = hermeticDir({ settings: { outputTranscript: false, ...settings } }); + const b = makePi(); + subagentsExtension(b.pi); + booted = b.lifecycle; + return b; +} + +const send = (lifecycle: Map, text: string) => + lifecycle.get("input")({ type: "input", text, source: "interactive" }, ctx()); + +describe("an agent started by a mention", () => { + it("relays its answer through the ordinary completion notification (direct mode)", async () => { + const { pi, lifecycle } = boot({ agentMentions: "direct" }); + vi.mocked(runAgent).mockResolvedValue({ + responseText: "found four planted bugs", + session: fakeSession(), + aborted: false, + steered: false, + failure: undefined, + } as any); + + await send(lifecycle, "@Explore find the planted bugs in src/"); + await new Promise(r => setTimeout(r, 500)); + + expect(pi.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + customType: "subagent-notification", + content: expect.stringContaining("found four planted bugs"), + }), + expect.objectContaining({ triggerTurn: true }), + ); + }); + + it("relays it when the clone fell back to a direct start (model mode)", async () => { + // What the user hits today: the clone reports it could not start the agent, + // index.ts starts it directly, and the answer still has to come back. + const { pi, lifecycle } = boot(); + vi.mocked(runMentionClone).mockResolvedValue({ spawned: false, error: "the conversation clone did not start it" }); + vi.mocked(runAgent).mockResolvedValue({ + responseText: "cyan, obviously", + session: fakeSession(), + aborted: false, + steered: false, + failure: undefined, + } as any); + + await send(lifecycle, "@Explore whats your favorite color"); + await new Promise(r => setTimeout(r, 500)); + + expect(pi.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + customType: "subagent-notification", + content: expect.stringContaining("cyan, obviously"), + }), + expect.objectContaining({ triggerTurn: true }), + ); + }); +}); diff --git a/pi-subagents/test/mention.test.ts b/pi-subagents/test/mention.test.ts new file mode 100644 index 0000000..d0b795c --- /dev/null +++ b/pi-subagents/test/mention.test.ts @@ -0,0 +1,202 @@ +/** + * mention.test.ts — the `@handle` grammar. + * + * Both halves are load-bearing in a way that fails silently. A handle that + * isn't `[\w-]` can never be typed back (the trigger regex would not match it), + * and a collision that reuses a name makes an older sibling permanently + * unreachable. On the parse side, every rejection here is a case where being + * too eager would swallow input the user meant for the main model — a leading + * file path, a bare handle, a mention mid-sentence. + */ +import { describe, expect, it } from "vitest"; +import { agentMentionReminder, assignHandle, describeMention, handleBase, isReservedHandle, MENTION_TRIGGER, parseMention, resolveHandleToType, stripAgentPrefix } from "../src/mention.js"; + +describe("handleBase", () => { + it("lowercases so the handle matches how it is typed", () => { + expect(handleBase("Explore")).toBe("explore"); + }); + + it("keeps a hyphenated type as-is", () => { + expect(handleBase("general-purpose")).toBe("general-purpose"); + }); + + it("reduces anything outside [\\w-] to hyphens, without leaving edge hyphens", () => { + expect(handleBase("Code Review!")).toBe("code-review"); + expect(handleBase(" spaced out ")).toBe("spaced-out"); + }); + + it("always produces something typeable", () => { + // A type made entirely of stripped characters would otherwise slug to "", + // and `@` alone can address nothing. + expect(handleBase("!!!")).toBe("agent"); + expect(handleBase("")).toBe("agent"); + }); + + it("caps a long name so one agent can't own an unreadable row", () => { + const slug = handleBase("x".repeat(200)); + expect(slug).toHaveLength(64); + }); + + it("never leaves a trailing hyphen the cap sliced into", () => { + // 63 chars, then a hyphen run: a naive slice(0, 64) keeps that hyphen. + expect(handleBase(`${"x".repeat(63)} tail`).endsWith("-")).toBe(false); + }); + + it("only ever produces handles the suggestion trigger can match", () => { + for (const type of ["Explore", "general-purpose", "Code Review!", "!!!", "デバッグ"]) { + expect(MENTION_TRIGGER.test(`@${handleBase(type)}`)).toBe(true); + } + }); +}); + +describe("assignHandle", () => { + it("takes the plain base when it is free", () => { + expect(assignHandle("explore", new Set())).toBe("explore"); + }); + + it("numbers from 2 on the first collision", () => { + expect(assignHandle("explore", new Set(["explore"]))).toBe("explore-2"); + }); + + it("keeps counting past every taken form", () => { + expect(assignHandle("explore", new Set(["explore", "explore-2"]))).toBe("explore-3"); + }); + + it("never hands out the reserved main handle", () => { + // `@main` addresses the main conversation. An agent holding that name + // would silently swallow the one escape hatch out of the mention grammar. + expect(assignHandle("main", new Set())).toBe("main-2"); + }); + + it("skips a gap rather than reusing a live handle", () => { + // explore-2 finished and was evicted; reusing it is fine, but explore-3 + // is still running and must not be shadowed. + expect(assignHandle("explore", new Set(["explore", "explore-3"]))).toBe("explore-2"); + }); +}); + +describe("resolveHandleToType", () => { + const TYPES = ["general-purpose", "Explore", "Code Review!"]; + + it("finds the type a handle was derived from, whatever its casing", () => { + expect(resolveHandleToType("explore", TYPES)).toBe("Explore"); + expect(resolveHandleToType("EXPLORE", TYPES)).toBe("Explore"); + }); + + it("resolves a type whose slug differs from its name", () => { + expect(resolveHandleToType("code-review", TYPES)).toBe("Code Review!"); + }); + + it("is exact, not a prefix match — a partial handle must not start an agent", () => { + expect(resolveHandleToType("ex", TYPES)).toBeUndefined(); + expect(resolveHandleToType("explore-2", TYPES)).toBeUndefined(); + }); + + it("round-trips every registered type", () => { + for (const type of TYPES) expect(resolveHandleToType(handleBase(type), TYPES)).toBe(type); + }); + + it("refuses to resolve the reserved handle, even to a type named for it", () => { + // Otherwise `@main do this` would start an agent instead of reaching the + // main model — and `assignHandle` already denies its instances that name, + // so resolving the type here would promise something unreachable. + expect(resolveHandleToType("main", ["main", ...TYPES])).toBeUndefined(); + }); +}); + +describe("isReservedHandle", () => { + it("recognizes main whatever its casing", () => { + expect(isReservedHandle("main")).toBe(true); + expect(isReservedHandle("MAIN")).toBe(true); + }); + + it("leaves every ordinary handle alone", () => { + for (const handle of ["explore", "mainframe", "main-2", "ma"]) { + expect(isReservedHandle(handle)).toBe(false); + } + }); +}); + +describe("stripAgentPrefix", () => { + it("unwraps Claude Code's manual @agent- spelling", () => { + expect(stripAgentPrefix("agent-explore")).toBe("explore"); + }); + + it("keeps the remainder intact when it is itself prefixed", () => { + expect(stripAgentPrefix("agent-agent-foo")).toBe("agent-foo"); + }); + + it("returns nothing when there is no prefix or nothing behind it", () => { + expect(stripAgentPrefix("explore")).toBeUndefined(); + expect(stripAgentPrefix("agent-")).toBeUndefined(); + expect(stripAgentPrefix("agentexplore")).toBeUndefined(); + }); + + it("only unwraps a prefix at the very start", () => { + // `@sub-agent-explore` names an agent called `sub-agent-explore`. Matching + // `agent-` anywhere would silently redirect it to `@explore`. + expect(stripAgentPrefix("sub-agent-explore")).toBeUndefined(); + }); +}); + +describe("describeMention", () => { + it("uses the message as the agent's short label", () => { + expect(describeMention("find every retry marker")).toBe("find every retry marker"); + }); + + it("takes the first line and collapses whitespace", () => { + expect(describeMention(" audit the RPC path\nthen report back ")).toBe("audit the RPC path"); + }); + + it("clips a long message rather than putting a paragraph in every agent surface", () => { + const label = describeMention("x".repeat(200)); + expect(label).toHaveLength(40); + expect(label.endsWith("…")).toBe(true); + }); +}); + +describe("parseMention", () => { + it("splits a leading handle from its message", () => { + expect(parseMention("@explore check the RPC path")).toEqual({ + handle: "explore", + message: "check the RPC path", + }); + }); + + it("trims the message and accepts a newline as the separator", () => { + expect(parseMention("@explore spaced ")).toEqual({ handle: "explore", message: "spaced" }); + expect(parseMention("@explore\nline1\nline2")).toEqual({ handle: "explore", message: "line1\nline2" }); + }); + + it("rejects a bare handle — that belongs to the main model", () => { + expect(parseMention("@explore")).toBeNull(); + expect(parseMention("@explore ")).toBeNull(); + expect(parseMention("@explore \t ")).toBeNull(); + }); + + it("rejects a leading file path so pi's @-attachment keeps working", () => { + expect(parseMention("@src/index.ts summarize this")).toBeNull(); + expect(parseMention("@README.md what changed")).toBeNull(); + }); + + it("rejects a mention that is not at the start of the input", () => { + expect(parseMention("hey @explore look at this")).toBeNull(); + expect(parseMention(" @explore look at this")).toBeNull(); + }); +}); + +describe("agentMentionReminder", () => { + it("is Claude Code's string, byte for byte", () => { + // Ported from the 2.1.233 bundle rather than paraphrased, so the model gets + // the wording it was trained against. Asserted whole — including the + // trailing space before the closing newline, which is in the original + // template literal and is exactly the kind of thing a tidy-up would drop. + expect(agentMentionReminder("code-review")).toBe( + '\nThe user has expressed a desire to invoke the agent "code-review". Please invoke the agent appropriately, passing in the required context to it. \n', + ); + }); + + it("names the agent it was given", () => { + expect(agentMentionReminder("Plan")).toContain('invoke the agent "Plan"'); + }); +}); diff --git a/pi-subagents/test/model-resolver.test.ts b/pi-subagents/test/model-resolver.test.ts new file mode 100644 index 0000000..c5e5cc8 --- /dev/null +++ b/pi-subagents/test/model-resolver.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { type ModelRegistry, resolveModel } from "../src/model-resolver.js"; + +// Mock model entries matching typical pi model registry shape +const MODELS = [ + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" }, +]; + +function makeRegistry(models = MODELS, available?: typeof MODELS): ModelRegistry { + return { + find(provider: string, modelId: string) { + return models.find(m => m.provider === provider && m.id === modelId); + }, + getAll() { + return models; + }, + getAvailable: available ? () => available : undefined, + }; +} + +describe("resolveModel", () => { + describe("exact match (provider/modelId)", () => { + it("resolves exact provider/modelId", () => { + const result = resolveModel("anthropic/claude-opus-4-6", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("resolves another exact provider/modelId", () => { + const result = resolveModel("openai/gpt-4o", makeRegistry()); + expect(result).toEqual(MODELS[3]); + }); + + it("falls through to fuzzy when exact provider/modelId not found", () => { + // "anthropic/haiku" is not an exact match, but fuzzy should find it + const result = resolveModel("anthropic/haiku", makeRegistry()); + expect(result).toEqual(MODELS[2]); // haiku + }); + }); + + describe("fuzzy match — exact id", () => { + it("matches exact model id without provider", () => { + const result = resolveModel("claude-opus-4-6", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("is case-insensitive", () => { + const result = resolveModel("Claude-Opus-4-6", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("matches exact id for non-anthropic models", () => { + const result = resolveModel("gpt-4o", makeRegistry()); + expect(result).toEqual(MODELS[3]); + }); + }); + + describe("fuzzy match — substring", () => { + it("matches 'haiku' to claude-haiku model", () => { + const result = resolveModel("haiku", makeRegistry()); + expect(result).toEqual(MODELS[2]); + }); + + it("matches 'sonnet' to claude-sonnet model", () => { + const result = resolveModel("sonnet", makeRegistry()); + expect(result).toEqual(MODELS[1]); + }); + + it("matches 'opus' to claude-opus model", () => { + const result = resolveModel("opus", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("matches 'gemini' to gemini model", () => { + const result = resolveModel("gemini", makeRegistry()); + expect(result).toEqual(MODELS[4]); + }); + + it("is case-insensitive for substring", () => { + const result = resolveModel("HAIKU", makeRegistry()); + expect(result).toEqual(MODELS[2]); + }); + }); + + describe("fuzzy match — separator equivalence (dash vs dot)", () => { + // id uses dashes and the name carries no version number — the case that + // failed before separators were normalized (the "4.5" token couldn't be + // found anywhere, so the dotted query matched nothing). + const HAIKU = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" }; + const dashReg = makeRegistry([HAIKU]); + + it("matches a dotted query to a dashed id", () => { + expect(resolveModel("claude-haiku-4.5", dashReg)).toEqual(HAIKU); + }); + + it("matches a dotted provider/id query to a dashed id", () => { + expect(resolveModel("anthropic/claude-haiku-4.5", dashReg)).toEqual(HAIKU); + }); + + it("matches a dashed query to a dotted id", () => { + expect(resolveModel("gemini-2-5-pro", makeRegistry())).toEqual(MODELS[4]); + }); + }); + + describe("fuzzy match — trailing date-stamp is optional", () => { + // A date-pinned config (e.g. an agent's frontmatter, or a shipped default) + // should still resolve when the registry lists the model without the stamp. + const HAIKU_DASH = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" }; + const HAIKU_DOT = { id: "claude-haiku-4.5", name: "Claude Haiku", provider: "anthropic" }; + + it("matches a dated provider/id config to an undated registry id", () => { + expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([HAIKU_DASH]))).toEqual(HAIKU_DASH); + }); + + it("matches a dated config to an undated *dotted* registry id (date + separator)", () => { + expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([HAIKU_DOT]))).toEqual(HAIKU_DOT); + }); + + it("still prefers an exact dated id when the registry has it", () => { + const dated = { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" }; + expect(resolveModel("anthropic/claude-haiku-4-5-20251001", makeRegistry([dated]))).toEqual(dated); + }); + }); + + describe("provider fallback (prefer named provider, else any)", () => { + const gatewayHaiku = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "openrouter" }; + const anthropicHaiku = { id: "claude-haiku-4-5", name: "Claude Haiku", provider: "anthropic" }; + + it("falls back to another provider when the named one lacks the model", () => { + expect(resolveModel("anthropic/claude-haiku-4-5", makeRegistry([gatewayHaiku]))).toEqual(gatewayHaiku); + }); + + it("prefers the named provider when it has the model", () => { + expect(resolveModel("anthropic/claude-haiku-4-5", makeRegistry([gatewayHaiku, anthropicHaiku]))).toEqual(anthropicHaiku); + }); + + it("still errors when no provider has the model", () => { + expect(typeof resolveModel("anthropic/nonexistent-xyz", makeRegistry([gatewayHaiku]))).toBe("string"); + }); + }); + + describe("fuzzy match — name contains", () => { + it("matches 'Opus 4.6' via model name", () => { + const result = resolveModel("Opus 4.6", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("matches 'Haiku 4.5' via model name", () => { + const result = resolveModel("Haiku 4.5", makeRegistry()); + expect(result).toEqual(MODELS[2]); + }); + }); + + describe("fuzzy match — multi-part", () => { + it("matches 'anthropic opus' across provider and id", () => { + const result = resolveModel("anthropic opus", makeRegistry()); + expect(result).toEqual(MODELS[0]); + }); + + it("matches 'google pro' across provider and id", () => { + const result = resolveModel("google pro", makeRegistry()); + expect(result).toEqual(MODELS[4]); + }); + }); + + describe("fuzzy match — prefers tighter matches", () => { + it("prefers exact id over substring", () => { + const result = resolveModel("gpt-4o", makeRegistry()); + expect(result).toEqual(MODELS[3]); + }); + + it("substring match prefers shorter model id (tighter fit)", () => { + // Both opus and sonnet contain their query as substring, but "opus" is a tighter match + // for "opus" than "sonnet" is for "sonnet" — each should resolve to itself + expect(resolveModel("opus", makeRegistry())).toEqual(MODELS[0]); + expect(resolveModel("sonnet", makeRegistry())).toEqual(MODELS[1]); + }); + }); + + describe("no match", () => { + it("returns error string for unknown model", () => { + const result = resolveModel("nonexistent-model", makeRegistry()); + expect(typeof result).toBe("string"); + expect(result).toContain('Model not found: "nonexistent-model"'); + expect(result).toContain("Available models:"); + }); + + it("error lists available models", () => { + const result = resolveModel("xyz", makeRegistry()); + expect(result).toContain("anthropic/claude-opus-4-6"); + expect(result).toContain("openai/gpt-4o"); + }); + + it("empty string matches a model (multi-part vacuous truth)", () => { + // Empty string splits to empty parts; every() on empty array is true + // This is fine — callers guard against empty input + const result = resolveModel("", makeRegistry()); + expect(typeof result).toBe("object"); + }); + }); + + describe("getAvailable filtering", () => { + it("uses getAvailable when present (filters to configured models)", () => { + const available = [MODELS[0], MODELS[2]]; // only opus and haiku + const result = resolveModel("sonnet", makeRegistry(MODELS, available)); + // sonnet is in getAll but not in getAvailable — should not fuzzy match + expect(typeof result).toBe("string"); + expect(result).toContain("Model not found"); + }); + + it("exact match fails when model is not in getAvailable (no auth)", () => { + const available = [MODELS[0]]; // only opus available + const result = resolveModel("anthropic/claude-sonnet-4-6", makeRegistry(MODELS, available)); + expect(typeof result).toBe("string"); + expect(result).toContain("Model not found"); + }); + + it("fuzzy matches against available models only", () => { + const available = [MODELS[2]]; // only haiku available + const result = resolveModel("haiku", makeRegistry(MODELS, available)); + expect(result).toEqual(MODELS[2]); + }); + }); + + describe("ambiguous matches", () => { + const SIMILAR_MODELS = [ + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, + { id: "claude-sonnet-4-5-20241022", name: "Claude Sonnet 4.5", provider: "anthropic" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", provider: "anthropic" }, + ]; + + it("'sonnet' prefers tighter id match (shorter id)", () => { + const result = resolveModel("sonnet", makeRegistry(SIMILAR_MODELS)); + // "sonnet" is a larger fraction of "claude-sonnet-4-6" than "claude-sonnet-4-5-20241022" + expect(result).toEqual(SIMILAR_MODELS[0]); + }); + + it("'sonnet 4.5' resolves to the 4.5 model via name", () => { + const result = resolveModel("sonnet 4.5", makeRegistry(SIMILAR_MODELS)); + expect(result).toEqual(SIMILAR_MODELS[1]); + }); + + it("'4-6' picks the 4.6 model", () => { + const result = resolveModel("4-6", makeRegistry(SIMILAR_MODELS)); + expect(result).toEqual(SIMILAR_MODELS[0]); + }); + }); + + describe("empty registry", () => { + it("returns error with empty available list", () => { + const result = resolveModel("haiku", makeRegistry([])); + expect(typeof result).toBe("string"); + expect(result).toContain("Model not found"); + }); + }); +}); diff --git a/pi-subagents/test/model-scope.test.ts b/pi-subagents/test/model-scope.test.ts new file mode 100644 index 0000000..2aeddfd --- /dev/null +++ b/pi-subagents/test/model-scope.test.ts @@ -0,0 +1,147 @@ +// src/model-scope.ts had no test file. Its `error` verdict was reachable only +// indirectly, through one nested-tools case; the `warn` verdict — the whole +// reason the policy is a three-way split rather than a boolean — was never +// exercised at all. +// +// The split is the point: a model the ORCHESTRATOR picked at runtime is refused, +// because it can pick again; a model the USER pinned in frontmatter (or that was +// inherited from the parent) only warns, because refusing it would break every +// pinned agent the moment someone enables the setting. Collapsing the two in +// either direction is a one-line edit with no test in the way. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ModelRegistryRef } from "../src/enabled-models.js"; +import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "../src/model-scope.js"; + +const MODELS = [ + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, +]; + +function makeRegistry(models = MODELS): ModelRegistryRef { + return { getAll() { return models; }, getAvailable: undefined }; +} + +const HAIKU = { provider: "anthropic", id: "claude-haiku-4-5" }; +const OPUS = { provider: "anthropic", id: "claude-opus-4-6" }; + +describe("checkModelScope", () => { + let projectDir: string; + let agentDir: string; + let prevAgentDir: string | undefined; + let prevEnabled: boolean; + + beforeEach(() => { + // resolveEnabledModels memoizes on (patterns, mtime+size of both settings + // files). A fresh project dir per test keeps one case's allowlist from + // being served to the next. + projectDir = mkdtempSync(join(tmpdir(), "pi-scope-project-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-scope-global-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + prevEnabled = isScopeModelsEnabled(); + }); + + afterEach(() => { + setScopeModelsEnabled(prevEnabled); // module-global — restore for other suites + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + rmSync(projectDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + }); + + function setEnabledModels(models: string[]) { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ enabledModels: models })); + } + + function check(overrides: Partial[0]> = {}) { + return checkModelScope({ + model: OPUS, + cwd: projectDir, + modelRegistry: makeRegistry(), + callerSupplied: true, + agentLabel: "scout", + ...overrides, + }); + } + + it("is a no-op while the feature is off, even for an out-of-scope model", () => { + setScopeModelsEnabled(false); + setEnabledModels(["anthropic/claude-haiku-4-5"]); + expect(check().kind).toBe("ok"); + }); + + it("is a no-op when no model was resolved", () => { + setScopeModelsEnabled(true); + setEnabledModels(["anthropic/claude-haiku-4-5"]); + expect(check({ model: undefined }).kind).toBe("ok"); + }); + + it("is a no-op when the user has no enabledModels configured", () => { + setScopeModelsEnabled(true); + expect(check().kind).toBe("ok"); + }); + + it("is a no-op when enabledModels resolves to nothing usable", () => { + // Globs and bare ids are deliberately unsupported; an unresolvable list + // must disable the check rather than lock the user out of every model. + setScopeModelsEnabled(true); + setEnabledModels(["anthropic/*", "haiku"]); + expect(check().kind).toBe("ok"); + }); + + it("passes an in-scope model", () => { + setScopeModelsEnabled(true); + setEnabledModels(["anthropic/claude-opus-4-6"]); + expect(check().kind).toBe("ok"); + }); + + describe("out of scope", () => { + beforeEach(() => { + setScopeModelsEnabled(true); + setEnabledModels(["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"]); + }); + + it("refuses a caller-supplied choice and lists what is allowed", () => { + const verdict = check({ callerSupplied: true, modelInput: "anthropic/claude-opus-4-6" }); + expect(verdict.kind).toBe("error"); + const message = (verdict as { message: string }).message; + expect(message).toContain('"anthropic/claude-opus-4-6"'); + expect(message).toContain(" anthropic/claude-haiku-4-5"); + expect(message).toContain(" anthropic/claude-sonnet-4-6"); + }); + + it("only warns for a frontmatter-pinned choice, so the spawn still proceeds", () => { + const verdict = check({ callerSupplied: false, modelInput: "anthropic/claude-opus-4-6" }); + expect(verdict.kind).toBe("warn"); + expect((verdict as { message: string }).message) + .toBe('Agent "scout" using out-of-scope model "anthropic/claude-opus-4-6"'); + }); + + it("names the resolved model in the warning when there was no raw input", () => { + // Parent-inherited: nothing was typed, so the label falls back to the + // resolved provider/id rather than rendering "undefined". + const verdict = check({ callerSupplied: false, modelInput: undefined }); + expect(verdict.kind).toBe("warn"); + expect((verdict as { message: string }).message).toContain("anthropic/claude-opus-4-6"); + expect((verdict as { message: string }).message).not.toContain("undefined"); + }); + + it("stops enforcing as soon as the setting is turned back off", () => { + expect(check().kind).toBe("error"); + setScopeModelsEnabled(false); + expect(check().kind).toBe("ok"); + }); + + it("treats scope as case-insensitive on both sides", () => { + setEnabledModels(["Anthropic/Claude-Opus-4-6"]); + expect(check({ model: OPUS }).kind).toBe("ok"); + expect(check({ model: HAIKU }).kind).toBe("error"); + }); + }); +}); diff --git a/pi-subagents/test/nested-delegation-e2e.test.ts b/pi-subagents/test/nested-delegation-e2e.test.ts new file mode 100644 index 0000000..3874036 --- /dev/null +++ b/pi-subagents/test/nested-delegation-e2e.test.ts @@ -0,0 +1,264 @@ +/** + * nested-delegation-e2e.test.ts — regression for opt-in nested delegation, run + * through the real stack: real pi loader + real extension + real runAgent + two + * real child sessions, on a faux model. + * + * Everything else that covers nesting stops short of a real session — the tool + * unit tests use a fake manager, and the runner tests assert against a mocked + * pi. That leaves the load-bearing integration facts unproven: that pi actually + * admits the injected `customTools` into a child session's ACTIVE tool set + * (they collide with EXCLUDED_TOOL_NAMES by design, so a registry gate could + * silently drop them), and that a grandchild's output travels back up two hops. + * Those are exactly the things that break quietly, so they are pinned here. + * + * Deliberately faux, not live: `PI_E2E_LIVE=1` cannot drive a three-level chain + * deterministically, and a live model choosing not to delegate would look like + * a passing test. Each run therefore pins `live: false` rather than trusting the + * env var to leave it alone — the pre-publish smoke sets it globally. + */ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type Context, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerAgents } from "../src/agent-types.js"; +import { loadCustomAgents } from "../src/custom-agents.js"; +import { encodeCwd } from "../src/output-file.js"; +import { + agentCall, + type FauxReply, + type PrintModeRun, + runPrintMode, +} from "./helpers/print-mode-runner.js"; + +vi.setConfig({ testTimeout: 30_000 }); + +/** Marker the deepest agent emits — it must survive two hops back to the parent. */ +const WORKER_MARKER = "WORKER-REACHED-THE-TOP"; + +/** First user message of a session — the only stable way to tell three faux sessions apart. */ +function firstUserText(context: Context): string { + const first = context.messages.find((m) => m.role === "user"); + const content = first?.content; + if (typeof content === "string") return content; + return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); +} + +/** Tool results in a session context, newest last, with their tool names. */ +function toolResultTexts(context: Context): Array<{ name: string; text: string }> { + const out: Array<{ name: string; text: string }> = []; + for (const m of context.messages) { + if (m.role !== "toolResult") continue; + const name = (m as { toolName?: string }).toolName ?? ""; + const text = ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); + out.push({ name, text }); + } + return out; +} + +/** Every `.output` transcript beneath a root, at any session/tasks depth. */ +function findOutputFiles(root: string): string[] { + let entries: string[]; + try { entries = readdirSync(root); } catch { return []; } + return entries.flatMap((e) => { + const full = join(root, e); + if (statSync(full).isDirectory()) return findOutputFiles(full); + return full.endsWith(".output") ? [full] : []; + }); +} + +function writeAgents(cwd: string): void { + const dir = join(cwd, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + // Opts into nesting, restricted to one type — the allowlist path, not `all`. + writeFileSync( + join(dir, "orchestrator.md"), + "---\ndescription: Delegating orchestrator\ntools: read\nextensions: false\nallowed_subagents: worker\n---\nDelegate to worker.\n", + ); + writeFileSync( + join(dir, "worker.md"), + "---\ndescription: Leaf worker\ntools: read\nextensions: false\n---\nDo the work.\n", + ); +} + +describe("nested delegation e2e (real pi-mono, faux model)", () => { + let run: PrintModeRun | undefined; + const tmpDirs: string[] = []; + + afterEach(async () => { + await run?.dispose(); + run = undefined; + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it("a child with allowed_subagents spawns its own child, and the output travels back up", async () => { + const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-")); + tmpDirs.push(cwd); + writeAgents(cwd); + + /** Tool names each session was actually offered, keyed by who it is. */ + const toolsSeen = new Map(); + + const respond = (context: Context): FauxReply => { + const text = firstUserText(context); + const names = (context.tools ?? []).map((t) => t.name); + + // Leaf: no nested tools (it never opted in) — just answer. + if (text.includes("Do the leaf work")) { + toolsSeen.set("worker", names); + return WORKER_MARKER; + } + + // Middle: opted in, so pi must have admitted the injected Agent tool. + if (text.includes("Delegate this downward")) { + toolsSeen.set("orchestrator", names); + const alreadySpawned = context.messages.some( + (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", + ); + if (alreadySpawned) { + const result = [...context.messages] + .reverse() + .find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent"); + const inner = ((result?.content ?? []) as Array<{ text?: string }>) + .map((b) => b.text ?? "") + .join(""); + // Echo the child's own text: if it never arrived, the marker is absent + // and the top-level assertion fails rather than passing vacuously. + return `orchestrator saw: ${inner}`; + } + return agentCall({ + subagent_type: "worker", + description: "leaf work", + prompt: "Do the leaf work.", + }); + } + + // Top-level parent. + toolsSeen.set("parent", names); + const spawned = context.messages.some( + (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", + ); + if (spawned) { + const result = [...context.messages] + .reverse() + .find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent"); + const inner = ((result?.content ?? []) as Array<{ text?: string }>) + .map((b) => b.text ?? "") + .join(""); + return `parent saw: ${inner}`; + } + return agentCall({ + subagent_type: "orchestrator", + description: "delegate", + prompt: "Delegate this downward.", + // Foreground: this test reads the parent's inline Agent tool result. + run_in_background: false, + }); + }; + + run = await runPrintMode({ + prompt: "Delegate the work.", + cwd, + respond, + live: false, + beforeRun: () => { registerAgents(loadCustomAgents(cwd)); }, + }); + + // pi admitted the injected nested tools into the opted-in child's active set, + // despite their names colliding with the ones stripped from every subagent. + expect(toolsSeen.get("orchestrator")).toContain("Agent"); + expect(toolsSeen.get("orchestrator")).toContain("get_subagent_result"); + expect(toolsSeen.get("orchestrator")).toContain("steer_subagent"); + + // The leaf never opted in, so it must not have them. + expect(toolsSeen.get("worker")).toBeDefined(); + expect(toolsSeen.get("worker")).not.toContain("Agent"); + + // Two hops home: worker → orchestrator → parent. + expect(run.responseText).toContain(WORKER_MARKER); + }); + + it("backgrounds a nested child, polls it by id, and streams its transcript", async () => { + const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-bg-")); + tmpDirs.push(cwd); + writeAgents(cwd); + const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd)); + rmSync(transcriptRoot, { recursive: true, force: true }); + + const respond = (context: Context): FauxReply => { + const text = firstUserText(context); + + if (text.includes("Do the leaf work")) return WORKER_MARKER; + + if (text.includes("Delegate this downward")) { + const results = toolResultTexts(context); + const spawned = results.find((r) => r.name === "Agent")?.text ?? ""; + const polled = results.find((r) => r.name === "get_subagent_result")?.text; + // Third turn: the poll came back — echo it so a lost result fails loudly. + if (polled !== undefined) return `orchestrator polled: ${polled}`; + // Second turn: the spawn returned an id; fetch by exactly that id, which + // also exercises the manager's ownership check from inside a child. + if (spawned) { + const id = /Agent ID:\s*(\S+)/.exec(spawned)?.[1]; + expect(id).toBeTruthy(); + return fauxToolCall("get_subagent_result", { agent_id: id, wait: true }); + } + return agentCall({ + subagent_type: "worker", + description: "leaf work", + prompt: "Do the leaf work.", + run_in_background: true, + }); + } + + if (toolResultTexts(context).some((r) => r.name === "Agent")) return "parent done"; + return agentCall({ + subagent_type: "orchestrator", + description: "delegate", + prompt: "Delegate this downward.", + // Foreground: this test reads the parent's inline Agent tool result. + run_in_background: false, + }); + }; + + try { + run = await runPrintMode({ + prompt: "Delegate the work.", + cwd, + respond, + live: false, + beforeRun: () => { registerAgents(loadCustomAgents(cwd)); }, + }); + + // The background child ran and its output came back through the id the + // spawn handed out — so it was never queued behind its waiting parent. + const orchestratorResult = run.parentSession.messages + .filter((m) => m.role === "toolResult") + .flatMap((m) => (m.content as Array<{ text?: string }>).map((b) => b.text ?? "")) + .join("\n"); + expect(orchestratorResult).toContain("orchestrator polled"); + expect(orchestratorResult).toContain(WORKER_MARKER); + + // Only the REAL manager wires onSessionCreated → streamToOutputFile for a + // nested spawn, and only real rootSessionId propagation puts the file under + // this root. Identify the WORKER's own transcript by the prompt in its + // initial entry — matching the marker alone would also match the + // orchestrator's transcript, which merely echoes it, and would pass even + // with nested transcripts switched off entirely. + // Match on the FIRST line — writeInitialEntry seeds each transcript with the + // prompt that agent was given. Searching the whole file would also match the + // orchestrator's, which records the same string inside its Agent tool-call + // arguments, and would pass with nested transcripts switched off entirely. + const transcripts = findOutputFiles(transcriptRoot).map((f) => readFileSync(f, "utf-8")); + const workerTranscript = transcripts.find((t) => { + const first = JSON.parse(t.split("\n")[0]) as { message?: { content?: unknown } }; + return first.message?.content === "Do the leaf work."; + }); + expect(workerTranscript).toBeDefined(); + // ...and it streamed the child's own turn, not just the seeded prompt. + expect(workerTranscript).toContain(WORKER_MARKER); + } finally { + rmSync(transcriptRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/pi-subagents/test/nested-tools.test.ts b/pi-subagents/test/nested-tools.test.ts new file mode 100644 index 0000000..ecf4af1 --- /dev/null +++ b/pi-subagents/test/nested-tools.test.ts @@ -0,0 +1,543 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getAvailableTypes, registerAgents, setFallbackSubagent } from "../src/agent-types.js"; +import { loadCustomAgents } from "../src/custom-agents.js"; +import { setScopeModelsEnabled } from "../src/model-scope.js"; +import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager, setMaxSubagentDepth } from "../src/nested-tools.js"; +import { encodeCwd } from "../src/output-file.js"; + +let cwd: string; +let manager: NestedAgentManager; +let records: Map; +let spawn: ReturnType; +let spawnAndWait: ReturnType; + +function writeAgent(name: string, extra = "") { + const dir = join(cwd, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.md`), `---\ndescription: ${name}\ntools: read\n${extra}---\n${name}\n`); +} + +const MODELS = [ + { id: "allowed", name: "Allowed", provider: "anthropic" }, + { id: "blocked", name: "Blocked", provider: "anthropic" }, +]; + +function ctx(executionCwd = cwd) { + return { + cwd: executionCwd, + model: undefined, + modelRegistry: { + find: (provider: string, id: string) => ({ provider, id }), + getAvailable: () => MODELS, + getAll: () => MODELS, + }, + } as any; +} + +function tools( + allowedSubagents: "all" | string[] = "all", + depth = 1, + maxSubagentDepth = 2, + configCwd = cwd, +) { + return createNestedSubagentTools({ + manager, + pi: {} as any, + parentAgentId: "parent-1", + depth, + maxSubagentDepth, + allowedSubagents, + configCwd, + }); +} + +async function execute(tool: any, params: Record, executionCwd = cwd) { + return tool.execute("call-1", params, undefined, undefined, ctx(executionCwd)); +} + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "nested-tools-test-")); + writeAgent("scout"); + writeAgent("reviewer"); + registerAgents(loadCustomAgents(cwd)); + records = new Map(); + spawn = vi.fn((_pi, _ctx, type, _prompt, options) => { + const id = `child-${records.size + 1}`; + records.set(id, { id, type, status: "running", parentAgentId: options.parentAgentId }); + return id; + }); + spawnAndWait = vi.fn(async (_pi, _ctx, type, _prompt, options) => { + const id = `child-${records.size + 1}`; + const record = { id, type, status: "completed", result: "done", parentAgentId: options.parentAgentId }; + records.set(id, record); + return { id, record }; + }); + manager = { + spawn, + spawnAndWait, + getRecord: (id: string) => records.get(id), + resume: vi.fn(), + } as any; +}); + +afterEach(() => { + setScopeModelsEnabled(false); + rmSync(cwd, { recursive: true, force: true }); +}); + +describe("child-safe nested Agent tools", () => { + it("allows any enabled agent when allowed_subagents is omitted", async () => { + const [agent] = tools(); + const result = await execute(agent, { + subagent_type: "reviewer", + description: "review evidence", + prompt: "Review it", + }); + + expect(result.isError).toBe(false); + expect(spawnAndWait).toHaveBeenCalledWith( + expect.anything(), expect.anything(), "reviewer", "Review it", + expect.objectContaining({ + depth: 2, + parentAgentId: "parent-1", + maxSubagentDepth: 2, + configCwd: cwd, + }), + expect.any(Function), // onSpawned — attaches the child's transcript + ); + }); + + it("keeps agent discovery rooted in inherited config, not the working directory", async () => { + const workCwd = mkdtempSync(join(tmpdir(), "nested-tools-work-")); + const workAgentDir = join(workCwd, ".pi", "agents"); + mkdirSync(workAgentDir, { recursive: true }); + writeFileSync(join(workAgentDir, "intruder.md"), "---\ndescription: intruder\n---\nintruder\n"); + + try { + const [agent] = tools(); + const result = await execute(agent, { + subagent_type: "intruder", + description: "untrusted agent", + prompt: "Do work", + }, workCwd); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown or disabled"); + expect(spawnAndWait).not.toHaveBeenCalled(); + } finally { + rmSync(workCwd, { recursive: true, force: true }); + } + }); + + it("enforces a narrow allowlist", async () => { + const [limited] = tools(["scout"]); + const denied = await execute(limited, { + subagent_type: "reviewer", + description: "review evidence", + prompt: "Review it", + }); + expect(denied.isError).toBe(true); + expect(denied.content[0].text).toContain("not allowed"); + expect(spawnAndWait).not.toHaveBeenCalled(); + + const allowed = await execute(limited, { + subagent_type: "scout", + description: "find files", + prompt: "Find them", + }); + expect(allowed.isError).toBe(false); + expect(spawnAndWait).toHaveBeenCalledTimes(1); + }); + + it("resolves nested types without touching the process-global registry", async () => { + // A worktree-isolated parent hands its own config root down. Resolving from + // it must not swap the registry the main session and every other agent read. + const otherCwd = mkdtempSync(join(tmpdir(), "nested-tools-config-")); + const otherAgentDir = join(otherCwd, ".pi", "agents"); + mkdirSync(otherAgentDir, { recursive: true }); + writeFileSync(join(otherAgentDir, "branch-only.md"), "---\ndescription: branch-only\n---\nbranch-only\n"); + const before = getAvailableTypes(); + + try { + const [agent] = tools("all", 1, 2, otherCwd); + const result = await execute(agent, { + subagent_type: "branch-only", + description: "branch agent", + prompt: "Do work", + }); + + // Resolved from the inherited root... + expect(result.isError).toBe(false); + // ...without leaking it into the shared registry. + expect(getAvailableTypes()).toEqual(before); + expect(getAvailableTypes()).not.toContain("branch-only"); + } finally { + rmSync(otherCwd, { recursive: true, force: true }); + } + }); + + it("applies the scopeModels allowlist to a caller-supplied model", async () => { + writeFileSync( + join(cwd, ".pi", "settings.json"), + JSON.stringify({ enabledModels: ["anthropic/allowed"] }), + ); + setScopeModelsEnabled(true); + const [agent] = tools(); + + const blocked = await execute(agent, { + subagent_type: "scout", + description: "find files", + prompt: "Find them", + model: "anthropic/blocked", + }); + expect(blocked.isError).toBe(true); + expect(blocked.content[0].text).toContain("Model not in scope"); + expect(spawnAndWait).not.toHaveBeenCalled(); + + const inScope = await execute(agent, { + subagent_type: "scout", + description: "find files", + prompt: "Find them", + model: "anthropic/allowed", + }); + expect(inScope.isError).toBe(false); + }); + + it("queues a steer for an owned child whose session is not ready yet", async () => { + const [, , steer] = tools(); + const record: Record = { + id: "child-1", + status: "running", + parentAgentId: "parent-1", + }; + records.set("child-1", record); + + const result = await execute(steer, { agent_id: "child-1", message: "focus on tests" }); + + expect(result.isError).toBe(false); + expect(record.pendingSteers).toEqual(["focus on tests"]); + }); + + it("blocks delegation at the inherited depth cap", async () => { + const [agent] = tools("all", 2, 2); + const result = await execute(agent, { + subagent_type: "scout", + description: "find files", + prompt: "Find them", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("depth=2, max=2"); + expect(spawnAndWait).not.toHaveBeenCalled(); + }); + + it("rejects unknown or disabled nested agent types instead of falling back", async () => { + writeAgent("disabled", "enabled: false\n"); + registerAgents(loadCustomAgents(cwd)); + const [agent] = tools(); + + for (const subagentType of ["missing", "disabled"]) { + const result = await execute(agent, { + subagent_type: subagentType, + description: "invalid agent", + prompt: "Do work", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown or disabled"); + } + }); + + it("supports background launches and ownership-scopes result, resume, and steer", async () => { + const [agent, getResult, steer] = tools(["scout"]); + const launched = await execute(agent, { + subagent_type: "scout", + description: "find files", + prompt: "Find them", + run_in_background: true, + }); + expect(launched.content[0].text).toContain("child-1"); + expect(spawn).toHaveBeenCalledWith( + expect.anything(), expect.anything(), "scout", "Find them", + expect.objectContaining({ isBackground: true, depth: 2, parentAgentId: "parent-1" }), + ); + + const own = await execute(getResult, { agent_id: "child-1" }); + expect(own.isError).toBe(false); + + records.set("foreign", { + id: "foreign", + status: "running", + result: "secret", + parentAgentId: "other", + session: { steer: vi.fn() }, + }); + expect((await execute(getResult, { agent_id: "foreign" })).isError).toBe(true); + expect((await execute(steer, { agent_id: "foreign", message: "stop" })).isError).toBe(true); + expect((await execute(agent, { + resume: "foreign", + subagent_type: "scout", + description: "resume foreign", + prompt: "Continue", + })).isError).toBe(true); + expect(manager.resume).not.toHaveBeenCalled(); + }); + + it("waits for a queued owned child to start and settle", async () => { + const [, getResult] = tools(); + const record = { + id: "queued-child", + status: "queued", + parentAgentId: "parent-1", + promise: undefined as Promise | undefined, + result: undefined as string | undefined, + }; + records.set(record.id, record); + setTimeout(() => { + record.status = "running"; + record.promise = Promise.resolve().then(() => { + record.status = "completed"; + record.result = "queued done"; + }); + }, 10); + + const result = await execute(getResult, { agent_id: record.id, wait: true }); + + expect(result.content[0].text).toBe("queued done"); + }); + + it("aborts a nested result wait without aborting the owned child", async () => { + const [, getResult] = tools(); + let settleChild: (() => void) | undefined; + const record = { + id: "running-child", + status: "running", + parentAgentId: "parent-1", + promise: new Promise(resolve => { settleChild = resolve; }), + }; + records.set(record.id, record); + + const controller = new AbortController(); + const outcome = getResult + .execute("call-abort", { agent_id: record.id, wait: true }, controller.signal, undefined, ctx()) + .then(() => "resolved", (e: unknown) => (e instanceof Error ? e.name : String(e))); + + controller.abort(); + const settled = await Promise.race([ + outcome, + new Promise(r => setTimeout(() => r("timed-out"), 100)), + ]); + + expect(settled).toBe("AbortError"); + // The wait was cancelled but the child was never aborted or consumed. + expect(record.status).toBe("running"); + settleChild?.(); + }); + + it("still rejects unknown types when the project configures a fallback", async () => { + // The contract nested delegation documents is "rejected rather than falling + // back" — a top-level fallback must not hand a nested caller an agent its + // allowlist never named. + setFallbackSubagent("scout"); + try { + const [agent] = tools(["scout"]); + const result = await execute(agent, { + subagent_type: "definitely-missing", + description: "typo", + prompt: "Do work", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown or disabled nested agent type"); + expect(spawnAndWait).not.toHaveBeenCalled(); + } finally { + setFallbackSubagent(undefined); + } + }); + + it("hands the branch cap down to the child it spawns", async () => { + const [agent] = tools("all", 1, 3); + const result = await execute(agent, { + subagent_type: "scout", + description: "child", + prompt: "Do work", + }); + + expect(result.isError).toBe(false); + expect(spawnAndWait).toHaveBeenCalledWith( + expect.anything(), expect.anything(), "scout", "Do work", + expect.objectContaining({ depth: 2, maxSubagentDepth: 3 }), + expect.any(Function), + ); + }); + + it("flags a truncated child run instead of passing partial output off as complete", async () => { + spawnAndWait.mockImplementation(async () => ({ + id: "child-1", + record: { id: "child-1", status: "steered", result: "half an answer", parentAgentId: "parent-1" }, + })); + const [agent] = tools(); + const result = await execute(agent, { + subagent_type: "scout", + description: "truncated", + prompt: "Do work", + }); + + expect(result.isError).toBe(false); + // Foreground: the whole output is inline and no id came back, so the note + // must not invite a get_subagent_result call the parent cannot make (#174). + expect(result.content[0].text).toContain("everything the agent produced is above"); + expect(result.content[0].text).not.toContain("output is partial"); + // The warning leads, so it can't read as part of the child's own answer. + expect(result.content[0].text.indexOf("half an answer")).toBeGreaterThan(0); + }); + + it("uses the fetchable wording when the parent polls a background child by id", async () => { + records.set("child-1", { + id: "child-1", status: "aborted", result: "partial work", parentAgentId: "parent-1", + }); + const [, getResult] = tools(); + const result = await execute(getResult, { agent_id: "child-1" }); + + // Here the parent does hold a valid id, so the background wording applies. + expect(result.content[0].text).toContain("output may be incomplete"); + expect(result.content[0].text).not.toContain("everything the agent produced is above"); + }); + + it("keeps a failed child's partial output alongside the error", async () => { + spawnAndWait.mockImplementation(async () => ({ + id: "child-1", + record: { + id: "child-1", status: "error", error: "provider exploded", + result: "got this far", parentAgentId: "parent-1", + }, + })); + const [agent] = tools(); + const result = await execute(agent, { + subagent_type: "scout", + description: "failing", + prompt: "Do work", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("provider exploded"); + expect(result.content[0].text).toContain("got this far"); + }); + + it("attributes a nested child's token spend to the owning parent", async () => { + const parent = { id: "parent-1", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } }; + records.set("parent-1", parent); + spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => { + options.onAssistantUsage?.({ input: 100, output: 20, cacheWrite: 5 }); + return "child-1"; + }); + + const [agent] = tools(); + await execute(agent, { + subagent_type: "scout", + description: "spender", + prompt: "Do work", + run_in_background: true, + }); + + expect(parent.lifetimeUsage).toEqual({ input: 100, output: 20, cacheWrite: 5 }); + }); + + it("attributes spend up the whole ancestor chain, not just one level", async () => { + // A spawn callback fires only for that child's own turns, so a deeper + // descendant would otherwise never reach the one record anyone can see. + const top = { id: "top", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } }; + const middle = { + id: "parent-1", status: "running", parentAgentId: "top", + lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, + }; + records.set("top", top); + records.set("parent-1", middle); + spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => { + options.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 1 }); + return "child-1"; + }); + + const [agent] = tools(); + await execute(agent, { + subagent_type: "scout", + description: "deep spender", + prompt: "Do work", + run_in_background: true, + }); + + expect(middle.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 }); + expect(top.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 }); + }); + + it("files a nested transcript under the root session, honoring output_transcript", async () => { + records.set("parent-1", { id: "parent-1", status: "running", rootSessionId: "root-session" }); + spawnAndWait.mockImplementation(async (_pi, _ctx, type, _prompt, options, onSpawned) => { + const record = { id: "child-1", type, status: "completed", result: "done", parentAgentId: options.parentAgentId }; + records.set("child-1", record); + onSpawned?.("child-1"); + return { id: "child-1", record }; + }); + + // Real path construction (not mocked here), so clean up what it writes. + const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd)); + try { + const [agent] = tools(); + await execute(agent, { subagent_type: "scout", description: "traced", prompt: "Do work" }); + expect(records.get("child-1").outputFile).toContain(join("root-session", "tasks", "child-1.output")); + + // The child's own frontmatter still wins. + writeAgent("quiet", "output_transcript: false\n"); + registerAgents(loadCustomAgents(cwd)); + records.delete("child-1"); + await execute(agent, { subagent_type: "quiet", description: "untraced", prompt: "Do work" }); + expect(records.get("child-1").outputFile).toBeUndefined(); + } finally { + rmSync(transcriptRoot, { recursive: true, force: true }); + } + }); + + it("forwards the execution context to the manager unmodified", async () => { + // Each AgentSession builds its own ExtensionRunner, so the ctx handed to + // execute is the CHILD's — capturing one at tool-build time instead would + // silently misroute the grandchild's cwd, conversation, and model. + const [agent] = tools(); + const executionCtx = ctx(); + await agent.execute("call-1", { + subagent_type: "scout", + description: "ctx check", + prompt: "Do work", + } as any, undefined, undefined, executionCtx); + + expect(spawnAndWait.mock.calls[0][1]).toBe(executionCtx); + }); +}); + +// setMaxSubagentDepth clamps its input, but test/settings.test.ts only asserts +// the applier SPY was called — the real Math.max(0, Math.floor(n)) never runs +// there. A hand-edited subagents.json reaches this unfiltered, and losing the +// clamp is silent in the worst direction: a negative depth makes the +// `depth >= maxSubagentDepth` check true everywhere, disabling nested +// delegation project-wide with no error. +describe("setMaxSubagentDepth clamping", () => { + let previous: number; + beforeEach(() => { previous = getMaxSubagentDepth(); }); + afterEach(() => { setMaxSubagentDepth(previous); }); + + it("floors a negative depth at 0 rather than storing it", () => { + setMaxSubagentDepth(-1); + expect(getMaxSubagentDepth()).toBe(0); + }); + + it("truncates a fractional depth toward zero", () => { + setMaxSubagentDepth(2.9); + expect(getMaxSubagentDepth()).toBe(2); + }); + + it("stores a valid depth unchanged", () => { + setMaxSubagentDepth(3); + expect(getMaxSubagentDepth()).toBe(3); + }); +}); diff --git a/pi-subagents/test/output-file-compaction-e2e.test.ts b/pi-subagents/test/output-file-compaction-e2e.test.ts new file mode 100644 index 0000000..f4d9c22 --- /dev/null +++ b/pi-subagents/test/output-file-compaction-e2e.test.ts @@ -0,0 +1,146 @@ +/** + * output-file-compaction-e2e.test.ts — regression for issue #145: output-file + * streaming must survive session compaction. + * + * Uses a REAL pi AgentSession (faux model backend, in-memory session manager) + * and drives a REAL session.compact() — no mocked compaction semantics, so + * this breaks if pi changes how compaction rebuilds session.messages. + */ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai"; +import { + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { streamToOutputFile, writeInitialEntry } from "../src/output-file.js"; +import { fauxModelBackend } from "./helpers/faux-model-backend.js"; +import { registerFauxProvider } from "./helpers/pi-ai.js"; + +const TURNS_BEFORE_COMPACT = 6; + +describe("output-file streaming across a real compaction (#145)", () => { + let tmp: string; + let prevAgentDir: string | undefined; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "issue-145-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = tmp; // hermetic: no dev-env extensions/themes + }); + + afterEach(() => { + if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + rmSync(tmp, { recursive: true, force: true }); + }); + + it("keeps writing post-compaction messages to the output file", async () => { + const cwd = tmp; + const faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-1", contextWindow: 200_000 }], + }); + const model = faux.getModel(); + const backend = fauxModelBackend(model); + // Context-branching responder: compaction issues a variable number of + // model calls (summary, plus a turn-prefix summary when the cut point + // splits a turn), so a fixed FIFO would desync. Decide from the request. + const respond = (context: { messages: Array<{ role: string; content: unknown }> }) => { + const last = context.messages[context.messages.length - 1]; + const text = typeof last?.content === "string" + ? last.content + : JSON.stringify(last?.content ?? ""); + // Compaction requests first — their instruction embeds the transcript, + // so the "question N" branch below would otherwise match inside it. + // Markers are pi's own prompt texts (SUMMARIZATION_PROMPT and + // TURN_PREFIX_SUMMARIZATION_PROMPT in core/compaction). + if (text.includes("conversation to summarize") || text.includes("PREFIX of a turn")) { + return fauxAssistantMessage([fauxText("summary of everything so far")]); + } + if (text.includes("final question")) { + return fauxAssistantMessage([fauxText("POST-COMPACTION-ANSWER")]); + } + const q = text.match(/question (\d+)/); + if (last?.role === "user" && q) { + return fauxAssistantMessage([fauxText(`answer-${q[1]} ${"filler ".repeat(200)}`)]); + } + throw new Error(`unexpected faux request: ${text.slice(0, 120)}`); + }; + faux.setResponses(Array.from({ length: 16 }, () => respond)); + + const agentDir = getAgentDir(); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + systemPromptOverride: () => "You are a test agent.", + appendSystemPromptOverride: () => [], + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); + await loader.reload(); + + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + // Registry for pre-0.80.8 Pi, runtime for post — each ignores the other. + modelRegistry: backend.modelRegistry as never, + modelRuntime: backend.modelRuntime as never, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(cwd), + settingsManager: SettingsManager.inMemory({ + // Auto-compaction off (deterministic manual compact() below); a small + // keep-window so the bulky early turns actually get summarized away. + compaction: { enabled: false, keepRecentTokens: 500 }, + retry: { enabled: false }, + }), + }); + + const outPath = join(tmp, "agent.output"); + writeInitialEntry(outPath, "agent-145", "repro", cwd); + const cleanup = streamToOutputFile(session as never, outPath, "agent-145", cwd); + + const readEntries = () => + readFileSync(outPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + + // Build up history, streaming as we go (turn_end fires per prompt). + for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) { + await session.prompt(`question ${i}`); + } + const writtenBeforeCompact = readEntries().length; + expect(writtenBeforeCompact).toBeGreaterThan(TURNS_BEFORE_COMPACT); // sanity: streaming worked pre-compaction + + const lenBefore = session.messages.length; + const result = await session.compact(); + expect(result?.summary).toContain("summary"); // sanity: real compaction ran + // Sanity: compaction actually shrank the array — the exact condition that + // used to strand writtenCount past the end and halt streaming (#145). + expect(session.messages.length).toBeLessThan(lenBefore); + + // The run continues after compaction... + await session.prompt("final question"); + cleanup(); + + // The post-compaction turn reaches the output file... + const entries = readEntries(); + const all = JSON.stringify(entries); + expect(all).toContain("final question"); + expect(all).toContain("POST-COMPACTION-ANSWER"); + // ...and re-anchoring didn't double-write the compaction-kept tail: every + // pre-compaction answer still appears exactly once. + for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) { + expect(all.split(`answer-${i} `).length - 1).toBe(1); + } + expect(entries.length).toBe(writtenBeforeCompact + 2); // + final question + its answer + }, 30_000); +}); diff --git a/pi-subagents/test/output-file-path.test.ts b/pi-subagents/test/output-file-path.test.ts new file mode 100644 index 0000000..85ac61e --- /dev/null +++ b/pi-subagents/test/output-file-path.test.ts @@ -0,0 +1,86 @@ +// README:142 promises the transcript root is owner-only `0700`. Transcripts +// hold the agent's full conversation — user prompts, file contents, tool output — +// in a shared temp dir, so that mode is the only thing keeping them from every +// other local user. `createOutputFilePath` is mocked in the one wiring test that +// touches it, so this body had never actually executed under test. +// +// This lives in its own file because it must mock `node:os`. The real root is +// `/pi-subagents-` — one path shared by every worker AND by the +// e2e suites that spawn real agents. Mutating it directly makes the test race +// against anything else writing a transcript (observed: passes alone, fails +// intermittently in the full run). Redirecting `tmpdir()` gives each run its own +// root and removes the shared state entirely. + +import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync } from "node:fs"; +import { tmpdir as realTmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fakeTmp = vi.hoisted(() => ({ dir: "" })); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tmpdir: () => fakeTmp.dir || actual.tmpdir() }; +}); + +import { createOutputFilePath } from "../src/output-file.js"; + +const UID = process.getuid?.() ?? 0; +const AGENT = "agent-xyz"; +const SESSION = "session-123"; + +describe("createOutputFilePath", () => { + let root: string; + + beforeEach(() => { + // realpath: macOS resolves /var → /private/var, and the module joins the + // raw tmpdir() value, so comparisons must use the same form. + fakeTmp.dir = realpathSync(mkdtempSync(join(realTmpdir(), "pi-outpath-"))); + root = join(fakeTmp.dir, `pi-subagents-${UID}`); + }); + + afterEach(() => { + rmSync(fakeTmp.dir, { recursive: true, force: true }); + fakeTmp.dir = ""; + }); + + it("builds the documented layout: ///tasks/.output", () => { + const path = createOutputFilePath("/home/user/project", AGENT, SESSION); + expect(path).toBe(join(root, "home-user-project", SESSION, "tasks", `${AGENT}.output`)); + }); + + it("creates the directory chain so the first write cannot fail", () => { + const path = createOutputFilePath("/home/user/project", AGENT, SESSION); + expect(existsSync(join(path, ".."))).toBe(true); + expect(statSync(join(path, "..")).isDirectory()).toBe(true); + }); + + it("keeps distinct cwds in separate subdirectories under the shared root", () => { + const a = createOutputFilePath("/home/user/project", AGENT, SESSION); + const b = createOutputFilePath("/home/user/other", "agent-2", SESSION); + expect(a).toContain("home-user-project"); + expect(b).toContain("home-user-other"); + expect(a).not.toBe(b); + }); + + it.skipIf(process.platform === "win32")("creates the root owner-only", () => { + createOutputFilePath("/home/user/project", AGENT, SESSION); + expect(statSync(root).mode & 0o777).toBe(0o700); + }); + + it.skipIf(process.platform === "win32")("re-tightens a pre-existing world-readable root", () => { + // The case the explicit chmod exists for. `mkdirSync(recursive: true)` does + // NOT alter an existing directory's mode, so a root left behind by an older + // version — or created by anything else under a permissive umask — would + // keep its wide permissions and every transcript written into it would be + // readable by every local user. (mkdir's own `mode: 0o700` cannot cover + // this: umask only clears bits, so a fresh mkdir is never too permissive.) + mkdirSync(root, { recursive: true, mode: 0o755 }); + chmodSync(root, 0o755); // defeat umask so the premise really holds + expect(statSync(root).mode & 0o777).toBe(0o755); + + createOutputFilePath("/home/user/project", AGENT, SESSION); + + expect(statSync(root).mode & 0o777).toBe(0o700); + }); +}); diff --git a/pi-subagents/test/output-file.test.ts b/pi-subagents/test/output-file.test.ts new file mode 100644 index 0000000..ed2274d --- /dev/null +++ b/pi-subagents/test/output-file.test.ts @@ -0,0 +1,296 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { encodeCwd, streamToOutputFile, writeInitialEntry } from "../src/output-file.js"; + +describe("encodeCwd", () => { + it("encodes a POSIX absolute path by stripping the leading slash and replacing separators", () => { + expect(encodeCwd("/home/user/project")).toBe("home-user-project"); + }); + + it("handles a POSIX root path", () => { + expect(encodeCwd("/")).toBe(""); + }); + + it("encodes a Windows drive-letter path by stripping the drive prefix", () => { + expect(encodeCwd("C:\\Users\\foo\\project")).toBe("Users-foo-project"); + }); + + it("handles lowercase Windows drives", () => { + expect(encodeCwd("c:\\foo")).toBe("foo"); + }); + + it("handles a Windows path written with forward slashes", () => { + expect(encodeCwd("C:/Users/foo/project")).toBe("Users-foo-project"); + }); + + it("preserves server and share for UNC paths", () => { + expect(encodeCwd("\\\\server\\share\\project")).toBe("server-share-project"); + }); + + it("handles mixed separators", () => { + expect(encodeCwd("/home\\user/project")).toBe("home-user-project"); + }); + + it("collapses runs of leading dashes after separator replacement", () => { + expect(encodeCwd("///foo")).toBe("foo"); + }); + + it("returns an empty string for an empty cwd", () => { + expect(encodeCwd("")).toBe(""); + }); + + it("leaves a relative-looking path with no leading separator alone", () => { + expect(encodeCwd("foo/bar")).toBe("foo-bar"); + }); +}); + +/** + * Minimal AgentSession fake. streamToOutputFile only reads `session.messages` + * and calls `session.subscribe(cb)`, so we provide just those — plus test-only + * helpers to mutate state and fire events deterministically. + */ +function makeFakeSession(initialMessages: unknown[] = []) { + let messages: unknown[] = [...initialMessages]; + let cb: ((event: unknown) => void) | null = null; + return { + get messages() { + return messages; + }, + subscribe(fn: (event: unknown) => void) { + cb = fn; + return () => { + cb = null; + }; + }, + push(...msgs: unknown[]) { + messages.push(...msgs); + }, + /** Swap the whole array, like pi's compaction (`agent.state.messages = ...`). */ + replaceAll(msgs: unknown[]) { + messages = msgs; + }, + fire(event: unknown) { + cb?.(event); + }, + isSubscribed() { + return cb !== null; + }, + }; +} + +/** Drain the microtask queue (the compaction re-anchor is deferred one tick). */ +const microtask = () => Promise.resolve(); + +describe("streamToOutputFile", () => { + let tmp: string; + let outPath: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "stream-out-test-")); + outPath = join(tmp, "agent.output"); + writeInitialEntry(outPath, "agent-1", "do the thing", "/work"); + }); + + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function readEntries(): Array> { + return readFileSync(outPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + } + + it("writes nothing past the initial entry until turn_end fires", () => { + const session = makeFakeSession([{ role: "user", content: "do the thing" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push({ role: "assistant", content: [{ type: "text", text: "ok" }] }); + expect(readEntries()).toHaveLength(1); // only the initial entry + + session.fire({ type: "turn_end" }); + expect(readEntries()).toHaveLength(2); + }); + + it("tags assistant, user, and tool messages with the correct type field", () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push( + { role: "assistant", content: [{ type: "text", text: "thinking" }] }, + { role: "user", content: "follow-up" }, + { role: "tool", content: [{ type: "tool_result", content: "x" }] }, + ); + session.fire({ type: "turn_end" }); + + const entries = readEntries(); + expect(entries.map((e) => e.type)).toEqual(["user", "assistant", "user", "toolResult"]); + expect(entries.every((e) => e.agentId === "agent-1" && e.isSidechain === true)).toBe(true); + expect(entries.every((e) => e.cwd === "/work")).toBe(true); + }); + + it("never re-emits messages already flushed on a previous turn_end", () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push({ role: "assistant", content: [{ type: "text", text: "one" }] }); + session.fire({ type: "turn_end" }); + + session.push({ role: "assistant", content: [{ type: "text", text: "two" }] }); + session.fire({ type: "turn_end" }); + + // Fire a redundant turn_end with no new messages — must not duplicate + session.fire({ type: "turn_end" }); + + expect(readEntries()).toHaveLength(3); + }); + + it("ignores session events other than turn_end", () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push({ role: "assistant", content: [{ type: "text", text: "x" }] }); + session.fire({ type: "message_start" }); + session.fire({ type: "tool_call" }); + session.fire({ type: "message_end" }); + + expect(readEntries()).toHaveLength(1); + }); + + // ---- Compaction (#145): pi replaces session.messages with a shorter, + // summarized array; streaming must survive it. Event sequences below mirror + // pi's real order of operations (verified against agent-session 0.80.6). + + it("resumes streaming after compaction shrinks the message array (#145)", async () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push( + { role: "assistant", content: [{ type: "text", text: "one" }] }, + { role: "user", content: "q2" }, + { role: "assistant", content: [{ type: "text", text: "two" }] }, + ); + session.fire({ type: "turn_end" }); // 4 messages flushed + + // Compaction: summary + kept tail, much shorter than what was written. + session.fire({ type: "compaction_start", reason: "manual" }); + session.replaceAll([ + { role: "user", content: "summary of earlier turns" }, + { role: "assistant", content: [{ type: "text", text: "two" }] }, + ]); + session.fire({ type: "compaction_end", reason: "manual", aborted: false, result: { summary: "s" } }); + await microtask(); + + session.push({ role: "assistant", content: [{ type: "text", text: "AFTER" }] }); + session.fire({ type: "turn_end" }); + + const entries = readEntries(); + // initial + one + q2 + two + AFTER — the kept tail is NOT re-written. + expect(entries).toHaveLength(5); + expect(JSON.stringify(entries.at(-1))).toContain("AFTER"); + }); + + it("flushes the not-yet-written tail before compaction discards it (#145)", () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + // A message lands with no turn_end yet (e.g. overflow mid-turn)... + session.push({ role: "assistant", content: [{ type: "text", text: "tail-before-compact" }] }); + expect(readEntries()).toHaveLength(1); + + // ...then compaction starts: the tail must reach the file before the array is replaced. + session.fire({ type: "compaction_start", reason: "overflow" }); + expect(JSON.stringify(readEntries().at(-1))).toContain("tail-before-compact"); + }); + + it("re-anchors after the overflow-retry trim, not at compaction_end (#145)", async () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.push({ role: "assistant", content: [{ type: "text", text: "big" }] }); + session.fire({ type: "turn_end" }); + + // pi's overflow-retry order: compaction_end fires, THEN the trailing error + // assistant message is sliced off. A synchronous anchor would sit one past + // the trimmed array and skip the first post-compaction message. + session.fire({ type: "compaction_start", reason: "overflow" }); + session.replaceAll([ + { role: "user", content: "summary" }, + { role: "assistant", content: [{ type: "text", text: "err" }], stopReason: "error" }, + ]); + session.fire({ type: "compaction_end", reason: "overflow", aborted: false, result: { summary: "s" }, willRetry: true }); + session.replaceAll([{ role: "user", content: "summary" }]); // the post-emit trim + await microtask(); + + session.push({ role: "assistant", content: [{ type: "text", text: "RETRY-ANSWER" }] }); + session.fire({ type: "turn_end" }); + + expect(JSON.stringify(readEntries().at(-1))).toContain("RETRY-ANSWER"); + }); + + it("does not re-anchor on aborted or failed compaction (#145)", async () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + session.fire({ type: "compaction_start", reason: "manual" }); + // Aborted/failed: session.messages is left untouched by pi. + session.fire({ type: "compaction_end", reason: "manual", aborted: true, result: undefined }); + session.fire({ type: "compaction_end", reason: "manual", aborted: false, result: undefined, errorMessage: "boom" }); + await microtask(); + + session.push({ role: "assistant", content: [{ type: "text", text: "still-streaming" }] }); + session.fire({ type: "turn_end" }); + + const entries = readEntries(); + expect(entries).toHaveLength(2); // nothing skipped, nothing duplicated + expect(JSON.stringify(entries.at(-1))).toContain("still-streaming"); + }); + + it("cleanup() does a final flush and detaches the subscription", () => { + const session = makeFakeSession([{ role: "user", content: "go" }]); + const cleanup = streamToOutputFile(session as never, outPath, "agent-1", "/work"); + + // Trailing message arrives with no turn_end before shutdown + session.push({ role: "assistant", content: [{ type: "text", text: "tail" }] }); + expect(readEntries()).toHaveLength(1); + + cleanup(); + expect(readEntries()).toHaveLength(2); + expect(session.isSubscribed()).toBe(false); + + // Post-cleanup messages must not be written, even if events would otherwise fire + session.push({ role: "assistant", content: [{ type: "text", text: "ghost" }] }); + session.fire({ type: "turn_end" }); + expect(readEntries()).toHaveLength(2); + }); + + // A background resume reuses the agent's existing transcript and its existing + // session, which already holds every prior turn. Anchoring at the default 1 + // would re-emit that whole history into the file behind the new run's turns. + it("starts at the given index so a resume appends only its own turns", () => { + const prior = [ + { role: "user", content: "do the thing" }, + { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + { role: "user", content: "and again" }, + { role: "assistant", content: [{ type: "text", text: "second answer" }] }, + ]; + const session = makeFakeSession(prior); + streamToOutputFile(session as never, outPath, "agent-1", "/work", session.messages.length); + + // The resumed prompt lands as an ordinary user message at the anchor index. + session.push({ role: "user", content: "keep going" }); + session.push({ role: "assistant", content: [{ type: "text", text: "third answer" }] }); + session.fire({ type: "turn_end" }); + + const entries = readEntries(); + // Initial entry from beforeEach + exactly the two new messages — none of the + // four the session was already carrying. + expect(entries).toHaveLength(3); + const body = JSON.stringify(entries); + expect(body).toContain("keep going"); + expect(body).toContain("third answer"); + expect(body).not.toContain("first answer"); + expect(body).not.toContain("second answer"); + }); +}); diff --git a/pi-subagents/test/output-transcript-wiring.test.ts b/pi-subagents/test/output-transcript-wiring.test.ts new file mode 100644 index 0000000..0a2dec3 --- /dev/null +++ b/pi-subagents/test/output-transcript-wiring.test.ts @@ -0,0 +1,195 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +// Only the filesystem side is faked; the transcript default is real module +// state (both spawn paths read it there), so it must be reset between tests. +vi.mock("../src/output-file.js", async () => { + const actual = await vi.importActual("../src/output-file.js"); + return { + ...actual, + createOutputFilePath: vi.fn(() => "/tmp/fake-subagent.output"), + writeInitialEntry: vi.fn(), + streamToOutputFile: vi.fn(() => vi.fn()), + }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { createOutputFilePath, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "../src/output-file.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const events = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + events.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +function makeCtx(cwd: string) { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd, + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "session-1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +describe("output_transcript agent wiring", () => { + let cwd: string; + let agentDir: string; + let previousCwd: string; + let previousAgentDir: string | undefined; + let previousHome: string | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "pi-output-transcript-cwd-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-output-transcript-agent-")); + previousCwd = process.cwd(); + previousAgentDir = process.env.PI_CODING_AGENT_DIR; + previousHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + mkdirSync(join(agentDir, "agents"), { recursive: true }); + process.chdir(cwd); + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options) => { + await Promise.resolve(); + const session = { messages: [], subscribe: vi.fn(() => vi.fn()), dispose: vi.fn() } as any; + options.onSessionCreated?.(session); + return { responseText: "done", session, aborted: false, steered: false }; + }); + }); + + afterEach(() => { + process.chdir(previousCwd); + if (previousAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousHome == null) delete process.env.HOME; + else process.env.HOME = previousHome; + rmSync(cwd, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + setOutputTranscriptDefault(true); + vi.clearAllMocks(); + }); + + it("creates no transcript when a custom agent sets output_transcript false", async () => { + writeFileSync(join(agentDir, "agents", "sensitive.md"), `---\ndescription: Sensitive in-memory agent\noutput_transcript: false\n---\n\nKeep data in memory.`); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tool-call", + { prompt: "process sensitive data", description: "Process sensitive data", subagent_type: "sensitive" }, + undefined, + undefined, + makeCtx(cwd), + ); + + expect(createOutputFilePath).not.toHaveBeenCalled(); + expect(writeInitialEntry).not.toHaveBeenCalled(); + expect(streamToOutputFile).not.toHaveBeenCalled(); + await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd)); + }); + + it("also suppresses the background transcript", async () => { + writeFileSync(join(agentDir, "agents", "sensitive.md"), `---\ndescription: Sensitive in-memory agent\noutput_transcript: false\nrun_in_background: true\n---\n\nKeep data in memory.`); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tool-call", + { prompt: "process sensitive data", description: "Process sensitive data", subagent_type: "sensitive" }, + undefined, + undefined, + makeCtx(cwd), + ); + + expect(createOutputFilePath).not.toHaveBeenCalled(); + expect(writeInitialEntry).not.toHaveBeenCalled(); + expect(streamToOutputFile).not.toHaveBeenCalled(); + await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd)); + }); + + it("keeps transcript creation as the default", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tool-call", + { prompt: "ordinary work", description: "Do ordinary work", subagent_type: "general-purpose" }, + undefined, + undefined, + makeCtx(cwd), + ); + + expect(createOutputFilePath).toHaveBeenCalledOnce(); + expect(writeInitialEntry).toHaveBeenCalledOnce(); + expect(streamToOutputFile).toHaveBeenCalledOnce(); + await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd)); + }); + + it("suppresses the transcript project-wide when subagents.json sets outputTranscript false", async () => { + // A plain default agent (no frontmatter) inherits the project default. + writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, outputTranscript: false })); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tool-call", + { prompt: "ordinary work", description: "Do ordinary work", subagent_type: "general-purpose" }, + undefined, + undefined, + makeCtx(cwd), + ); + + expect(createOutputFilePath).not.toHaveBeenCalled(); + expect(writeInitialEntry).not.toHaveBeenCalled(); + expect(streamToOutputFile).not.toHaveBeenCalled(); + await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd)); + }); + + it("lets agent frontmatter output_transcript true override a project outputTranscript false", async () => { + writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, outputTranscript: false })); + writeFileSync(join(agentDir, "agents", "audited.md"), `---\ndescription: Always keeps a transcript\noutput_transcript: true\n---\n\nWrite a transcript regardless of the project default.`); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tool-call", + { prompt: "audited work", description: "Do audited work", subagent_type: "audited" }, + undefined, + undefined, + makeCtx(cwd), + ); + + expect(createOutputFilePath).toHaveBeenCalledOnce(); + expect(writeInitialEntry).toHaveBeenCalledOnce(); + expect(streamToOutputFile).toHaveBeenCalledOnce(); + await lifecycle.get("session_shutdown")?.({}, makeCtx(cwd)); + }); +}); diff --git a/pi-subagents/test/permission-system-bridge.test.ts b/pi-subagents/test/permission-system-bridge.test.ts new file mode 100644 index 0000000..6487904 --- /dev/null +++ b/pi-subagents/test/permission-system-bridge.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PERMISSION_CHILD_CREATED_CHANNEL, + PERMISSION_CHILD_DISPOSED_CHANNEL, + registerPermissionChildSession, + unregisterPermissionChildSession, + withActiveAgentIdentity, +} from "../src/permission-system-bridge.js"; + +function session(id: string | undefined) { + return { sessionManager: { getSessionId: () => id } } as any; +} + +function publisher() { + return { events: { emit: vi.fn() } } as any; +} + +describe("permission-system child lifecycle bridge", () => { + it("registers the child against the interactive root and disposes it once", () => { + const pi = publisher(); + const child = session(" child-session "); + + expect(registerPermissionChildSession(pi, child, " root-session ")).toBe(true); + expect(registerPermissionChildSession(pi, child, "root-session")).toBe(true); + expect(pi.events.emit).toHaveBeenCalledTimes(1); + expect(pi.events.emit).toHaveBeenNthCalledWith(1, PERMISSION_CHILD_CREATED_CHANNEL, { + sessionId: "child-session", + parentSessionId: "root-session", + }); + + unregisterPermissionChildSession(child); + unregisterPermissionChildSession(child); + expect(pi.events.emit).toHaveBeenCalledTimes(2); + expect(pi.events.emit).toHaveBeenNthCalledWith(2, PERMISSION_CHILD_DISPOSED_CHANNEL, { + sessionId: "child-session", + }); + }); + + it("does not claim integration without both session identities and an event bus", () => { + expect(registerPermissionChildSession({} as any, session("child"), "root")).toBe(false); + expect(registerPermissionChildSession(publisher(), session(undefined), "root")).toBe(false); + expect(registerPermissionChildSession(publisher(), session("child"), " ")).toBe(false); + }); + + it("fails child startup when a lifecycle subscriber rejects registration", () => { + const pi = { events: { emit: vi.fn(() => { throw new Error("registry unavailable"); }) } } as any; + expect(() => registerPermissionChildSession(pi, session("child"), "root")) + .toThrow("registry unavailable"); + }); + + it("replaces inherited identity with the stable child agent key", () => { + expect(withActiveAgentIdentity( + 'parent\n\nchild instructions', + "security-auditor", + )).toBe('parent\nchild instructions\n\n'); + }); + + it("omits an unsafe agent key instead of emitting malformed identity markup", () => { + expect(withActiveAgentIdentity("prompt", 'bad" name')).toBe("prompt"); + }); +}); diff --git a/pi-subagents/test/print-mode.test.ts b/pi-subagents/test/print-mode.test.ts new file mode 100644 index 0000000..df9d3fa --- /dev/null +++ b/pi-subagents/test/print-mode.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { + ...actual, + runAgent: vi.fn(), + }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const handlers = new Map(); + const eventHandlers = new Map(); + + return { + pi: { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: any) => { + tools.set(tool.name, tool); + }), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => { + handlers.set(event, handler); + }), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + eventHandlers.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(() => { + throw new Error("stale extension context"); + }), + } as any, + tools, + handlers, + }; +} + +function makeHeadlessCtx() { + return { + hasUI: false, + ui: { + setStatus: vi.fn(), + setWidget: vi.fn(), + }, + cwd: "/tmp", + model: undefined, + modelRegistry: { + find: vi.fn(), + getAvailable: vi.fn(() => []), + }, + sessionManager: { + getSessionId: vi.fn(() => "session-1"), + getBranch: vi.fn(() => []), + }, + getSystemPrompt: vi.fn(() => "parent prompt"), + } as any; +} + +describe("print mode background notifications", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("ignores stale-context errors from delayed completion nudges", async () => { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + + const { pi, tools, handlers } = makePi(); + subagentsExtension(pi); + vi.useFakeTimers(); + + const agentTool = tools.get("Agent"); + await agentTool.execute( + "tool-call-1", + { + prompt: "reply done", + description: "tiny child", + subagent_type: "general-purpose", + run_in_background: true, + }, + undefined, + undefined, + makeHeadlessCtx(), + ); + + await vi.advanceTimersByTimeAsync(100); // smart-join batch debounce + await vi.advanceTimersByTimeAsync(200); // notification hold window + + expect(pi.sendMessage).toHaveBeenCalled(); + + await handlers.get("session_shutdown")?.({}, makeHeadlessCtx()); + }); +}); diff --git a/pi-subagents/test/prompts.test.ts b/pi-subagents/test/prompts.test.ts new file mode 100644 index 0000000..f882597 --- /dev/null +++ b/pi-subagents/test/prompts.test.ts @@ -0,0 +1,436 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getAgentConfig, registerAgents } from "../src/agent-types.js"; +import { buildAgentPrompt } from "../src/prompts.js"; +import type { AgentConfig, EnvInfo } from "../src/types.js"; + +const env: EnvInfo = { + isGitRepo: true, + branch: "main", + platform: "darwin", +}; + +const envNoGit: EnvInfo = { + isGitRepo: false, + branch: "", + platform: "linux", +}; + +// Initialize default agents +beforeEach(() => { + registerAgents(new Map()); +}); + +function getDefaultConfig(name: string): AgentConfig { + return getAgentConfig(name)!; +} + +describe("buildAgentPrompt", () => { + it("includes cwd and git info", () => { + const config = getDefaultConfig("general-purpose"); + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("/workspace"); + expect(prompt).toContain("Branch: main"); + expect(prompt).toContain("darwin"); + }); + + it("handles non-git repos", () => { + const config = getDefaultConfig("Explore"); + const prompt = buildAgentPrompt(config, "/workspace", envNoGit); + expect(prompt).toContain("Not a git repository"); + expect(prompt).not.toContain("Branch:"); + }); + + it("Explore prompt is read-only", () => { + const config = getDefaultConfig("Explore"); + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("READ-ONLY"); + expect(prompt).toContain("file search specialist"); + }); + + it("Plan prompt is read-only", () => { + const config = getDefaultConfig("Plan"); + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("READ-ONLY"); + expect(prompt).toContain("software architect"); + }); + + it("general-purpose uses append mode (parent twin)", () => { + const config = getDefaultConfig("general-purpose"); + const parentPrompt = "You are a parent coding agent with full powers."; + const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt); + expect(prompt).toContain("parent coding agent with full powers"); + expect(prompt).toContain(""); + expect(prompt).not.toContain(""); + expect(prompt).not.toContain("READ-ONLY"); + // Empty systemPrompt means no section + expect(prompt).not.toContain(""); + }); + + it("general-purpose without parent prompt falls back to generic base", () => { + const config = getDefaultConfig("general-purpose"); + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("general-purpose coding agent"); + expect(prompt).not.toContain("READ-ONLY"); + }); + + it("append mode with parent prompt includes parent + custom instructions", () => { + const config: AgentConfig = { + name: "appender", + description: "Appender", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Extra custom instructions here.", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const parentPrompt = "You are a parent coding agent with special powers."; + const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt); + expect(prompt).toContain("/workspace"); + expect(prompt).toContain("parent coding agent with special powers"); + expect(prompt).toContain(""); + expect(prompt).not.toContain(""); + expect(prompt).toContain(""); + expect(prompt).toContain("Extra custom instructions here."); + }); + + it("append mode without parent prompt falls back to generic base", () => { + const config: AgentConfig = { + name: "appender", + description: "Appender", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Extra custom instructions here.", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("/workspace"); + expect(prompt).toContain("general-purpose coding agent"); + expect(prompt).toContain("Extra custom instructions here."); + }); + + it("append mode with empty systemPrompt is a pure parent clone", () => { + const config: AgentConfig = { + name: "clone", + description: "Clone", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const parentPrompt = "You are a parent coding agent."; + const prompt = buildAgentPrompt(config, "/workspace", env, parentPrompt); + expect(prompt).toContain("parent coding agent"); + expect(prompt).toContain(""); + expect(prompt).not.toContain(""); + expect(prompt).not.toContain(""); + }); + + it("replace mode uses config systemPrompt directly", () => { + const config: AgentConfig = { + name: "custom", + description: "Custom", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "You are a specialized agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain("You are a specialized agent."); + expect(prompt).toContain("/workspace"); + expect(prompt).toContain("You are a pi coding agent sub-agent"); + }); + + it("replace mode ignores parent prompt", () => { + const config: AgentConfig = { + name: "standalone", + description: "Standalone", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "You are a standalone agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env, "SECRET parent prompt content"); + expect(prompt).toContain("You are a standalone agent."); + expect(prompt).not.toContain("SECRET parent prompt content"); + expect(prompt).not.toContain(""); + }); + + it("append mode bridge contains tool reminders", () => { + const config = getDefaultConfig("general-purpose"); + const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt."); + expect(prompt).toContain("Use the read tool instead of cat"); + expect(prompt).toContain("Use the edit tool instead of sed"); + expect(prompt).toContain("Use the grep tool instead of"); + }); + + it("append mode without parent prompt still has bridge", () => { + const config: AgentConfig = { + name: "no-parent", + description: "No parent", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Extra stuff.", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain(""); + expect(prompt).not.toContain(""); + expect(prompt).toContain("Use the read tool instead of cat"); + expect(prompt).toContain("general-purpose coding agent"); + expect(prompt).toContain("Extra stuff."); + }); + + it("injects memory block in replace mode", () => { + const config: AgentConfig = { + name: "mem-agent", + description: "Memory Agent", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "You are a memory agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const extras = { memoryBlock: "# Agent Memory\nYou have persistent memory at /tmp/mem/" }; + const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras); + expect(prompt).toContain("You are a memory agent."); + expect(prompt).toContain("Agent Memory"); + expect(prompt).toContain("persistent memory"); + }); + + it("injects memory block in append mode", () => { + const config: AgentConfig = { + name: "mem-append", + description: "Memory Append", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Custom instructions.", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const extras = { memoryBlock: "# Agent Memory\nPersistent memory here." }; + const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt.", extras); + expect(prompt).toContain(""); + expect(prompt).toContain("Agent Memory"); + expect(prompt).toContain("Custom instructions."); + }); + + it("injects preloaded skill blocks", () => { + const config: AgentConfig = { + name: "skill-agent", + description: "Skill Agent", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "You are a skill agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const extras = { + skillBlocks: [ + { name: "api-conventions", content: "Use REST endpoints." }, + { name: "error-handling", content: "Handle errors gracefully." }, + ], + }; + const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras); + expect(prompt).toContain("Preloaded Skill: api-conventions"); + expect(prompt).toContain("Use REST endpoints."); + expect(prompt).toContain("Preloaded Skill: error-handling"); + expect(prompt).toContain("Handle errors gracefully."); + }); + + it("injects both memory and skills", () => { + const config: AgentConfig = { + name: "full-agent", + description: "Full Agent", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Full agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const extras = { + memoryBlock: "# Memory\nRemember this.", + skillBlocks: [{ name: "skill1", content: "Skill content." }], + }; + const prompt = buildAgentPrompt(config, "/workspace", env, undefined, extras); + expect(prompt).toContain("# Memory"); + expect(prompt).toContain("Preloaded Skill: skill1"); + }); + + it("no extras means no extra sections", () => { + const config: AgentConfig = { + name: "plain", + description: "Plain", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Plain agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).not.toContain("Agent Memory"); + expect(prompt).not.toContain("Preloaded Skill"); + }); + + describe("active_agent tag", () => { + it("tag is present at start of prompt in replace mode", () => { + const config: AgentConfig = { + name: "my-agent", + description: "Test", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "You are a test agent.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toMatch(/^/); + }); + + it("tag follows the cacheable inherited prefix in append mode", () => { + const config: AgentConfig = { + name: "my-agent", + description: "Test", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Custom instructions.", + promptMode: "append", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt."); + // Parent prompt must form the verbatim, cacheable byte prefix. + expect(prompt.startsWith("Parent prompt.")).toBe(true); + // The varying tag follows the static bridge. + const ctxIdx = prompt.indexOf(""); + const tagIdx = prompt.indexOf(''); + expect(ctxIdx).toBeGreaterThan(-1); + expect(tagIdx).toBeGreaterThan(ctxIdx); + }); + + it("tag uses agent name verbatim", () => { + const config: AgentConfig = { + name: "Some Agent With Spaces", + description: "Test", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Test.", + promptMode: "replace", + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env); + expect(prompt).toContain(''); + }); + + it("tag appears before the env block in both modes", () => { + for (const promptMode of ["replace", "append"] as const) { + const config: AgentConfig = { + name: "test-agent", + description: "Test", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Test.", + promptMode, + inheritContext: false, + runInBackground: false, + isolated: false, + }; + const prompt = buildAgentPrompt(config, "/workspace", env, "Parent."); + const tagIndex = prompt.indexOf(''); + const envIndex = prompt.indexOf("# Environment"); + expect(tagIndex).toBeLessThan(envIndex); + } + }); + }); + + // #187: the inherited parent prompt names the main checkout as cwd, so a + // worktree agent needs to be told which of the two paths is really its own. + describe("worktree isolation block", () => { + function worktreeConfig(promptMode: "append" | "replace"): AgentConfig { + return { + name: "test-agent", + description: "Test", + builtinToolNames: [], + extensions: true, + skills: true, + systemPrompt: "Custom instructions.", + promptMode, + inheritContext: false, + runInBackground: false, + isolated: false, + }; + } + + it("is absent without a worktree base", () => { + for (const promptMode of ["replace", "append"] as const) { + const prompt = buildAgentPrompt(worktreeConfig(promptMode), "/wt/copy", env, "Parent.", {}); + expect(prompt).not.toContain(""); + } + }); + + it("names the parent checkout and follows the env block in both modes", () => { + for (const promptMode of ["replace", "append"] as const) { + const prompt = buildAgentPrompt(worktreeConfig(promptMode), "/wt/copy", env, "Parent.", { + worktreeBase: "/repo", + }); + expect(prompt).toContain("isolated git worktree copy of /repo"); + expect(prompt).toContain("never in /repo, even if other instructions name that path"); + expect(prompt.indexOf("")).toBeGreaterThan(prompt.indexOf("Working directory: /wt/copy")); + } + }); + + it("stays out of the cacheable inherited prefix", () => { + const prompt = buildAgentPrompt(worktreeConfig("append"), "/wt/copy", env, "Parent prompt.", { + worktreeBase: "/repo", + }); + expect(prompt.startsWith("Parent prompt.")).toBe(true); + expect(prompt.indexOf("")).toBeGreaterThan(prompt.indexOf("")); + }); + }); +}); diff --git a/pi-subagents/test/rpc-lifecycle-gating.test.ts b/pi-subagents/test/rpc-lifecycle-gating.test.ts new file mode 100644 index 0000000..911b045 --- /dev/null +++ b/pi-subagents/test/rpc-lifecycle-gating.test.ts @@ -0,0 +1,234 @@ +/** + * rpc-lifecycle-gating.test.ts — issue #142. + * + * pi runs every extension factory BEFORE applying an agent's `extensions:` + * filter, and only delivers lifecycle events (session_start, …) to the + * survivors — but the `pi.events` bus is shared with the filtered-out + * activations. The old code registered the RPC handlers and emitted + * `subagents:ready` at factory time, so a child session that excluded + * pi-subagents still saw `subagents:ready` + a working `subagents:rpc:ping`, + * yet every spawn failed with "No active session" (its session_start never + * fired, so currentCtx stayed undefined). + * + * The fix defers BOTH the RPC registration and the readiness broadcast to the + * first bound session_start. These tests drive the real extension factory with + * a mock ExtensionAPI and assert the timing: nothing is wired at factory time; + * everything is wired (once) on session_start. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +const RPC_CHANNELS = ["subagents:rpc:ping", "subagents:rpc:spawn", "subagents:rpc:stop"] as const; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); // pi.on(...) — session_start, session_shutdown, … + const busHandlers = new Map unknown>(); // pi.events.on(...) — rpc channels + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + busHandlers.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle, busHandlers }; +} + +function ctx(hasUI = false, setWidget = vi.fn()) { + return { + hasUI, + ui: { + setStatus: vi.fn(), + setWidget, + notify: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), + getEditorText: vi.fn(() => ""), + custom: vi.fn(), + }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const readyEmits = (pi: any): unknown[] => + pi.events.emit.mock.calls.filter((c: any[]) => c[0] === "subagents:ready"); +const onCallsFor = (pi: any, channel: string): unknown[] => + pi.events.on.mock.calls.filter((c: any[]) => c[0] === channel); + +describe("issue #142: RPC handlers + subagents:ready are gated on session_start", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + // Hermetic cwd + global dir with scheduling off, so session_start doesn't + // spin a scheduler or touch the dev's filesystem — isolates the RPC wiring. + tmpDir = mkdtempSync(join(tmpdir(), "pi-142-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-142-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("does NOT advertise or register RPC at factory time (the filtered-out case)", () => { + const { pi, busHandlers } = makePi(); + + // A filtered-out activation only ever gets the factory run — its + // session_start never fires. So after the factory alone, nothing should + // be on the shared bus. + subagentsExtension(pi); + + expect(readyEmits(pi), "no subagents:ready before session_start").toHaveLength(0); + for (const channel of RPC_CHANNELS) { + expect(busHandlers.has(channel), `${channel} must not be registered at factory time`).toBe(false); + } + }); + + it("advertises and registers RPC on session_start, and spawn works once bound", async () => { + const { pi, lifecycle, busHandlers } = makePi(); + subagentsExtension(pi); + + await lifecycle.get("session_start")({}, ctx()); + + // Readiness broadcast once, all three channels now live. + expect(readyEmits(pi), "subagents:ready fires once bound").toHaveLength(1); + for (const channel of RPC_CHANNELS) { + expect(busHandlers.has(channel), `${channel} registered on session_start`).toBe(true); + } + + // spawn no longer hits the "No active session" trap — currentCtx is set. + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // never resolves + const requestId = "req-142"; + await busHandlers.get("subagents:rpc:spawn")!({ + requestId, + type: "general-purpose", + prompt: "go", + options: { description: "rpc gating test" }, + }); + + const reply = pi.events.emit.mock.calls.find( + (c: any[]) => c[0] === `subagents:rpc:spawn:reply:${requestId}`, + ); + expect(reply, "spawn emitted a reply").toBeTruthy(); + expect(reply![1].success, `spawn succeeded, got: ${JSON.stringify(reply![1])}`).toBe(true); + expect(reply![1].data.id).toBeTruthy(); + }); + + it("renders an RPC-spawned agent in the native widget while it is running", async () => { + const { pi, lifecycle, busHandlers } = makePi(); + const activeCtx = ctx(true); + subagentsExtension(pi); + + await lifecycle.get("session_start")({}, activeCtx); + + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}) as any); // keep agent running + try { + await busHandlers.get("subagents:rpc:spawn")!({ + requestId: "req-widget", + type: "general-purpose", + prompt: "go", + options: { description: "visible RPC agent" }, + }); + + await vi.waitFor(() => { + expect(activeCtx.ui.setWidget).toHaveBeenCalledWith( + "agents", + expect.any(Function), + { placement: "aboveEditor" }, + ); + expect(activeCtx.ui.setStatus).toHaveBeenCalledWith("subagents", "1 running agent"); + }); + } finally { + await lifecycle.get("session_shutdown")(); + } + }); + + it("shows live tool activity for an RPC-spawned background agent", async () => { + const { pi, lifecycle, busHandlers } = makePi(); + let widgetFactory: any; + const setWidget = vi.fn((key: string, content: any) => { + if (key === "agents" && content) widgetFactory = content; + }); + const extensionCtx = ctx(true, setWidget); + let onToolActivity: ((activity: { type: "start" | "end"; toolName: string }) => void) | undefined; + vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, options: any) => { + onToolActivity = options.onToolActivity; + options.onSessionCreated?.({ subscribe: () => vi.fn() }); + return new Promise(() => {}) as any; + }); + subagentsExtension(pi); + + await lifecycle.get("session_start")({}, extensionCtx); + // TaskExecute runs inside a root tool call, so the extension already has + // the UI context before pi-tasks sends its cross-extension spawn request. + await lifecycle.get("tool_execution_start")({}, extensionCtx); + await busHandlers.get("subagents:rpc:spawn")!({ + requestId: "req-activity", + type: "general-purpose", + prompt: "go", + options: { description: "rpc activity test", isBackground: true }, + }); + await vi.waitFor(() => expect(onToolActivity).toBeTypeOf("function")); + onToolActivity!({ type: "start", toolName: "bash" }); + + expect(widgetFactory).toBeTypeOf("function"); + const lines = widgetFactory( + { terminal: { columns: 120 }, requestRender: vi.fn() }, + { fg: (_color: string, text: string) => text, bold: (text: string) => text }, + ).render().join("\n"); + expect(lines).toContain("running command…"); + expect(lines).not.toContain("thinking…"); + }); + + it("is idempotent — a second session_start does not re-advertise or double-register", async () => { + const { pi, lifecycle } = makePi(); + subagentsExtension(pi); + + await lifecycle.get("session_start")({}, ctx()); + await lifecycle.get("session_start")({}, ctx()); + + expect(readyEmits(pi), "subagents:ready emitted exactly once across two session_starts").toHaveLength(1); + for (const channel of RPC_CHANNELS) { + expect(onCallsFor(pi, channel), `${channel} registered exactly once`).toHaveLength(1); + } + }); +}); diff --git a/pi-subagents/test/schedule-e2e.test.ts b/pi-subagents/test/schedule-e2e.test.ts new file mode 100644 index 0000000..b0a4940 --- /dev/null +++ b/pi-subagents/test/schedule-e2e.test.ts @@ -0,0 +1,237 @@ +/** + * schedule-e2e.test.ts — End-to-end integration test for the scheduler. + * + * Unlike `schedule.test.ts` (which uses vi.useFakeTimers), this exercises + * the full real-timer firing path: real `setTimeout` triggers `executeJob`, + * a faithful `AgentManager` mock (promise resolves with the right semantics) + * runs through to `finalize`, and the on-disk `ScheduleStore` reflects the + * outcome. Catches integration bugs that fake-timer microtask scheduling + * can hide. + * + * Uses very short timings (100–300ms) so the test stays fast. + */ + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubagentScheduler } from "../src/schedule.js"; +import { ScheduleStore } from "../src/schedule-store.js"; + +type FakeRecord = { status: string; promise: Promise; resolve: () => void }; + +/** + * Faithful AgentManager mock: spawn returns an id, getRecord returns a + * record whose promise can be resolved at test time, status is mutable so + * the test can assert success vs error inference. + */ +function makeFaithfulManager(initialStatus = "completed") { + const records = new Map(); + return { + records, + initialStatus, + spawn: vi.fn(function (this: any) { + const id = "agent-" + Math.random().toString(36).slice(2, 10); + let resolve!: () => void; + const promise = new Promise(r => { resolve = () => r(""); }); + records.set(id, { status: initialStatus, promise, resolve }); + // Auto-resolve on next tick — mimics a fast-finishing real agent. + queueMicrotask(() => records.get(id)?.resolve()); + return id; + }), + getRecord: vi.fn(function (this: any, id: string) { + return records.get(id); + }), + } as any; +} + +function makePi() { + return { events: { emit: vi.fn() } } as any; +} + +function makeCtx() { + return { + cwd: "/tmp", + modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] }, + sessionManager: { getSessionId: () => "sess-e2e" }, + } as any; +} + +/** Wait for a predicate, polling at 5ms intervals, with a deadline. */ +async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + await new Promise(r => setTimeout(r, 5)); + } +} + +describe("SubagentScheduler — end-to-end with real timers", () => { + let tmp: string; + let store: ScheduleStore; + let scheduler: SubagentScheduler; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "schedule-e2e-")); + store = new ScheduleStore(join(tmp, "schedules.json")); + scheduler = new SubagentScheduler(); + }); + + afterEach(() => { + scheduler.stop(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it("one-shot job: real setTimeout fires, agent runs, store reflects success", async () => { + const manager = makeFaithfulManager("completed"); + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + // Fire ~100ms in the future. detectSchedule normalizes "+100ms" — but our + // parser only accepts s/m/h/d, so use a near-future ISO timestamp instead. + const future = new Date(Date.now() + 100).toISOString(); + const job = scheduler.addJob({ + name: "e2e-once", + description: "test", + schedule: future, + subagent_type: "general-purpose", + prompt: "hello", + }); + expect(job.scheduleType).toBe("once"); + + // Wait for the spawn to occur (real timer fires) and the finalize promise + // chain to settle. Polling, no fake timers. + await waitFor(() => manager.spawn.mock.calls.length === 1); + await waitFor(() => scheduler.list().find(j => j.id === job.id)?.lastStatus === "success"); + + const final = scheduler.list().find(j => j.id === job.id)!; + expect(final.lastStatus).toBe("success"); + expect(final.runCount).toBe(1); + expect(final.enabled).toBe(false); // one-shot auto-disabled + expect(final.lastRun).toBeDefined(); + }); + + it("one-shot job that errors: store records lastStatus error (regression — bug #1)", async () => { + const manager = makeFaithfulManager("error"); // Agent terminates with error status + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + const future = new Date(Date.now() + 100).toISOString(); + const job = scheduler.addJob({ + name: "e2e-fail", + description: "test", + schedule: future, + subagent_type: "general-purpose", + prompt: "fail", + }); + + await waitFor(() => manager.spawn.mock.calls.length === 1); + await waitFor(() => scheduler.list().find(j => j.id === job.id)?.lastStatus !== "running"); + + expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error"); + expect(scheduler.list().find(j => j.id === job.id)?.runCount).toBe(1); + }); + + it("interval job: fires repeatedly, runCount grows", async () => { + const manager = makeFaithfulManager("completed"); + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + // 100ms interval — wait for ~3 fires + const job = scheduler.addJob({ + name: "e2e-interval", + description: "test", + schedule: "100s", // Will be too long; override below. + subagent_type: "general-purpose", + prompt: "tick", + }); + // Replace with a literal 100ms interval — easier than crafting a parseable shorthand for ms. + // (parseInterval doesn't accept "ms"; we patch the persisted job and re-arm.) + scheduler.updateJob(job.id, { intervalMs: 100, schedule: "100ms" }); + + await waitFor(() => manager.spawn.mock.calls.length >= 3, 2000); + + const final = scheduler.list().find(j => j.id === job.id)!; + expect(final.runCount).toBeGreaterThanOrEqual(3); + expect(final.lastStatus).toBe("success"); + expect(final.enabled).toBe(true); // intervals don't auto-disable + + scheduler.removeJob(job.id); + }); + + it("persistence: schedules survive re-instantiating the store on the same file", async () => { + const manager = makeFaithfulManager("completed"); + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + const future = new Date(Date.now() + 60_000).toISOString(); // far enough not to fire + const job = scheduler.addJob({ + name: "persistent", + description: "x", + schedule: future, + subagent_type: "general-purpose", + prompt: "x", + }); + + // Tear down the live scheduler; re-load from disk + scheduler.stop(); + const reloadedStore = new ScheduleStore(join(tmp, "schedules.json")); + expect(reloadedStore.list()).toHaveLength(1); + expect(reloadedStore.list()[0].id).toBe(job.id); + expect(reloadedStore.list()[0].name).toBe("persistent"); + }); + + it("on-disk file shape: version=1 plus jobs array", async () => { + const manager = makeFaithfulManager("completed"); + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + scheduler.addJob({ + name: "shape-test", + description: "x", + schedule: "1h", + subagent_type: "general-purpose", + prompt: "x", + }); + + const onDisk = JSON.parse(readFileSync(join(tmp, "schedules.json"), "utf-8")); + expect(onDisk.version).toBe(1); + expect(onDisk.jobs).toHaveLength(1); + expect(onDisk.jobs[0]).toMatchObject({ + name: "shape-test", + schedule: "1h", + scheduleType: "interval", + enabled: true, + runCount: 0, + }); + }); + + it("subagents:scheduled events fire across the lifecycle", async () => { + const manager = makeFaithfulManager("completed"); + const pi = makePi(); + scheduler.start(pi, makeCtx(), manager, store); + + const future = new Date(Date.now() + 100).toISOString(); + const job = scheduler.addJob({ + name: "events", description: "x", schedule: future, + subagent_type: "general-purpose", prompt: "x", + }); + + await waitFor(() => manager.spawn.mock.calls.length === 1); + + const eventTypes = pi.events.emit.mock.calls + .filter((c: any[]) => c[0] === "subagents:scheduled") + .map((c: any[]) => c[1].type); + + expect(eventTypes).toContain("added"); + expect(eventTypes).toContain("fired"); + + scheduler.removeJob(job.id); + const after = pi.events.emit.mock.calls + .filter((c: any[]) => c[0] === "subagents:scheduled") + .map((c: any[]) => c[1].type); + expect(after).toContain("removed"); + }); +}); diff --git a/pi-subagents/test/schedule-menu.test.ts b/pi-subagents/test/schedule-menu.test.ts new file mode 100644 index 0000000..f8004ba --- /dev/null +++ b/pi-subagents/test/schedule-menu.test.ts @@ -0,0 +1,187 @@ +// src/ui/schedule-menu.ts had 0% coverage — every one of its 50 statements. +// That is not a cosmetic gap: the menu's only action is DESTRUCTIVE (cancel a +// scheduled job), and it identifies the chosen job by matching the selected +// label string back against the label list. +// +// Labels are built by `formatJob`, which truncates the name to 18 characters +// (`j.name.padEnd(18).slice(0, 18)`). Job names come from the `Agent` call's +// `description`, which is LLM-authored and routinely shares a prefix. Two jobs +// whose names agree in the first 18 chars, with the same schedule, type, run +// count and run times, produce byte-identical labels — and `labels.indexOf` +// then resolves to the FIRST of them regardless of which the user picked. +// +// CHANGELOG 0.10.0 states the menu "lets you cancel any one of them", so this +// contradicts documented intent rather than merely being unspecified. + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubagentScheduler } from "../src/schedule.js"; +import { ScheduleStore } from "../src/schedule-store.js"; +import { showSchedulesMenu } from "../src/ui/schedule-menu.js"; + +/** ctx.ui stub: `select` returns whichever label index we tell it to. */ +function makeCtx(opts: { pick?: (labels: string[]) => string | undefined; confirm?: boolean } = {}) { + const notify = vi.fn(); + const select = vi.fn(async (_title: string, labels: string[]) => opts.pick?.(labels)); + const confirm = vi.fn(async () => opts.confirm ?? true); + return { ctx: { ui: { select, confirm, notify } } as any, select, confirm, notify }; +} + +describe("showSchedulesMenu", () => { + let dir: string; + let scheduler: SubagentScheduler; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "pi-schedmenu-")); + scheduler = new SubagentScheduler(); + scheduler.start( + { events: { emit: vi.fn() } } as any, + { cwd: dir } as any, + { spawn: vi.fn(), getRecord: vi.fn() } as any, + new ScheduleStore(join(dir, "jobs.json")), + ); + }); + + afterEach(() => { + scheduler.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + const addJob = (name: string) => + scheduler.addJob({ + name, + description: name, + schedule: "0 0 9 * * 1", + subagent_type: "general-purpose", + prompt: "go", + }); + + it("warns and does nothing when the scheduler is not active", async () => { + const idle = new SubagentScheduler(); + const { ctx, select, notify } = makeCtx(); + await showSchedulesMenu(ctx, idle); + expect(select).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith(expect.stringContaining("not active"), "warning"); + }); + + it("reports an empty list without opening a picker", async () => { + const { ctx, select, notify } = makeCtx(); + await showSchedulesMenu(ctx, scheduler); + expect(select).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith("No scheduled jobs.", "info"); + }); + + it("cancels the job the user selected", async () => { + const a = addJob("alpha job"); + const b = addJob("beta job"); + const { ctx } = makeCtx({ pick: (labels) => labels[1] }); + + await showSchedulesMenu(ctx, scheduler); + + expect(scheduler.list().map(j => j.id)).toEqual([a.id]); + expect(scheduler.list().map(j => j.id)).not.toContain(b.id); + }); + + // Names are truncated to 18 chars for column alignment, and they come from the + // LLM-authored `Agent` description, so two rows formatting identically is + // ordinary. Resolving the pick by string match then cancelled whichever came + // first — silently, with the confirm dialog showing the wrong job's details. + describe("rows that format identically after truncation", () => { + const NAME_A = "review the auth module A"; + const NAME_B = "review the auth module B"; + + it("gives every row a distinct label", () => { + // The invariant the fix rests on. Everything below depends on it. + addJob(NAME_A); + addJob(NAME_B); + const { ctx, select } = makeCtx({ pick: () => undefined }); + return showSchedulesMenu(ctx, scheduler).then(() => { + const labels = select.mock.calls[0][1] as string[]; + expect(new Set(labels).size).toBe(labels.length); + }); + }); + + it("cancels the second job when the second row is picked", async () => { + const first = addJob(NAME_A); + const second = addJob(NAME_B); + const { ctx } = makeCtx({ pick: (labels) => labels[1] }); + + await showSchedulesMenu(ctx, scheduler); + + expect(scheduler.list().map(j => j.id)).toEqual([first.id]); + expect(scheduler.list().map(j => j.id)).not.toContain(second.id); + }); + + it("cancels the first job when the first row is picked", async () => { + // The other direction — an off-by-one or a last-match resolver would pass + // the test above and fail this one. + const first = addJob(NAME_A); + const second = addJob(NAME_B); + const { ctx } = makeCtx({ pick: (labels) => labels[0] }); + + await showSchedulesMenu(ctx, scheduler); + + expect(scheduler.list().map(j => j.id)).toEqual([second.id]); + expect(scheduler.list().map(j => j.id)).not.toContain(first.id); + }); + + it("shows the SELECTED job's untruncated details in the confirm dialog", async () => { + // Guards the half of the bug the user could never catch: deleting the + // right job while confirming against the wrong one, or vice versa. + addJob(NAME_A); + addJob(NAME_B); + const { ctx, confirm } = makeCtx({ pick: (labels) => labels[1] }); + + await showSchedulesMenu(ctx, scheduler); + + expect(confirm.mock.calls[0][0]).toContain(NAME_B); + expect(confirm.mock.calls[0][0]).not.toContain(NAME_A); + }); + + it("cancels the right job past the single-digit boundary", async () => { + // 11 rows: insurance against any refactor that parses the number back out + // of the label and confuses "1" with "11". + const jobs = Array.from({ length: 11 }, (_, i) => addJob(`review the auth module ${i}`)); + const { ctx } = makeCtx({ pick: (labels) => labels[10] }); + + await showSchedulesMenu(ctx, scheduler); + + const surviving = scheduler.list().map(j => j.id); + expect(surviving).toHaveLength(10); + expect(surviving).not.toContain(jobs[10].id); + }); + }); + + it("cancels nothing when the picker returns a label we never offered", async () => { + addJob("alpha job"); + const { ctx, confirm } = makeCtx({ pick: () => "something else entirely" }); + + await showSchedulesMenu(ctx, scheduler); + + expect(confirm).not.toHaveBeenCalled(); + expect(scheduler.list()).toHaveLength(1); + }); + + it("leaves every job intact when the user escapes the picker", async () => { + addJob("alpha job"); + addJob("beta job"); + const { ctx, confirm } = makeCtx({ pick: () => undefined }); + + await showSchedulesMenu(ctx, scheduler); + + expect(confirm).not.toHaveBeenCalled(); + expect(scheduler.list()).toHaveLength(2); + }); + + it("leaves the job intact when the user declines the confirm", async () => { + addJob("alpha job"); + const { ctx, notify } = makeCtx({ pick: (labels) => labels[0], confirm: false }); + + await showSchedulesMenu(ctx, scheduler); + + expect(scheduler.list()).toHaveLength(1); + expect(notify).not.toHaveBeenCalledWith(expect.stringContaining("Cancelled"), "info"); + }); +}); diff --git a/pi-subagents/test/schedule-params-wiring.test.ts b/pi-subagents/test/schedule-params-wiring.test.ts new file mode 100644 index 0000000..2c9e2cf --- /dev/null +++ b/pi-subagents/test/schedule-params-wiring.test.ts @@ -0,0 +1,232 @@ +/** + * schedule-params-wiring.test.ts — what the Agent tool actually persists when + * `schedule` is set. + * + * A scheduled job's config is written to disk now and consumed at fire time, + * possibly days later, in a session nobody is watching. So a field that fails to + * reach the store — or reaches it with the wrong value — surfaces as an agent + * that misbehaves long after the call that configured it, with no error anywhere. + * That makes this the one part of the schedule branch worth pinning; the six + * refusal messages next to it are straight-line guards that fail loudly at the + * call site and are deliberately not tested. + * + * The job is read back through the real ScheduleStore rather than by spying on + * `addJob`, so the assertion covers persistence too. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { getDefaultMaxTurns, normalizeMaxTurns } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { resolveStorePath, ScheduleStore } from "../src/schedule-store.js"; +import type { ScheduledSubagent } from "../src/types.js"; +import { ctx, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js"; + +const SESSION_ID = "sched-wiring-session"; + +function bootedCtx() { + return ctx({ + sessionManager: { getSessionId: vi.fn(() => SESSION_ID), getBranch: vi.fn(() => []) }, + }); +} + +/** + * Boot the extension in a temp cwd, bind session_start (the scheduler is + * inactive until then), schedule one agent, and read the job back off disk. + */ +async function scheduleAndReadBack( + params: Record, +): Promise<{ job: ScheduledSubagent; reply: string; restore: () => void }> { + const hermetic = hermeticDir(); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const c = bootedCtx(); + await lifecycle.get("session_start")?.({}, c); + + const reply = textOf( + await tools.get("Agent").execute( + "tc-sched", + { prompt: "do the thing", description: "nightly sweep", schedule: "0 3 * * * *", ...params }, + undefined, + undefined, + c, + ), + ); + + const store = new ScheduleStore(resolveStorePath(c.cwd, SESSION_ID)); + const jobs = store.list(); + await lifecycle.get("session_shutdown")?.(); + expect(jobs, `expected one persisted job, reply was: ${reply}`).toHaveLength(1); + return { job: jobs[0], reply, restore: hermetic.restore }; +} + +describe("Agent tool → persisted scheduled job", () => { + it("persists the run-shaping params the job will fire with", async () => { + const { job, restore } = await scheduleAndReadBack({ + subagent_type: "general-purpose", + thinking: "high", + isolated: true, + isolation: "worktree", + }); + try { + expect(job.thinking).toBe("high"); + expect(job.isolated).toBe(true); + expect(job.isolation).toBe("worktree"); + expect(job.prompt).toBe("do the thing"); + expect(job.enabled).toBe(true); + } finally { + restore(); + } + }); + + it("persists the normalized turn limit, not the raw parameter", async () => { + // max_turns goes through normalizeMaxTurns and the agent-config/default + // fallback chain before it is stored. Persisting the raw param instead + // would give the scheduled run a different limit from an identical + // immediate run — and only at fire time. + const { job, restore } = await scheduleAndReadBack({ + subagent_type: "general-purpose", + max_turns: 0, // 0 means "unlimited", not "zero turns" + }); + try { + expect(job.max_turns).toBe(normalizeMaxTurns(0)); + } finally { + restore(); + } + }); + + it("falls back to the configured default turn limit when the call omits one", async () => { + const { job, restore } = await scheduleAndReadBack({ subagent_type: "general-purpose" }); + try { + expect(job.max_turns).toBe(normalizeMaxTurns(getDefaultMaxTurns())); + } finally { + restore(); + } + }); + + it("stores the caller's own subagent_type, not the fallback substitute", async () => { + // The scheduler re-resolves the type at fire time, and the stored name is + // what a user sees and edits in /agents. Baking in today's substitute would + // permanently rewrite their job to an agent they never asked for. + const { job, reply, restore } = await scheduleAndReadBack({ subagent_type: "does-not-exist" }); + try { + expect(job.subagent_type).toBe("does-not-exist"); + expect(reply).toContain("Scheduled"); + } finally { + restore(); + } + }); +}); + +// README documents three combinations that scheduling refuses. Each is a +// published contract an orchestrator reads before calling, and none was pinned +// (`grep "Cannot combine" test/` returned nothing) — so the wording could drift, +// or a guard could be dropped, without any test noticing. The exact strings are +// asserted because the ORDER of these guards decides which message a caller +// gets when two apply at once; a substring match would let a reordering pass. +describe("Agent tool → schedule restrictions", () => { + /** Boot + bind, then make one scheduling call and return its reply text. */ + async function scheduleCall( + params: Record, + settings?: Record, + ): Promise<{ reply: string; jobCount: number; restore: () => void }> { + const hermetic = hermeticDir(settings ? { settings } : {}); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const c = bootedCtx(); + await lifecycle.get("session_start")?.({}, c); + + const reply = textOf( + await tools.get("Agent").execute( + "tc-sched", + { + prompt: "do the thing", + description: "nightly sweep", + schedule: "0 3 * * * *", + subagent_type: "general-purpose", + ...params, + }, + undefined, + undefined, + c, + ), + ); + + let jobCount = 0; + try { + jobCount = new ScheduleStore(resolveStorePath(c.cwd, SESSION_ID)).list().length; + } catch { /* store never created — nothing was scheduled */ } + await lifecycle.get("session_shutdown")?.(); + return { reply, jobCount, restore: hermetic.restore }; + } + + it("refuses `schedule` with `resume` — schedules create fresh agents", async () => { + const { reply, jobCount, restore } = await scheduleCall({ resume: "agent-123" }); + try { + expect(reply).toBe("Cannot combine `schedule` with `resume` — schedules create fresh agents."); + expect(jobCount).toBe(0); // refused, not scheduled-and-warned + } finally { + restore(); + } + }); + + it("refuses `schedule` with `inherit_context` — no parent conversation at fire time", async () => { + const { reply, jobCount, restore } = await scheduleCall({ inherit_context: true }); + try { + expect(reply).toBe( + "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.", + ); + expect(jobCount).toBe(0); + } finally { + restore(); + } + }); + + it("refuses `schedule` with `run_in_background: false` rather than silently coercing it", async () => { + // README:91 long claimed this parameter was "forced to true". It is not — + // the call is refused. Silently flipping a parameter the caller explicitly + // set is the failure mode #37 was filed about; refusing is the intended + // behavior and this pins it. + const { reply, jobCount, restore } = await scheduleCall({ run_in_background: false }); + try { + expect(reply).toBe( + "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.", + ); + expect(jobCount).toBe(0); + } finally { + restore(); + } + }); + + it("accepts `run_in_background: true` and an omitted `run_in_background`", async () => { + // The mirror: only an explicit `false` is refused, so a caller that sets the + // flag by habit is not blocked. + for (const params of [{ run_in_background: true }, {}]) { + const { reply, jobCount, restore } = await scheduleCall(params); + try { + expect(reply).toContain("Scheduled"); + expect(jobCount).toBe(1); + } finally { + restore(); + } + } + }); + + it("refuses scheduling entirely when the project disabled it", async () => { + const { reply, jobCount, restore } = await scheduleCall({}, { schedulingEnabled: false }); + try { + expect(reply).toBe( + "Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.", + ); + expect(jobCount).toBe(0); + } finally { + restore(); + } + }); +}); diff --git a/pi-subagents/test/schedule-store.test.ts b/pi-subagents/test/schedule-store.test.ts new file mode 100644 index 0000000..2b7ac7c --- /dev/null +++ b/pi-subagents/test/schedule-store.test.ts @@ -0,0 +1,179 @@ +/** + * schedule-store.test.ts — Persistence + concurrency for ScheduleStore. + * + * Mirrors the patterns from pi-chonky-tasks's task-store testing: round-trip + * load/save, parse-error self-heal, stale-lock recovery. + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveStorePath, ScheduleStore } from "../src/schedule-store.js"; +import type { ScheduledSubagent } from "../src/types.js"; + +function makeJob(overrides: Partial = {}): ScheduledSubagent { + return { + id: "job-" + Math.random().toString(36).slice(2, 10), + name: "test-job", + description: "test", + schedule: "5m", + scheduleType: "interval", + intervalMs: 5 * 60_000, + subagent_type: "general-purpose", + prompt: "hello", + enabled: true, + createdAt: new Date().toISOString(), + runCount: 0, + ...overrides, + }; +} + +describe("ScheduleStore", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "schedule-store-test-")); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("resolveStorePath produces session-scoped path under .pi/subagent-schedules/", () => { + const p = resolveStorePath("/repo", "abc123"); + expect(p).toBe("/repo/.pi/subagent-schedules/abc123.json"); + }); + + it("starts empty and round-trips a job through add/list", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + expect(store.list()).toEqual([]); + const job = makeJob(); + store.add(job); + expect(store.list()).toEqual([job]); + + // New instance on same file — verifies persistence + const fresh = new ScheduleStore(join(tmp, "s.json")); + expect(fresh.list()).toEqual([job]); + }); + + it("update returns merged record and persists the patch", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + const job = makeJob({ name: "before" }); + store.add(job); + + const updated = store.update(job.id, { name: "after", runCount: 3 }); + expect(updated).toMatchObject({ id: job.id, name: "after", runCount: 3 }); + + const fresh = new ScheduleStore(join(tmp, "s.json")); + expect(fresh.list()[0]).toMatchObject({ name: "after", runCount: 3 }); + }); + + it("update returns undefined for unknown id and does not create a record", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + const r = store.update("nonexistent", { name: "x" }); + expect(r).toBeUndefined(); + expect(store.list()).toEqual([]); + }); + + it("remove returns true on existing job and false on missing", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + const job = makeJob(); + store.add(job); + expect(store.remove(job.id)).toBe(true); + expect(store.list()).toEqual([]); + expect(store.remove(job.id)).toBe(false); + }); + + it("hasName excludes a given id (for rename safety)", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + const job = makeJob({ name: "alpha" }); + store.add(job); + expect(store.hasName("alpha")).toBe(true); + expect(store.hasName("alpha", job.id)).toBe(false); // excluded — own record + expect(store.hasName("beta")).toBe(false); + }); + + it("uses atomic temp+rename — write produces final file, no .tmp leftover", () => { + const file = join(tmp, "s.json"); + const store = new ScheduleStore(file); + store.add(makeJob()); + expect(existsSync(file)).toBe(true); + expect(existsSync(file + ".tmp")).toBe(false); + }); + + it("self-heals from a corrupt JSON file — load silently empties, next save rewrites", () => { + const file = join(tmp, "s.json"); + writeFileSync(file, "{ this is not valid JSON"); + const store = new ScheduleStore(file); + expect(store.list()).toEqual([]); + + // Next mutation overwrites the broken file with healthy JSON + store.add(makeJob({ id: "fresh" })); + const data = JSON.parse(readFileSync(file, "utf-8")); + expect(data.version).toBe(1); + expect(data.jobs).toHaveLength(1); + expect(data.jobs[0].id).toBe("fresh"); + }); + + it("recovers from a stale lock left by a dead process", () => { + const file = join(tmp, "s.json"); + const lockFile = file + ".lock"; + // Simulate a stale lock file containing a non-existent PID. + // PID 999_999_999 is virtually never a live process — kill -0 returns ESRCH. + writeFileSync(lockFile, "999999999"); + + const store = new ScheduleStore(file); + // The mutation will detect the stale lock, unlink it, and proceed. + expect(() => store.add(makeJob())).not.toThrow(); + expect(store.list()).toHaveLength(1); + expect(existsSync(lockFile)).toBe(false); + }); + + it("releases the lock after a successful mutation so subsequent ones don't deadlock", () => { + const store = new ScheduleStore(join(tmp, "s.json")); + const a = makeJob({ id: "a" }); + const b = makeJob({ id: "b" }); + store.add(a); + store.add(b); // would hang if the lock from the first add wasn't released + expect(store.list().map(j => j.id).sort()).toEqual(["a", "b"]); + }); + + it("does not create the backing directory until a mutation persists", () => { + const dir = join(tmp, ".pi", "subagent-schedules"); + const file = join(dir, "sess.json"); + + // Constructing + read-only use must not touch the filesystem. + const store = new ScheduleStore(file); + expect(store.list()).toEqual([]); + expect(existsSync(dir)).toBe(false); + + // First mutation lazily creates the directory. + store.add(makeJob()); + expect(existsSync(dir)).toBe(true); + expect(existsSync(file)).toBe(true); + }); + + it("no-op update/remove of an unknown id never creates the backing directory", () => { + const dir = join(tmp, ".pi", "subagent-schedules"); + const file = join(dir, "sess.json"); + const store = new ScheduleStore(file); + + expect(store.update("nonexistent", { name: "x" })).toBeUndefined(); + expect(store.remove("nonexistent")).toBe(false); + expect(existsSync(dir)).toBe(false); + }); + + it("deleteFileIfEmpty unlinks file only when no jobs remain", () => { + const file = join(tmp, "s.json"); + const store = new ScheduleStore(file); + const job = makeJob(); + store.add(job); + store.deleteFileIfEmpty(); // not empty — should be a no-op + expect(existsSync(file)).toBe(true); + + store.remove(job.id); + store.deleteFileIfEmpty(); + expect(existsSync(file)).toBe(false); + }); +}); diff --git a/pi-subagents/test/schedule.test.ts b/pi-subagents/test/schedule.test.ts new file mode 100644 index 0000000..56b9f22 --- /dev/null +++ b/pi-subagents/test/schedule.test.ts @@ -0,0 +1,460 @@ +/** + * schedule.test.ts — SubagentScheduler engine. + * + * Tests: + * - Static format parsers (cron / relative / interval / detection) + * - Job lifecycle (add / update / remove / cleanup) + * - Fire path (interval, one-shot) with mocked AgentManager + fake timers + * - Past-timestamp rejection + * - One-shot auto-disable + * - Concurrency-bypass option flows through to manager.spawn + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js"; +import { SubagentScheduler } from "../src/schedule.js"; +import { ScheduleStore } from "../src/schedule-store.js"; + +function makeMockManager() { + const spawnFn = vi.fn(() => "agent-" + Math.random().toString(36).slice(2, 10)); + return { + spawn: spawnFn, + getRecord: vi.fn(() => ({ promise: Promise.resolve("done") })), + } as any; +} + +function makeMockPi() { + return { + events: { emit: vi.fn() }, + } as any; +} + +function makeMockCtx() { + return { + cwd: "/tmp", + modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] }, + sessionManager: { getSessionId: () => "sess-1" }, + } as any; +} + +describe("SubagentScheduler — static format parsers", () => { + it("parseRelativeTime accepts +Ns/Nm/Nh/Nd and rejects bare numbers", () => { + const before = Date.now(); + const iso = SubagentScheduler.parseRelativeTime("+10s"); + expect(iso).not.toBeNull(); + const t = new Date(iso!).getTime(); + expect(t - before).toBeGreaterThanOrEqual(9_000); + expect(t - before).toBeLessThanOrEqual(11_000); + + expect(SubagentScheduler.parseRelativeTime("+5m")).not.toBeNull(); + expect(SubagentScheduler.parseRelativeTime("+1h")).not.toBeNull(); + expect(SubagentScheduler.parseRelativeTime("+2d")).not.toBeNull(); + + // Bare digits / wrong unit / no plus → null + expect(SubagentScheduler.parseRelativeTime("10s")).toBeNull(); + expect(SubagentScheduler.parseRelativeTime("+5x")).toBeNull(); + expect(SubagentScheduler.parseRelativeTime("hello")).toBeNull(); + }); + + it("parseInterval converts unit-suffixed strings to milliseconds", () => { + expect(SubagentScheduler.parseInterval("10s")).toBe(10_000); + expect(SubagentScheduler.parseInterval("5m")).toBe(300_000); + expect(SubagentScheduler.parseInterval("1h")).toBe(3_600_000); + expect(SubagentScheduler.parseInterval("2d")).toBe(172_800_000); + + expect(SubagentScheduler.parseInterval("+5m")).toBeNull(); // relative isn't an interval + expect(SubagentScheduler.parseInterval("5x")).toBeNull(); + expect(SubagentScheduler.parseInterval("five-minutes")).toBeNull(); + }); + + it("validateCronExpression rejects non-6-field expressions", () => { + expect(SubagentScheduler.validateCronExpression("* * * * *").valid).toBe(false); // 5 fields + expect(SubagentScheduler.validateCronExpression("0 0 9 * * 1").valid).toBe(true); + expect(SubagentScheduler.validateCronExpression("0 0 9 * * *").valid).toBe(true); + expect(SubagentScheduler.validateCronExpression("not-a-cron").valid).toBe(false); + }); + + it("detectSchedule tags type and normalizes input", () => { + expect(SubagentScheduler.detectSchedule("+10m").type).toBe("once"); + expect(SubagentScheduler.detectSchedule("5m").type).toBe("interval"); + expect(SubagentScheduler.detectSchedule("5m").intervalMs).toBe(300_000); + expect(SubagentScheduler.detectSchedule("0 0 9 * * 1").type).toBe("cron"); + + const iso = "2099-01-01T00:00:00.000Z"; + const r = SubagentScheduler.detectSchedule(iso); + expect(r.type).toBe("once"); + expect(r.normalized).toBe(iso); + + expect(() => SubagentScheduler.detectSchedule("garbage")).toThrow(/Invalid schedule/); + }); +}); + +describe("SubagentScheduler — lifecycle", () => { + let tmp: string; + let store: ScheduleStore; + let scheduler: SubagentScheduler; + let manager: any; + let pi: any; + let ctx: any; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "scheduler-test-")); + store = new ScheduleStore(join(tmp, "s.json")); + scheduler = new SubagentScheduler(); + manager = makeMockManager(); + pi = makeMockPi(); + ctx = makeMockCtx(); + scheduler.start(pi, ctx, manager, store); + }); + + afterEach(() => { + scheduler.stop(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it("isActive() reports start/stop state", () => { + expect(scheduler.isActive()).toBe(true); + scheduler.stop(); + expect(scheduler.isActive()).toBe(false); + }); + + it("addJob persists, arms, and emits added event", () => { + const job = scheduler.addJob({ + name: "j1", + description: "test", + schedule: "1h", + subagent_type: "general-purpose", + prompt: "hi", + }); + expect(job.scheduleType).toBe("interval"); + expect(scheduler.list()).toHaveLength(1); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "added" })); + }); + + it("addJob rejects duplicate names", () => { + scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); + expect(() => scheduler.addJob({ + name: "j1", description: "y", schedule: "2h", subagent_type: "general-purpose", prompt: "p2", + })).toThrow(/already exists/); + }); + + it("removeJob clears the job and emits removed", () => { + const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); + expect(scheduler.removeJob(job.id)).toBe(true); + expect(scheduler.list()).toEqual([]); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "removed", jobId: job.id })); + }); + + it("updateJob({enabled: false}) unschedules but keeps the record", () => { + const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); + scheduler.updateJob(job.id, { enabled: false }); + expect(scheduler.list()[0].enabled).toBe(false); + expect(scheduler.getNextRun(job.id)).toBeUndefined(); + }); + + // Regression: getNextRun on a freshly-created interval used to return undefined + // (the lastRun-based branch needs lastRun, which is undefined before first fire), + // surfacing as "Next run: (unknown)" in the agent's create-response. + it("getNextRun returns an approximate future time for a fresh interval (no lastRun yet)", () => { + const before = Date.now(); + const job = scheduler.addJob({ + name: "fresh-interval", description: "x", schedule: "1h", + subagent_type: "general-purpose", prompt: "p", + }); + const next = scheduler.getNextRun(job.id); + expect(next).toBeDefined(); + const t = new Date(next!).getTime(); + // Should be ~now + 1h, with a small tolerance for the time spent in the call + expect(t - before).toBeGreaterThanOrEqual(3_600_000 - 1_000); + expect(t - before).toBeLessThanOrEqual(3_600_000 + 1_000); + }); + + // Once a fire happens and `lastRun` is set, getNextRun should pivot to it. + it("getNextRun uses lastRun when present for interval jobs", () => { + const job = scheduler.addJob({ + name: "ran-once", description: "x", schedule: "1h", + subagent_type: "general-purpose", prompt: "p", + }); + const lastRun = new Date(Date.now() - 30 * 60_000).toISOString(); // 30m ago + scheduler.updateJob(job.id, { lastRun }); + const next = scheduler.getNextRun(job.id); + expect(next).toBe(new Date(new Date(lastRun).getTime() + 3_600_000).toISOString()); + }); + + it("rejects past one-shot timestamps upfront — no record created", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(() => scheduler.addJob({ + name: "past", description: "x", schedule: past, subagent_type: "general-purpose", prompt: "p", + })).toThrow(/in the past/); + // No dead-on-arrival record left behind + expect(scheduler.list()).toEqual([]); + }); + + // The safety net in scheduleJob's past-branch only fires on store reload — + // a once-job persisted with a future ISO whose time has now passed (process + // restart after the trigger window). detectSchedule rejects past timestamps + // at create time, so this is the only remaining production path. + it("disables a previously-enabled one-shot reloaded from disk past its time", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + // Direct store insert bypasses addJob's upfront validation, mimicking a + // record that was valid when written but is now stale on reload. + store.add({ + id: "reload-test", + name: "reload", + description: "reload", + schedule: past, + scheduleType: "once", + subagent_type: "general-purpose", + prompt: "x", + enabled: true, + createdAt: past, + runCount: 0, + }); + // Re-arm: stop drops timers, start re-reads store.list() and calls scheduleJob + // for every enabled job → the past-branch fires for our seeded record. + scheduler.stop(); + scheduler.start(pi, ctx, manager, store); + + const reloaded = scheduler.list().find(j => j.id === "reload-test"); + expect(reloaded?.enabled).toBe(false); + expect(reloaded?.lastStatus).toBe("error"); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ + type: "error", jobId: "reload-test", error: expect.stringMatching(/in the past/), + })); + }); +}); + +describe("SubagentScheduler — fire path", () => { + let tmp: string; + let store: ScheduleStore; + let scheduler: SubagentScheduler; + let manager: any; + let pi: any; + let ctx: any; + + beforeEach(() => { + vi.useFakeTimers(); + tmp = mkdtempSync(join(tmpdir(), "scheduler-fire-")); + store = new ScheduleStore(join(tmp, "s.json")); + scheduler = new SubagentScheduler(); + manager = makeMockManager(); + pi = makeMockPi(); + ctx = makeMockCtx(); + scheduler.start(pi, ctx, manager, store); + }); + + afterEach(() => { + scheduler.stop(); + vi.useRealTimers(); + // Module-global: restore here, not at the end of a test body, so a failing + // assertion can't leak strict dispatch into every test that follows. + setFallbackSubagent(undefined); + registerAgents(new Map()); + rmSync(tmp, { recursive: true, force: true }); + }); + + it("interval jobs fire repeatedly via setInterval", () => { + scheduler.addJob({ + name: "every-10s", description: "tick", schedule: "10s", + subagent_type: "general-purpose", prompt: "tick", + }); + + expect(manager.spawn).toHaveBeenCalledTimes(0); + vi.advanceTimersByTime(10_000); + expect(manager.spawn).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(20_000); + expect(manager.spawn).toHaveBeenCalledTimes(3); + }); + + it("refuses at fire time when the job's agent type no longer resolves", () => { + // The registry is what production populates at activation; a job outliving + // its agent must not silently run something else (#183). + registerAgents(new Map()); + setFallbackSubagent(NO_FALLBACK); + const job = scheduler.addJob({ + name: "gone", description: "vanished agent", schedule: "+1s", + subagent_type: "deleted-since", prompt: "run", + }); + + vi.advanceTimersByTime(2_000); + + expect(manager.spawn).not.toHaveBeenCalled(); + const stored = store.get(job.id); + expect(stored?.lastStatus).toBe("error"); + // Pin WHY it failed — "didn't spawn" alone would be satisfied by any + // unrelated pre-spawn throw. + expect(pi.events.emit).toHaveBeenCalledWith( + "subagents:scheduled", + expect.objectContaining({ + type: "error", + error: expect.stringContaining("Unknown or disabled agent type"), + }), + ); + }); + + it("one-shot fires once and auto-disables", async () => { + const job = scheduler.addJob({ + name: "soon", description: "once", schedule: "+1s", + subagent_type: "general-purpose", prompt: "once", + }); + + vi.advanceTimersByTime(2_000); + expect(manager.spawn).toHaveBeenCalledTimes(1); + + // The auto-disable update happens synchronously inside the timer callback + expect(scheduler.list().find(j => j.id === job.id)?.enabled).toBe(false); + + // Subsequent ticks shouldn't fire again + vi.advanceTimersByTime(60_000); + expect(manager.spawn).toHaveBeenCalledTimes(1); + }); + + it("fire passes bypassQueue: true to manager.spawn", () => { + scheduler.addJob({ + name: "every-1s", description: "x", schedule: "1s", + subagent_type: "general-purpose", prompt: "x", + }); + + vi.advanceTimersByTime(1_000); + expect(manager.spawn).toHaveBeenCalledTimes(1); + const optsArg = manager.spawn.mock.calls[0][4]; + expect(optsArg.bypassQueue).toBe(true); + expect(optsArg.isBackground).toBe(true); + }); + + it("disabled jobs do not fire", () => { + const job = scheduler.addJob({ + name: "off", description: "x", schedule: "1s", + subagent_type: "general-purpose", prompt: "x", + }); + scheduler.updateJob(job.id, { enabled: false }); + vi.advanceTimersByTime(5_000); + expect(manager.spawn).toHaveBeenCalledTimes(0); + }); + + it("emits fired event with agentId on successful spawn", () => { + scheduler.addJob({ + name: "fire-once", description: "x", schedule: "+1s", + subagent_type: "general-purpose", prompt: "x", + }); + vi.advanceTimersByTime(2_000); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ + type: "fired", name: "fire-once", agentId: expect.stringMatching(/^agent-/), + })); + }); + + it("records lastStatus error and emits when manager.spawn throws", async () => { + manager.spawn.mockImplementationOnce(() => { throw new Error("no slots"); }); + const job = scheduler.addJob({ + name: "boom", description: "x", schedule: "+1s", + subagent_type: "general-purpose", prompt: "x", + }); + vi.advanceTimersByTime(2_000); + + // Update is synchronous in the spawn-throw path + expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error"); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ + type: "error", jobId: job.id, error: "no slots", + })); + }); + + // ── Status reflection from record.status (regression for bug #1) ──── + // The real AgentManager's promise *always* resolves (its .catch returns ""), + // so the schedule's success/error must be inferred from `record.status`, + // not from promise resolution. These two tests model that contract. + describe("infers success vs error from record.status, not promise resolution", () => { + type FakeRecord = { status: string; promise: Promise; resolve: () => void }; + + function installFaithfulMock(): Map { + const records = new Map(); + manager.spawn.mockImplementation(() => { + const id = "agent-" + Math.random().toString(36).slice(2, 10); + let resolve!: () => void; + const promise = new Promise(r => { resolve = () => r(""); }); + records.set(id, { status: "running", promise, resolve }); + return id; + }); + manager.getRecord.mockImplementation((id: string) => records.get(id)); + return records; + } + + it("records lastStatus 'error' when the agent terminates with status='error'", async () => { + const records = installFaithfulMock(); + const job = scheduler.addJob({ + name: "fail-job", description: "x", schedule: "+1s", + subagent_type: "general-purpose", prompt: "x", + }); + + vi.advanceTimersByTime(2_000); + expect(manager.spawn).toHaveBeenCalledTimes(1); + + // The agent ran and ended in error — same shape the real AgentManager produces. + const r = [...records.values()][0]; + r.status = "error"; + r.resolve(); + + // Flush microtasks so .then(finalize) runs. + await vi.advanceTimersByTimeAsync(0); + + expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error"); + }); + + it("records lastStatus 'success' when the agent terminates with status='completed'", async () => { + const records = installFaithfulMock(); + const job = scheduler.addJob({ + name: "ok-job", description: "x", schedule: "+1s", + subagent_type: "general-purpose", prompt: "x", + }); + + vi.advanceTimersByTime(2_000); + const r = [...records.values()][0]; + r.status = "completed"; + r.resolve(); + + await vi.advanceTimersByTimeAsync(0); + + expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("success"); + }); + + it("treats aborted and stopped as errors (terminal failure states)", async () => { + const records = installFaithfulMock(); + const a = scheduler.addJob({ + name: "abort-job", description: "x", schedule: "+1s", + subagent_type: "general-purpose", prompt: "x", + }); + const b = scheduler.addJob({ + name: "stop-job", description: "x", schedule: "+2s", + subagent_type: "general-purpose", prompt: "x", + }); + + vi.advanceTimersByTime(3_000); + const recs = [...records.values()]; + recs[0].status = "aborted"; + recs[0].resolve(); + recs[1].status = "stopped"; + recs[1].resolve(); + + await vi.advanceTimersByTimeAsync(0); + + expect(scheduler.list().find(j => j.id === a.id)?.lastStatus).toBe("error"); + expect(scheduler.list().find(j => j.id === b.id)?.lastStatus).toBe("error"); + }); + }); +}); + +describe("SubagentScheduler — stopped state", () => { + it("throws on mutation when not started", () => { + const scheduler = new SubagentScheduler(); + expect(() => scheduler.addJob({ + name: "x", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p", + })).toThrow(/not started/); + }); + + it("list() returns empty array when not started", () => { + const scheduler = new SubagentScheduler(); + expect(scheduler.list()).toEqual([]); + }); +}); diff --git a/pi-subagents/test/settings.test.ts b/pi-subagents/test/settings.test.ts new file mode 100644 index 0000000..f1f9a26 --- /dev/null +++ b/pi-subagents/test/settings.test.ts @@ -0,0 +1,811 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyAndEmitLoaded, + applySettings, + loadSettings, + persistToastFor, + type SettingsAppliers, + saveAndEmitChanged, + saveSettings, +} from "../src/settings.js"; + +/** + * Tests for persistent settings. Uses two tmp directories: + * - `globalDir`: redirected via PI_CODING_AGENT_DIR so getAgentDir() returns it. + * Simulates `~/.pi/agent/` — the global scope. + * - `projectDir`: passed explicitly as cwd to load/save. + * Simulates the user's project root. Settings live at `/.pi/subagents.json`. + */ +describe("settings persistence", () => { + let globalDir: string; + let projectDir: string; + let originalAgentDirEnv: string | undefined; + + const globalFile = () => join(globalDir, "subagents.json"); + const projectFile = () => join(projectDir, ".pi", "subagents.json"); + + beforeEach(() => { + globalDir = mkdtempSync(join(tmpdir(), "pi-settings-global-")); + projectDir = mkdtempSync(join(tmpdir(), "pi-settings-project-")); + originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = globalDir; + }); + + afterEach(() => { + if (originalAgentDirEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv; + rmSync(globalDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeGlobal(obj: unknown) { + writeFileSync(globalFile(), JSON.stringify(obj)); + } + + function writeProject(obj: unknown) { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), JSON.stringify(obj)); + } + + it("returns {} when both files are missing", () => { + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("returns {} when both files are malformed JSON", () => { + writeFileSync(globalFile(), "not json {{"); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), "also not json"); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("loads from global when no project file", () => { + writeGlobal({ maxConcurrent: 16, graceTurns: 10 }); + expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 16, graceTurns: 10 }); + }); + + it("loads from project when no global file", () => { + writeProject({ maxConcurrent: 8, defaultJoinMode: "group" }); + expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 8, defaultJoinMode: "group" }); + }); + + it("merges global + project with project winning on conflicts", () => { + writeGlobal({ maxConcurrent: 16, graceTurns: 10, defaultJoinMode: "async" }); + writeProject({ maxConcurrent: 4, defaultMaxTurns: 50 }); + expect(loadSettings(projectDir)).toEqual({ + maxConcurrent: 4, // project wins + graceTurns: 10, // from global + defaultJoinMode: "async", // from global + defaultMaxTurns: 50, // from project only + }); + }); + + it("round-trips values: saveSettings then loadSettings", () => { + const settings = { + maxConcurrent: 7, + defaultMaxTurns: 30, + graceTurns: 3, + defaultJoinMode: "smart" as const, + schedulingEnabled: false, + toolDescriptionMode: "compact" as const, + }; + saveSettings(settings, projectDir); + expect(loadSettings(projectDir)).toEqual(settings); + }); + + it("round-trips schedulingEnabled (true and false), and absence stays absent", () => { + saveSettings({ schedulingEnabled: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: false }); + + saveSettings({ schedulingEnabled: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: true }); + + // Absence — caller's "use default" signal — must not become a stored false. + saveSettings({}, projectDir); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("round-trips fleetView (true and false); keeps boolean, drops non-boolean", () => { + saveSettings({ fleetView: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ fleetView: false }); + saveSettings({ fleetView: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ fleetView: true }); + writeProject({ fleetView: "on" } as any); + expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped + }); + + it("round-trips agentMentions modes; drops an unknown one", () => { + for (const mode of ["model", "direct", "off"] as const) { + saveSettings({ agentMentions: mode }, projectDir); + expect(loadSettings(projectDir)).toEqual({ agentMentions: mode }); + } + writeProject({ agentMentions: "on" } as any); + expect(loadSettings(projectDir)).toEqual({}); // unknown mode dropped + }); + + it("reads the pre-mode agentMentions booleans as their modes", () => { + // The setting shipped as a boolean before `model` existed, so a config + // written then — or hand-written from the old README — must keep working. + // `true` meant "on", and on is now `model`. + writeProject({ agentMentions: true } as any); + expect(loadSettings(projectDir)).toEqual({ agentMentions: "model" }); + writeProject({ agentMentions: false } as any); + expect(loadSettings(projectDir)).toEqual({ agentMentions: "off" }); + }); + + it("round-trips rememberAgents (true and false); keeps boolean, drops non-boolean", () => { + saveSettings({ rememberAgents: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ rememberAgents: false }); + saveSettings({ rememberAgents: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ rememberAgents: true }); + writeProject({ rememberAgents: "on" } as any); + expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped + }); + + it("round-trips widgetMode; keeps valid values, drops invalid", () => { + saveSettings({ widgetMode: "off" }, projectDir); + expect(loadSettings(projectDir)).toEqual({ widgetMode: "off" }); + saveSettings({ widgetMode: "background" }, projectDir); + expect(loadSettings(projectDir)).toEqual({ widgetMode: "background" }); + writeProject({ widgetMode: "sideways" } as any); + expect(loadSettings(projectDir)).toEqual({}); // invalid value dropped + }); + + it("round-trips outputTranscript; drops non-boolean", () => { + saveSettings({ outputTranscript: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ outputTranscript: false }); + saveSettings({ outputTranscript: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ outputTranscript: true }); + writeProject({ outputTranscript: "no" } as any); + expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped + }); + + it("round-trips backgroundByDefault (true and false), and absence stays absent", () => { + // `false` is the load-bearing case: it's how a user restores the previous + // foreground default, so it must survive a save/load rather than being + // read back as absent and re-defaulting to background. + saveSettings({ backgroundByDefault: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: false }); + + saveSettings({ backgroundByDefault: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: true }); + + saveSettings({}, projectDir); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("sanitize drops non-boolean backgroundByDefault silently", () => { + writeProject({ backgroundByDefault: "yes" } as any); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ backgroundByDefault: 0 } as any); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("round-trips worktreeIsolation; drops non-boolean", () => { + saveSettings({ worktreeIsolation: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: false }); + saveSettings({ worktreeIsolation: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: true }); + writeProject({ worktreeIsolation: "off" } as any); + expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped + }); + + it("round-trips reportUsage and showCost; drops non-boolean", () => { + saveSettings({ reportUsage: true, showCost: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ reportUsage: true, showCost: true }); + saveSettings({ reportUsage: false, showCost: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ reportUsage: false, showCost: false }); + // The sanitizer is an allowlist: a key it does not name is dropped, and the + // setting silently never applies. + writeProject({ reportUsage: "on", showCost: 1 } as any); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("sanitize drops non-boolean schedulingEnabled silently", async () => { + writeProject({ schedulingEnabled: "yes" } as any); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ schedulingEnabled: 1 } as any); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("saveSettings writes only to the project file; global is untouched", () => { + writeGlobal({ maxConcurrent: 16 }); + saveSettings({ maxConcurrent: 2 }, projectDir); + + // Project file contains the new value + expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 }); + // Global file unchanged + expect(JSON.parse(readFileSync(globalFile(), "utf-8"))).toEqual({ maxConcurrent: 16 }); + }); + + it("saveSettings creates /.pi/ when missing", () => { + expect(existsSync(join(projectDir, ".pi"))).toBe(false); + saveSettings({ maxConcurrent: 4 }, projectDir); + expect(existsSync(projectFile())).toBe(true); + }); + + it("round-trips defaultMaxTurns: 0 (unlimited marker)", () => { + saveSettings({ defaultMaxTurns: 0 }, projectDir); + expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 }); + }); + + it("ignores unknown extra fields on load (forward-compat)", () => { + writeProject({ maxConcurrent: 2, futureField: "ignored" }); + const loaded = loadSettings(projectDir); + expect(loaded.maxConcurrent).toBe(2); + // Unknown fields are stripped by the sanitizer — old versions won't persist garbage + expect((loaded as Record).futureField).toBeUndefined(); + }); + + it("composes partial global + partial project correctly", () => { + writeGlobal({ graceTurns: 10 }); + writeProject({ maxConcurrent: 2 }); + expect(loadSettings(projectDir)).toEqual({ graceTurns: 10, maxConcurrent: 2 }); + }); + + describe("sanitizer", () => { + it("drops maxConcurrent < 1", () => { + writeProject({ maxConcurrent: 0, graceTurns: 5 }); + expect(loadSettings(projectDir)).toEqual({ graceTurns: 5 }); + }); + + it("drops negative maxConcurrent", () => { + writeProject({ maxConcurrent: -3 }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("drops non-integer maxConcurrent (floats, NaN, strings)", () => { + writeProject({ maxConcurrent: 3.5 }); + expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); + writeProject({ maxConcurrent: "four" }); + expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); + writeProject({ maxConcurrent: null }); + expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); + }); + + it("accepts defaultMaxTurns: 0 (explicit unlimited)", () => { + writeProject({ defaultMaxTurns: 0 }); + expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 }); + }); + + it("drops negative defaultMaxTurns", () => { + writeProject({ defaultMaxTurns: -1 }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("drops graceTurns < 1", () => { + writeProject({ graceTurns: 0 }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("keeps maxSubagentDepth 0 (nesting off) but drops negative, fractional, and over-ceiling values", () => { + writeProject({ maxSubagentDepth: 0 }); + expect(loadSettings(projectDir)).toEqual({ maxSubagentDepth: 0 }); + writeProject({ maxSubagentDepth: -1 }); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ maxSubagentDepth: 1.5 }); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ maxSubagentDepth: 17 }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("accepts `none` and `false` as the disabled fallback, nothing else", () => { + // Only the boolean needs an alias: it would otherwise be dropped, leaving + // the PERMISSIVE default while the author believed strict was on. Every + // string stays an agent name, so a mistaken "off" fails loudly at dispatch + // instead of meaning one thing here and another in the resolver. + for (const spelling of ["none", "NONE", " none ", false]) { + writeProject({ fallbackSubagent: spelling }); + expect(loadSettings(projectDir).fallbackSubagent?.toLowerCase()).toBe("none"); + } + writeProject({ fallbackSubagent: "off" }); + expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "off" }); + }); + + it("drops values that aren't a string or `false`, without coercing them", () => { + // String(["none"]) is "none" — coercing would silently enable strict mode. + for (const junk of [["none"], null, 42, true, {}]) { + writeProject({ fallbackSubagent: junk }); + expect(loadSettings(projectDir)).toEqual({}); + } + }); + + it("keeps a named fallback agent and drops non-strings", () => { + writeProject({ fallbackSubagent: " my-router " }); + expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "my-router" }); + writeProject({ fallbackSubagent: 42 }); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ fallbackSubagent: " " }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("drops invalid defaultJoinMode values", () => { + writeProject({ defaultJoinMode: "invalid" }); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ defaultJoinMode: 42 }); + expect(loadSettings(projectDir)).toEqual({}); + writeProject({ defaultJoinMode: "" }); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("accepts all three valid join modes", () => { + for (const mode of ["async", "group", "smart"] as const) { + writeProject({ defaultJoinMode: mode }); + expect(loadSettings(projectDir)).toEqual({ defaultJoinMode: mode }); + } + }); + + it("accepts scopeModels boolean (true and false)", () => { + writeProject({ scopeModels: true }); + expect(loadSettings(projectDir)).toEqual({ scopeModels: true }); + writeProject({ scopeModels: false }); + expect(loadSettings(projectDir)).toEqual({ scopeModels: false }); + }); + + it("accepts strictAgentFiles boolean (true and false)", () => { + writeProject({ strictAgentFiles: true }); + expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: true }); + writeProject({ strictAgentFiles: false }); + expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: false }); + }); + + it("drops non-boolean strictAgentFiles", () => { + writeProject({ strictAgentFiles: "yes" }); + expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined(); + writeProject({ strictAgentFiles: 1 }); + expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined(); + }); + + it("drops non-boolean scopeModels", () => { + writeProject({ scopeModels: "yes" }); + expect(loadSettings(projectDir).scopeModels).toBeUndefined(); + writeProject({ scopeModels: 1 }); + expect(loadSettings(projectDir).scopeModels).toBeUndefined(); + writeProject({ scopeModels: null }); + expect(loadSettings(projectDir).scopeModels).toBeUndefined(); + }); + + it("accepts disableDefaultAgents boolean (true and false)", () => { + writeProject({ disableDefaultAgents: true }); + expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: true }); + writeProject({ disableDefaultAgents: false }); + expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: false }); + }); + + it("drops non-boolean disableDefaultAgents", () => { + writeProject({ disableDefaultAgents: "yes" }); + expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); + writeProject({ disableDefaultAgents: 1 }); + expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); + writeProject({ disableDefaultAgents: null }); + expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); + }); + + it("accepts all valid toolDescriptionMode values", () => { + for (const mode of ["full", "compact", "custom"] as const) { + writeProject({ toolDescriptionMode: mode }); + expect(loadSettings(projectDir)).toEqual({ toolDescriptionMode: mode }); + } + }); + + it("drops invalid toolDescriptionMode", () => { + writeProject({ toolDescriptionMode: "tiny" }); + expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); + writeProject({ toolDescriptionMode: true }); + expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); + writeProject({ toolDescriptionMode: null }); + expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); + }); + + it("returns {} when the JSON root is not an object (array, string, null)", () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), '["not", "an", "object"]'); + expect(loadSettings(projectDir)).toEqual({}); + writeFileSync(projectFile(), '"just a string"'); + expect(loadSettings(projectDir)).toEqual({}); + writeFileSync(projectFile(), "null"); + expect(loadSettings(projectDir)).toEqual({}); + }); + + it("keeps valid fields while dropping invalid siblings", () => { + writeProject({ + maxConcurrent: 4, // ok + defaultMaxTurns: -5, // dropped + graceTurns: 3, // ok + defaultJoinMode: "nope", // dropped + }); + expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 4, graceTurns: 3 }); + }); + + it("accepts values at the ceiling (maxConcurrent=1024, defaultMaxTurns=10000, graceTurns=1000)", () => { + writeProject({ maxConcurrent: 1024, defaultMaxTurns: 10_000, graceTurns: 1_000 }); + expect(loadSettings(projectDir)).toEqual({ + maxConcurrent: 1024, + defaultMaxTurns: 10_000, + graceTurns: 1_000, + }); + }); + + it("drops values above the ceiling", () => { + writeProject({ maxConcurrent: 1025 }); + expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); + writeProject({ defaultMaxTurns: 10_001 }); + expect(loadSettings(projectDir).defaultMaxTurns).toBeUndefined(); + writeProject({ graceTurns: 1_001 }); + expect(loadSettings(projectDir).graceTurns).toBeUndefined(); + }); + + it("drops absurdly large values (e.g. 1e6)", () => { + writeProject({ maxConcurrent: 1_000_000, defaultMaxTurns: 1_000_000, graceTurns: 1_000_000 }); + expect(loadSettings(projectDir)).toEqual({}); + }); + }); + + describe("save result + corrupt-file warning", () => { + it("saveSettings returns true on success", () => { + expect(saveSettings({ maxConcurrent: 2 }, projectDir)).toBe(true); + expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 }); + }); + + it("saveSettings returns false when the target dir cannot be created", () => { + // Place a regular file where the parent of the settings file would go — + // mkdirSync + writeFileSync both fail with ENOTDIR / EEXIST. + const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`); + writeFileSync(filePosingAsCwd, ""); + try { + expect(saveSettings({ maxConcurrent: 1 }, filePosingAsCwd)).toBe(false); + } finally { + rmSync(filePosingAsCwd, { force: true }); + } + }); + + it("warns to console.warn when an existing file is malformed", () => { + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), "not valid json {{{"); + try { + expect(loadSettings(projectDir)).toEqual({}); + expect(spy).toHaveBeenCalledTimes(1); + expect(String(spy.mock.calls[0][0])).toMatch(/Ignoring malformed settings/); + } finally { + spy.mockRestore(); + } + }); + + it("does NOT warn when a file is simply missing", () => { + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(loadSettings(projectDir)).toEqual({}); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + }); + + describe("applySettings", () => { + let appliers: SettingsAppliers; + + beforeEach(() => { + appliers = { + setMaxConcurrent: vi.fn(), + setDefaultMaxTurns: vi.fn(), + setGraceTurns: vi.fn(), + setDefaultJoinMode: vi.fn(), + setBackgroundByDefault: vi.fn(), + setSchedulingEnabled: vi.fn(), + setScopeModels: vi.fn(), + setStrictAgentFiles: vi.fn(), + setDisableDefaultAgents: vi.fn(), + setToolDescriptionMode: vi.fn(), + setFleetView: vi.fn(), + setAgentMentions: vi.fn(), + setRememberAgents: vi.fn(), + setWidgetMode: vi.fn(), + setOutputTranscript: vi.fn(), + setWorktreeIsolation: vi.fn(), + setMaxSubagentDepth: vi.fn(), + setFallbackSubagent: vi.fn(), + setReportUsage: vi.fn(), + setShowCost: vi.fn(), + }; + }); + + it("applies reportUsage and showCost", () => { + applySettings({ reportUsage: true, showCost: true }, appliers); + expect(appliers.setReportUsage).toHaveBeenCalledWith(true); + expect(appliers.setShowCost).toHaveBeenCalledWith(true); + + applySettings({ reportUsage: false, showCost: false }, appliers); + expect(appliers.setReportUsage).toHaveBeenCalledWith(false); + expect(appliers.setShowCost).toHaveBeenCalledWith(false); + }); + + it("is a no-op on an empty settings object", () => { + applySettings({}, appliers); + expect(appliers.setReportUsage).not.toHaveBeenCalled(); + expect(appliers.setShowCost).not.toHaveBeenCalled(); + expect(appliers.setMaxConcurrent).not.toHaveBeenCalled(); + expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); + expect(appliers.setGraceTurns).not.toHaveBeenCalled(); + expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); + expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); + expect(appliers.setScopeModels).not.toHaveBeenCalled(); + expect(appliers.setDisableDefaultAgents).not.toHaveBeenCalled(); + expect(appliers.setToolDescriptionMode).not.toHaveBeenCalled(); + }); + + it("applies fallbackSubagent through to the registry", () => { + // Without this, deleting the applySettings line for this field leaves the + // whole suite green while `subagents.json` silently stops working. + applySettings({ fallbackSubagent: "none" }, appliers); + expect(appliers.setFallbackSubagent).toHaveBeenCalledWith("none"); + }); + + it("applies only the fields that are present", () => { + applySettings({ maxConcurrent: 4, graceTurns: 3, maxSubagentDepth: 1 }, appliers); + expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(4); + expect(appliers.setGraceTurns).toHaveBeenCalledWith(3); + expect(appliers.setMaxSubagentDepth).toHaveBeenCalledWith(1); + expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); + expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); + expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); + expect(appliers.setScopeModels).not.toHaveBeenCalled(); + }); + + it("applies all fields when all are present", () => { + applySettings( + { + maxConcurrent: 8, + defaultMaxTurns: 50, + graceTurns: 7, + defaultJoinMode: "group", + schedulingEnabled: false, + scopeModels: true, + disableDefaultAgents: true, + toolDescriptionMode: "compact", + fleetView: false, + widgetMode: "off", + }, + appliers, + ); + expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(8); + expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(50); + expect(appliers.setGraceTurns).toHaveBeenCalledWith(7); + expect(appliers.setDefaultJoinMode).toHaveBeenCalledWith("group"); + expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false); + expect(appliers.setScopeModels).toHaveBeenCalledWith(true); + expect(appliers.setStrictAgentFiles).not.toHaveBeenCalled(); // absent from this snapshot + expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(true); + expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact"); + expect(appliers.setFleetView).toHaveBeenCalledWith(false); + expect(appliers.setWidgetMode).toHaveBeenCalledWith("off"); + }); + + it("applies strictAgentFiles; skips it when absent", () => { + applySettings({ strictAgentFiles: true }, appliers); + expect(appliers.setStrictAgentFiles).toHaveBeenCalledWith(true); + applySettings({}, appliers); + expect(appliers.setStrictAgentFiles).toHaveBeenCalledTimes(1); + }); + + it("applies widgetMode; skips it when absent", () => { + applySettings({ widgetMode: "off" }, appliers); + expect(appliers.setWidgetMode).toHaveBeenCalledWith("off"); + applySettings({}, appliers); + expect(appliers.setWidgetMode).toHaveBeenCalledTimes(1); // absence is "use default" + }); + + it("applies fleetView (true and false); skips it when absent", () => { + applySettings({ fleetView: true }, appliers); + expect(appliers.setFleetView).toHaveBeenCalledWith(true); + applySettings({}, appliers); + expect(appliers.setFleetView).toHaveBeenCalledTimes(1); // absence is "use default" + }); + + it("applies agentMentions; skips it when absent", () => { + applySettings({ agentMentions: "direct" }, appliers); + expect(appliers.setAgentMentions).toHaveBeenCalledWith("direct"); + applySettings({}, appliers); + expect(appliers.setAgentMentions).toHaveBeenCalledTimes(1); // absence is "use default" + }); + + it("applies rememberAgents; skips it when absent", () => { + applySettings({ rememberAgents: false }, appliers); + expect(appliers.setRememberAgents).toHaveBeenCalledWith(false); + applySettings({}, appliers); + expect(appliers.setRememberAgents).toHaveBeenCalledTimes(1); // absence is "use default" + }); + + it("applies scopeModels: false", () => { + applySettings({ scopeModels: false }, appliers); + expect(appliers.setScopeModels).toHaveBeenCalledWith(false); + }); + + it("applies disableDefaultAgents: false", () => { + applySettings({ disableDefaultAgents: false }, appliers); + expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(false); + }); + + it("applies toolDescriptionMode", () => { + applySettings({ toolDescriptionMode: "full" }, appliers); + expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("full"); + }); + + it("applies outputTranscript (both true and false)", () => { + applySettings({ outputTranscript: false }, appliers); + expect(appliers.setOutputTranscript).toHaveBeenCalledWith(false); + applySettings({ outputTranscript: true }, appliers); + expect(appliers.setOutputTranscript).toHaveBeenCalledWith(true); + }); + + it("applies worktreeIsolation (both true and false)", () => { + applySettings({ worktreeIsolation: false }, appliers); + expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(false); + applySettings({ worktreeIsolation: true }, appliers); + expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(true); + }); + + it("applies defaultMaxTurns: 0 as the explicit unlimited marker", () => { + applySettings({ defaultMaxTurns: 0 }, appliers); + expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(0); + }); + + it("calls setBackgroundByDefault with either boolean", () => { + applySettings({ backgroundByDefault: false }, appliers); + expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(false); + applySettings({ backgroundByDefault: true }, appliers); + expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(true); + }); + + // Absence must leave the in-memory default (background) alone — calling + // the applier with `undefined` would read as foreground at the spawn site. + it("does not call setBackgroundByDefault when the field is absent", () => { + applySettings({ maxConcurrent: 4 }, appliers); + expect(appliers.setBackgroundByDefault).not.toHaveBeenCalled(); + }); + + // Wiring tests for the master switch — ensures the schedulingEnabled + // field flows from the parsed settings into the applier callback that + // sets the in-memory flag in index.ts. + it("calls setSchedulingEnabled(true) when schedulingEnabled is true", () => { + applySettings({ schedulingEnabled: true }, appliers); + expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(true); + }); + + it("calls setSchedulingEnabled(false) when schedulingEnabled is false", () => { + applySettings({ schedulingEnabled: false }, appliers); + expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false); + }); + + // Absence preserves the in-memory default — the applier must NOT be + // called, otherwise loading a settings file without the field would + // overwrite the runtime default with `undefined`. + it("does not call setSchedulingEnabled when the field is absent", () => { + applySettings({ maxConcurrent: 4 }, appliers); + expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); + }); + }); + + describe("persistToastFor", () => { + it("returns info-level toast with the plain message on success", () => { + expect(persistToastFor("Max concurrency set to 7", true)).toEqual({ + message: "Max concurrency set to 7", + level: "info", + }); + }); + + it("returns warning-level toast with session-only suffix on failure", () => { + expect(persistToastFor("Max concurrency set to 7", false)).toEqual({ + message: "Max concurrency set to 7 (session only; failed to persist)", + level: "warning", + }); + }); + }); + + describe("applyAndEmitLoaded", () => { + let appliers: SettingsAppliers; + + beforeEach(() => { + appliers = { + setMaxConcurrent: vi.fn(), + setDefaultMaxTurns: vi.fn(), + setGraceTurns: vi.fn(), + setDefaultJoinMode: vi.fn(), + setBackgroundByDefault: vi.fn(), + setSchedulingEnabled: vi.fn(), + setScopeModels: vi.fn(), + setStrictAgentFiles: vi.fn(), + setDisableDefaultAgents: vi.fn(), + setToolDescriptionMode: vi.fn(), + setFleetView: vi.fn(), + setAgentMentions: vi.fn(), + setRememberAgents: vi.fn(), + setWidgetMode: vi.fn(), + setOutputTranscript: vi.fn(), + setWorktreeIsolation: vi.fn(), + setMaxSubagentDepth: vi.fn(), + setFallbackSubagent: vi.fn(), + setReportUsage: vi.fn(), + setShowCost: vi.fn(), + }; + }); + + it("loads, applies, and emits subagents:settings_loaded with merged settings", () => { + writeGlobal({ maxConcurrent: 16 }); + writeProject({ graceTurns: 7 }); + const emit = vi.fn(); + + const result = applyAndEmitLoaded(appliers, emit, projectDir); + + expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(16); + expect(appliers.setGraceTurns).toHaveBeenCalledWith(7); + expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); + expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { + settings: { maxConcurrent: 16, graceTurns: 7 }, + }); + expect(result).toEqual({ maxConcurrent: 16, graceTurns: 7 }); + }); + + it("still emits the event when both files are missing (payload carries {})", () => { + const emit = vi.fn(); + + const result = applyAndEmitLoaded(appliers, emit, projectDir); + + expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { settings: {} }); + expect(result).toEqual({}); + // No setters fired — defaults preserved + expect(appliers.setMaxConcurrent).not.toHaveBeenCalled(); + expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); + expect(appliers.setGraceTurns).not.toHaveBeenCalled(); + expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); + }); + }); + + describe("saveAndEmitChanged", () => { + it("persists, emits with persisted=true, and returns info toast on success", () => { + const emit = vi.fn(); + const snapshot = { maxConcurrent: 5, graceTurns: 2 }; + + const toast = saveAndEmitChanged(snapshot, "Max concurrency set to 5", emit, projectDir); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith("subagents:settings_changed", { + settings: snapshot, + persisted: true, + }); + expect(toast).toEqual({ message: "Max concurrency set to 5", level: "info" }); + // File actually written + expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual(snapshot); + }); + + it("emits with persisted=false and returns warning toast on save failure", () => { + const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`); + writeFileSync(filePosingAsCwd, ""); + const emit = vi.fn(); + try { + const toast = saveAndEmitChanged( + { maxConcurrent: 5 }, + "Max concurrency set to 5", + emit, + filePosingAsCwd, + ); + expect(emit).toHaveBeenCalledWith("subagents:settings_changed", { + settings: { maxConcurrent: 5 }, + persisted: false, + }); + expect(toast).toEqual({ + message: "Max concurrency set to 5 (session only; failed to persist)", + level: "warning", + }); + } finally { + rmSync(filePosingAsCwd, { force: true }); + } + }); + }); +}); diff --git a/pi-subagents/test/skill-loader.test.ts b/pi-subagents/test/skill-loader.test.ts new file mode 100644 index 0000000..e4d277a --- /dev/null +++ b/pi-subagents/test/skill-loader.test.ts @@ -0,0 +1,237 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { preloadSkills } from "../src/skill-loader.js"; + +describe("preloadSkills", () => { + let tmpDir: string; + let originalAgentDir: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-skill-test-")); + originalAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(tmpDir, "user-agent-dir"); + }); + + afterEach(() => { + if (originalAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalAgentDir; + } + rmSync(tmpDir, { recursive: true, force: true }); + }); + + const projectRoot = () => join(tmpDir, ".pi", "skills"); + const globalRoot = () => join(process.env.PI_CODING_AGENT_DIR!, "skills"); + + function writeFlat(root: string, name: string, content: string, ext = ".md") { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, name + ext), content); + } + + function writeSkillDir(root: string, name: string, content: string) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), content); + } + + it("returns empty array for empty skill list", () => { + expect(preloadSkills([], tmpDir)).toEqual([]); + }); + + it("loads a top-level flat .md skill from project", () => { + writeFlat(projectRoot(), "api-conventions", "# API Conventions"); + const result = preloadSkills(["api-conventions"], tmpDir); + expect(result[0].content).toContain("API Conventions"); + }); + + it("ignores .txt files (only .md is supported)", () => { + writeFlat(projectRoot(), "error-handling", "should not load", ".txt"); + expect(preloadSkills(["error-handling"], tmpDir)[0].content).toContain("not found"); + }); + + it("ignores extensionless files (only .md is supported)", () => { + writeFlat(projectRoot(), "bare-skill", "should not load", ""); + expect(preloadSkills(["bare-skill"], tmpDir)[0].content).toContain("not found"); + }); + + it("loads a top-level /SKILL.md from project", () => { + writeSkillDir(projectRoot(), "writing-go", "# Writing Go"); + expect(preloadSkills(["writing-go"], tmpDir)[0].content).toContain("Writing Go"); + }); + + it("loads a top-level /SKILL.md from getAgentDir()/skills", () => { + writeSkillDir(globalRoot(), "writing-python", "# Writing Python"); + expect(preloadSkills(["writing-python"], tmpDir)[0].content).toContain("Writing Python"); + }); + + it("loads a flat .md from getAgentDir()/skills", () => { + writeFlat(globalRoot(), "shell-tips", "use rg"); + expect(preloadSkills(["shell-tips"], tmpDir)[0].content).toBe("use rg"); + }); + + it("finds nested //SKILL.md in getAgentDir()/skills", () => { + writeSkillDir(join(globalRoot(), "dev-tools"), "using-modern-cli", "# Modern CLI"); + expect(preloadSkills(["using-modern-cli"], tmpDir)[0].content).toContain("Modern CLI"); + }); + + it("loads /SKILL.md from project .agents/skills (Agent Skills spec)", () => { + writeSkillDir(join(tmpDir, ".agents", "skills"), "writing-rust", "# Writing Rust"); + expect(preloadSkills(["writing-rust"], tmpDir)[0].content).toContain("Writing Rust"); + }); + + it("prefers .pi/skills over .agents/skills in the same project", () => { + writeSkillDir(projectRoot(), "shared", "from-pi"); + writeSkillDir(join(tmpDir, ".agents", "skills"), "shared", "from-agents"); + expect(preloadSkills(["shared"], tmpDir)[0].content).toBe("from-pi"); + }); + + it("finds nested //SKILL.md", () => { + writeSkillDir(join(projectRoot(), "dev-tools"), "using-modern-cli", "# Modern CLI"); + expect(preloadSkills(["using-modern-cli"], tmpDir)[0].content).toContain("Modern CLI"); + }); + + it("prefers project over global", () => { + writeSkillDir(projectRoot(), "shared", "from-project"); + writeSkillDir(globalRoot(), "shared", "from-global"); + expect(preloadSkills(["shared"], tmpDir)[0].content).toBe("from-project"); + }); + + it("prefers shallower match (lex tie-break)", () => { + // Different depths — shallower wins. + writeSkillDir(join(projectRoot(), "z-deep", "nested"), "collide", "deep"); + writeSkillDir(join(projectRoot(), "a-shallow"), "collide", "shallow"); + expect(preloadSkills(["collide"], tmpDir)[0].content).toBe("shallow"); + + // Same depth — alphabetical wins. + writeSkillDir(join(projectRoot(), "b-sibling"), "tie", "b"); + writeSkillDir(join(projectRoot(), "a-sibling"), "tie", "a"); + expect(preloadSkills(["tie"], tmpDir)[0].content).toBe("a"); + }); + + it("descends past a same-named dir that lacks SKILL.md to find a deeper match", () => { + // .pi/skills/foo exists empty; .pi/skills/foo/inner/foo/SKILL.md is the real skill. + mkdirSync(join(projectRoot(), "foo"), { recursive: true }); + writeSkillDir(join(projectRoot(), "foo", "inner"), "foo", "deeper"); + expect(preloadSkills(["foo"], tmpDir)[0].content).toBe("deeper"); + }); + + it("does not descend into a sibling skill directory (skills don't nest)", () => { + // .pi/skills/outer is itself a skill; .pi/skills/outer/target/SKILL.md must NOT be found. + writeSkillDir(projectRoot(), "outer", "outer-skill"); + writeSkillDir(join(projectRoot(), "outer"), "target", "hidden"); + expect(preloadSkills(["target"], tmpDir)[0].content).toContain("not found"); + }); + + it("skips node_modules during recursion", () => { + writeSkillDir(join(projectRoot(), "node_modules", "some-pkg"), "leaked", "should not load"); + expect(preloadSkills(["leaked"], tmpDir)[0].content).toContain("not found"); + }); + + it("skips dotfile directories during recursion", () => { + writeSkillDir(join(projectRoot(), ".hidden-tree"), "buried", "should not load"); + expect(preloadSkills(["buried"], tmpDir)[0].content).toContain("not found"); + }); + + it("returns fallback for missing skills", () => { + const result = preloadSkills(["nonexistent"], tmpDir); + expect(result[0].name).toBe("nonexistent"); + expect(result[0].content).toContain("not found"); + }); + + it("loads multiple skills", () => { + writeFlat(projectRoot(), "a", "Content A"); + writeSkillDir(projectRoot(), "b", "Content B"); + const result = preloadSkills(["a", "b"], tmpDir); + expect(result.map((r) => r.content)).toEqual(["Content A", expect.stringContaining("Content B")]); + }); + + it("skips skill names with path traversal (..)", () => { + expect(preloadSkills(["../../etc/passwd"], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips skill names with forward slash", () => { + expect(preloadSkills(["sub/dir"], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips skill names with backslash", () => { + expect(preloadSkills(["sub\\dir"], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips skill names with spaces", () => { + expect(preloadSkills(["my skill"], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips skill names starting with a dot", () => { + expect(preloadSkills([".hidden"], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips empty skill names", () => { + expect(preloadSkills([""], tmpDir)[0].content).toContain("path traversal"); + }); + + it("skips skill names exceeding 128 characters", () => { + const longName = "a".repeat(129); + expect(preloadSkills([longName], tmpDir)[0].content).toContain("path traversal"); + }); + + it("loads valid skills alongside skipped unsafe ones", () => { + writeFlat(projectRoot(), "legit", "Good content"); + const result = preloadSkills(["../evil", "legit"], tmpDir); + expect(result[0].content).toContain("path traversal"); + expect(result[1].content).toBe("Good content"); + }); + + it("rejects symlinked flat .md files", () => { + mkdirSync(projectRoot(), { recursive: true }); + const secret = join(tmpDir, "secret.md"); + writeFileSync(secret, "TOP SECRET"); + symlinkSync(secret, join(projectRoot(), "evil.md")); + const result = preloadSkills(["evil"], tmpDir); + expect(result[0].content).toContain("not found"); + expect(result[0].content).not.toContain("TOP SECRET"); + }); + + it("rejects symlinked skill directories", () => { + mkdirSync(projectRoot(), { recursive: true }); + const realDir = join(tmpDir, "real-skill"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "SKILL.md"), "TOP SECRET"); + symlinkSync(realDir, join(projectRoot(), "evil-dir")); + const result = preloadSkills(["evil-dir"], tmpDir); + expect(result[0].content).toContain("not found"); + expect(result[0].content).not.toContain("TOP SECRET"); + }); + + it("rejects symlinked skill root", () => { + // /.pi/skills → symlink to a directory that holds real-looking skills. + const realRoot = join(tmpDir, "elsewhere"); + mkdirSync(realRoot, { recursive: true }); + writeFileSync(join(realRoot, "leaked-flat.md"), "TOP SECRET FLAT"); + mkdirSync(join(realRoot, "leaked-dir"), { recursive: true }); + writeFileSync(join(realRoot, "leaked-dir", "SKILL.md"), "TOP SECRET DIR"); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + symlinkSync(realRoot, projectRoot()); + + const flatResult = preloadSkills(["leaked-flat"], tmpDir)[0].content; + expect(flatResult).toContain("not found"); + expect(flatResult).not.toContain("TOP SECRET"); + + const dirResult = preloadSkills(["leaked-dir"], tmpDir)[0].content; + expect(dirResult).toContain("not found"); + expect(dirResult).not.toContain("TOP SECRET"); + }); + + it("rejects symlinked SKILL.md inside a real skill directory", () => { + const skillDir = join(projectRoot(), "evil-inner"); + mkdirSync(skillDir, { recursive: true }); + const secret = join(tmpDir, "secret.md"); + writeFileSync(secret, "TOP SECRET"); + symlinkSync(secret, join(skillDir, "SKILL.md")); + const result = preloadSkills(["evil-inner"], tmpDir); + expect(result[0].content).toContain("not found"); + expect(result[0].content).not.toContain("TOP SECRET"); + }); +}); diff --git a/pi-subagents/test/status-note-wiring.test.ts b/pi-subagents/test/status-note-wiring.test.ts new file mode 100644 index 0000000..1a281c9 --- /dev/null +++ b/pi-subagents/test/status-note-wiring.test.ts @@ -0,0 +1,317 @@ +/** + * status-note-wiring.test.ts — proves the status note actually reaches the + * PARENT through the real tool handlers, not just that getStatusNote() returns + * a string. Drives the registered `Agent` / `get_subagent_result` tools and + * inspects the text delivered back, for a turn-limit abort and a user stop. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const eventHandlers = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + eventHandlers.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, eventHandlers, lifecycle }; +} + +// The RPC channels are registered on the first bound session_start (#142), so a +// test that drives them must fire it first — as a real session always does. A +// sessionId-less ctx makes startScheduler short-circuit (no filesystem touch). +async function bind(lifecycle: Map) { + const bindCtx = ctx(); + bindCtx.sessionManager.getSessionId = vi.fn(() => undefined); + await lifecycle.get("session_start")({}, bindCtx); +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: "/tmp", + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; + +describe("status note reaches the parent through the real handlers", () => { + afterEach(() => { + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + vi.restoreAllMocks(); + }); + + it("foreground turn-limit abort → the Agent result flags an incomplete outcome", async () => { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "partial work so far", + session: { dispose: vi.fn() } as any, + aborted: true, // hard turn-limit abort + steered: false, + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + + const res = await tools.get("Agent").execute( + "tc1", + { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: false }, + undefined, undefined, ctx(), + ); + + const out = textOf(res); + // Exact lead clause, not just "turn limit": a steered/aborted mix-up would + // otherwise slip through, and they are different outcomes. + expect(out).toContain("aborted at the turn limit"); + expect(out).toContain("partial work so far"); // partial result still delivered + expect(out).not.toContain("STOPPED BY THE USER"); // not mislabelled as a user stop + + // The two answers a foreground parent needs: is this all of it, and is the + // task done. The first is what #174 turned on — the parent has no agent id, + // so it must not read "partial" as "go fetch the rest". + expect(out).toContain("everything the agent produced is above"); + expect(out).toContain("the task is unfinished"); + // State only, never an instruction to act (see getForegroundOutcomeNote): + // advising a fresh run to save one wasted tool call is a bet nothing here + // can measure. And naming the tool we steer away from only raises its salience. + expect(out).not.toContain("re-spawn"); + expect(out).not.toContain("get_subagent_result"); + }); + + it("foreground user-stop → tells the parent NOT to restart it unasked", async () => { + // Pi delivers a user ESC as an abort on the tool's signal; the manager wires + // that to abort(id) (#44), landing the record on "stopped" — deliberately + // distinct from a turn-limit "aborted", because the correct next action is + // the opposite one. + let finish: (v: any) => void = () => {}; + vi.mocked(runAgent).mockReturnValue(new Promise((r) => { finish = r; }) as any); + + const { pi, tools } = makePi(); + subagentsExtension(pi); + + const parent = new AbortController(); + const call = tools.get("Agent").execute( + "tc-stop", + { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: false }, + parent.signal, undefined, ctx(), + ); + + // The manager only wires addEventListener("abort", …) and never checks + // signal.aborted upfront (agent-manager.ts:240-243), so aborting before the + // listener is attached would silently land on "completed" instead. Flush + // first rather than relying on spawn() happening in execute()'s synchronous + // prefix, which any future await in that path would quietly break. + await new Promise((r) => setImmediate(r)); + parent.abort(); // the user hits ESC + finish({ responseText: "partial work so far", session: { dispose: vi.fn() }, aborted: false, steered: false }); + + const out = textOf(await call); + expect(out).toContain("STOPPED BY THE USER"); + expect(out).toContain("everything the agent produced is above"); + // Same claim, same confidence, same words as the aborted case — only the + // lead clause distinguishes them. + expect(out).toContain("the task is unfinished"); + // State only, here most of all. Advice to re-spawn would re-run work a human + // deliberately killed; advice to ask first presumes someone is there to ask, + // which is false under `pi -p`, scheduled jobs, and background-driven runs. + expect(out).not.toContain("re-spawn"); + expect(out).not.toContain("ask before"); + }); + + it("hides nested records from top-level tools, registry, transcripts, and lifecycle", async () => { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "nested result", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + const registry = (globalThis as any)[Symbol.for("pi-subagents:manager")]; + + // External registry/RPC callers cannot mint internal ownership metadata. + const topId = registry.spawn(pi, ctx(), "general-purpose", "top", { + description: "top-level owner", + isBackground: false, + parentAgentId: "forged-parent", + depth: 99, + maxSubagentDepth: 99, + configCwd: "/untrusted/config", + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(registry.getRecord(topId)).toEqual(expect.objectContaining({ + parentAgentId: undefined, + depth: 1, + })); + + // Internal scoped tools receive the raw owning manager through nestedRuntime. + const rawManager = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.manager; + pi.events.emit.mockClear(); + pi.appendEntry.mockClear(); + pi.sendMessage.mockClear(); + const id = rawManager.spawn(pi, ctx(), "general-purpose", "nested", { + description: "nested child", + isBackground: true, + parentAgentId: topId, + depth: 2, + maxSubagentDepth: 2, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(registry.getRecord(id)).toBeUndefined(); + for (const [name, params] of [ + ["get_subagent_result", { agent_id: id }], + ["steer_subagent", { agent_id: id, message: "stop" }], + ["Agent", { resume: id, prompt: "continue", description: "resume", subagent_type: "general-purpose" }], + ] as const) { + const result = await tools.get(name).execute("tc-nested", params, undefined, undefined, ctx()); + expect(textOf(result)).toContain("Agent not found"); + } + expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:started", expect.objectContaining({ id })); + expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:completed", expect.objectContaining({ id })); + expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:failed", expect.objectContaining({ id })); + expect(pi.appendEntry).not.toHaveBeenCalledWith("subagents:record", expect.objectContaining({ id })); + expect(pi.sendMessage).not.toHaveBeenCalled(); + }); + + it("background user-stop → get_subagent_result flags STOPPED BY THE USER (not completed)", async () => { + // A background agent that never settles on its own — only a stop ends it. + vi.mocked(runAgent).mockReturnValue(new Promise(() => {}) as any); + const { pi, tools, eventHandlers, lifecycle } = makePi(); + subagentsExtension(pi); + await bind(lifecycle); // register RPC channels via session_start (#142) + + const spawn = await tools.get("Agent").execute( + "tc2", + { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: true }, + undefined, undefined, ctx(), + ); + const id = textOf(spawn).match(/Agent ID: (\S+)/)?.[1]; + expect(id, "background spawn should surface an agent id").toBeTruthy(); + + // The user stops it — same path the viewer's stop key uses (manager.abort). + eventHandlers.get("subagents:rpc:stop")?.({ requestId: "r1", agentId: id }); + + const res = await tools.get("get_subagent_result").execute( + "tc3", { agent_id: id }, undefined, undefined, ctx(), + ); + + const out = textOf(res); + expect(out).toContain("STOPPED BY THE USER"); + expect(out).toContain("the task was NOT finished"); + expect(out).not.toContain("Done"); // not surfaced as a normal completion + + // The background/retrieval path keeps getStatusNote, NOT the foreground + // note. Its caller holds a 500-char preview and a real agent id, so + // "everything the agent produced is above" would be a lie here. Folding the + // two functions back together is exactly the regression this guards. + expect(out).not.toContain("everything the agent produced is above"); + }); +}); + +// `subagents:compacted` is a documented cross-extension contract (README:495) +// with an exact payload. Nothing inside this process reads it, so a dropped or +// renamed field is completely silent here and only breaks a consumer. Its +// sibling `subagents:started` is already covered — including the negative case +// for nested children — so this is the twin that was missed. +describe("subagents:compacted", () => { + /** Run an agent and fire one compaction from inside runAgent's callbacks. */ + function runWithCompaction(info: { reason: string; tokensBefore: number }) { + vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, opts: any) => { + opts.onCompaction?.(info); + return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false }; + }); + } + + it("emits the documented payload when a top-level agent's session compacts", async () => { + runWithCompaction({ reason: "threshold", tokensBefore: 12345 }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tc-compact", + { prompt: "go", description: "compacting agent", subagent_type: "general-purpose" }, + undefined, undefined, ctx(), + ); + + expect(pi.events.emit).toHaveBeenCalledWith("subagents:compacted", expect.objectContaining({ + id: expect.any(String), + type: "general-purpose", + description: "compacting agent", + reason: "threshold", + tokensBefore: 12345, + compactionCount: 1, + })); + }); + + it("counts repeated compactions on the same agent", async () => { + vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, opts: any) => { + opts.onCompaction?.({ reason: "overflow", tokensBefore: 1 }); + opts.onCompaction?.({ reason: "overflow", tokensBefore: 2 }); + return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false }; + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tc-compact2", + { prompt: "go", description: "twice", subagent_type: "general-purpose" }, + undefined, undefined, ctx(), + ); + + const counts = pi.events.emit.mock.calls + .filter((c: any[]) => c[0] === "subagents:compacted") + .map((c: any[]) => c[1].compactionCount); + expect(counts).toEqual([1, 2]); + }); + + it("stays silent for a nested child, like every other lifecycle event", async () => { + // Nested records are internal to their parent; leaking their compactions + // would spam the parent session's bus with ids no consumer can resolve. + runWithCompaction({ reason: "threshold", tokensBefore: 999 }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + + await tools.get("Agent").execute( + "tc-parent", + { prompt: "go", description: "parent", subagent_type: "general-purpose" }, + undefined, undefined, ctx(), + ); + const rawManager = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.manager; + const parentId = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.parentAgentId; + pi.events.emit.mockClear(); + + rawManager.spawn(pi, ctx(), "general-purpose", "nested", { + description: "nested child", + isBackground: true, + parentAgentId: parentId, + depth: 2, + maxSubagentDepth: 2, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:compacted", expect.anything()); + }); +}); diff --git a/pi-subagents/test/steer-subagent-wiring.test.ts b/pi-subagents/test/steer-subagent-wiring.test.ts new file mode 100644 index 0000000..850f8fb --- /dev/null +++ b/pi-subagents/test/steer-subagent-wiring.test.ts @@ -0,0 +1,176 @@ +/** + * steer-subagent-wiring.test.ts — the steer_subagent path that can silently + * swallow user input. + * + * A steer issued between spawn and session creation is parked on the record as + * `pendingSteers` and flushed later by AgentManager. The tool tells the caller + * the message was queued, so if the queue is dropped or overwritten the user is + * told their correction landed when it never will. That whole branch had no + * coverage — the only existing assertion on this tool is that a nested agent id + * reports "Agent not found". + * + * The plain rejections (unknown id, non-running status) are deliberately not + * tested: they are single-line guards whose failure is immediately visible in + * the tool's own reply. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn(), steerAgent: vi.fn() }; +}); + +import { runAgent, steerAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; +import { ctx, flush, makePi, textOf } from "./helpers/boot-extension.js"; + +// steerAgent and runAgent are module-level mocks shared by every case here, so +// call history has to be reset or a "was never called" assertion depends on the +// order the cases happen to run in. +beforeEach(() => { + vi.mocked(steerAgent).mockReset(); + vi.mocked(runAgent).mockReset(); +}); + +/** Enough of an AgentSession for the manager's and index's onSessionCreated hooks. */ +function fakeSession(overrides: Record = {}) { + return { + steer: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + messages: [], + getActiveToolNames: vi.fn(() => []), + ...overrides, + } as any; +} + +/** A runAgent that never settles, and only creates its session when told to. */ +function heldRun() { + let createSession: ((session: any) => void) | undefined; + vi.mocked(runAgent).mockImplementation( + (_ctx: any, _type: any, _prompt: any, opts: any) => + new Promise(() => { + createSession = (session: any) => opts.onSessionCreated?.(session); + }) as any, + ); + return { + create(session: any) { + createSession?.(session); + }, + }; +} + +async function spawnBackground(tools: Map): Promise { + const r = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "steer wiring agent", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctx(), + ); + return /Agent ID: (\S+)/.exec(textOf(r))![1]; +} + +const steer = (tools: Map, agent_id: string, message: string) => + tools.get("steer_subagent").execute("tc-steer", { agent_id, message }, undefined, undefined, ctx()); + +describe("steer_subagent before the session exists", () => { + it("queues the message on the record and says so", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + heldRun(); + + const id = await spawnBackground(tools); + await flush(); + + const result = await steer(tools, id, "change course"); + expect(textOf(result)).toContain("queued"); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:steered", { id, message: "change course" }); + + await lifecycle.get("session_shutdown")?.(); + }); + + it("appends a second queued steer instead of replacing the first", async () => { + // Overwriting would lose the earlier correction while still reporting success. + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const run = heldRun(); + + const id = await spawnBackground(tools); + await flush(); + + await steer(tools, id, "first"); + await steer(tools, id, "second"); + + // Observe the queue through its only real consumer: the flush on session + // creation, which must deliver both, in order. + const sessionSteer = vi.fn().mockResolvedValue(undefined); + run.create(fakeSession({ steer: sessionSteer })); + await flush(); + + expect(sessionSteer.mock.calls.map((c) => c[0])).toEqual(["first", "second"]); + + await lifecycle.get("session_shutdown")?.(); + }); + + it("does not call steerAgent — there is no session to steer yet", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + heldRun(); + + const id = await spawnBackground(tools); + await flush(); + await steer(tools, id, "hello"); + + expect(steerAgent).not.toHaveBeenCalled(); + + await lifecycle.get("session_shutdown")?.(); + }); +}); + +describe("steer_subagent once the session exists", () => { + it("reports failure and emits no event when the steer throws", async () => { + // The event is emitted only AFTER steerAgent resolves, so a failed steer + // must not announce itself as delivered. + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const run = heldRun(); + + const id = await spawnBackground(tools); + await flush(); + run.create(fakeSession()); + await flush(); + + vi.mocked(steerAgent).mockRejectedValueOnce(new Error("session closed")); + const result = await steer(tools, id, "too late"); + + expect(textOf(result)).toContain("Failed to steer agent"); + expect(textOf(result)).toContain("session closed"); + expect(pi.events.emit).not.toHaveBeenCalledWith( + "subagents:steered", + expect.objectContaining({ message: "too late" }), + ); + + await lifecycle.get("session_shutdown")?.(); + }); + + it("delivers through steerAgent and announces the steer on success", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const run = heldRun(); + + const id = await spawnBackground(tools); + await flush(); + run.create(fakeSession()); + await flush(); + + vi.mocked(steerAgent).mockResolvedValueOnce(undefined as any); + const result = await steer(tools, id, "refocus"); + + expect(steerAgent).toHaveBeenCalledWith(expect.anything(), "refocus"); + expect(textOf(result)).toContain("Steering message sent"); + expect(pi.events.emit).toHaveBeenCalledWith("subagents:steered", { id, message: "refocus" }); + + await lifecycle.get("session_shutdown")?.(); + }); +}); diff --git a/pi-subagents/test/strict-agent-files-wiring.test.ts b/pi-subagents/test/strict-agent-files-wiring.test.ts new file mode 100644 index 0000000..f89a551 --- /dev/null +++ b/pi-subagents/test/strict-agent-files-wiring.test.ts @@ -0,0 +1,124 @@ +/** + * strict-agent-files-wiring.test.ts — proves `strictAgentFiles` gates the real + * extension activation, and that it is a STARTUP decision only. + * + * The load-bearing pair: activating with the setting on must throw (that is the + * whole point — pi refuses to start rather than run a substituted agent), while + * a later reload of the same broken file must not. Agents reload once per + * `Agent` call, so a strict reload would kill the session on an unrelated spawn + * long after the bad edit, where the failure looks disconnected from its cause. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAgents } from "../src/agent-types.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + return { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn(), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; +} + +const BROKEN = "---\nname: broken\ndescription: Use this: that\n---\n\nBroken.\n"; + +let cwd: string; +let originalCwd: string; +let originalAgentDir: string | undefined; +let originalHome: string | undefined; + +function writeSettings(settings: Record): void { + const dir = join(cwd, ".pi"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "subagents.json"), JSON.stringify(settings)); +} + +function writeBrokenAgent(): string { + const dir = join(cwd, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "broken.md"); + writeFileSync(path, BROKEN); + return path; +} + +describe("strictAgentFiles gates extension activation", () => { + let warn: ReturnType; + + beforeEach(() => { + originalCwd = process.cwd(); + cwd = mkdtempSync(join(tmpdir(), "strict-agent-files-")); + process.chdir(cwd); + // A developer's real ~/.pi/subagents.json would otherwise set this very + // setting under the tests, and their global agents would pollute the roster. + originalAgentDir = process.env.PI_CODING_AGENT_DIR; + originalHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir"); + process.env.HOME = cwd; + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + process.chdir(originalCwd); + if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDir; + if (originalHome == null) delete process.env.HOME; + else process.env.HOME = originalHome; + registerAgents(new Map()); + rmSync(cwd, { recursive: true, force: true }); + }); + + it("aborts activation naming the file when enabled", () => { + const path = writeBrokenAgent(); + writeSettings({ strictAgentFiles: true }); + + expect(() => subagentsExtension(makePi())).toThrow(path); + }); + + it("skips the file and activates when disabled (the default)", () => { + writeBrokenAgent(); + + expect(() => subagentsExtension(makePi())).not.toThrow(); + expect(String(warn.mock.calls[0]?.[0])).toContain("Skipping agent file"); + }); + + it("is a startup decision: a later reload of the same file does not throw", async () => { + const path = writeBrokenAgent(); + writeSettings({ strictAgentFiles: true }); + + // Start clean, so the session exists — then break the file underneath it. + writeFileSync(path, "---\ndescription: Fixed\n---\n\nFixed.\n"); + const pi = makePi(); + expect(() => subagentsExtension(pi)).not.toThrow(); + writeFileSync(path, BROKEN); + + const agentTool = (pi.registerTool as any).mock.calls + .map((c: any[]) => c[0]) + .find((t: any) => t.name === "Agent"); + expect(agentTool).toBeDefined(); + + // The Agent tool reloads the registry per call. That reload must be + // non-strict: it returns a normal "unknown type" result, not a YAML throw. + const uiCtx = { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd, + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; + + const result = await agentTool.execute("call-1", { subagent_type: "nope", prompt: "x" }, undefined, vi.fn(), uiCtx); + expect(JSON.stringify(result)).not.toContain("Nested mappings"); + }); +}); diff --git a/pi-subagents/test/subagent-error-status-e2e.test.ts b/pi-subagents/test/subagent-error-status-e2e.test.ts new file mode 100644 index 0000000..6b28c39 --- /dev/null +++ b/pi-subagents/test/subagent-error-status-e2e.test.ts @@ -0,0 +1,108 @@ +/** + * subagent-error-status-e2e.test.ts — regression for issue #144: a subagent + * whose final assistant turn is a provider error must be reported as a + * failure, not as "completed" with an empty (or stale) result. + * + * Full-stack: real pi loader + real extension + real runAgent + real child + * sessions on a faux model. Faux is the point, not a shortcut — the scenario is + * a provider error with zero content, which no live model will produce on + * request. Each run pins `live: false` so the pre-publish smoke's global + * `PI_E2E_LIVE=1` can't swap a real model in and turn this suite red. + */ +import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + agentCall, + type PrintModeRun, + routeBySession, + runPrintMode, +} from "./helpers/print-mode-runner.js"; + +/** Text of the parent's Agent tool result — what the orchestrator LLM sees. */ +function agentToolResult(session: AgentSession): string { + const msg = [...session.messages].reverse().find( + (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", + ); + return ((msg?.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); +} + +vi.setConfig({ testTimeout: 30_000 }); + +// Not matched by pi's transient-error patterns → no auto-retry, deterministic. +const FATAL = "invalid request: provider rejected the prompt"; + +describe("issue #144 — empty-error final turns must not be 'completed'", () => { + let run: PrintModeRun | undefined; + afterEach(async () => { + await run?.dispose(); + run = undefined; + }); + + it("a run whose ONLY turn errors with no output is a failure, not an empty success", async () => { + run = await runPrintMode({ + prompt: "Delegate.", + respond: routeBySession({ + parentInitial: agentCall({ run_in_background: false, description: "doomed", prompt: "Do work." }), + parentFinal: "parent done", + // The child's one and only turn: provider error, zero content. + subagent: () => fauxAssistantMessage([], { stopReason: "error", errorMessage: FATAL }), + }), + live: false, + }); + + // DESIRED: the orchestrator sees a failure naming the provider error — + // not a clean success reading "No output.". + const toolResult = agentToolResult(run.parentSession); + expect(toolResult).toContain(FATAL); + expect(toolResult).not.toContain("No output."); + }); + + it("an earlier turn's text must not mask a failed final turn as a fresh success", async () => { + run = await runPrintMode({ + prompt: "Delegate.", + respond: routeBySession({ + parentInitial: agentCall({ run_in_background: false, description: "masked", prompt: "Do work." }), + parentFinal: "parent done", + subagent: (ctx) => { + const hasToolResult = ctx.messages.some((m) => m.role === "toolResult"); + // Turn 1: real text + a tool call. Turn 2 (after the tool result): + // provider error with zero content. + return hasToolResult + ? fauxAssistantMessage([], { stopReason: "error", errorMessage: FATAL }) + : fauxAssistantMessage([ + fauxText("EARLIER-PARTIAL-TEXT"), + fauxToolCall("bash", { command: "echo hi" }), + ]); + }, + }), + live: false, + }); + + // The orchestrator sees the failure (not the earlier text as a clean + // answer), AND the partial output is salvaged, clearly labeled as + // pre-failure so it can't be mistaken for the final answer. + const toolResult = agentToolResult(run.parentSession); + expect(toolResult).toContain(FATAL); + expect(toolResult).toContain("Partial output before the failure:"); + expect(toolResult).toContain("EARLIER-PARTIAL-TEXT"); + // The failure headline comes before the salvaged partial output. + expect(toolResult.indexOf(FATAL)).toBeLessThan(toolResult.indexOf("EARLIER-PARTIAL-TEXT")); + }); + + it("a pure empty-error run shows no 'partial output' section", async () => { + run = await runPrintMode({ + prompt: "Delegate.", + respond: routeBySession({ + parentInitial: agentCall({ run_in_background: false, description: "empty", prompt: "Do work." }), + parentFinal: "parent done", + subagent: () => fauxAssistantMessage([], { stopReason: "error", errorMessage: FATAL }), + }), + live: false, + }); + + const toolResult = agentToolResult(run.parentSession); + expect(toolResult).toContain(FATAL); + expect(toolResult).not.toContain("Partial output before the failure:"); + }); +}); diff --git a/pi-subagents/test/subagents-nested-print-mode-e2e.test.ts b/pi-subagents/test/subagents-nested-print-mode-e2e.test.ts new file mode 100644 index 0000000..09d02f0 --- /dev/null +++ b/pi-subagents/test/subagents-nested-print-mode-e2e.test.ts @@ -0,0 +1,467 @@ +/** + * subagents-nested-print-mode-e2e.test.ts — remaining real-session coverage for + * opt-in nested delegation after #164 landed. + * + * `test/nested-delegation-e2e.test.ts` already pins the happy path (tool + * admission + two-hop foreground return + background poll/transcript). This + * file covers the production-boundary cases that suite still leaves open: + * default-off injection, depth-cap tool stripping, background parent holds + * while a child nests, and cross-parent ownership denial against the published + * root manager lifecycle. + * + * Extracted from codesoda/pi-subagents#2 (test-only follow-up to #164) and + * reconciled with the merged #164 frontmatter contract (`allowed_subagents` + * opt-in; no nested tools injected at the depth cap). + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Context, ToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerAgents } from "../src/agent-types.js"; +import { loadCustomAgents } from "../src/custom-agents.js"; +import { + agentCall, + type FauxResponder, + type PrintModeRun, + runPrintMode, +} from "./helpers/print-mode-runner.js"; + +vi.setConfig({ testTimeout: 30_000 }); + +const NESTED_TOOLS = ["Agent", "get_subagent_result", "steer_subagent"]; + +function userPrompt(ctx: Context): string { + for (const message of ctx.messages) { + if (message.role !== "user") continue; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const text = content.find( + (block: { type?: string; text?: string }) => block.type === "text", + ) as { text?: string } | undefined; + if (text?.text) return text.text; + } + } + return ""; +} + +function tools(ctx: Context): string[] { + return (ctx.tools ?? []).map((tool) => tool.name); +} + +function toolResults(ctx: Context, name: string): string[] { + return ctx.messages.flatMap((message) => { + if ( + message.role !== "toolResult" || + (message as { toolName?: string }).toolName !== name + ) { + return []; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) return []; + return [ + content + .map((block: { type?: string; text?: string }) => + block.type === "text" ? (block.text ?? "") : "", + ) + .join(""), + ]; + }); +} + +function nestedToolsIn(toolNames: string[] | undefined): string[] { + return (toolNames ?? []).filter((name) => NESTED_TOOLS.includes(name)); +} + +async function waitForChildReady( + ready: Promise, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + ready, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Nested child did not enter its deferred response within ${timeoutMs}ms`, + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function lastToolResult(ctx: Context, name: string): string { + const results = toolResults(ctx, name); + return results[results.length - 1] ?? ""; +} + +function toolCall( + name: string, + args: Record, + id: string, +): ToolCall { + return { type: "toolCall", id, name, arguments: args } as ToolCall; +} + +function writeAgents(cwd: string, agents: Record): void { + const dir = join(cwd, ".pi", "agents"); + mkdirSync(dir, { recursive: true }); + for (const [name, frontmatter] of Object.entries(agents)) { + writeFileSync( + join(dir, `${name}.md`), + `---\ndescription: ${name}\n${frontmatter}---\n${name} agent\n`, + ); + } +} + +async function runWithAgents( + agents: Record, + respond: FauxResponder, + options: { prompt: string; maxModelCalls?: number; hold?: boolean } = { + prompt: "root", + }, +): Promise<{ run: PrintModeRun; cwd: string }> { + const cwd = mkdtempSync(join(tmpdir(), "subagents-nested-e2e-")); + writeAgents(cwd, agents); + const run = await runPrintMode({ + ...options, + cwd, + respond, + // Pinned faux: every case here scripts exact tool calls, so the pre-publish + // smoke's global `PI_E2E_LIVE=1` must not swap a real model in. + live: false, + beforeRun: () => registerAgents(loadCustomAgents(cwd)), + }); + return { run, cwd }; +} + +describe("PR #164 nested agents through the real print-mode boundary", () => { + let run: PrintModeRun | undefined; + let cwd: string | undefined; + + afterEach(async () => { + await run?.dispose(); + run = undefined; + if (cwd) rmSync(cwd, { recursive: true, force: true }); + cwd = undefined; + }); + + it("does not inject nested orchestration tools into a custom agent by default", async () => { + const observed = new Map(); + ({ run, cwd } = await runWithAgents( + { plain: "" }, + (ctx) => { + const route = userPrompt(ctx); + if (route === "plain-child") { + observed.set(route, tools(ctx)); + return "PLAIN_CHILD_RESULT"; + } + if (toolResults(ctx, "Agent").length === 0) { + return agentCall({ + subagent_type: "plain", + description: "plain child", + prompt: "plain-child", + run_in_background: false, + }); + } + return lastToolResult(ctx, "Agent"); + }, + { prompt: "root-default" }, + )); + + expect(run.responseText).toContain("PLAIN_CHILD_RESULT"); + expect(observed.get("plain-child")).toBeDefined(); + expect(nestedToolsIn(observed.get("plain-child"))).toEqual([]); + }); + + it("strips nested tools at the depth cap instead of injecting always-failing ones", async () => { + // Default maxSubagentDepth is 2: main(0) → level_one(1) → level_two(2). + // #164 injects nested tools only while depth < max, so the agent at the cap + // never sees Agent/get/steer even when it opts in via allowed_subagents. + const observed = new Map(); + ({ run, cwd } = await runWithAgents( + { + level_one: "allowed_subagents: level_two\n", + level_two: "allowed_subagents: level_three\n", + level_three: "", + }, + (ctx) => { + const route = userPrompt(ctx); + observed.set(route, tools(ctx)); + if (route === "level_three-child") return "UNEXPECTED_LEVEL_THREE"; + if (route === "level_two-child") { + const nested = nestedToolsIn(tools(ctx)); + // Cap agents must complete directly — they have no nested tools. + return `AT_CAP tools=${nested.length === 0 ? "none" : nested.join(",")}`; + } + if (route === "level_one-child") { + if (toolResults(ctx, "Agent").length === 0) { + return agentCall({ + subagent_type: "level_two", + description: "allowed level", + prompt: "level_two-child", + run_in_background: false, + }); + } + return lastToolResult(ctx, "Agent"); + } + if (toolResults(ctx, "Agent").length === 0) { + return agentCall({ + subagent_type: "level_one", + description: "recursive chain", + prompt: "level_one-child", + run_in_background: false, + }); + } + return lastToolResult(ctx, "Agent"); + }, + { prompt: "root-depth", maxModelCalls: 24 }, + )); + + expect(run.responseText).toContain("AT_CAP tools=none"); + expect(observed.get("level_one-child")).toEqual( + expect.arrayContaining(NESTED_TOOLS), + ); + expect(nestedToolsIn(observed.get("level_two-child"))).toEqual([]); + expect(observed.has("level_three-child")).toBe(false); + }); + + it("holds a background child while it performs real nested delegation", async () => { + const calls = new Map(); + ({ run, cwd } = await runWithAgents( + { + background_delegator: "allowed_subagents: background_grandchild\n", + background_grandchild: "", + }, + async (ctx) => { + const route = userPrompt(ctx); + calls.set(route, (calls.get(route) ?? 0) + 1); + if (route === "background-grandchild-child") { + await new Promise((resolve) => setTimeout(resolve, 50)); + return "BACKGROUND_NESTED_RESULT"; + } + if (route === "background-delegator-child") { + if (toolResults(ctx, "Agent").length === 0) { + return agentCall({ + subagent_type: "background_grandchild", + description: "nested foreground work", + prompt: "background-grandchild-child", + run_in_background: false, + }); + } + return lastToolResult(ctx, "Agent"); + } + const agents = toolResults(ctx, "Agent"); + if (agents.length === 0) { + return agentCall({ + subagent_type: "background_delegator", + description: "background nested work", + prompt: "background-delegator-child", + run_in_background: true, + }); + } + if (toolResults(ctx, "get_subagent_result").length === 0) { + const id = agents[0].match(/Agent ID: ([^\s]+)/)?.[1]; + if (!id) throw new Error(`No background agent ID in: ${agents[0]}`); + return toolCall( + "get_subagent_result", + { agent_id: id, wait: true }, + "get-background-result", + ); + } + return lastToolResult(ctx, "get_subagent_result"); + }, + { prompt: "root-background", maxModelCalls: 24 }, + )); + + expect(run.responseText).toContain("BACKGROUND_NESTED_RESULT"); + expect(calls.get("background-delegator-child")).toBe(2); + expect(calls.get("background-grandchild-child")).toBe(1); + expect( + run.parentSession.messages.some( + (message) => + message.role === "assistant" && + message.content.some( + (block) => + block.type === "toolCall" && block.name === "get_subagent_result", + ), + ), + ).toBe(true); + }); + + it("keeps nested get and steer ownership-scoped to the calling parent", async () => { + // #164 aborts nested children when their owner settles, so the owner must + // stay running while a foreign peer probes the live nested record. Spawn + // both owner and probe as top-level background agents (hold:false so the + // root can continue); the nested tools themselves enforce ownership. + const probeResults: { get?: string; steer?: string } = {}; + let ownedChildBlocked = false; + let releaseOwnedChild = () => {}; + let releaseOwner = () => {}; + let ownedChildEntered!: () => void; + let ownerHolding!: () => void; + let nestedId = ""; + const ownedChildReady = new Promise((resolve) => { + ownedChildEntered = resolve; + }); + const ownedChildRelease = new Promise((resolve) => { + releaseOwnedChild = resolve; + }); + const ownerHold = new Promise((resolve) => { + releaseOwner = resolve; + }); + const ownerHoldingReady = new Promise((resolve) => { + ownerHolding = resolve; + }); + let waitForAll: Promise | undefined; + + try { + ({ run, cwd } = await runWithAgents( + { + owner: "allowed_subagents: owned_child\n", + // Unrestricted nested allowlist — still ownership-scoped at runtime. + probe: "allowed_subagents: all\n", + owned_child: "", + }, + async (ctx) => { + const route = userPrompt(ctx); + if (route === "owned-child") { + ownedChildBlocked = true; + ownedChildEntered(); + await ownedChildRelease; + return "OWNED_NESTED_RESULT"; + } + if (route === "owner-child") { + const agents = toolResults(ctx, "Agent"); + if (agents.length === 0) { + return agentCall({ + subagent_type: "owned_child", + description: "owned nested child", + prompt: "owned-child", + run_in_background: true, + }); + } + const id = agents[0].match(/Agent ID: ([^\s]+)/)?.[1]; + if (!id) throw new Error(`No nested ID in: ${agents[0]}`); + nestedId = id; + ownerHolding(); + // Stay running so #164 does not abortOwnedChildren(owned_child). + await ownerHold; + return `OWNER_NESTED_ID:${id}`; + } + if (route === "probe-child") { + await ownedChildReady; + // Wait until the owner has published the nested id. + await waitForChildReady(ownerHoldingReady, 5_000); + const id = nestedId; + if (!id) throw new Error("Owner never published a nested agent id"); + const gets = toolResults(ctx, "get_subagent_result"); + const steers = toolResults(ctx, "steer_subagent"); + if (gets.length === 0) { + return toolCall( + "get_subagent_result", + { agent_id: id }, + "foreign-get", + ); + } + if (steers.length === 0) { + return toolCall( + "steer_subagent", + { agent_id: id, message: "foreign guidance" }, + "foreign-steer", + ); + } + probeResults.get = gets.at(-1); + probeResults.steer = steers.at(-1); + return "OWNERSHIP_PROBE_DONE"; + } + const agents = toolResults(ctx, "Agent"); + if (agents.length === 0) { + return agentCall({ + subagent_type: "owner", + description: "nested record owner", + prompt: "owner-child", + run_in_background: true, + }); + } + if (agents.length === 1) { + // Fire probe without waiting for the background owner to finish. + return agentCall({ + subagent_type: "probe", + description: "foreign ownership probe", + prompt: "probe-child", + run_in_background: true, + }); + } + const results = toolResults(ctx, "get_subagent_result"); + if (results.length === 0) { + // Poll the probe (second background agent) for the denial outcome. + const probeSpawn = agents[1]; + const probeId = probeSpawn.match(/Agent ID: ([^\s]+)/)?.[1]; + if (!probeId) throw new Error(`No probe ID in: ${probeSpawn}`); + return toolCall( + "get_subagent_result", + { agent_id: probeId, wait: true }, + "await-probe", + ); + } + return lastToolResult(ctx, "get_subagent_result"); + }, + { prompt: "root-ownership", maxModelCalls: 32, hold: false }, + )); + + await waitForChildReady(ownedChildReady, 5_000); + expect(ownedChildBlocked).toBe(true); + expect(run.manager).toBeDefined(); + const manager = run.manager; + if (!manager) throw new Error("Print-mode manager was not published"); + // Owner (and its nested child) are still live — root finished without hold. + expect(manager.hasRunning()).toBe(true); + waitForAll = manager.waitForAll(); + let waitTimer: ReturnType | undefined; + const waitState = await Promise.race([ + waitForAll.then(() => "settled" as const), + new Promise<"pending">((resolve) => { + waitTimer = setTimeout(() => resolve("pending"), 50); + }), + ]); + if (waitTimer) clearTimeout(waitTimer); + expect(waitState).toBe("pending"); + + expect(run.responseText).toContain("OWNERSHIP_PROBE_DONE"); + expect(probeResults.get).toBeDefined(); + expect(probeResults.steer).toBeDefined(); + expect(probeResults.get).toMatch( + /^Nested agent not found or not owned by this parent:/i, + ); + expect(probeResults.steer).toMatch( + /^Running nested agent not found or not owned by this parent:/i, + ); + expect(probeResults.get).not.toContain("OWNED_NESTED_RESULT"); + expect(probeResults.steer).not.toContain("Steering message sent"); + + releaseOwnedChild(); + releaseOwner(); + await expect(waitForAll).resolves.toBeUndefined(); + expect(manager.hasRunning()).toBe(false); + } finally { + releaseOwnedChild(); + releaseOwner(); + if (waitForAll) { + await Promise.race([ + waitForAll, + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + } + } + }); +}); diff --git a/pi-subagents/test/subagents-print-mode-e2e.test.ts b/pi-subagents/test/subagents-print-mode-e2e.test.ts new file mode 100644 index 0000000..71c671f --- /dev/null +++ b/pi-subagents/test/subagents-print-mode-e2e.test.ts @@ -0,0 +1,388 @@ +/** + * subagents-print-mode-e2e.test.ts — REAL end-to-end subagent runs through the + * headless print-mode host (`test/helpers/print-mode-runner.ts`). + * + * Unlike agent-runner-e2e / ext-templates-e2e (which assert on the gated tool + * set captured at construction and never drive a turn), these tests drive a real + * parent turn that calls the `Agent` tool, lets the extension spawn a real child + * session via the real `runAgent`, and waits for it through the real subagent + * hold condition — then asserts on what actually flowed back. + * + * Deterministic by default: a scripted faux model drives both parent and child + * (no network). The same runner also drives a real LLM when PI_E2E_LIVE=1 — the + * `live` describe below is a smoke test for that opt-in path. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Context } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + agentCall, + agentToolCalls, + agentToolResults, + conversationText, + invokedToolNames, + type PrintModeRun, + routeBySession, + runPrintMode, +} from "./helpers/print-mode-runner.js"; + +// Real pi-mono (loader + dynamic extension import + two live sessions) — a cold +// run under full-suite CPU contention can exceed vitest's 5s default. +vi.setConfig({ testTimeout: 30_000 }); + +const LIVE = /^(1|true|yes)$/i.test(process.env.PI_E2E_LIVE ?? ""); + +describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)", () => { + let run: PrintModeRun | undefined; + const tmpDirs: string[] = []; + + afterEach(async () => { + await run?.dispose(); + run = undefined; + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it("spawns a FOREGROUND subagent and routes its real output back to the parent", async () => { + run = await runPrintMode({ + prompt: "Delegate the greeting to a subagent.", + respond: routeBySession({ + parentInitial: agentCall({ + subagent_type: "general-purpose", + description: "greet", + prompt: "Say hello.", + run_in_background: false, + }), + // NON-circular: the parent's final answer echoes whatever the child's + // result actually was in context. If the child output didn't reach the + // parent, this returns CHILD_MISSING and the responseText assertion fails. + parentFinal: (ctx: Context) => { + const childOut = [...ctx.messages] + .reverse() + .find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent"); + const text = ((childOut?.content ?? []) as Array<{ text?: string }>) + .map((b) => b.text ?? "") + .join(""); + return `Parent relays: ${text.includes("CHILD_GREETING_OK") ? "CHILD_GREETING_OK" : "CHILD_MISSING"}`; + }, + subagent: "CHILD_GREETING_OK", + }), + }); + + // The child actually ran: its output reached the parent via the Agent tool + // result (real record.result), and the parent's final answer was derived + // from that result — not a value the test hard-coded into the parent. + const toolResults = agentToolResults(run.parentSession); + expect(toolResults.length).toBe(1); + expect(toolResults[0]).toContain("CHILD_GREETING_OK"); + expect(run.responseText).toContain("CHILD_GREETING_OK"); + expect(run.responseText).not.toContain("CHILD_MISSING"); + // Parent t1 (Agent call) + child t1 (reply) + parent t2 (final) = 3 calls. + expect(run.modelCalls).toBeGreaterThanOrEqual(3); + }); + + it("the hold condition is load-bearing: it keeps a BACKGROUND child alive (vs abandoned without it)", async () => { + // The child takes a beat to "think" (a real delay in its faux turn). That + // delay is what makes the contrast causal and deterministic: + // - WITHOUT the hold, the parent's turn ends and the runner tears down + // before the child ever streams → the child is abandoned (2 model calls: + // parent's tool-call turn + its summary turn; the child never runs). + // - WITH the hold, the parent loop blocks in waitForAll() until the child + // finishes → the child's own model turn actually runs (≥3 calls). + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const respond = async (ctx: Context) => { + const isParent = (ctx.tools ?? []).some((t) => t.name === "Agent"); + if (!isParent) { + await sleep(80); // child takes long enough that a non-held parent exits first + return "CHILD_BG_RAN"; + } + const spawned = ctx.messages.some( + (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", + ); + return spawned + ? "summarized" + : agentCall({ description: "bg work", prompt: "Do background work.", run_in_background: true }); + }; + + // Control: no hold → the child hasn't run by the time the parent turn ends. + // `modelCalls` is snapshotted at that moment (it's a plain number on the + // result), so draining afterwards to tear down cleanly doesn't change it. + const noHold = await runPrintMode({ prompt: "go", hold: false, respond }); + const abandonedCalls = noHold.modelCalls; + await noHold.manager?.waitForAll(); // let the orphan finish before dispose (avoids stale-ctx) + await noHold.dispose(); + + // Subject: hold on → child runs to completion before the parent finishes. + run = await runPrintMode({ prompt: "go", hold: true, respond }); + + // Background spawn returns its envelope synchronously either way. + expect(agentToolResults(run.parentSession)[0]).toMatch(/background/i); + // The hold is load-bearing: only with it does the child's turn actually run. + expect(abandonedCalls).toBe(2); // parent tool-call + summary; child never streamed + expect(run.modelCalls).toBeGreaterThan(abandonedCalls); + expect(run.modelCalls).toBeGreaterThanOrEqual(3); + }); + + it("spawns a FRONTMATTER-defined (.pi/agents/*.md) agent and its prompt reaches the child", async () => { + // A project agent whose body is a distinctive system prompt. Proving the + // child SAW it proves the full chain: the extension discovers the .md from + // process.cwd(), parses its frontmatter, and runAgent's buildAgentPrompt + // feeds the body into the real child session. + const MARKER = "SPYMARKER_FRONTMATTER_REACHED_CHILD"; + const cwd = mkdtempSync(join(tmpdir(), "subagents-fm-")); + tmpDirs.push(cwd); + mkdirSync(join(cwd, ".pi", "agents"), { recursive: true }); + writeFileSync( + join(cwd, ".pi", "agents", "echo-spy.md"), + `---\ndescription: "Echoes a marker proving its frontmatter prompt reached the child."\n---\n${MARKER}\n`, + ); + + run = await runPrintMode({ + prompt: "Delegate to the echo-spy agent.", + cwd, // runner chdir's here so the extension discovers echo-spy.md + respond: routeBySession({ + parentInitial: agentCall({ + subagent_type: "echo-spy", + description: "echo", + prompt: "Report what you were told.", + run_in_background: false, + }), + parentFinal: "Reported.", + // The child reflects whether the frontmatter body reached its own prompt. + subagent: (ctx: Context) => + `child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`, + }), + }); + + const toolResults = agentToolResults(run.parentSession); + expect(toolResults.length).toBe(1); + expect(toolResults[0]).toContain(MARKER); + expect(toolResults[0]).not.toContain("MISSING"); + // The custom type resolved — it did NOT silently fall back to general-purpose. + expect(toolResults[0]).not.toMatch(/Unknown agent type/i); + }); + + it("spawns a FRONTMATTER-defined (.agents/agents/*.md) agent and its prompt reaches the child", async () => { + const MARKER = "SPYMARKER_AGENTS_FRONTMATTER_REACHED_CHILD"; + const cwd = mkdtempSync(join(tmpdir(), "subagents-agents-fm-")); + tmpDirs.push(cwd); + mkdirSync(join(cwd, ".agents", "agents"), { recursive: true }); + writeFileSync( + join(cwd, ".agents", "agents", "agents-spy.md"), + `---\ndescription: "Echoes a marker from the .agents/agents workspace dir."\n---\n${MARKER}\n`, + ); + + run = await runPrintMode({ + prompt: "Delegate to the agents-spy agent.", + cwd, + respond: routeBySession({ + parentInitial: agentCall({ + subagent_type: "agents-spy", + description: "echo workspace", + prompt: "Report what you were told.", + run_in_background: false, + }), + parentFinal: "Reported.", + subagent: (ctx: Context) => + `child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`, + }), + }); + + const toolResults = agentToolResults(run.parentSession); + expect(toolResults.length).toBe(1); + expect(toolResults[0]).toContain(MARKER); + expect(toolResults[0]).not.toContain("MISSING"); + expect(toolResults[0]).not.toMatch(/Unknown agent type/i); + }); + + it("a colored agent's name badge never reaches print-mode text", async () => { + // Badges are a TUI concern: print mode renders no tool components, and the text the + // model and `pi -p` see is built from plain display names. An escape sequence here + // would mean color leaking into transcripts, headless output and the parent prompt. + const cwd = mkdtempSync(join(tmpdir(), "subagents-color-")); + tmpDirs.push(cwd); + mkdirSync(join(cwd, ".pi", "agents"), { recursive: true }); + writeFileSync( + join(cwd, ".pi", "agents", "painted.md"), + '---\nname: Painted Agent\ncolor: purple\ndescription: "A colored agent."\n---\nBe brief.\n', + ); + + run = await runPrintMode({ + prompt: "Delegate to the painted agent.", + cwd, + respond: routeBySession({ + parentInitial: agentCall({ + subagent_type: "painted", + description: "paint", + prompt: "Report in.", + run_in_background: false, + }), + parentFinal: "Done.", + subagent: "Painted Agent reporting in.", + }), + }); + + const result = agentToolResults(run.parentSession)[0]; + expect(result).toContain("Painted Agent reporting in."); // the escape check below is not vacuous + expect(result).not.toContain("\u001b"); + expect(conversationText(run.parentSession)).not.toContain("\u001b"); + }); + + it("errors clearly when faux mode is given no script", async () => { + await expect(runPrintMode({ prompt: "x" })).rejects.toThrow(/provide `respond` or `steps`/); + }); + + it("times out with the runner's own descriptive error and restores the environment", async () => { + const prevCwd = process.cwd(); + // A responder that never resolves — the turn stalls until the wall-clock guard fires. + await expect( + runPrintMode({ prompt: "stall", respond: () => new Promise(() => {}), timeoutMs: 300 }), + ).rejects.toThrow(/print-mode runner timed out after 300ms/); + // The failure path ran dispose(): cwd and global isolation were restored even + // though the caller never received a dispose handle. + expect(process.cwd()).toBe(prevCwd); + expect((globalThis as Record)[Symbol.for("pi-subagents:manager")]).toBeUndefined(); + }); +}); + +// Opt-in real-LLM smoke tests — exercise the SAME runner against a live model +// (auto-resolved from the local `pi` login). Skipped unless PI_E2E_LIVE=1. +// +// These are SMOKE tests, not strict assertions: a live model decides whether and +// how to call the tool, so we cover the subset it can be reliably steered into +// (foreground spawn, background spawn + get_subagent_result, an Explore spawn) +// and assert robust invariants (a real spawn happened and produced output). +// Per-feature determinism lives in the faux suite above, which scripts exact calls. +const LIVE_TIMEOUT = 150_000; +// SELF-SMOKE chains three live spawns in one session; passing runs land ~145s, +// but live variance (slow turns, provider retries, extra polling) has blown past +// 2× that — give it 4× so the smoke doesn't flake on latency alone. +const SELF_SMOKE_TIMEOUT = 600_000; +// The vitest per-test timer starts before runPrintMode and should not fire +// first: the runner's own timeoutMs guard produces a descriptive error and +// aborts the live session + subagents, while a vitest timeout is generic and +// leaks them. The slack covers live setup/teardown outside the runner's guard. +const VITEST_SLACK = 30_000; +const LIVE_VITEST_TIMEOUT = LIVE_TIMEOUT + VITEST_SLACK; +const SELF_SMOKE_VITEST_TIMEOUT = SELF_SMOKE_TIMEOUT + VITEST_SLACK; + +describe.runIf(LIVE)("subagents print-mode e2e (live LLM, opt-in)", () => { + let run: PrintModeRun | undefined; + afterEach(async () => { + await run?.dispose(); + run = undefined; + }); + + it( + "FOREGROUND spawn — real model spawns a subagent and reports its output", + async () => { + run = await runPrintMode({ + prompt: + "Use the Agent tool to spawn a general-purpose subagent (run_in_background: false) " + + "whose only task is to reply with the exact word PONG, then tell me what it replied.", + timeoutMs: LIVE_TIMEOUT, + }); + expect(run.modelCalls).toBe(0); // live mode doesn't use the faux counter + expect(invokedToolNames(run.parentSession)).toContain("Agent"); + // The child actually ran and its output came back through the tool result. + expect(agentToolResults(run.parentSession).join("\n")).toMatch(/PONG/i); + expect(run.responseText).toMatch(/PONG/i); + }, + LIVE_VITEST_TIMEOUT, + ); + + it( + "BACKGROUND spawn + get_subagent_result — model backgrounds work then retrieves it", + async () => { + run = await runPrintMode({ + prompt: + "Spawn a general-purpose subagent IN THE BACKGROUND (run_in_background: true) whose " + + "only task is to reply with the exact word BGPONG. After it finishes, use the " + + "get_subagent_result tool to fetch its result, then tell me exactly what it said.", + timeoutMs: LIVE_TIMEOUT, + }); + const calls = agentToolCalls(run.parentSession); + // The model used the background feature… + expect(calls.some((c) => c.run_in_background === true)).toBe(true); + // …and the spawn returned the "started in background" envelope… + expect(agentToolResults(run.parentSession).join("\n")).toMatch(/background/i); + // …and the background child genuinely ran (its result surfaced somewhere: + // via get_subagent_result and/or the held final answer). + expect(run.responseText).toMatch(/BGPONG/i); + }, + LIVE_VITEST_TIMEOUT, + ); + + it( + "Explore subagent_type — model dispatches a non-default agent type", + async () => { + run = await runPrintMode({ + prompt: + "Use the Agent tool with subagent_type 'Explore' to look at the current working " + + "directory and report a one-line summary of what's there.", + timeoutMs: LIVE_TIMEOUT, + }); + const calls = agentToolCalls(run.parentSession); + // The non-default type was actually selected (case-insensitive per README). + expect( + calls.some((c) => String(c.subagent_type ?? "").toLowerCase() === "explore"), + ).toBe(true); + expect(run.responseText.length).toBeGreaterThan(0); + }, + LIVE_VITEST_TIMEOUT, + ); + + it( + "SELF-SMOKE — the agent drives a multi-feature smoke of its own Agent toolset", + async () => { + // Agent-driven (not puppeted): one prompt, the model itself exercises three + // Agent capabilities in a single session and self-reports. We then assert it + // genuinely invoked each feature (not just that it claimed to in prose). + run = await runPrintMode({ + prompt: [ + "You are smoke-testing your own Agent toolset. Do these steps IN ORDER, then print a", + "final report with one PASS/FAIL line per step:", + "1) FOREGROUND: spawn a general-purpose subagent (run_in_background: false) whose only", + " task is to reply with the exact token FG_OK. Confirm you got FG_OK back.", + "2) BACKGROUND: spawn a general-purpose subagent with run_in_background: true whose only", + " task is to reply with the exact token BG_OK. After it finishes, call get_subagent_result", + " to retrieve its output. Confirm you got BG_OK.", + "3) EXPLORE: spawn a subagent with subagent_type 'Explore' to summarize the current", + " working directory in one line.", + "Finish with: 'SELF-SMOKE COMPLETE' followed by the PASS/FAIL lines.", + ].join("\n"), + timeoutMs: SELF_SMOKE_TIMEOUT, + }); + + const calls = agentToolCalls(run.parentSession); + const tools = invokedToolNames(run.parentSession); + + // Each capability was actually exercised at the tool layer (not just narrated): + // — a foreground spawn (run_in_background not true on at least one Agent call) + expect(calls.some((c) => c.run_in_background !== true)).toBe(true); + // — a background spawn + expect(calls.some((c) => c.run_in_background === true)).toBe(true); + // — the result-retrieval tool was called + expect(tools).toContain("get_subagent_result"); + // — the Explore type was dispatched + expect(calls.some((c) => String(c.subagent_type ?? "").toLowerCase() === "explore")).toBe(true); + // — and the real child outputs materialized in the conversation (the + // foreground tool result + the get_subagent_result result). We check the + // whole transcript, not the final message: the agent's closing report + // tends to summarize ("Step 1 PASS") rather than re-echo the raw tokens. + const transcript = conversationText(run.parentSession); + expect(transcript).toMatch(/FG_OK/i); + expect(transcript).toMatch(/BG_OK/i); + // The agent ran the whole script to completion and self-reported. Checked + // against the transcript, not `responseText`: step 2's background agent + // completes asynchronously, so its completion nudge can land AFTER the + // final report and draw one more turn out of the model ("Acknowledged, all + // three steps PASS"). The report is then the second-to-last message and a + // last-message assertion fails a run that did everything right. + expect(transcript).toMatch(/SELF-SMOKE COMPLETE/i); + expect(run.responseText.length).toBeGreaterThan(0); + }, + SELF_SMOKE_VITEST_TIMEOUT, + ); +}); diff --git a/pi-subagents/test/tool-description-mode.test.ts b/pi-subagents/test/tool-description-mode.test.ts new file mode 100644 index 0000000..1cf0466 --- /dev/null +++ b/pi-subagents/test/tool-description-mode.test.ts @@ -0,0 +1,411 @@ +// End-to-end test for `toolDescriptionMode` (#91): settings file → sanitize → +// applier → registration-time description pick. Instantiates the real extension +// with a mock pi (same pattern as print-mode.test.ts) inside a temp cwd, then +// inspects the registered Agent tool's description. + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import subagentsExtension from "../src/index.js"; +import { setWorktreeIsolationEnabled } from "../src/worktree.js"; + +const EXAMPLE_TEMPLATE = fileURLToPath(new URL("../examples/agent-tool-description.md", import.meta.url)); + +function makePi() { + const tools = new Map(); + const handlers = new Map(); + + return { + pi: { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((tool: any) => { + tools.set(tool.name, tool); + }), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => { + handlers.set(event, handler); + }), + events: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any, + tools, + handlers, + }; +} + +describe("toolDescriptionMode", () => { + let tmpDir: string; + let hermeticAgentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + let shutdown: (() => Promise) | undefined; + + function setup(settings?: Record, beforeInstantiate?: () => void) { + tmpDir = mkdtempSync(join(tmpdir(), "pi-tooldesc-")); + // Isolate global settings (getAgentDir / ~/.pi) so the dev's real + // subagents.json can't leak into the "default is full" assertion. + hermeticAgentDir = mkdtempSync(join(tmpdir(), "pi-tooldesc-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = hermeticAgentDir; + process.env.HOME = hermeticAgentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + if (settings) { + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify(settings)); + } + beforeInstantiate?.(); + process.chdir(tmpDir); + + const { pi, tools, handlers } = makePi(); + subagentsExtension(pi); + shutdown = async () => { + await handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} } as any); + }; + return tools; + } + + afterEach(async () => { + await shutdown?.(); + shutdown = undefined; + // applySettings only applies keys that are PRESENT, so a subagents.json + // without `worktreeIsolation` leaves the module singleton wherever the + // previous test left it. Reset it so each setup()'s settings decide, and + // so the "default" assertions below really test the default. + setWorktreeIsolationEnabled(true); + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(hermeticAgentDir, { recursive: true, force: true }); + }); + + it("defaults to the full description", () => { + const tools = setup(); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("## Usage notes"); + expect(desc).toContain("## Writing the prompt"); + // Full agent descriptions are embedded (a late Explore sentence survives). + expect(desc).toContain("very thorough"); + }); + + it("compact mode swaps in the short description with one-line type list", () => { + const tools = setup({ toolDescriptionMode: "compact" }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("Launch an autonomous agent"); + expect(desc).not.toContain("## Usage notes"); + expect(desc).not.toContain("## Writing the prompt"); + // Type list keeps every agent but only the first sentence of each description. + expect(desc).toContain("- general-purpose:"); + expect(desc).toContain("- Explore: Fast read-only search agent for locating code. (Tools:"); + expect(desc).not.toContain("very thorough"); + // The point of the feature: materially smaller than the full version. + expect(desc.length).toBeLessThan(1600); + }); + + it("invalid mode in the settings file is dropped — full description", () => { + const tools = setup({ toolDescriptionMode: "tiny" }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("## Usage notes"); + }); + + it("compact keeps every load-bearing contract — fails when a behavior change forgets compact", () => { + const tools = setup({ toolDescriptionMode: "compact" }); + const desc: string = tools.get("Agent").description; + // One keyword per behavioral contract the orchestrator must know about. + // If you change one of these behaviors, update BOTH descriptions. + for (const contract of [ + "run_in_background", + "resume", + "steer_subagent", + 'isolation: "worktree"', + ".pi/agents/", + "self-contained", + ]) { + expect(desc).toContain(contract); + } + }); + + // The compact test above pins the prose alone, which is right for compact — + // it is the only place that mode states these. `full` is different: several + // contracts are stated twice, in the description AND in the param schema, so + // pinning prose alone would block a legitimate move of one into the other + // while missing the failure that actually matters — a contract that ends up + // in neither. Asserting over description + schema is the invariant that + // survives either choice. The second test then keeps the schema half honest, + // so "it's also in the schema" can never degrade to an empty stub. + it("full states every load-bearing contract in the description or the schema", () => { + const tool = setup().get("Agent"); + const visible = `${tool.description}\n${JSON.stringify(tool.parameters)}`; + for (const contract of [ + "run_in_background", + "resume", + "steer_subagent", + "worktree", + ".pi/agents/", + "self-contained", + "model", + "thinking", + "inherit_context", + ]) { + expect(visible).toContain(contract); + } + }); + + it("every strategy param carries a real description of its own", () => { + const props = setup().get("Agent").parameters?.properties ?? {}; + for (const name of ["run_in_background", "model", "thinking", "inherit_context"]) { + // Long enough to be an explanation the model can act on, not a bare label. + expect(props[name]?.description?.length ?? 0).toBeGreaterThan(40); + } + }); + + it("custom mode renders the project template with placeholders substituted", () => { + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync( + join(tmpDir, ".pi", "agent-tool-description.md"), + "My agents:\n{{typeList}}\n\nGlobal dir: {{agentDir}}\nUnknown: {{nope}}\nCost: $& stays literal", + ); + }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("My agents:"); + expect(desc).toContain("- general-purpose:"); // {{typeList}} expanded + expect(desc).toContain(`Global dir: ${hermeticAgentDir}`); // {{agentDir}} expanded + expect(desc).toContain("Unknown: {{nope}}"); // unknown placeholder left verbatim + expect(desc).toContain("Cost: $& stays literal"); // no $-pattern expansion + expect(desc).not.toContain("## Usage notes"); + }); + + it("custom mode falls back to the global file when no project file exists", () => { + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync(join(hermeticAgentDir, "agent-tool-description.md"), "GLOBAL CUSTOM\n{{compactTypeList}}"); + }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("GLOBAL CUSTOM"); + expect(desc).toContain("- Explore: Fast read-only search agent for locating code. (Tools:"); + }); + + it("{{scheduleGuideline}} expands to the schedule bullet when scheduling is on (default)", () => { + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{scheduleGuideline}}\nEND"); + }); + const desc: string = tools.get("Agent").description; + // The expansion carries its own leading "\n- " bullet. + expect(desc).toContain("RULES:\n- Use `schedule` only when"); + }); + + it("{{scheduleGuideline}} expands to the empty string when scheduling is disabled", () => { + const tools = setup({ toolDescriptionMode: "custom", schedulingEnabled: false }, () => { + writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{scheduleGuideline}}\nEND"); + }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("RULES:\nEND"); + expect(desc).not.toContain("schedule"); + }); + + it("{{isolationGuideline}} expands to the isolation bullet when worktrees are on (default)", () => { + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{isolationGuideline}}\nEND"); + }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain('RULES:\n- Use isolation: "worktree"'); + }); + + it("{{isolationGuideline}} expands to the empty string when worktree isolation is disabled", () => { + const tools = setup({ toolDescriptionMode: "custom", worktreeIsolation: false }, () => { + writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{isolationGuideline}}\nEND"); + }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("RULES:\nEND"); + expect(desc).not.toContain("isolation"); + }); + + it("every documented placeholder is replaced — no {{ }} residue", () => { + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync( + join(tmpDir, ".pi", "agent-tool-description.md"), + "A {{typeList}} B {{compactTypeList}} C {{agentDir}} D {{scheduleGuideline}} E {{isolationGuideline}} F", + ); + }); + const desc: string = tools.get("Agent").description; + expect(desc).not.toContain("{{"); + expect(desc).not.toContain("}}"); + }); + + it("the shipped example template renders byte-identical to the full description", async () => { + // Guards examples/agent-tool-description.md against going stale: it must + // reproduce the full description exactly. If you edit one, edit the other. + const example = readFileSync(EXAMPLE_TEMPLATE, "utf-8"); + const tools = setup({ toolDescriptionMode: "custom" }, () => { + writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), example); + }); + const customDesc: string = tools.get("Agent").description; + + // Second instance in the same hermetic cwd, flipped to full mode. + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ toolDescriptionMode: "full" })); + const second = makePi(); + subagentsExtension(second.pi); + try { + expect(customDesc).toBe(second.tools.get("Agent").description); + } finally { + await second.handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} } as any); + } + }); + + it("custom mode without a file falls back to the full description with a warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const tools = setup({ toolDescriptionMode: "custom" }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("## Usage notes"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("no agent-tool-description.md found")); + } finally { + warn.mockRestore(); + } + }); + + // README:87 promises that disabling scheduling "removes `schedule` from the + // `Agent` tool spec (no LLM-context cost)". Only the {{scheduleGuideline}} + // TEXT expansion was tested — nothing asserted the schema itself, so the + // parameter could keep costing tokens (and stay callable) while the prose + // claimed otherwise. + describe("schedulingEnabled gates the schedule parameter", () => { + const props = (tools: Map) => + Object.keys(tools.get("Agent").parameters?.properties ?? {}); + + it("advertises `schedule` by default", () => { + expect(props(setup())).toContain("schedule"); + }); + + it("removes `schedule` from the tool schema when scheduling is disabled", () => { + const names = props(setup({ schedulingEnabled: false })); + expect(names).not.toContain("schedule"); + // The rest of the parameter surface is untouched — this gates one field, + // not the tool. + expect(names).toEqual(expect.arrayContaining(["prompt", "description", "subagent_type"])); + }); + }); + + // The schema half of `worktreeIsolation: false` shipped without the prose + // half: `isolationParam` dropped the field while both descriptions kept + // telling the model to pass it. Nothing rejects the undeclared key (TypeBox + // sets no additionalProperties: false) and, by design, nothing notes the + // downgrade on the result — so the model had every reason to report a + // `pi-agent-*` branch that was never created. Schema and prose have to move + // together, which is why both are asserted here. + describe("worktreeIsolation gates the isolation parameter and its prose", () => { + const props = (tools: Map) => + Object.keys(tools.get("Agent").parameters?.properties ?? {}); + + it("advertises `isolation` in schema and prose by default", () => { + const tools = setup(); + expect(props(tools)).toContain("isolation"); + expect(tools.get("Agent").description).toContain('Use isolation: "worktree"'); + }); + + it("drops both when worktree isolation is disabled", () => { + const tools = setup({ worktreeIsolation: false }); + const names = props(tools); + expect(names).not.toContain("isolation"); + expect(tools.get("Agent").description).not.toContain("isolation"); + // One field, not the tool — and the neighbouring gate is unaffected. + expect(names).toEqual(expect.arrayContaining(["prompt", "description", "subagent_type", "schedule"])); + }); + + it("drops the compact description's bullet too", () => { + const enabled = setup({ toolDescriptionMode: "compact" }); + expect(enabled.get("Agent").description).toContain('isolation: "worktree"'); + }); + + it("compact mode says nothing about isolation when disabled", () => { + const tools = setup({ toolDescriptionMode: "compact", worktreeIsolation: false }); + expect(tools.get("Agent").description).not.toContain("isolation"); + // The bullet above it survives — the gate trims a suffix, not the list. + expect(tools.get("Agent").description).toContain("resume continues a previous agent by ID"); + }); + }); + + // The tool description is the only thing the orchestrator LLM knows about an + // agent's capabilities before spawning it. `tools: none` and an `ext:`-only + // `tools:` both parse to zero built-ins (custom-agents.ts parseToolsField), + // and test/fixtures/.pi/agents/tools-none.md pins that the *runtime* really + // does drop every built-in. So the description must not claim otherwise — + // an agent advertised as having `bash` that cannot run `bash` gets routed + // work it can only fail at. + describe("tool scope suffix reflects the real built-in set", () => { + function withAgent(name: string, frontmatter: string, settings?: Record) { + const extra = frontmatter ? `${frontmatter}\n` : ""; + return setup(settings, () => { + mkdirSync(join(tmpDir, ".pi", "agents"), { recursive: true }); + writeFileSync( + join(tmpDir, ".pi", "agents", `${name}.md`), + `---\ndescription: ${name} agent.\n${extra}---\n\nBody.\n`, + ); + }); + } + + it("`tools: none` never claims the full built-in set", () => { + const tools = withAgent("quiet", "tools: none"); + const desc: string = tools.get("Agent").description; + expect(desc).not.toContain("- quiet: quiet agent. (Tools: *)"); + }); + + it("`tools: none` says none only when the agent can call nothing at all", () => { + // extensions: false and isolated: true both leave the agent with zero + // built-ins AND zero extension tools — the one case "none" is true. + for (const fm of ["tools: none\nextensions: false", "tools: none\nisolated: true"]) { + const tools = withAgent("silent", fm); + expect(tools.get("Agent").description).toContain("- silent: silent agent. (Tools: none)"); + } + }); + + it("`tools: none` with extensions loaded is not described as having no tools", () => { + // Zero built-ins is not zero tools: test/fixtures/.pi/agents/tools-none.md + // pins that such an agent still surfaces alpha_read, alpha_write, beta_tool. + // Saying "none" understates it and routes work away from the only agent + // that could do it — the mirror of the bug this suffix used to have. + const tools = withAgent("probe", 'tools: none\nextensions: "./ext-alpha.mjs"'); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("- probe: probe agent. (Tools: no built-ins, extension tools only)"); + expect(desc).not.toContain("- probe: probe agent. (Tools: *)"); + expect(desc).not.toContain("- probe: probe agent. (Tools: none)"); + }); + + it("an ext:-only `tools:` is described by what it actually has", () => { + const tools = withAgent("extonly", 'tools: "ext:probe.mjs"'); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("- extonly: extonly agent. (Tools: no built-ins, extension tools only)"); + expect(desc).not.toContain("- extonly: extonly agent. (Tools: *)"); + }); + + it("compact mode shares the suffix builder and must not diverge", () => { + const tools = withAgent("quiet", "tools: none\nextensions: false", { toolDescriptionMode: "compact" }); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("- quiet: quiet agent. (Tools: none)"); + expect(desc).not.toContain("- quiet: quiet agent. (Tools: *)"); + }); + + it("an omitted `tools:` still renders as * — absent means all built-ins", () => { + // Guards the fix from over-correcting: undefined (inherit everything, + // as the shipped defaults do) is not the same as [] (explicitly zero). + const tools = withAgent("broad", ""); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("- broad: broad agent. (Tools: *)"); + }); + + it("a narrowed `tools:` still lists the names it actually has", () => { + const tools = withAgent("narrow", "tools: read, grep"); + const desc: string = tools.get("Agent").description; + expect(desc).toContain("- narrow: narrow agent. (Tools: read, grep)"); + }); + }); +}); diff --git a/pi-subagents/test/usage-reporting.test.ts b/pi-subagents/test/usage-reporting.test.ts new file mode 100644 index 0000000..512d01d --- /dev/null +++ b/pi-subagents/test/usage-reporting.test.ts @@ -0,0 +1,354 @@ +/** + * usage-reporting.test.ts — proves subagent spend actually reaches the parent + * session (#193), through the real registered tools. + * + * Pi folds `toolResult.usage` into `getSessionStats()`, which is what the + * footer, the statusline and `/cost` read. So the observable contract is not + * "we tracked a number" but "the tool result carries a complete pi `Usage`" — + * and every assertion here is about that object: that it appears, that it + * appears exactly once per message of spend, that it never appears when the + * user did not ask for it, and that it is complete enough for pi to consume + * without throwing. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() }; +}); + +import { resumeAgent, runAgent } from "../src/agent-runner.js"; +import { registerAgents } from "../src/agent-types.js"; +import subagentsExtension from "../src/index.js"; +import { addUsage } from "../src/usage.js"; +import { ctx, flush, type Hermetic, hermeticDir, makePi } from "./helpers/boot-extension.js"; + +/** Drive one foreground run that spends `usage` on a single assistant message. */ +function runSpending(usage: { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number }) { + vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => { + opts.onAssistantUsage?.(usage); + return { responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false }; + }); +} + +/** Nothing spent — the agent errored before any message_end fired. */ +function runSpendingNothing() { + vi.mocked(runAgent).mockImplementation(async () => ( + { responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false } + )); +} + +const spawn = (tools: Map, toolCallId: string | undefined) => + tools.get("Agent").execute( + toolCallId, + { prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: false }, + undefined, undefined, ctx(), + ); + +describe("reporting subagent usage back to the parent session", () => { + let hermetic: Hermetic; + + function boot(settings: Record) { + hermetic = hermeticDir({ settings }); + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + return { pi, tools, lifecycle }; + } + + beforeEach(() => { + vi.mocked(runAgent).mockReset(); + vi.mocked(resumeAgent).mockReset(); + }); + + afterEach(() => { + delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; + registerAgents(new Map()); + hermetic?.restore(); + }); + + it("attaches a complete pi Usage to the tool result", async () => { + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 }); + + const result = await spawn(tools, "tc-1"); + + // Every field pi's `addUsageToTotals` touches must exist: it reads + // `usage.cost.total` with no guard, so a partial object throws inside pi. + expect(result.usage).toEqual({ + input: 100, + output: 50, + // Included, unlike our own display total (#38): pi sums cacheRead across + // the parent's own messages into this same figure, so withholding it + // would make a subagent's rows count differently from every other row. + cacheRead: 900, + cacheWrite: 10, + totalTokens: 1060, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.0123 }, + }); + }); + + it("reports each message's spend exactly once", async () => { + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 }); + + await spawn(tools, "tc-1"); + runSpendingNothing(); + const second = await spawn(tools, "tc-2"); + + // Not "the second result is smaller" — a pool that failed to reset would + // re-report the first run's spend here, and the parent's totals would climb + // on every later tool call for work that happened once. + expect(second.usage).toBeUndefined(); + }); + + it("carries what a later run spends on the later result", async () => { + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 }); + await spawn(tools, "tc-1"); + + runSpending({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 }); + const second = await spawn(tools, "tc-2"); + + expect(second.usage.totalTokens).toBe(10); + expect(second.usage.cost.total).toBe(0.002); + }); + + it("attaches nothing when the setting is off", async () => { + const { tools } = boot({ reportUsage: false }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 }); + + const result = await spawn(tools, "tc-1"); + + expect(result.usage).toBeUndefined(); + // And the text result is untouched — the setting must not change what the + // orchestrator reads. + expect(result.content[0].text).toContain("Agent completed"); + }); + + it("defaults to off", async () => { + const { tools } = boot({}); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 }); + + expect((await spawn(tools, "tc-1")).usage).toBeUndefined(); + }); + + it("attaches nothing to a call with no tool-call id, and loses none of it", async () => { + // The `@handle` mention path: a fork of the conversation calls the + // registered tool with `undefined`, and its session is discarded. Usage hung + // on that result is spend the user paid for and nobody counted. + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 }); + + const cloned = await spawn(tools, undefined); + expect(cloned.usage).toBeUndefined(); + + // It was not dropped — the next result the real session gets carries it. + runSpendingNothing(); + const real = await spawn(tools, "tc-2"); + expect(real.usage.cost.total).toBe(0.0123); + expect(real.usage.totalTokens).toBe(160); + }); + + it("attaches nothing when a run produced no usage at all", async () => { + const { tools } = boot({ reportUsage: true }); + runSpendingNothing(); + + expect((await spawn(tools, "tc-1")).usage).toBeUndefined(); + }); + + it("reports an unpriced model's tokens with a zero cost rather than dropping them", async () => { + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0 }); + + const result = await spawn(tools, "tc-1"); + + expect(result.usage.totalTokens).toBe(160); + expect(result.usage.cost.total).toBe(0); + }); + + it("reports what a background resume spends, on the next call", async () => { + // Resuming detached is the default since #237, and it runs through a + // different manager path again — one whose result is an ID, not the spend. + // The next tool call is what has to carry it. + const { pi, tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 }); + await spawn(tools, "tc-1"); + await flush(); + const id = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed")?.[1]?.id; + + vi.mocked(resumeAgent).mockImplementation(async (_session: any, _prompt: any, opts: any) => { + opts.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 }); + return { text: "resumed" }; + }); + + const started = await tools.get("Agent").execute( + "tc-2", + { prompt: "more", description: "spend", subagent_type: "general-purpose", resume: id, run_in_background: true }, + undefined, undefined, ctx(), + ); + await flush(); + runSpendingNothing(); + const next = await spawn(tools, "tc-3"); + + // Which of the two carries it depends on how fast the detached run + // finishes, so the invariant is that it is reported once and in full — + // not that it lands on a particular call. + const reported = [started.usage, next.usage].filter(Boolean); + expect(reported).toHaveLength(1); + expect(reported[0].cost.total).toBe(0.002); + expect(reported[0].totalTokens).toBe(10); + }); + + it("counts a nested child's spend once, on the top-level agent's report", async () => { + // Nested agents are hidden from every reporting surface, so their spend is + // deliberately double-booked into every ancestor record to stay visible + // somewhere. Anything that summed those records would bill this session + // twice for one child message — which is why the pool is fed from the + // manager hook instead. + const { pi, tools } = boot({ reportUsage: true }); + let nested = false; + + vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => { + opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 }); + if (!nested) { + nested = true; + // `nestedRuntime` is exactly what nested-tools.ts is handed: the real + // manager and the id of the agent that owns the child. + const { manager, parentAgentId } = opts.nestedRuntime; + const childId = manager.spawn(pi, ctx(), "general-purpose", "sub", { + description: "nested", + isBackground: false, + parentAgentId, + // The ancestor walk, verbatim from nested-tools. + onAssistantUsage: (u: any) => addUsage(manager.getRecord(parentAgentId).lifetimeUsage, u), + }); + await manager.getRecord(childId).promise; + } + return { responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false }; + }); + + const result = await spawn(tools, "tc-1"); + + // Two messages, one per agent — not three, which is what summing the + // double-booked parent record would have produced. + expect(nested).toBe(true); + expect(result.usage.totalTokens).toBe(300); + expect(result.usage.cost.total).toBeCloseTo(0.02, 10); + }); + + it("reports what a resume spends, not only the first run", async () => { + // A resumed agent runs through a different manager path from a spawn, with + // its own usage wiring. Miss it and every continuation of an agent is free + // as far as the parent session is concerned. + const { pi, tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 }); + await spawn(tools, "tc-1"); + await flush(); + + vi.mocked(resumeAgent).mockImplementation(async (_session: any, _prompt: any, opts: any) => { + opts.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 }); + return { text: "resumed" }; + }); + + const id = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed")?.[1]?.id; + const result = await tools.get("Agent").execute( + "tc-2", + { prompt: "more", description: "spend", subagent_type: "general-purpose", resume: id, run_in_background: false }, + undefined, undefined, ctx(), + ); + + expect(result.usage.totalTokens).toBe(10); + expect(result.usage.cost.total).toBe(0.002); + }); + + describe("the lifecycle event payload", () => { + /** The payload `subagents:completed` was emitted with. */ + async function completedPayload(pi: any) { + await flush(); + const call = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed"); + return call?.[1]; + } + + it("carries the run's spend as a pi Usage", async () => { + // pi's convention for handing spend to a consumer: every extension-facing + // payload that carries it takes the whole object. Following it means + // `usage.cost.total` is where a listener already looks, and whatever pi + // adds to `Usage` later needs no change here. + const { pi, tools } = boot({}); + runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 }); + + await spawn(tools, "tc-1"); + + expect((await completedPayload(pi)).usage).toEqual({ + input: 100, + output: 50, + cacheRead: 900, + cacheWrite: 10, + totalTokens: 1060, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.0123 }, + }); + }); + + it("carries it regardless of either setting", async () => { + // Both settings govern what a human is shown or what the session counts. + // A listener subscribed to the event asked for the data itself. + const { pi, tools } = boot({ reportUsage: false, showCost: false }); + runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 }); + + await spawn(tools, "tc-1"); + + expect((await completedPayload(pi)).usage.cost.total).toBe(0.01); + }); + + it("keeps `tokens` as the display total it has always been", async () => { + // The other convention — a flat view model like pi's own SessionStats, + // excluding cacheRead (#38). It is not derived from `usage` and must not + // start matching it. + const { pi, tools } = boot({}); + runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 }); + + await spawn(tools, "tc-1"); + + expect((await completedPayload(pi)).tokens).toEqual({ input: 100, output: 50, total: 160 }); + }); + + it("omits usage entirely when nothing was spent", async () => { + // So a listener can tell "spent nothing" from "never ran". + const { pi, tools } = boot({}); + runSpendingNothing(); + + await spawn(tools, "tc-1"); + + const payload = await completedPayload(pi); + expect(payload.usage).toBeUndefined(); + expect(payload.tokens).toBeUndefined(); + }); + + it("reports an unpriced model's tokens with a zero cost", async () => { + const { pi, tools } = boot({}); + runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0 }); + + await spawn(tools, "tc-1"); + + const usage = (await completedPayload(pi)).usage; + expect(usage.totalTokens).toBe(150); + expect(usage.cost.total).toBe(0); + }); + }); + + it("reports spend through get_subagent_result too", async () => { + // Background agents finish with no tool result of their own to ride on; + // whichever of our tools is called next has to carry them. + const { tools } = boot({ reportUsage: true }); + runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 }); + + await spawn(tools, undefined); // spend accumulates, nothing attached + await flush(); + + const result = await tools.get("get_subagent_result").execute( + "tc-2", { agent_id: "nope" }, undefined, undefined, ctx(), + ); + + expect(result.usage.cost.total).toBe(0.0123); + }); +}); diff --git a/pi-subagents/test/usage.test.ts b/pi-subagents/test/usage.test.ts new file mode 100644 index 0000000..bd2c6ef --- /dev/null +++ b/pi-subagents/test/usage.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { addUsage, getLifetimeCost, getLifetimeTotal, getSessionContextPercent, getSessionTokens, PendingUsagePool } from "../src/usage.js"; + +// Regression for issue #38 — token semantics + context indicator +describe("usage", () => { + describe("getSessionTokens", () => { + it("uses billed-token semantics (input + output + cacheWrite), not inflated total", () => { + const session = { + getSessionStats: () => ({ + tokens: { input: 100, output: 200, cacheRead: 500_000, cacheWrite: 50, total: 500_350 } as any, + contextUsage: { tokens: 50_300, contextWindow: 200_000, percent: 25 }, + }), + }; + expect(getSessionTokens(session)).toBe(350); + }); + + it("returns 0 when session is undefined or stats throw", () => { + expect(getSessionTokens(undefined)).toBe(0); + const broken = { getSessionStats: () => { throw new Error("nope"); } } as any; + expect(getSessionTokens(broken)).toBe(0); + }); + }); + + describe("getSessionContextPercent", () => { + it("returns null when contextUsage is unavailable", () => { + const session = { + getSessionStats: () => ({ tokens: { input: 10, output: 20, cacheWrite: 5 } }), + }; + expect(getSessionContextPercent(session)).toBeNull(); + }); + + it("returns null when percent is null (post-compaction)", () => { + const session = { + getSessionStats: () => ({ + tokens: { input: 10, output: 20, cacheWrite: 5 }, + contextUsage: { tokens: null, contextWindow: 200_000, percent: null }, + }), + }; + expect(getSessionContextPercent(session)).toBeNull(); + }); + + it("returns the upstream percent when available", () => { + const session = { + getSessionStats: () => ({ + tokens: { input: 10, output: 20, cacheWrite: 5 }, + contextUsage: { tokens: 50_000, contextWindow: 200_000, percent: 25 }, + }), + }; + expect(getSessionContextPercent(session)).toBe(25); + }); + }); + + describe("getLifetimeTotal", () => { + it("sums components and handles undefined", () => { + expect(getLifetimeTotal(undefined)).toBe(0); + expect(getLifetimeTotal({ input: 100, output: 200, cacheWrite: 50 })).toBe(350); + }); + + // getSessionTokens reads upstream session stats (resets at compaction); + // getLifetimeTotal reads our independent accumulator (survives compaction). + // They agree pre-compaction, diverge after — both legitimate signals. + it("agrees with getSessionTokens pre-compaction, diverges after", () => { + let sessionStatsTokens = { input: 100, output: 200, cacheWrite: 50 }; + const session = { + getSessionStats: () => ({ tokens: sessionStatsTokens }), + }; + const lifetime = { input: 100, output: 200, cacheWrite: 50 }; + + expect(getSessionTokens(session)).toBe(350); + expect(getLifetimeTotal(lifetime)).toBe(350); + + // Compaction: upstream replaces session.state.messages, so stats reset. + // Our accumulator is independent — it keeps growing. + sessionStatsTokens = { input: 0, output: 0, cacheWrite: 0 }; + + expect(getSessionTokens(session)).toBe(0); // reset + expect(getLifetimeTotal(lifetime)).toBe(350); // preserved + + // Subsequent message_end events feed both: session re-fills, accumulator continues + sessionStatsTokens = { input: 80, output: 150, cacheWrite: 30 }; + lifetime.input += 80; lifetime.output += 150; lifetime.cacheWrite += 30; + + expect(getSessionTokens(session)).toBe(260); // post-compaction window + expect(getLifetimeTotal(lifetime)).toBe(610); // 350 + 260, monotone + }); + + // The accumulator survives compaction because it lives on AgentActivity / + // AgentRecord, not on session.state.messages (which compaction replaces). + it("stays monotone across simulated compaction when fed via addUsage-style accumulation", () => { + const usage = { input: 0, output: 0, cacheWrite: 0 }; + const onUsage = (u: { input: number; output: number; cacheWrite: number }) => { + usage.input += u.input; + usage.output += u.output; + usage.cacheWrite += u.cacheWrite; + }; + + // 5 normal turns + for (let i = 0; i < 5; i++) onUsage({ input: 1000, output: 200, cacheWrite: 50 }); + expect(getLifetimeTotal(usage)).toBe(5 * 1250); + + // Compaction would replace session.state.messages, dropping any sum + // re-derived from it. Our accumulator is independent — no reset. + const beforeCompaction = getLifetimeTotal(usage); + + // 3 more turns post-"compaction" + for (let i = 0; i < 3; i++) onUsage({ input: 800, output: 150, cacheWrite: 30 }); + expect(getLifetimeTotal(usage)).toBe(beforeCompaction + 3 * 980); + expect(getLifetimeTotal(usage)).toBeGreaterThan(beforeCompaction); // monotone + + // input + output + cacheWrite = total — by construction, no drift + expect(usage.input + usage.output + usage.cacheWrite).toBe(getLifetimeTotal(usage)); + }); + }); + + describe("cost accumulation", () => { + it("sums cost across messages but keeps it out of the token total", () => { + const usage = { input: 0, output: 0, cacheWrite: 0 }; + addUsage(usage, { input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.002 }); + addUsage(usage, { input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.004 }); + + expect(getLifetimeCost(usage)).toBeCloseTo(0.006, 10); + // The load-bearing half: the display total takes neither the money nor + // the re-read prefix, even though both are accumulated on the same object. + expect(getLifetimeTotal(usage)).toBe(460); + expect(usage.cacheRead).toBe(2700); + }); + + it("leaves cost absent when nothing priced anything", () => { + // An unpriced model reports 0 per message. Distinguishable from "counted + // and free" only by the field never being written at all. + const usage: { input: number; output: number; cacheWrite: number; cost?: number } = + { input: 0, output: 0, cacheWrite: 0 }; + addUsage(usage, { input: 10, output: 5, cacheWrite: 0, cost: 0 }); + + expect(usage.cost).toBeUndefined(); + expect(getLifetimeCost(usage)).toBe(0); + }); + + it("reads a missing cost as 0", () => { + expect(getLifetimeCost(undefined)).toBe(0); + expect(getLifetimeCost({ input: 1, output: 1, cacheWrite: 0 })).toBe(0); + }); + }); + + describe("PendingUsagePool", () => { + it("drains what it accumulated as a complete pi Usage", () => { + const pool = new PendingUsagePool(); + pool.add({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.01 }); + pool.add({ input: 200, output: 80, cacheWrite: 20, cacheRead: 1800, cost: 0.02 }); + + expect(pool.drain()).toEqual({ + input: 300, + output: 130, + // Summed, unlike the display total (#38): pi counts the parent's own + // messages this way, and the prefix is re-billed on every call. + cacheRead: 2700, + cacheWrite: 30, + totalTokens: 3160, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.03 }, + }); + }); + + it("empties on drain, so no message is reported twice", () => { + const pool = new PendingUsagePool(); + pool.add({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 }); + + expect(pool.drain()?.totalTokens).toBe(160); + expect(pool.drain()).toBeUndefined(); + }); + + it("handles an accumulator that never saw a cacheRead or a cost", () => { + // Both fields are optional and written lazily, so an agent on a provider + // that reports neither leaves them absent rather than zero. + const pool = new PendingUsagePool(); + pool.add({ input: 100, output: 50, cacheWrite: 10 }); + + expect(pool.drain()).toEqual({ + input: 100, output: 50, cacheRead: 0, cacheWrite: 10, totalTokens: 160, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); + }); + + it("returns undefined when nothing has been added", () => { + expect(new PendingUsagePool().drain()).toBeUndefined(); + }); + + it("still reports tokens spent by a model with no pricing", () => { + const pool = new PendingUsagePool(); + pool.add({ input: 100, output: 50, cacheWrite: 10, cost: 0 }); + + const drained = pool.drain(); + expect(drained?.totalTokens).toBe(160); + expect(drained?.cost.total).toBe(0); + }); + + it("reports nothing for a message that spent nothing", () => { + const pool = new PendingUsagePool(); + pool.add({ input: 0, output: 0, cacheWrite: 0, cost: 0 }); + + expect(pool.drain()).toBeUndefined(); + }); + }); +}); diff --git a/pi-subagents/test/wait-queued.test.ts b/pi-subagents/test/wait-queued.test.ts new file mode 100644 index 0000000..de1f2b4 --- /dev/null +++ b/pi-subagents/test/wait-queued.test.ts @@ -0,0 +1,228 @@ +/** + * wait-queued.test.ts — get_subagent_result(wait: true) lifecycle behavior. + * + * Queued records have no promise yet (it's created when the queue starts + * them), so the old `status === "running" && record.promise` condition + * skipped the wait entirely and returned "still running" — forcing the + * caller into a poll loop against the concurrency queue. + * + * Wiring test through the REAL extension: spawn background agents until one + * queues, call the real tool with wait:true, drain the queue, and assert the + * call returns the final result. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; +const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +/** runAgent mock where each call blocks until we resolve it manually. */ +function deferredRuns() { + const resolvers: Array<(v: any) => void> = []; + vi.mocked(runAgent).mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(() => + resolve({ + responseText: "THE-RESULT-PAYLOAD", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }), + ); + }) as any, + ); + return resolvers; +} + +async function spawnBackground(tools: Map): Promise<{ id: string; queued: boolean }> { + const r = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "queued-wait test agent", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctx(), + ); + const id = /Agent ID: (\S+)/.exec(textOf(r))![1]; + return { id, queued: textOf(r).includes("queued in background") }; +} + +describe("get_subagent_result wait:true on a queued agent", () => { + it("waits through queue start and returns the result (no 'still running')", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const resolvers = deferredRuns(); + + // Spawn until one lands in the queue (concurrency limit is config-dependent). + let queuedId: string | undefined; + for (let i = 0; i < 20 && !queuedId; i++) { + const { id, queued } = await spawnBackground(tools); + if (queued) queuedId = id; + } + expect(queuedId, "expected to hit the concurrency limit within 20 spawns").toBeDefined(); + + // wait:true on the QUEUED agent — must not return "still running". + const waitPromise = tools + .get("get_subagent_result") + .execute("tc-wait", { agent_id: queuedId, wait: true }, undefined, undefined, ctx()); + + // Drain: resolve running agents until the queued one starts and finishes. + let settled = false; + void waitPromise.then(() => { settled = true; }); + for (let i = 0; i < 40 && !settled; i++) { + while (resolvers.length > 0) resolvers.shift()!(); + await flush(); + await new Promise((r) => setTimeout(r, 100)); // outlive one 250ms poll tick + } + + const result = await waitPromise; + expect(textOf(result)).toContain("THE-RESULT-PAYLOAD"); + expect(textOf(result)).not.toContain("still running"); + + await new Promise((r) => setTimeout(r, 350)); + expect(JSON.stringify(pi.sendMessage.mock.calls)).not.toContain(queuedId); + + await lifecycle.get("session_shutdown")?.(); + }, 20_000); + + it("aborts a running result wait without aborting or consuming the child", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + let resolveRun: (() => void) | undefined; + let childSignal: AbortSignal | undefined; + vi.mocked(runAgent).mockImplementation( + (_ctx, _type, _prompt, options) => + new Promise((resolve) => { + childSignal = options.signal; + resolveRun = () => resolve({ + responseText: "THE-RESULT-PAYLOAD", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + }), + ); + + const { id } = await spawnBackground(tools); + const controller = new AbortController(); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + const waitOutcome = tools + .get("get_subagent_result") + .execute("tc-wait-abort", { agent_id: id, wait: true }, controller.signal, undefined, ctx()) + .then( + () => "resolved", + (error: unknown) => error instanceof Error ? error.name : String(error), + ); + + controller.abort(); + const outcome = await Promise.race([ + waitOutcome, + new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)), + ]); + const childWasAborted = childSignal?.aborted; + + resolveRun?.(); + await flush(); + await waitOutcome; + await new Promise((r) => setTimeout(r, 350)); + + const completedResult = await tools + .get("get_subagent_result") + .execute("tc-result", { agent_id: id }, undefined, undefined, ctx()); + + await lifecycle.get("session_shutdown")?.(); + + expect(outcome).toBe("AbortError"); + expect(childWasAborted).toBe(false); + expect(removeListener).toHaveBeenCalledTimes(1); + expect(pi.sendMessage).toHaveBeenCalledTimes(1); + expect(textOf(completedResult)).toContain("THE-RESULT-PAYLOAD"); + }); + + it("aborts a queued result wait before the agent starts", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const resolvers = deferredRuns(); + let queuedId: string | undefined; + for (let i = 0; i < 20 && !queuedId; i++) { + const { id, queued } = await spawnBackground(tools); + if (queued) queuedId = id; + } + expect(queuedId, "expected to hit the concurrency limit within 20 spawns").toBeDefined(); + + const controller = new AbortController(); + const waitOutcome = tools + .get("get_subagent_result") + .execute("tc-queued-abort", { agent_id: queuedId, wait: true }, controller.signal, undefined, ctx()) + .then( + () => "resolved", + (error: unknown) => error instanceof Error ? error.name : String(error), + ); + + controller.abort(); + const outcome = await Promise.race([ + waitOutcome, + new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)), + ]); + + let completedResult: any; + for (let i = 0; i < 40 && !completedResult; i++) { + while (resolvers.length > 0) resolvers.shift()!(); + await flush(); + const result = await tools + .get("get_subagent_result") + .execute("tc-queued-result", { agent_id: queuedId }, undefined, undefined, ctx()); + if (textOf(result).includes("THE-RESULT-PAYLOAD")) completedResult = result; + await new Promise((r) => setTimeout(r, 25)); + } + + await waitOutcome; + await lifecycle.get("session_shutdown")?.(); + + expect(outcome).toBe("AbortError"); + expect(textOf(completedResult)).toContain("THE-RESULT-PAYLOAD"); + }); +}); diff --git a/pi-subagents/test/worktree-isolation-e2e.test.ts b/pi-subagents/test/worktree-isolation-e2e.test.ts new file mode 100644 index 0000000..bce516f --- /dev/null +++ b/pi-subagents/test/worktree-isolation-e2e.test.ts @@ -0,0 +1,189 @@ +/** + * worktree-isolation-e2e.test.ts — `isolation: "worktree"` through the real + * stack: a real git repo, a real pi-mono session, the real extension, real + * `createWorktree`/`cleanupWorktree`, on a faux model. + * + * Coverage sat on either side of this seam and never joined it. + * test/worktree.test.ts drives real git but calls createWorktree/cleanupWorktree + * directly, with no agent anywhere. test/agent-manager.test.ts (and -gc, and + * agent-startup-error) mock ../src/worktree.js entirely and assert only the gate + * and the fail-loud throw. So the chain the feature actually promises was never + * pinned end to end: spawn → the child's cwd IS the copy → its edits stay out of + * the main checkout → cleanup commits them to a branch the result names → the + * copy is gone. Every link was tested; the chain was not. + * + * Deliberately faux, not live: a live model may decline to spawn at all, which + * would look like a pass. Each run pins `live: false` rather than trusting the + * env var to leave it alone — the pre-publish smoke sets PI_E2E_LIVE globally. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Context } from "@earendil-works/pi-ai"; +import { fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setWorktreeIsolationEnabled } from "../src/worktree.js"; +import { agentCall, type FauxReply, type PrintModeRun, runPrintMode } from "./helpers/print-mode-runner.js"; + +vi.setConfig({ testTimeout: 30_000 }); + +/** The child creates this in whatever cwd it was actually given. */ +const MARKER_FILE = "agent-work.txt"; +/** Emitted by the child once its edit has landed. */ +const CHILD_MARKER = "CHILD-EDITED-ITS-TREE"; +const CHILD_PROMPT = "Create the marker file."; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, stdio: "pipe", timeout: 10_000 }).toString().trim(); +} + +/** A real repo with one commit — `git worktree add` needs a HEAD to branch from. */ +function initGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "wt-iso-e2e-")); + git(dir, "init"); + git(dir, "config", "user.email", "test@test.com"); + git(dir, "config", "user.name", "Test"); + writeFileSync(join(dir, "README.md"), "# Test repo"); + git(dir, "add", "README.md"); + git(dir, "commit", "-m", "initial"); + return dir; +} + +function firstUserText(context: Context): string { + const first = context.messages.find((m) => m.role === "user"); + const content = first?.content; + if (typeof content === "string") return content; + return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); +} + +function toolResultNames(context: Context): string[] { + return context.messages + .filter((m) => m.role === "toolResult") + .map((m) => (m as { toolName?: string }).toolName ?? ""); +} + +/** Every Agent tool result the parent session received, concatenated. */ +function agentResultText(session: Context): string { + return session.messages + .filter((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent") + .flatMap((m) => ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "")) + .join("\n"); +} + +/** + * One responder for both sessions, split on the first user message. + * + * The child writes through the REAL bash tool rather than having the test touch + * the filesystem for it: the whole question is which directory the agent's own + * tools resolve against, and writing the file from the test would answer it by + * assumption. + */ +function respondSpawning(isolation: "worktree" | undefined): (context: Context) => FauxReply { + return (context: Context): FauxReply => { + if (firstUserText(context).includes(CHILD_PROMPT)) { + if (!toolResultNames(context).includes("bash")) { + return fauxToolCall("bash", { command: `echo isolated > ${MARKER_FILE}` }); + } + return CHILD_MARKER; + } + // Parent: spawn once, then echo the tool result so a lost one fails loudly. + if (toolResultNames(context).includes("Agent")) { + return `parent saw: ${agentResultText(context)}`; + } + return agentCall({ + // Foreground: this test reads the child's marker out of the parent's + // inline Agent tool result, which a background spawn replaces with a + // "started in background" receipt. + run_in_background: false, + description: "worktree work", + prompt: CHILD_PROMPT, + ...(isolation ? { isolation } : {}), + }); + }; +} + +describe("worktree isolation e2e (real git, real pi-mono, faux model)", () => { + let run: PrintModeRun | undefined; + const repos: string[] = []; + + afterEach(async () => { + await run?.dispose(); + run = undefined; + // Module-level switch: applySettings only applies keys that are PRESENT, so + // a later run without the key would otherwise inherit whatever this one set. + setWorktreeIsolationEnabled(true); + for (const dir of repos.splice(0)) { + try { git(dir, "worktree", "prune"); } catch { /* repo may be gone */ } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runs the child in the copy and lands its changes on a branch, not the main checkout", async () => { + const repo = initGitRepo(); + repos.push(repo); + + run = await runPrintMode({ + prompt: "Delegate the work.", + cwd: repo, + respond: respondSpawning("worktree"), + live: false, + }); + + // The child's own tools resolved against the copy — the main checkout never + // saw the file. This is the guarantee; everything below is its bookkeeping. + expect(existsSync(join(repo, MARKER_FILE))).toBe(false); + + const result = agentResultText(run.parentSession); + expect(result).toContain(CHILD_MARKER); + + // The result names a branch and the command to merge it — the only artifact, + // since the worktree directory does not survive. + const branch = /Changes saved to branch `(pi-agent-[^`]+)`/.exec(result)?.[1]; + expect(branch).toBeTruthy(); + expect(result).toContain(`git merge ${branch}`); + + // That branch exists in the MAIN repo and carries the child's file. + expect(git(repo, "branch", "--list", branch!)).toContain(branch!); + expect(git(repo, "ls-tree", "--name-only", branch!)).toContain(MARKER_FILE); + + // And the copy is gone: `git worktree list` is down to the main checkout. + expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1); + }); + + it("downgrades to the main checkout when the project set worktreeIsolation: false", async () => { + const repo = initGitRepo(); + repos.push(repo); + mkdirSync(join(repo, ".pi"), { recursive: true }); + writeFileSync(join(repo, ".pi", "subagents.json"), JSON.stringify({ worktreeIsolation: false })); + + // The caller passes `isolation: "worktree"` even though the setting drops + // the parameter from the schema — exactly what a model holding a cached tool + // spec does, and the case the downgrade (rather than a throw) exists for. + // + // Mutation note: the resolver gate (invocation-config) and the manager gate + // (agent-manager) are redundant on THIS path, so removing either one alone + // leaves this test green — verified, not assumed. That is the point of the + // second gate, which exists for cross-extension RPC, where options skip the + // resolver entirely. This test pins the behaviour and goes red when both are + // gone; each gate is pinned individually by its own unit test. + run = await runPrintMode({ + prompt: "Delegate the work.", + cwd: repo, + respond: respondSpawning("worktree"), + live: false, + }); + + const result = agentResultText(run.parentSession); + expect(result).toContain(CHILD_MARKER); + + // Ran in the main checkout: the file is right there, and no branch was made. + expect(existsSync(join(repo, MARKER_FILE))).toBe(true); + expect(git(repo, "branch", "--list", "pi-agent-*")).toBe(""); + expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1); + + // Silent by design — no per-result note, which is why the tool description + // drops the isolation bullet alongside the parameter (see index.ts). + expect(result).not.toContain("Changes saved to branch"); + }); +}); diff --git a/pi-subagents/test/worktree.test.ts b/pi-subagents/test/worktree.test.ts new file mode 100644 index 0000000..55bcd9b --- /dev/null +++ b/pi-subagents/test/worktree.test.ts @@ -0,0 +1,373 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + cleanupWorktree, + createWorktree, + isWorktreeIsolationEnabled, + pruneWorktrees, + setWorktreeIsolationEnabled, +} from "../src/worktree.js"; + +/** + * Helper: create a temporary git repo with an initial commit. + */ +function initGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-wt-test-")); + execFileSync("git", ["init"], { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "README.md"), "# Test repo"); + execFileSync("git", ["add", "README.md"], { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: dir, stdio: "pipe" }); + return dir; +} + +describe("worktree", () => { + let repoDir: string; + + beforeEach(() => { + repoDir = initGitRepo(); + }); + + afterEach(() => { + // Clean up any lingering worktrees first, then remove repo + try { pruneWorktrees(repoDir); } catch { /* ignore */ } + rmSync(repoDir, { recursive: true, force: true }); + }); + + describe("createWorktree", () => { + it("creates a worktree in tmpdir", () => { + const wt = createWorktree(repoDir, "test-id-1"); + expect(wt).toBeDefined(); + expect(existsSync(wt!.path)).toBe(true); + expect(wt!.branch).toBe("pi-agent-test-id-1"); + expect(wt!.baseSha).toBe(execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoDir, stdio: "pipe", + }).toString().trim()); + + // Verify it's a valid worktree with the repo's files + expect(existsSync(join(wt!.path, "README.md"))).toBe(true); + + // Cleanup + try { execFileSync("git", ["worktree", "remove", "--force", wt!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("returns undefined for non-git directory", () => { + const nonGit = mkdtempSync(join(tmpdir(), "pi-wt-nongit-")); + try { + const wt = createWorktree(nonGit, "test-id-2"); + expect(wt).toBeUndefined(); + } finally { + rmSync(nonGit, { recursive: true, force: true }); + } + }); + + it("returns undefined for git repo with no commits", () => { + const emptyRepo = mkdtempSync(join(tmpdir(), "pi-wt-empty-")); + try { + execFileSync("git", ["init"], { cwd: emptyRepo, stdio: "pipe" }); + const wt = createWorktree(emptyRepo, "no-commits"); + expect(wt).toBeUndefined(); + } finally { + rmSync(emptyRepo, { recursive: true, force: true }); + } + }); + + it("workPath equals path when created from the repo root", () => { + const wt = createWorktree(repoDir, "root-wp")!; + expect(wt.workPath).toBe(wt.path); + try { execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("workPath preserves subdirectory scoping (monorepo package cwd)", () => { + mkdirSync(join(repoDir, "packages", "api"), { recursive: true }); + writeFileSync(join(repoDir, "packages", "api", "index.ts"), "export {}"); + execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "add package"], { cwd: repoDir, stdio: "pipe" }); + + const wt = createWorktree(join(repoDir, "packages", "api"), "subdir-wp")!; + expect(wt).toBeDefined(); + expect(wt.workPath).toBe(join(wt.path, "packages", "api")); + expect(existsSync(wt.workPath)).toBe(true); + try { execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("uses unique paths for multiple worktrees", () => { + const wt1 = createWorktree(repoDir, "multi-1"); + const wt2 = createWorktree(repoDir, "multi-2"); + expect(wt1).toBeDefined(); + expect(wt2).toBeDefined(); + expect(wt1!.path).not.toBe(wt2!.path); + + // Cleanup + try { execFileSync("git", ["worktree", "remove", "--force", wt1!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + try { execFileSync("git", ["worktree", "remove", "--force", wt2!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + }); + + describe("cleanupWorktree", () => { + it("removes worktree when no changes made", () => { + const wt = createWorktree(repoDir, "clean-1")!; + expect(wt).toBeDefined(); + + const result = cleanupWorktree(repoDir, wt, "test cleanup"); + expect(result.hasChanges).toBe(false); + expect(result.branch).toBeUndefined(); + }); + + it("commits changes and creates branch when changes exist", () => { + const wt = createWorktree(repoDir, "dirty-1")!; + expect(wt).toBeDefined(); + + // Make a change in the worktree + writeFileSync(join(wt.path, "new-file.txt"), "agent wrote this"); + + const result = cleanupWorktree(repoDir, wt, "added new file"); + expect(result.hasChanges).toBe(true); + expect(result.branch).toBeDefined(); + expect(result.branch).toContain("pi-agent-dirty-1"); + + // Verify the branch exists in the main repo + const branches = execFileSync("git", ["branch", "--list", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString().trim(); + expect(branches).toContain(result.branch!); + + // Verify the commit message + const log = execFileSync("git", ["log", "--oneline", "-1", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString().trim(); + expect(log).toContain("pi-agent: added new file"); + + // Cleanup branch + try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("commits changes even when a pre-commit hook rejects (--no-verify)", () => { + // A failing pre-commit hook in the main repo also applies to its + // worktrees — without --no-verify it would abort the preservation commit. + const hookPath = join(repoDir, ".git", "hooks", "pre-commit"); + writeFileSync(hookPath, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + + const wt = createWorktree(repoDir, "hooked-1")!; + expect(wt).toBeDefined(); + writeFileSync(join(wt.path, "hooked-file.txt"), "agent wrote this"); + + const result = cleanupWorktree(repoDir, wt, "hook should not block"); + expect(result.hasChanges).toBe(true); + expect(result.branch).toBe("pi-agent-hooked-1"); + + // Cleanup branch + try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("creates branch when worktree is clean but HEAD moved", () => { + const wt = createWorktree(repoDir, "committed-1")!; + expect(wt).toBeDefined(); + + writeFileSync(join(wt.path, "committed-file.txt"), "agent committed this"); + execFileSync("git", ["add", "committed-file.txt"], { cwd: wt.path, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "agent commit"], { cwd: wt.path, stdio: "pipe" }); + const agentCommit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: wt.path, stdio: "pipe", + }).toString().trim(); + + const result = cleanupWorktree(repoDir, wt, "already committed"); + expect(result.hasChanges).toBe(true); + expect(result.branch).toBeDefined(); + expect(result.branch).toBe("pi-agent-committed-1"); + + const branchCommit = execFileSync("git", ["rev-parse", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString().trim(); + expect(branchCommit).toBe(agentCommit); + expect(existsSync(wt.path)).toBe(false); + + // Cleanup branch + try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("does not force-overwrite existing branch", () => { + // Create first worktree, make changes, cleanup → creates branch + const wt1 = createWorktree(repoDir, "conflict-1")!; + writeFileSync(join(wt1.path, "file1.txt"), "first run"); + const result1 = cleanupWorktree(repoDir, wt1, "first"); + expect(result1.branch).toBe("pi-agent-conflict-1"); + + // Create second worktree with same agent ID, make changes + const wt2 = createWorktree(repoDir, "conflict-1")!; + writeFileSync(join(wt2.path, "file2.txt"), "second run"); + const result2 = cleanupWorktree(repoDir, wt2, "second"); + + // Should use a different branch name (timestamp suffix) + expect(result2.hasChanges).toBe(true); + expect(result2.branch).toBeDefined(); + expect(result2.branch).not.toBe("pi-agent-conflict-1"); + expect(result2.branch).toContain("pi-agent-conflict-1-"); + + // Both branches should exist + const branches = execFileSync("git", ["branch", "--list", "pi-agent-conflict-1*"], { + cwd: repoDir, stdio: "pipe", + }).toString().trim(); + expect(branches).toContain("pi-agent-conflict-1"); + expect(branches).toContain(result2.branch!); + + // Cleanup + try { execFileSync("git", ["branch", "-D", result1.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + try { execFileSync("git", ["branch", "-D", result2.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + + it("handles already-deleted worktree gracefully", () => { + const wt = createWorktree(repoDir, "gone-1")!; + // Manually delete the worktree directory + rmSync(wt.path, { recursive: true, force: true }); + + const result = cleanupWorktree(repoDir, wt, "already gone"); + expect(result.hasChanges).toBe(false); + }); + + it("truncates commit message at 200 chars", () => { + const wt = createWorktree(repoDir, "long-msg")!; + writeFileSync(join(wt.path, "change.txt"), "something"); + const longDesc = "x".repeat(300); + const result = cleanupWorktree(repoDir, wt, longDesc); + expect(result.hasChanges).toBe(true); + + const log = execFileSync("git", ["log", "--oneline", "-1", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString().trim(); + // "pi-agent: " prefix (10 chars) + 200 chars of x = 210 total max + expect(log.length).toBeLessThanOrEqual(220); // some slack for hash prefix + + // Cleanup + try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } + }); + }); + + describe("pruneWorktrees", () => { + it("does not throw on a clean repo", () => { + expect(() => pruneWorktrees(repoDir)).not.toThrow(); + }); + + it("does not throw on non-git directory", () => { + const nonGit = mkdtempSync(join(tmpdir(), "pi-wt-nongit-")); + try { + expect(() => pruneWorktrees(nonGit)).not.toThrow(); + } finally { + rmSync(nonGit, { recursive: true, force: true }); + } + }); + }); +}); + +// cleanupWorktree's outer catch is the only place in the repo where a caught +// error can DESTROY user work while reporting success-shaped output: it removes +// the worktree and returns `{ hasChanges: false }`, which the manager renders as +// "the agent changed nothing". If the commit or branch step fails, the agent's +// commits go with the worktree and nobody is told. +describe("cleanupWorktree — failure path", () => { + let repoDir: string; + + beforeEach(() => { repoDir = initGitRepo(); }); + afterEach(() => { + try { pruneWorktrees(repoDir); } catch { /* ignore */ } + rmSync(repoDir, { recursive: true, force: true }); + }); + + it("short-circuits when the worktree directory is already gone", () => { + // Hits the existsSync guard at the top of cleanupWorktree, not the outer + // catch — cleanup can be called twice (settle path plus dispose), so it has + // to be idempotent rather than throw on the second call. + const wt = createWorktree(repoDir, "vanished")!; + expect(wt).toBeDefined(); + rmSync(wt.path, { recursive: true, force: true }); + + const result = cleanupWorktree(repoDir, wt, "agent that vanished"); + + expect(result.hasChanges).toBe(false); + expect(result.branch).toBeUndefined(); + }); + + it("swallows a git failure inside a still-present worktree and reports no changes", () => { + // The outer catch. The directory exists — so the existsSync guard above + // does not fire — but git cannot operate in it, which is what a corrupted + // or externally-detached worktree looks like. The agent's work is lost + // either way; what matters is that cleanup does not throw out of the + // manager's settle path and take the whole record down with it. + const wt = createWorktree(repoDir, "corrupt")!; + writeFileSync(join(wt.path, "work.txt"), "agent output"); + // Break the worktree's link back to the repo. + writeFileSync(join(wt.path, ".git"), "gitdir: /nonexistent/path/that/is/not/a/repo"); + + const result = cleanupWorktree(repoDir, wt, "corrupted agent"); + + expect(result.hasChanges).toBe(false); + expect(result.branch).toBeUndefined(); + }); + + it("creates the branch BEFORE removing the worktree, so a removal failure cannot lose commits", () => { + // Ordering is the actual safety property. If a refactor moved + // removeWorktree above the `git branch` call, the commits would be + // unreachable the moment removal succeeded and branching failed. + const wt = createWorktree(repoDir, "ordered")!; + writeFileSync(join(wt.path, "work.txt"), "agent output"); + + const result = cleanupWorktree(repoDir, wt, "ordered agent"); + + expect(result.hasChanges).toBe(true); + expect(result.branch).toBeDefined(); + // The branch must exist in the MAIN repo after the worktree is gone — + // that is what makes the agent's work recoverable. + const branches = execFileSync("git", ["branch", "--list", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString(); + expect(branches).toContain(result.branch!); + expect(existsSync(wt.path)).toBe(false); + // And the commit is reachable from that branch. + const files = execFileSync("git", ["ls-tree", "--name-only", result.branch!], { + cwd: repoDir, stdio: "pipe", + }).toString(); + expect(files).toContain("work.txt"); + }); +}); + +/** + * The project switch itself (`worktreeIsolation`, #184). Its consumers — + * agent-manager, both tool schemas, the invocation resolver — all mock this + * module, so without this block the real singleton is never executed and its + * default is never exercised. That default is what every "worktree isolation + * still behaves as before" claim rests on. + */ +describe("worktree isolation switch", () => { + afterEach(() => setWorktreeIsolationEnabled(true)); + + it("defaults to enabled", () => { + expect(isWorktreeIsolationEnabled()).toBe(true); + }); + + it("round-trips both ways", () => { + setWorktreeIsolationEnabled(false); + expect(isWorktreeIsolationEnabled()).toBe(false); + setWorktreeIsolationEnabled(true); + expect(isWorktreeIsolationEnabled()).toBe(true); + }); + + // The switch gates callers; it deliberately does not disarm createWorktree + // itself, so a caller that has already decided (agent-manager checks first) + // still gets a real worktree rather than a silent no-op. + it("does not disable createWorktree directly", () => { + const repoDir = initGitRepo(); + try { + setWorktreeIsolationEnabled(false); + const wt = createWorktree(repoDir, "switch-test"); + expect(wt).toBeDefined(); + cleanupWorktree(repoDir, wt!, "switch test"); + } finally { + pruneWorktrees(repoDir); + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); diff --git a/pi-subagents/tsconfig.json b/pi-subagents/tsconfig.json new file mode 100644 index 0000000..a5cba40 --- /dev/null +++ b/pi-subagents/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pi-subagents/vitest.config.ts b/pi-subagents/vitest.config.ts new file mode 100644 index 0000000..d136c1f --- /dev/null +++ b/pi-subagents/vitest.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The print-mode e2e suite (test/subagents-print-mode-e2e.test.ts) drives REAL + // faux-model turns through pi-coding-agent + pi-agent-core. That requires ONE + // shared @earendil-works/pi-ai instance so the faux provider the test registers + // lands in the same api-registry the session streams through. npm physically + // duplicates pi-ai (a top-level copy and one nested under pi-coding-agent), which + // otherwise yields two registries and "No API provider registered" errors. + // Inlining the @earendil-works packages routes them through Vite's resolver so + // dedupe can collapse pi-ai to a single instance — for the parent AND for every + // subagent session the extension spawns. dedupe alone is insufficient (it only + // affects modules Vite resolves; without inline the runtime stays externalized). + test: { + server: { deps: { inline: [/@earendil-works\/pi-/] } }, + // Local reporting only — deliberately no `thresholds`, and not wired into + // CI. src/index.ts is mostly the /agents wizard, which is TUI flow with + // almost no logic and is not worth a fake-TUI harness; any global floor + // would therefore either sit below what the rest of the suite achieves + // (and ratchet down as tests are deleted) or force exactly that harness. + // Coverage here measures lines touched, not behavior pinned. + // + // `istanbul`, NOT the `v8` default. v8 under-reports badly on this suite: + // `src/env.ts` came out at 54.54% for the full run but 90.9% for + // `vitest run --coverage test/env.test.ts` — coverage cannot fall as more + // tests run. Bisecting pins it on test/subagents-print-mode-e2e.test.ts, + // which boots a REAL pi session; pi's own resource loader re-loads the + // extension and its imports, so the same source file appears to v8 twice — + // once executed, once barely — and the merge takes the wrong one rather + // than the union. The damage was not confined to env.ts: v8 also reported + // module-level `const` declarations in src/settings.ts as uncovered, which + // is impossible since they run on import. + // + // istanbul instruments at transform time and accumulates per source path, + // so a second load adds to the same counters instead of shadowing them. + // Same suite, same run: v8 68.6% vs istanbul 76.5% statements, and the + // per-file numbers now match what a single-file run reports. If you switch + // this back to v8, re-check env.ts against `--coverage test/env.test.ts` + // before trusting anything the table says. + coverage: { + provider: "istanbul", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + }, + }, + resolve: { dedupe: ["@earendil-works/pi-ai"] }, +}); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index d22ba06..293f6eb 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -11,6 +11,7 @@ - Added limits for active groups, generated group size, and total dynamic tools, plus current-host catalog/config/LRU tests. - Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present. - Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows. +- Added the enabled bundle's `Agent`, `get_subagent_result`, and `steer_subagent` tools to a checked-in `subagents` orchestration group. ## [0.3.6] - 2026-04-24 diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index 3c87f16..fbeaa23 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -37,6 +37,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm | `context-administration` | Context upgrade and destructive purge | | `memory-recall` | Official Hippo recall/context/status and condensed-output recovery | | `memory-management` | Official Hippo remember/outcome tools | +| `subagents` | Launch agents, collect background results, and steer running agents | | `mcp-management` | Shared MCP adapter management | Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget. diff --git a/pi-tool-search/docs/dynamic-tool-loading.md b/pi-tool-search/docs/dynamic-tool-loading.md index 778090a..cfcb736 100644 --- a/pi-tool-search/docs/dynamic-tool-loading.md +++ b/pi-tool-search/docs/dynamic-tool-loading.md @@ -12,7 +12,7 @@ 8. Group load and member execution update an in-session LRU counter. Loading past `maxActiveGroups` or `maxDynamicTools` removes the least-recently-used dynamic groups. 9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog. -The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded. +The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded. A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint. diff --git a/pi-tool-search/extensions/bundle-groups.ts b/pi-tool-search/extensions/bundle-groups.ts index 82a0f39..93d8a66 100644 --- a/pi-tool-search/extensions/bundle-groups.ts +++ b/pi-tool-search/extensions/bundle-groups.ts @@ -121,6 +121,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [ keywords: ["remember", "outcome", "lesson", "error", "记住", "反馈", "项目经验"], tools: ["hippo_remember", "hippo_outcome"], }, + { + id: "subagents", + title: "Subagent orchestration", + summary: "Launch isolated subagents, collect background results, and steer running delegated work.", + useWhen: ["A bounded task benefits from an isolated specialist, background execution, or delegated follow-up"], + avoidWhen: ["The current agent can complete a small direct task without delegation overhead"], + keywords: ["subagent", "agent", "delegate", "background", "steer", "子代理", "委派", "后台"], + tools: ["Agent", "get_subagent_result", "steer_subagent"], + }, { id: "mcp-management", title: "MCP management", diff --git a/pi-tool-search/test/bundle-groups.test.ts b/pi-tool-search/test/bundle-groups.test.ts index dfc4f34..6fc14ed 100644 --- a/pi-tool-search/test/bundle-groups.test.ts +++ b/pi-tool-search/test/bundle-groups.test.ts @@ -29,6 +29,9 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => { assert.equal(result.catalog.tools.find((card) => card.name === "tavily_web_fetch")?.primaryGroup, "web-tavily"); assert.equal(result.catalog.tools.find((card) => card.name === "hippo_recall")?.primaryGroup, "memory-recall"); assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management"); + assert.equal(result.catalog.tools.find((card) => card.name === "Agent")?.primaryGroup, "subagents"); + assert.equal(result.catalog.tools.find((card) => card.name === "get_subagent_result")?.primaryGroup, "subagents"); + assert.equal(result.catalog.tools.find((card) => card.name === "steer_subagent")?.primaryGroup, "subagents"); }); test("user overrides take priority and unknown tools retain deterministic fallback groups", () => { diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 177d76f..60df636 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -9,6 +9,40 @@ import test from "node:test"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0; +test("enabled subagents wrapper loads with its bundle-owned mandatory permission entry", { skip: !hasPi }, async () => { + const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as { + dependencies: Record; + files: string[]; + pi: { extensions: string[] }; + }; + assert.ok(packageJson.files.includes("pi-subagents")); + assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents"); + assert.ok( + packageJson.pi.extensions.includes("./extensions/subagents.ts"), + "the bundle must default-load the locally maintained subagents wrapper", + ); + + const home = await mkdtemp(join(tmpdir(), "my-pi-subagents-wrapper-load-")); + const result = spawnSync( + "pi", + ["--no-extensions", "-e", join(repositoryRoot, "extensions", "subagents.ts"), "--mode", "rpc"], + { + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + PI_CODING_AGENT_DIR: join(home, ".pi-agent"), + PI_OFFLINE: "1", + }, + }, + ); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension/u); +}); + test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => { const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as { dependencies: Record; @@ -36,6 +70,8 @@ test("all package extensions load together without global registration conflicts "the packed bundle must include the locally maintained Codex Fast mode source", ); assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer"); + assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents"); + assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts")); assert.equal( packageJson.pi.extensions.some((entry) => entry.includes("pi-hermes-memory") || entry.includes("the-forge-flow")), false, @@ -51,7 +87,7 @@ test("all package extensions load together without global registration conflicts await chmod(hippo, 0o755); const args = ["--no-extensions"]; for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry)); - args.push("--help"); + args.push("--mode", "rpc"); const result = spawnSync("pi", args, { encoding: "utf8",