one-code-extension 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/agents/general-purpose.md +2 -2
- package/docs/guide/README.md +2 -1
- package/docs/guide/bring-your-claude-code-setup.md +60 -9
- package/docs/guide/configuration.md +48 -14
- package/docs/guide/differences-from-claude-code.md +40 -28
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +26 -4
- package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
- package/docs/guide/providers-and-models.md +137 -54
- package/docs/guide/reference.md +27 -15
- package/docs/guide/sessions-and-context.md +69 -16
- package/docs/guide/skills-plugins-and-mcp.md +57 -20
- package/docs/guide/subagents-and-workflows.md +95 -29
- package/docs/guide/tasks-and-background-work.md +101 -26
- package/docs/guide/terminal-interface.md +24 -12
- package/docs/guide/tools.md +46 -5
- package/docs/guide/troubleshooting.md +13 -9
- package/docs/guide/windows.md +58 -31
- package/extensions/artifacts/gallery.ts +227 -0
- package/extensions/artifacts/index.ts +288 -0
- package/extensions/artifacts/store.ts +570 -0
- package/extensions/artifacts/viewer.ts +199 -0
- package/extensions/ask-user/description.ts +67 -0
- package/extensions/ask-user/dialogs.ts +80 -0
- package/extensions/ask-user/index.ts +37 -33
- package/extensions/ask-user/questions.ts +32 -7
- package/extensions/ask-user/widget.ts +45 -25
- package/extensions/auto-mode/actions.ts +6 -0
- package/extensions/auto-mode/cache.ts +88 -0
- package/extensions/auto-mode/classifier-prompt.ts +5 -4
- package/extensions/auto-mode/classifier.ts +290 -65
- package/extensions/auto-mode/config.ts +72 -20
- package/extensions/auto-mode/decision-log.ts +2 -0
- package/extensions/auto-mode/defaults.ts +1 -1
- package/extensions/auto-mode/denials.ts +124 -0
- package/extensions/auto-mode/git-checkout-programs.ts +187 -0
- package/extensions/auto-mode/git-status-meta.ts +159 -0
- package/extensions/auto-mode/history.ts +189 -0
- package/extensions/auto-mode/instructions.ts +58 -52
- package/extensions/auto-mode/model-select.ts +159 -134
- package/extensions/auto-mode/paths.ts +57 -5
- package/extensions/auto-mode/pause.ts +57 -2
- package/extensions/auto-mode/prompt.ts +37 -21
- package/extensions/auto-mode/read-only-options.ts +724 -0
- package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
- package/extensions/auto-mode/rules.ts +18 -1
- package/extensions/auto-mode/safety-floor.ts +361 -18
- package/extensions/auto-mode/setup-run.ts +20 -14
- package/extensions/auto-mode/shell-analysis.ts +764 -490
- package/extensions/auto-mode/shell-parse.ts +885 -0
- package/extensions/auto-mode/system-note.ts +33 -0
- package/extensions/auto-mode/transcript.ts +80 -51
- package/extensions/background/cron.ts +642 -0
- package/extensions/background/index.ts +614 -248
- package/extensions/background/loop-fire.ts +173 -0
- package/extensions/background/loop-preamble.ts +59 -0
- package/extensions/background/loop-skill.ts +163 -0
- package/extensions/background/monitor-batch.ts +53 -0
- package/extensions/background/noop-fold.ts +128 -0
- package/extensions/background/switch-guard.ts +56 -0
- package/extensions/background/wakeup.ts +178 -55
- package/extensions/bash/background.ts +53 -17
- package/extensions/bash/child.ts +7 -0
- package/extensions/bash/description.ts +188 -0
- package/extensions/bash/guards.ts +15 -67
- package/extensions/bash/index.ts +66 -35
- package/extensions/branding/compaction-keep.ts +63 -0
- package/extensions/branding/declined.ts +22 -0
- package/extensions/branding/index.ts +133 -9
- package/extensions/branding/prompt-editor.ts +53 -6
- package/extensions/branding/queued-edit.ts +31 -0
- package/extensions/branding/replaced-builtins.ts +68 -0
- package/extensions/branding/startup.ts +33 -71
- package/extensions/btw/index.ts +250 -78
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +436 -63
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +219 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +35 -15
- package/extensions/context-management/index.ts +36 -28
- package/extensions/doctor/build.ts +59 -8
- package/extensions/doctor/builtins.ts +84 -0
- package/extensions/doctor/cli.ts +7 -1
- package/extensions/doctor/compat.ts +88 -23
- package/extensions/doctor/dependencies.ts +41 -5
- package/extensions/doctor/fix-prompt.ts +9 -2
- package/extensions/doctor/index.ts +102 -58
- package/extensions/doctor/models.ts +102 -63
- package/extensions/doctor/presets.ts +42 -27
- package/extensions/doctor/report.ts +19 -2
- package/extensions/doctor/viewer.ts +49 -27
- package/extensions/effort/index.ts +141 -25
- package/extensions/effort/settings.ts +21 -0
- package/extensions/effort/slider.ts +24 -20
- package/extensions/exit/index.ts +32 -1
- package/extensions/file-tracker/index.ts +420 -42
- package/extensions/file-tracker/replay.ts +137 -24
- package/extensions/file-tracker/restore.ts +66 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +45 -15
- package/extensions/footer/footer-line.ts +45 -24
- package/extensions/footer/index.ts +47 -2
- package/extensions/footer/pr.ts +34 -5
- package/extensions/hooks/compaction.ts +7 -0
- package/extensions/hooks/executor.ts +32 -30
- package/extensions/hooks/index.ts +209 -58
- package/extensions/hooks/matcher.ts +3 -0
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/protocol.ts +41 -3
- package/extensions/hooks/settings.ts +50 -19
- package/extensions/hooks/subagent-bridge.ts +2 -0
- package/extensions/hooks/trust.ts +26 -12
- package/extensions/idle-compact/index.ts +119 -0
- package/extensions/idle-compact/policy.ts +143 -0
- package/extensions/init/index.ts +11 -10
- package/extensions/init/prompt.ts +25 -32
- package/extensions/interrupted/index.ts +13 -1
- package/extensions/kept-thinking/index.ts +27 -0
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +146 -0
- package/extensions/lib/agent-loader.ts +18 -3
- package/extensions/lib/agent-view.ts +85 -0
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/anthropic-payload.ts +82 -1
- package/extensions/lib/anthropic-server-call.ts +230 -0
- package/extensions/lib/anthropic-server-tools.ts +485 -0
- package/extensions/lib/app-launch.mjs +94 -0
- package/extensions/lib/argument-hints.ts +190 -0
- package/extensions/lib/atomic-write.ts +6 -2
- package/extensions/lib/auto-memory.ts +75 -0
- package/extensions/lib/bash-parser.ts +84 -0
- package/extensions/lib/btw-fork.ts +93 -0
- package/extensions/lib/child-extensions.ts +52 -0
- package/extensions/lib/child-writes.ts +45 -0
- package/extensions/lib/claude-context.ts +515 -100
- package/extensions/lib/claude-external-includes.ts +68 -0
- package/extensions/lib/claude-id.ts +22 -0
- package/extensions/lib/claude-rules.ts +323 -0
- package/extensions/lib/claude-settings.ts +76 -4
- package/extensions/lib/compaction-boundary.ts +36 -0
- package/extensions/lib/compaction-keep.mjs +83 -0
- package/extensions/lib/config-mode.ts +112 -0
- package/extensions/lib/consent-dialogs.ts +63 -0
- package/extensions/lib/consent-preview.ts +5 -1
- package/extensions/lib/consent-stores.ts +48 -0
- package/extensions/lib/context-facts.ts +97 -0
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +359 -25
- package/extensions/lib/dock-rows.ts +60 -0
- package/extensions/lib/empty-tool-result.ts +27 -0
- package/extensions/lib/environment-block.ts +76 -0
- package/extensions/lib/fetch-timeout.ts +24 -6
- package/extensions/{system-prompt → lib}/git-status.ts +51 -13
- package/extensions/lib/git.ts +75 -13
- package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
- package/extensions/lib/grammars/README.md +2 -0
- package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
- package/extensions/lib/guide-docs.ts +45 -0
- package/extensions/lib/headless-output.ts +46 -0
- package/extensions/lib/hook-gate.ts +7 -7
- package/extensions/lib/interrupt.ts +20 -4
- package/extensions/lib/key-input.ts +455 -0
- package/extensions/lib/keys.ts +14 -1
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +71 -19
- package/extensions/lib/mcp-overrides.ts +1 -1
- package/extensions/lib/mcp-share.ts +46 -0
- package/extensions/lib/memory-content.ts +146 -0
- package/extensions/lib/memory-index.ts +18 -0
- package/extensions/lib/memory.ts +134 -123
- package/extensions/lib/model-catalog/huggingface.json +178 -0
- package/extensions/lib/model-catalog/models-dev.json +18533 -0
- package/extensions/lib/model-catalog/openrouter.json +5050 -0
- package/extensions/lib/model-catalog-data.ts +513 -0
- package/extensions/lib/model-catalog.ts +462 -0
- package/extensions/lib/model-choice-warnings.ts +137 -0
- package/extensions/lib/model-pairings.ts +152 -0
- package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
- package/extensions/lib/model-policy.ts +112 -15
- package/extensions/lib/model-tier.ts +283 -355
- package/extensions/lib/model-unusable.ts +1 -1
- package/extensions/lib/newer-model.ts +100 -0
- package/extensions/lib/notices.ts +87 -0
- package/extensions/lib/notifications.ts +357 -73
- package/extensions/lib/one-code-settings.ts +74 -8
- package/extensions/lib/open-browser.ts +63 -0
- package/extensions/lib/openrouter-generation.ts +94 -0
- package/extensions/lib/operation-deadline.ts +30 -0
- package/extensions/lib/original-command.ts +4 -4
- package/extensions/lib/package-version.ts +1 -1
- package/extensions/lib/panel-focus.ts +134 -0
- package/extensions/lib/paths.ts +17 -2
- package/extensions/lib/permission-gate.ts +72 -6
- package/extensions/lib/persisted-output.ts +60 -8
- package/extensions/lib/pi-ai-estimate.ts +19 -3
- package/extensions/lib/pi-version.ts +38 -2
- package/extensions/lib/plan-mode-channels.ts +42 -5
- package/extensions/lib/plugins.ts +26 -17
- package/extensions/lib/powershell-parse-script.ts +208 -0
- package/extensions/lib/powershell-parser.ts +344 -0
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +132 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +370 -358
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +547 -0
- package/extensions/lib/scratchpad.ts +81 -14
- package/extensions/lib/send-now-channel.ts +7 -0
- package/extensions/lib/session-model-tier.ts +51 -0
- package/extensions/lib/session-work.ts +64 -0
- package/extensions/lib/settings-channels.ts +4 -4
- package/extensions/lib/shell-exit-semantics.ts +170 -0
- package/extensions/lib/shell-quote.ts +11 -0
- package/extensions/lib/shell-result.ts +70 -0
- package/extensions/lib/shell-spawn.ts +70 -6
- package/extensions/lib/shell-tool.ts +75 -25
- package/extensions/lib/side-call-cache.ts +124 -0
- package/extensions/lib/side-call-run.ts +96 -0
- package/extensions/lib/side-call-usage.ts +38 -0
- package/extensions/lib/side-call.ts +24 -1
- package/extensions/lib/single-flight.ts +18 -0
- package/extensions/lib/skill-body.ts +42 -0
- package/extensions/lib/skill-scan.ts +63 -24
- package/extensions/lib/spill-file.ts +140 -0
- package/extensions/lib/subagent-handback.ts +112 -0
- package/extensions/lib/system-role.ts +156 -0
- package/extensions/lib/terminal-text.ts +114 -0
- package/extensions/lib/text-width.ts +47 -16
- package/extensions/lib/timer-ops.ts +20 -0
- package/extensions/lib/tool-additions.ts +129 -0
- package/extensions/lib/tool-call-corruption.ts +134 -0
- package/extensions/lib/tool-path.ts +66 -0
- package/extensions/lib/tool-variants.ts +69 -0
- package/extensions/lib/tui-render.ts +255 -27
- package/extensions/lib/turn-budget-layout.ts +159 -0
- package/extensions/lib/update-check.mjs +15 -2
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/user-denial.ts +38 -0
- package/extensions/lib/workspace-channel.ts +16 -0
- package/extensions/lib/worktree-channel.ts +31 -3
- package/extensions/lib/worktree-isolation.ts +40 -10
- package/extensions/lsp/client.ts +8 -9
- package/extensions/lsp/index.ts +76 -12
- package/extensions/lsp/keep-alive.ts +1 -1
- package/extensions/lsp/trust.ts +119 -0
- package/extensions/lsp/watcher.ts +1 -1
- package/extensions/mcp/announce.ts +90 -43
- package/extensions/mcp/client.ts +161 -48
- package/extensions/mcp/config.ts +132 -8
- package/extensions/mcp/discovery.ts +19 -0
- package/extensions/mcp/index.ts +220 -121
- package/extensions/mcp/oauth/browser.ts +38 -17
- package/extensions/mcp/oauth/flow.ts +25 -17
- package/extensions/mcp/oauth/provider.ts +14 -8
- package/extensions/mcp/oauth/store.ts +34 -16
- package/extensions/mcp/panel/keys.ts +12 -11
- package/extensions/mcp/panel/render.ts +9 -6
- package/extensions/mcp/schema.ts +104 -5
- package/extensions/mcp/trust.ts +164 -73
- package/extensions/memory/entries.ts +23 -14
- package/extensions/memory/index.ts +134 -28
- package/extensions/memory/open-external.ts +33 -18
- package/extensions/memory/panel.ts +135 -26
- package/extensions/model-default/index.ts +86 -19
- package/extensions/newer-model/index.ts +31 -0
- package/extensions/notebook/description.ts +18 -0
- package/extensions/notebook/index.ts +6 -5
- package/extensions/notebook/notebook.ts +1 -1
- package/extensions/permissions/index.ts +1426 -398
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +11 -4
- package/extensions/permissions/panel/host.ts +130 -0
- package/extensions/permissions/panel/keys.ts +48 -0
- package/extensions/permissions/panel/render.ts +450 -0
- package/extensions/permissions/panel/state.ts +550 -0
- package/extensions/permissions/powershell-cmdlets.ts +198 -0
- package/extensions/permissions/powershell-paths.ts +197 -0
- package/extensions/permissions/powershell-rules.ts +207 -280
- package/extensions/permissions/powershell-tree.ts +557 -0
- package/extensions/permissions/project-trust.ts +27 -11
- package/extensions/permissions/protected-paths.ts +14 -2
- package/extensions/permissions/session-grant.ts +6 -0
- package/extensions/permissions/settings.ts +166 -31
- package/extensions/permissions/subagent-gate.ts +25 -0
- package/extensions/permissions/workspace.ts +78 -0
- package/extensions/plan-mode/index.ts +276 -92
- package/extensions/plan-mode/reminder.ts +89 -21
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plan-mode/texts.ts +177 -0
- package/extensions/plan-mode/viewer.ts +92 -27
- package/extensions/plugins/counts.ts +3 -3
- package/extensions/plugins/index.ts +64 -46
- package/extensions/plugins/install/install.ts +16 -0
- package/extensions/plugins/marketplace/sync.ts +9 -3
- package/extensions/plugins/marketplace/types.ts +3 -1
- package/extensions/plugins/panel/keys.ts +21 -24
- package/extensions/plugins/panel/render.ts +12 -6
- package/extensions/plugins/shell-expand.ts +107 -0
- package/extensions/plugins/template.ts +32 -15
- package/extensions/powershell/child.ts +7 -0
- package/extensions/powershell/description.ts +38 -6
- package/extensions/powershell/guards.ts +1 -1
- package/extensions/powershell/index.ts +14 -7
- package/extensions/recap/index.ts +73 -71
- package/extensions/recap/prompt.ts +2 -2
- package/extensions/recap/scheduler.ts +3 -5
- package/extensions/search-tools/grep-merge.ts +39 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/send-now/chord.ts +62 -0
- package/extensions/send-now/index.ts +133 -0
- package/extensions/session-title/index.ts +3 -0
- package/extensions/session-title/title.ts +4 -1
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/code-review.ts +97 -0
- package/extensions/skill/index.ts +469 -132
- package/extensions/skill/invoke.ts +118 -75
- package/extensions/skill/listing.ts +133 -9
- package/extensions/skill/panel/keys.ts +15 -16
- package/extensions/skill/panel/render.ts +5 -2
- package/extensions/skill/panel/state.ts +12 -1
- package/extensions/subagents/agent-args.ts +39 -0
- package/extensions/subagents/agent-description.ts +132 -0
- package/extensions/subagents/agents.ts +146 -31
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +82 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1594 -852
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +5 -2
- package/extensions/subagents/panel-keys.ts +73 -31
- package/extensions/subagents/panel-render.ts +120 -96
- package/extensions/subagents/panel-widget.ts +60 -89
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +135 -80
- package/extensions/subagents/runs.ts +44 -5
- package/extensions/subagents/send-to-main-tool.ts +1 -1
- package/extensions/subagents/session-turns.ts +32 -8
- package/extensions/subagents/shell-panel.ts +36 -178
- package/extensions/subagents/tasks-dialog.ts +170 -0
- package/extensions/subagents/viewport-mirror.ts +95 -0
- package/extensions/subagents/worktree.ts +57 -13
- package/extensions/system-prompt/environment.ts +19 -19
- package/extensions/system-prompt/idle-turn.ts +65 -0
- package/extensions/system-prompt/index.ts +165 -47
- package/extensions/system-prompt/template.ts +34 -51
- package/extensions/system-prompt/tiers/common.ts +23 -64
- package/extensions/system-prompt/tiers/frontier.ts +32 -11
- package/extensions/system-prompt/tiers/low.ts +67 -45
- package/extensions/system-prompt/tiers/mid.ts +96 -38
- package/extensions/system-reminder/index.ts +204 -70
- package/extensions/tasks/descriptions.ts +190 -0
- package/extensions/tasks/index.ts +120 -41
- package/extensions/tasks/store.ts +83 -16
- package/extensions/tool-call-corruption/child.ts +7 -0
- package/extensions/tool-call-corruption/index.ts +237 -0
- package/extensions/tool-search/index.ts +366 -144
- package/extensions/tool-style/index.ts +68 -10
- package/extensions/tool-style/write-description.ts +40 -0
- package/extensions/tool-style/write-render.ts +237 -0
- package/extensions/turn-duration/index.ts +7 -3
- package/extensions/turn-duration/line.ts +47 -4
- package/extensions/web/backends.ts +6 -9
- package/extensions/web/description.ts +50 -0
- package/extensions/web/index.ts +118 -53
- package/extensions/web/native-call.ts +15 -0
- package/extensions/web/native-results.ts +41 -0
- package/extensions/web-fetch/description.ts +42 -0
- package/extensions/web-fetch/extract.ts +53 -6
- package/extensions/web-fetch/index.ts +125 -76
- package/extensions/web-fetch/page-content.ts +19 -0
- package/extensions/web-fetch/response.ts +49 -0
- package/extensions/web-fetch/summarize.ts +9 -10
- package/extensions/workflow/agent-session.ts +108 -18
- package/extensions/workflow/args.ts +24 -0
- package/extensions/workflow/description.ts +67 -0
- package/extensions/workflow/globals.ts +17 -3
- package/extensions/workflow/index.ts +157 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +33 -6
- package/extensions/workflow/saved-workflows.ts +3 -2
- package/extensions/workflow/types.ts +22 -1
- package/extensions/workflow/viewer.ts +54 -24
- package/extensions/workflow/widget.ts +20 -0
- package/extensions/worktree/descriptions.ts +91 -0
- package/extensions/worktree/guards.ts +316 -54
- package/extensions/worktree/index.ts +162 -44
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +21 -7
- package/skills/artifact-design/SKILL.md +85 -0
- package/skills/code-review/SKILL.md +20 -20
- package/skills/code-review/cells/arguments-and-flags.md +19 -0
- package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
- package/skills/code-review/cells/high-8-inline.md +122 -0
- package/skills/code-review/cells/low-1-pass-max4.md +27 -0
- package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
- package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
- package/skills/code-review/cells/medium-3x5-verify.md +132 -0
- package/skills/code-review/cells/minimal-prompt.md +32 -0
- package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
- package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
- package/skills/fewer-permission-prompts/SKILL.md +1 -1
- package/skills/loop/SKILL.md +11 -0
- package/skills/security-review/SKILL.md +2 -5
- package/skills/simplify/SKILL.md +7 -5
- package/skills/workflow-authoring/SKILL.md +143 -0
- package/themes/onecode-light.json +1 -1
- package/themes/onecode.json +1 -1
- package/types/app.d.ts +58 -0
- package/types/community-packages.d.ts +26 -1
- package/extensions/auto-mode/recoverability.ts +0 -229
- package/extensions/lib/capability-index.ts +0 -466
- package/extensions/lib/model-facts.generated.json +0 -2675
- package/extensions/lib/model-facts.ts +0 -162
- package/extensions/tool-search/announce.ts +0 -42
|
@@ -16,17 +16,21 @@
|
|
|
16
16
|
* children persist their sessions per run to make that possible).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { CHILD_WROTE_CHANNEL, type ChildWrote } from "../lib/child-writes.ts";
|
|
20
|
+
import { canShowCustomUi } from "../lib/headless-output.ts";
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync, readFileSync } from "node:fs";
|
|
22
|
+
import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, agentCronTools, agentOwnsCronJobs } from "../lib/agent-cron.ts";
|
|
20
23
|
import os from "node:os";
|
|
21
24
|
import { dirname, join } from "node:path";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
22
26
|
import { fileURLToPath } from "node:url";
|
|
23
27
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
24
|
-
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
25
|
-
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { Type } from "typebox";
|
|
28
|
+
import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
29
|
+
import { type AgentToolResult, defineTool, getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type Static, Type } from "typebox";
|
|
27
31
|
import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
|
|
28
|
-
import { type AgentDefinition,
|
|
29
|
-
import { modelIdentity, modelSpec, supportsImageInput } from "../lib/model-policy.ts";
|
|
32
|
+
import { type AgentDefinition, agentFileSources, agentSourcesStamp, BUNDLED_AGENTS_DIR, discoverAgents } from "./agents.ts";
|
|
33
|
+
import { findConfigured, modelIdentity, modelSpec, supportsImageInput } from "../lib/model-policy.ts";
|
|
30
34
|
import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent, withoutUnusable } from "../lib/model-unusable.ts";
|
|
31
35
|
import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel, type SubagentDefault } from "./default-model.ts";
|
|
32
36
|
import {
|
|
@@ -35,27 +39,44 @@ import {
|
|
|
35
39
|
type SubagentModelResolution,
|
|
36
40
|
SUBAGENT_STATUS_CHANNEL,
|
|
37
41
|
subagentModelNotes,
|
|
42
|
+
refusedModelNote,
|
|
38
43
|
subagentModelsReminder,
|
|
39
44
|
subagentStatusModel,
|
|
40
45
|
} from "./model-select.ts";
|
|
41
|
-
import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../
|
|
46
|
+
import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../lib/model-picker.ts";
|
|
47
|
+
import { chosenModelWarnings } from "../lib/model-choice-warnings.ts";
|
|
48
|
+
import { readSuggestNewerModels } from "../lib/one-code-settings.ts";
|
|
42
49
|
import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
50
|
+
import { guideDocs } from "../lib/guide-docs.ts";
|
|
51
|
+
import { extensionVersion } from "../lib/package-version.ts";
|
|
52
|
+
import { scanSkills } from "../lib/skill-scan.ts";
|
|
53
|
+
import { type GuideInput, guideAgentDefinition, settingsSetup } from "./guide-agent.ts";
|
|
54
|
+
import { MCP_STATUS_CHANNEL, MCP_STATUS_REQUEST_CHANNEL, type McpServerStatus, type McpStatusEvent, type McpStatusKind } from "../lib/mcp-status.ts";
|
|
55
|
+
|
|
56
|
+
import { DEFER_CHANNEL, queryDeferred } from "../lib/deferred.ts";
|
|
57
|
+
import { requestSystemPrompt } from "../lib/prompt-options.ts";
|
|
58
|
+
import { captureMatches, LAST_REQUEST_CHANNEL, type RequestCapture } from "../lib/request-replay.ts";
|
|
59
|
+
import { BTW_FORK_CHANNEL, btwForkDescription, btwForkName, btwForkRecord, btwForkTaskId, type BtwForkRequest, type BtwForkResult } from "../lib/btw-fork.ts";
|
|
60
|
+
import { watchMcpTools } from "../lib/mcp-share.ts";
|
|
61
|
+
import { sessionModelTier } from "../lib/session-model-tier.ts";
|
|
62
|
+
import { type DescriptionForm, followDescriptionForm, registerVariantTool } from "../lib/tool-variants.ts";
|
|
63
|
+
import { agentDescription } from "./agent-description.ts";
|
|
64
|
+
import { prepareAgentArguments } from "./agent-args.ts";
|
|
65
|
+
import { projectConfigDirName } from "../lib/config-mode.ts";
|
|
66
|
+
import { pendingClaimReminder, runningAfterCompactionReminder } from "./pending-claim.ts";
|
|
47
67
|
import { watchPermissionBridge } from "../permissions/subagent-gate.ts";
|
|
48
68
|
import { watchHookBridge } from "../hooks/subagent-bridge.ts";
|
|
49
|
-
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
69
|
+
import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, type ReminderPayload, toldBlockFact } from "../lib/reminders.ts";
|
|
50
70
|
import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
|
|
51
71
|
import { type ChildAction } from "../auto-mode/actions.ts";
|
|
52
72
|
import { type ChildOutcome, forkTaskMessage, OUTPUT_CAP, type RpcChildHandle } from "./outcome.ts";
|
|
53
|
-
import { type AgentRunRecord, resolveRunName, RunRegistry } from "./runs.ts";
|
|
73
|
+
import { type AgentRunRecord, FORK_AGENT, freeRunName, resolveRunName, RunRegistry } from "./runs.ts";
|
|
54
74
|
import { SubagentRuntime } from "./runner.ts";
|
|
55
75
|
import { emptyUsage, formatStats, type UsageTotals } from "./usage.ts";
|
|
56
|
-
import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "./worktree.ts";
|
|
57
|
-
import {
|
|
58
|
-
import { registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
|
|
76
|
+
import { cleanupWorktree, createWorktree, isGitRepo, keptWorktreeNote, type Worktree } from "./worktree.ts";
|
|
77
|
+
import { findProjectRoot } from "../lib/git.ts";
|
|
78
|
+
import { followEnteredWorktree, registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
|
|
79
|
+
import { sessionWorkCwd } from "../lib/worktree-channel.ts";
|
|
59
80
|
import {
|
|
60
81
|
AGENT_NOTE,
|
|
61
82
|
agentMessage,
|
|
@@ -80,14 +101,32 @@ import { recordUsage } from "../lib/usage-bus.ts";
|
|
|
80
101
|
import { SUBAGENT_DEFAULT_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
|
|
81
102
|
import { SubagentWidget } from "./panel-widget.ts";
|
|
82
103
|
import { type ProseRenderer, renderTranscript } from "./panel-render.ts";
|
|
83
|
-
import { decodeStripKey, type StripKey } from "./panel-keys.ts";
|
|
84
|
-
import {
|
|
104
|
+
import { decodeStripKey, editorYieldsDown, isStripEntryKey, type StripKey } from "./panel-keys.ts";
|
|
105
|
+
import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
|
|
106
|
+
import { AGENT_VIEW_CHANNEL, type AgentViewAnnouncement, agentViewTarget, userMessageForAgent } from "../lib/agent-view.ts";
|
|
107
|
+
import { isKeyRelease, keyId, keyText } from "../lib/key-input.ts";
|
|
108
|
+
import { moveTasksSelection, renderShellDetail, renderTasksDialog, settleTasksView, shellDetailKey, tasksItems, type TasksView } from "./tasks-dialog.ts";
|
|
109
|
+
import { reduceShellKey, shellRows } from "./shell-panel.ts";
|
|
85
110
|
import { trackShellTasks } from "../lib/shell-tasks.ts";
|
|
86
111
|
import { createMarkdownProse } from "./prose.ts";
|
|
112
|
+
import { asScrollableViewport, ViewportMirror } from "./viewport-mirror.ts";
|
|
87
113
|
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
import { extendingCompletions } from "../lib/argument-hints.ts";
|
|
115
|
+
import { readStringsState, recordState } from "../lib/ledger/state.ts";
|
|
116
|
+
import { panelUi } from "../lib/panel-focus.ts";
|
|
117
|
+
import { dockRowsReporter, trackDockRows } from "../lib/dock-rows.ts";
|
|
118
|
+
|
|
119
|
+
/** How the guide's setup list names a server that is not connected. */
|
|
120
|
+
const MCP_STATUS_LABELS: Record<McpStatusKind, string | undefined> = {
|
|
121
|
+
connected: undefined,
|
|
122
|
+
failed: "failed to connect",
|
|
123
|
+
authNeeded: "needs authentication",
|
|
124
|
+
connecting: "connecting",
|
|
125
|
+
disabled: "disabled",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** This package's root (the `one-code-extension` directory). */
|
|
129
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
91
130
|
|
|
92
131
|
interface RunRequest {
|
|
93
132
|
agent: string;
|
|
@@ -105,6 +144,8 @@ interface RunRequest {
|
|
|
105
144
|
fallbackModel?: string;
|
|
106
145
|
thinking?: string;
|
|
107
146
|
worktree?: boolean;
|
|
147
|
+
/** Claude Code's `description`: the run's title (AgentRunRecord.description). */
|
|
148
|
+
description?: string;
|
|
108
149
|
}
|
|
109
150
|
|
|
110
151
|
/**
|
|
@@ -134,6 +175,8 @@ interface TaskResult {
|
|
|
134
175
|
usage: UsageTotals;
|
|
135
176
|
failed?: boolean;
|
|
136
177
|
worktreePath?: string;
|
|
178
|
+
/** The kept worktree's branch, which holds any commits the agent made. */
|
|
179
|
+
worktreeBranch?: string;
|
|
137
180
|
worktreeKept?: boolean;
|
|
138
181
|
/** What the child did, for auto mode's return review. */
|
|
139
182
|
actions?: ChildAction[];
|
|
@@ -143,12 +186,10 @@ const SubagentParams = Type.Object({
|
|
|
143
186
|
subagent_type: Type.Optional(
|
|
144
187
|
Type.String({
|
|
145
188
|
description:
|
|
146
|
-
'The agent to run — a name from the "Available agents" system reminder, or "fork" to clone this conversation.
|
|
189
|
+
'The agent to run — a name from the "Available agents" system reminder (general-purpose when omitted), or "fork" to clone this conversation. To run several in parallel, issue multiple Agent tool calls in one turn.',
|
|
147
190
|
}),
|
|
148
191
|
),
|
|
149
|
-
task: Type.
|
|
150
|
-
Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups). Required with subagent_type." }),
|
|
151
|
-
),
|
|
192
|
+
task: Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups)." }),
|
|
152
193
|
// `prompt` is Claude Code's name for the task text and `description` its short
|
|
153
194
|
// title. A CC-trained model writes `{subagent_type, description, prompt}`; pi
|
|
154
195
|
// does not reject unknown keys, so without these the task silently became ""
|
|
@@ -186,7 +227,15 @@ const SubagentParams = Type.Object({
|
|
|
186
227
|
),
|
|
187
228
|
});
|
|
188
229
|
|
|
189
|
-
|
|
230
|
+
|
|
231
|
+
/** The Agent tool's schema inside a non-fork child: the nested run's options only. */
|
|
232
|
+
const NESTED_AGENT_PARAMS = Type.Object({
|
|
233
|
+
subagent_type: Type.Optional(Type.String({ description: "An agent name from the list in this tool's description (default: general-purpose)" })),
|
|
234
|
+
task: Type.String({ description: "The task — a complete, self-contained instruction" }),
|
|
235
|
+
prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
|
|
236
|
+
description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
|
|
237
|
+
name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
|
|
238
|
+
});
|
|
190
239
|
|
|
191
240
|
/**
|
|
192
241
|
* The anti-fabrication sentence, carried by the tool RESULT of a background
|
|
@@ -218,10 +267,44 @@ interface Resident {
|
|
|
218
267
|
* ahead of the report in its notification.
|
|
219
268
|
*/
|
|
220
269
|
turnHandlers: Array<(outcome: ChildOutcome, review: HandBackVerdict | undefined, stopped: boolean) => void>;
|
|
270
|
+
/** Finished turns whose hand-back is still waiting on auto mode's review, so not yet delivered. */
|
|
271
|
+
reviewing: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether a resident has a report still to deliver: a turn is running, a turn's
|
|
276
|
+
* handler is queued, or a finished turn waits on its review. A resident that
|
|
277
|
+
* has delivered stays alive and idle for SendMessage; counting it as pending
|
|
278
|
+
* told a cheap-tier model to retract an answer the agent really gave
|
|
279
|
+
* (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M4).
|
|
280
|
+
*/
|
|
281
|
+
function residentPending(resident: Resident): boolean {
|
|
282
|
+
return !resident.handle.exited() && (resident.handle.busy() || resident.turnHandlers.length > 0 || resident.reviewing > 0);
|
|
221
283
|
}
|
|
222
284
|
|
|
285
|
+
/** The three capability blocks subagents owns on message 1. */
|
|
286
|
+
type SubagentBlockKey = "subagent-models" | "subagent-agents" | "subagent-delegation";
|
|
287
|
+
const BLOCK_ORDER: Record<SubagentBlockKey, number> = {
|
|
288
|
+
"subagent-models": CONTEXT_ORDER.subagentModels,
|
|
289
|
+
"subagent-agents": CONTEXT_ORDER.agents,
|
|
290
|
+
"subagent-delegation": CONTEXT_ORDER.delegation,
|
|
291
|
+
};
|
|
292
|
+
/** The `one-code:state` key of the models this account refused (lib/model-unusable.ts). */
|
|
293
|
+
const REFUSED_MODELS_STATE = "subagents:refused-models";
|
|
294
|
+
|
|
223
295
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
296
|
+
const requestTier = sessionModelTier(pi);
|
|
224
297
|
const registry = new RunRegistry();
|
|
298
|
+
/**
|
|
299
|
+
* The session's permission mode as the permissions extension last announced
|
|
300
|
+
* it; auto (the shipped default) until the first announcement. It picks the
|
|
301
|
+
* hand-back's shape (notifyHandBack).
|
|
302
|
+
*/
|
|
303
|
+
let permissionMode = "auto";
|
|
304
|
+
pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
|
|
305
|
+
const mode = (data as Partial<PermissionStatus> | undefined)?.mode;
|
|
306
|
+
if (typeof mode === "string") permissionMode = mode;
|
|
307
|
+
});
|
|
225
308
|
/**
|
|
226
309
|
* Task ids with a blocking child (nested spawn or SendMessage resume) currently
|
|
227
310
|
* running (SendMessage must wait for these). Keyed by task id, like `residents`:
|
|
@@ -231,6 +314,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
231
314
|
const runningIds = new Set<string>();
|
|
232
315
|
/** Task id → live resident. */
|
|
233
316
|
const residents = new Map<string, Resident>();
|
|
317
|
+
/**
|
|
318
|
+
* Task id → the main session's tool call that started the run's current
|
|
319
|
+
* turn (its Agent call, or the latest SendMessage to it); a nested run
|
|
320
|
+
* answers with its parent's (`parentTaskOf`). The permission gate reads the
|
|
321
|
+
* main session only as far as that call (auto-mode/history.ts
|
|
322
|
+
* childHistoryCursor). Ids only, a few per run.
|
|
323
|
+
*/
|
|
324
|
+
const mainCallFor = new Map<string, string>();
|
|
325
|
+
const parentTaskOf = new Map<string, string>();
|
|
326
|
+
const mainCallOf = (taskId: string): string | undefined => {
|
|
327
|
+
// Bounded: nesting is capped far below this, and a cycle must not hang the gate.
|
|
328
|
+
for (let id: string | undefined = taskId, depth = 0; id !== undefined && depth < 8; id = parentTaskOf.get(id), depth++) {
|
|
329
|
+
const call = mainCallFor.get(id);
|
|
330
|
+
if (call) return call;
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
};
|
|
234
334
|
/**
|
|
235
335
|
* Task ids of background runs spawned during the agent loop now in flight.
|
|
236
336
|
* Cleared at `agent_start`; read at `agent_end` to notice a turn that ended
|
|
@@ -256,7 +356,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
256
356
|
const stoppedTaskIds = new Set<string>();
|
|
257
357
|
let lastCtx: ExtensionContext | undefined;
|
|
258
358
|
const shellTasks = trackShellTasks(pi);
|
|
259
|
-
const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks);
|
|
359
|
+
const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks, dockRowsReporter(pi.events, "subagents"));
|
|
360
|
+
// The other strips below the editor (workflow status rows), so the agent view leaves their rows free.
|
|
361
|
+
const dock = trackDockRows(pi.events);
|
|
260
362
|
|
|
261
363
|
/**
|
|
262
364
|
* Register a child in the live panel and return the wiring the spawn sites
|
|
@@ -273,6 +375,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
273
375
|
liveRuns.register({
|
|
274
376
|
taskId: record.taskId,
|
|
275
377
|
name: record.name,
|
|
378
|
+
description: record.description,
|
|
276
379
|
agentType: request.agent,
|
|
277
380
|
model: request.model,
|
|
278
381
|
thinking: request.thinking,
|
|
@@ -301,37 +404,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
301
404
|
|
|
302
405
|
// Live MCP tool definitions published by the mcp extension; injected into
|
|
303
406
|
// child sessions so subagents share the parent's open connections (no reconnect).
|
|
304
|
-
|
|
305
|
-
// The parent's MCP connect runs in the background, so a spawn in the first
|
|
306
|
-
// seconds of a session waits for the settled publish (capped, so a hung server
|
|
307
|
-
// can't stall spawns) instead of baking in a still-connecting snapshot.
|
|
308
|
-
const MCP_SETTLE_CAP_MS = 10_000;
|
|
309
|
-
let mcpTools: ToolDefinition[] = [];
|
|
310
|
-
let mcpSettled = false;
|
|
311
|
-
let resolveMcpSettled: (() => void) | undefined;
|
|
312
|
-
const mcpSettledPromise = new Promise<void>((resolve) => {
|
|
313
|
-
resolveMcpSettled = resolve;
|
|
314
|
-
});
|
|
315
|
-
pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
|
|
316
|
-
const payload = data as McpToolsPayload | undefined;
|
|
317
|
-
mcpTools = payload?.tools ?? [];
|
|
318
|
-
if (payload?.settled) {
|
|
319
|
-
mcpSettled = true;
|
|
320
|
-
resolveMcpSettled?.();
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
const awaitMcpTools = async (): Promise<ToolDefinition[]> => {
|
|
324
|
-
if (!mcpSettled) {
|
|
325
|
-
await Promise.race([
|
|
326
|
-
mcpSettledPromise,
|
|
327
|
-
new Promise<void>((resolve) => {
|
|
328
|
-
const timer = setTimeout(resolve, MCP_SETTLE_CAP_MS);
|
|
329
|
-
timer.unref?.();
|
|
330
|
-
}),
|
|
331
|
-
]);
|
|
332
|
-
}
|
|
333
|
-
return mcpTools;
|
|
334
|
-
};
|
|
407
|
+
const awaitMcpTools = watchMcpTools(pi.events);
|
|
335
408
|
|
|
336
409
|
// The parent permissions extension's decision closure, used to gate a child's
|
|
337
410
|
// tool calls through the real pipeline (mode inheritance, classifier, prompts
|
|
@@ -355,16 +428,30 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
355
428
|
const rememberUnusable = (model: string) => {
|
|
356
429
|
if (unusableModels.has(model)) return false;
|
|
357
430
|
unusableModels.add(model);
|
|
431
|
+
// A refusal is a durable fact of the account, not UI prose: a resumed
|
|
432
|
+
// session must not pick the same model and announce it again.
|
|
433
|
+
recordState(pi, REFUSED_MODELS_STATE, [...unusableModels]);
|
|
358
434
|
return true;
|
|
359
435
|
};
|
|
360
436
|
pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
|
|
361
437
|
// The default may have been that model: recompute and republish the
|
|
362
438
|
// reminder/banner so the next request already names the replacement.
|
|
363
439
|
const event = data as ModelUnusableEvent;
|
|
364
|
-
if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx);
|
|
440
|
+
if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx, lastCtx.model, event.model);
|
|
365
441
|
});
|
|
366
442
|
|
|
367
|
-
|
|
443
|
+
// After `enter_worktree` the session works in the worktree, but pi keeps
|
|
444
|
+
// ctx.cwd at the original checkout: spawns start where the session works.
|
|
445
|
+
const enteredWorktree = followEnteredWorktree(pi.events);
|
|
446
|
+
const workCwd = (ctx: ExtensionContext) => sessionWorkCwd(enteredWorktree(), ctx.cwd);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The in-process runner, built lazily on first run and shared across all runs.
|
|
450
|
+
* A failed build (a malformed models.json or auth.json) is forgotten, so the
|
|
451
|
+
* next spawn tries again instead of every later spawn failing on the same
|
|
452
|
+
* rejected promise; callers await it before they mark a run as running
|
|
453
|
+
* (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 L8).
|
|
454
|
+
*/
|
|
368
455
|
let runtimePromise: Promise<SubagentRuntime> | undefined;
|
|
369
456
|
const getRuntime = (ctx: ExtensionContext) =>
|
|
370
457
|
(runtimePromise ??= SubagentRuntime.create(
|
|
@@ -386,19 +473,94 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
386
473
|
pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
|
|
387
474
|
const live = liveUiCtx(lastCtx);
|
|
388
475
|
live?.ui.notify(`Subagent model ${model} is not usable on this account (${reason}); automatic selection skips it from now on.`, "warning");
|
|
389
|
-
if (lastCtx) emitModelStatus(lastCtx);
|
|
476
|
+
if (lastCtx) emitModelStatus(lastCtx, lastCtx.model, model);
|
|
477
|
+
},
|
|
478
|
+
(path, agent) => pi.events.emit(CHILD_WROTE_CHANNEL, { path, ...(agent ? { agent } : {}) } satisfies ChildWrote),
|
|
479
|
+
() => queryDeferred(pi.events),
|
|
480
|
+
// The mode a fork's calls are checked in, read live at each of its requests (parent-mode.ts).
|
|
481
|
+
() => permissionMode,
|
|
482
|
+
).catch((error: unknown) => {
|
|
483
|
+
runtimePromise = undefined;
|
|
484
|
+
throw error;
|
|
485
|
+
}));
|
|
486
|
+
/** The runtime, or the startup error as a message a failed run can report. */
|
|
487
|
+
const runtimeOrError = (ctx: ExtensionContext): Promise<SubagentRuntime | string> =>
|
|
488
|
+
getRuntime(ctx)
|
|
489
|
+
.then((runtime) => shuttingDown ? "the session ended before the agent started" : runtime)
|
|
490
|
+
.catch((error: unknown) => `could not start the subagent runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
491
|
+
|
|
492
|
+
// The mcp extension answers a status request synchronously on the bus.
|
|
493
|
+
let mcpStatus: McpServerStatus[] | undefined;
|
|
494
|
+
pi.events.on(MCP_STATUS_CHANNEL, (data) => {
|
|
495
|
+
mcpStatus = (data as McpStatusEvent).servers;
|
|
496
|
+
});
|
|
497
|
+
/** The configured servers now; empty when the mcp extension is not loaded. */
|
|
498
|
+
const requestMcpStatus = (): McpServerStatus[] => {
|
|
499
|
+
mcpStatus = undefined;
|
|
500
|
+
pi.events.emit(MCP_STATUS_REQUEST_CHANNEL, {});
|
|
501
|
+
return (mcpStatus as McpServerStatus[] | undefined) ?? [];
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* What the code-defined `one-code-guide`'s prompt lists (guide-agent.ts): the
|
|
506
|
+
* user's custom skills and agents, enabled plugins, MCP servers, installed pi
|
|
507
|
+
* packages and extensions, and pi's settings keys. Read only when a spawn
|
|
508
|
+
* reads the guide's prompt.
|
|
509
|
+
*/
|
|
510
|
+
const guideInput = (cwd: string, catalog: readonly AgentDefinition[], plugins: ReturnType<typeof discoverPlugins>): GuideInput => {
|
|
511
|
+
const agentDir = getAgentDir();
|
|
512
|
+
let settingsText: string | undefined;
|
|
513
|
+
try {
|
|
514
|
+
settingsText = readFileSync(join(agentDir, "settings.json"), "utf-8");
|
|
515
|
+
} catch {
|
|
516
|
+
// No settings file yet: no packages or keys to list.
|
|
517
|
+
}
|
|
518
|
+
let extensions: string[] = [];
|
|
519
|
+
try {
|
|
520
|
+
extensions = readdirSync(join(agentDir, "extensions")).filter((entry) => !entry.startsWith("."));
|
|
521
|
+
} catch {
|
|
522
|
+
// No user extensions directory.
|
|
523
|
+
}
|
|
524
|
+
// Every configured server, from the mcp extension's status snapshot: a
|
|
525
|
+
// failed, disabled or resource-only server registers no tools.
|
|
526
|
+
const mcpServers = requestMcpStatus().map((server) => ({ name: server.name, status: MCP_STATUS_LABELS[server.status] }));
|
|
527
|
+
return {
|
|
528
|
+
docs: guideDocs(),
|
|
529
|
+
install: {
|
|
530
|
+
shape: process.env.ONECODE_INSTALL_METHOD ? "app" : "extension",
|
|
531
|
+
method: process.env.ONECODE_INSTALL_METHOD,
|
|
532
|
+
agentDir,
|
|
533
|
+
version: process.env.CC_VERSION ?? extensionVersion(),
|
|
534
|
+
},
|
|
535
|
+
setup: {
|
|
536
|
+
skills: scanSkills(cwd, os.homedir(), agentDir, plugins.skills).map((skill) => skill.name),
|
|
537
|
+
agents: catalog.filter((agent) => agent.source !== "built-in" && !agent.source.startsWith(BUNDLED_AGENTS_DIR)).map((agent) => agent.name),
|
|
538
|
+
plugins: plugins.enabledPlugins.map((plugin) => plugin.name),
|
|
539
|
+
mcpServers,
|
|
540
|
+
extensions,
|
|
541
|
+
...settingsSetup(settingsText, PACKAGE_ROOT),
|
|
390
542
|
},
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Plugin agents sit between bundled and user definitions, and are exposed
|
|
547
|
+
// namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
|
|
548
|
+
const agentSources = (cwd: string) => {
|
|
549
|
+
const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd));
|
|
550
|
+
return { plugins, fileSources: agentFileSources(cwd, os.homedir(), plugins.agentDirs) };
|
|
551
|
+
};
|
|
552
|
+
type AgentSources = ReturnType<typeof agentSources>;
|
|
553
|
+
|
|
554
|
+
/** `sources` when the caller already resolved them (the catalog stamp). */
|
|
555
|
+
const loadAgents = (cwd: string, sources: AgentSources = agentSources(cwd)) => {
|
|
556
|
+
// The code-defined guide ranks below every agent file, so one of the same
|
|
557
|
+
// name replaces it.
|
|
558
|
+
const { plugins, fileSources } = sources;
|
|
559
|
+
// The prompt is built from the catalog it sits in, when a spawn reads it.
|
|
560
|
+
let catalog: AgentDefinition[] = [];
|
|
561
|
+
const guide = guideAgentDefinition(() => guideInput(cwd, catalog, plugins));
|
|
562
|
+
catalog = discoverAgents([{ agents: [guide] }, ...fileSources]);
|
|
563
|
+
return catalog;
|
|
402
564
|
};
|
|
403
565
|
|
|
404
566
|
// Claude Code closes every catalog row with a tools clause — "(Tools: *)",
|
|
@@ -408,12 +570,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
408
570
|
const toolsClause = (a: AgentDefinition): string => {
|
|
409
571
|
if (a.tools && a.tools.length > 0) return ` (Tools: ${a.tools.join(", ")})`;
|
|
410
572
|
if (a.excludeTools && a.excludeTools.length > 0) return ` (Tools: All tools except ${a.excludeTools.join(", ")})`;
|
|
411
|
-
|
|
573
|
+
// Claude Code writes "*" for its built-in agents and "All tools" for an agent file that lists none.
|
|
574
|
+
return a.source === "built-in" || a.source.startsWith(BUNDLED_AGENTS_DIR) ? " (Tools: *)" : " (Tools: All tools)";
|
|
412
575
|
};
|
|
413
576
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
577
|
+
/** One catalog row per agent, the shape every catalog the model reads uses. */
|
|
578
|
+
const catalogLines = (agents: AgentDefinition[]): string[] => agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
|
|
579
|
+
|
|
580
|
+
const describeAgents = (cwd: string, sources?: AgentSources) => {
|
|
581
|
+
const lines = catalogLines(loadAgents(cwd, sources));
|
|
417
582
|
lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
|
|
418
583
|
return lines.join("\n");
|
|
419
584
|
};
|
|
@@ -435,14 +600,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
435
600
|
// A kept worktree can host a resumed run in a NEW process, where
|
|
436
601
|
// createWorktree's registration no longer exists — re-register so the
|
|
437
602
|
// git-isolation guard survives the restart. A removed worktree's entry
|
|
438
|
-
// is inert (no session runs there any more).
|
|
603
|
+
// is inert (no session runs there any more). findProjectRoot instead of
|
|
439
604
|
// `git rev-parse` because session_start handlers must stay fast
|
|
440
605
|
// (findings §15); a slightly-off sharedRoot only softens one message —
|
|
441
606
|
// the worktree containment check itself uses the exact record.cwd.
|
|
442
607
|
if (record.worktree && !worktreesSeen.has(record.cwd)) {
|
|
443
608
|
worktreesSeen.add(record.cwd);
|
|
444
609
|
if (!existsSync(record.cwd)) continue;
|
|
445
|
-
sharedRoot ??=
|
|
610
|
+
sharedRoot ??= findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
446
611
|
registerWorktreeIsolation(record.cwd, sharedRoot);
|
|
447
612
|
}
|
|
448
613
|
}
|
|
@@ -486,63 +651,97 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
486
651
|
return resolution;
|
|
487
652
|
};
|
|
488
653
|
|
|
654
|
+
// The three blocks are live state: what an epoch's message 1 says. Inside an
|
|
655
|
+
// epoch message 1 is the record (extensions/ledger), so a refused model, a
|
|
656
|
+
// /model or a /subagent change rides the request's tail, decided at render
|
|
657
|
+
// time from the text the epoch last told, never a rewrite of the menu that
|
|
658
|
+
// would re-cache the conversation and drop its earlier thinking (findings §46).
|
|
659
|
+
/** The live text of each block; null when the block is absent. */
|
|
660
|
+
const liveBlocks: Record<SubagentBlockKey, string | null> = { "subagent-models": null, "subagent-agents": null, "subagent-delegation": null };
|
|
661
|
+
/** A model the account refused since the menu was last told: its narrow correction stands in for the whole menu. */
|
|
662
|
+
let refusal: { refused: string[]; replacement: Model<Api> | undefined } | undefined;
|
|
663
|
+
const publishBlock = (key: SubagentBlockKey, text: string | null) => {
|
|
664
|
+
liveBlocks[key] = text;
|
|
665
|
+
if (text === null) pi.events.emit(REMINDER_CHANNEL, { key, remove: true });
|
|
666
|
+
else pi.events.emit(REMINDER_CHANNEL, { text, key, placement: "first-prepend", order: BLOCK_ORDER[key], facts: [toldBlockFact(key, text)] });
|
|
667
|
+
};
|
|
668
|
+
pi.events.on(RENDER_CHANNEL, (data) => {
|
|
669
|
+
const request = data as RenderRequest;
|
|
670
|
+
if (request.epoch) {
|
|
671
|
+
refusal = undefined;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
for (const key of Object.keys(liveBlocks) as SubagentBlockKey[]) {
|
|
675
|
+
const text = liveBlocks[key];
|
|
676
|
+
// An absent block is told as its empty text; one never told needs no withdrawal.
|
|
677
|
+
const told = request.toldBlocks.get(key);
|
|
678
|
+
const fact = toldBlockFact(key, text ?? "");
|
|
679
|
+
if (told === fact.hash || (text === null && told === undefined)) continue;
|
|
680
|
+
const facts = [fact];
|
|
681
|
+
if (text === null) request.blocks.push({ text: "The subagent delegation guidance previously shown for this conversation is no longer active.", facts });
|
|
682
|
+
else if (key === "subagent-models" && refusal) request.blocks.push({ text: refusal.refused.map((model) => refusedModelNote(model, refusal?.replacement)).join("\n"), facts });
|
|
683
|
+
else request.blocks.push({ text, facts });
|
|
684
|
+
}
|
|
685
|
+
refusal = undefined;
|
|
686
|
+
});
|
|
687
|
+
|
|
489
688
|
/**
|
|
490
689
|
* The every-turn menu reminder and the banner's subagent-default status.
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* replaces it: the very next LLM call, even mid-turn, carries the update.
|
|
690
|
+
* `refused` names a model the account just refused: inside an epoch that
|
|
691
|
+
* change rides a narrow correction instead of the whole menu.
|
|
494
692
|
*/
|
|
495
|
-
const
|
|
693
|
+
const modelMenu = (ctx: ExtensionContext, sessionModel = ctx.model) => {
|
|
496
694
|
const available = usableModels(ctx);
|
|
497
695
|
const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
|
|
498
696
|
const resolution = resolveAutoDefault(sessionModel, available, configured);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
available,
|
|
503
|
-
sessionModel,
|
|
504
|
-
defaultModel: resolution.model,
|
|
505
|
-
defaultSource: resolution.source,
|
|
506
|
-
configured,
|
|
507
|
-
requireImageInput: supportsImageInput(sessionModel),
|
|
508
|
-
}),
|
|
509
|
-
scope: "every-turn",
|
|
510
|
-
key: "subagent-models",
|
|
511
|
-
placement: "first-prepend",
|
|
512
|
-
order: CONTEXT_ORDER.subagentModels,
|
|
697
|
+
const text = subagentModelsReminder({
|
|
698
|
+
available, sessionModel, defaultModel: resolution.model, defaultSource: resolution.source, configured,
|
|
699
|
+
requireImageInput: supportsImageInput(sessionModel),
|
|
513
700
|
});
|
|
701
|
+
return { available, configured, resolution, text };
|
|
702
|
+
};
|
|
703
|
+
const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model, refused?: string) => {
|
|
704
|
+
const { available, configured, resolution, text } = modelMenu(ctx, sessionModel);
|
|
705
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
706
|
+
// The user's own subagentModel setting is honoured, so say what it costs:
|
|
707
|
+
// a newer model in its line, or a model far weaker than the session's.
|
|
708
|
+
if (resolution.source === "default" && resolution.model) {
|
|
709
|
+
for (const warning of chosenModelWarnings({ available, sessionModel, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
|
|
710
|
+
notifyModelOnce(ctx, `${warning.text} ${warning.fix}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Every refusal since the menu was last told, each named, with the default as it is now.
|
|
714
|
+
if (refused !== undefined) refusal = { refused: [...(refusal?.refused ?? []).filter((model) => model !== refused), refused], replacement: resolution.model };
|
|
715
|
+
else if (refusal) refusal = { ...refusal, replacement: resolution.model };
|
|
716
|
+
publishBlock("subagent-models", text);
|
|
514
717
|
pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
|
|
515
718
|
};
|
|
516
719
|
|
|
720
|
+
/** The last catalog text and the stamp of the agent files it was built from. */
|
|
721
|
+
let catalogMemo: { stamp: string; text: string } | undefined;
|
|
517
722
|
/**
|
|
518
723
|
* The agent catalog as an every-turn reminder, as Claude Code does ("Available
|
|
519
724
|
* agent types are listed in <system-reminder> messages") — without it the
|
|
520
|
-
* model has to guess names or make a discovery call first.
|
|
521
|
-
*
|
|
725
|
+
* model has to guess names or make a discovery call first. Rebuilt only when
|
|
726
|
+
* the agent files changed: every run start asks, and stating the files is
|
|
727
|
+
* cheaper than reading and parsing each one. The sources are resolved once
|
|
728
|
+
* per check (the plugin discovery behind them is a process-wide memo), and a
|
|
729
|
+
* rebuild reads the same ones the stamp stated.
|
|
522
730
|
*/
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
const emitDelegationSteer = (sessionModel = lastCtx?.model) => {
|
|
534
|
-
if (resolveModelTier(sessionModel) === "tiny") {
|
|
535
|
-
pi.events.emit(REMINDER_CHANNEL, {
|
|
536
|
-
scope: "every-turn",
|
|
537
|
-
key: "subagent-delegation",
|
|
538
|
-
text: DELEGATION_STEER,
|
|
539
|
-
placement: "first-prepend",
|
|
540
|
-
order: CONTEXT_ORDER.delegation,
|
|
541
|
-
});
|
|
542
|
-
} else {
|
|
543
|
-
pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
|
|
731
|
+
const agentCatalog = (ctx: ExtensionContext) => {
|
|
732
|
+
const sources = agentSources(ctx.cwd);
|
|
733
|
+
const stamp = `${ctx.cwd}\n${agentSourcesStamp(sources.fileSources)}`;
|
|
734
|
+
if (catalogMemo?.stamp !== stamp) {
|
|
735
|
+
catalogMemo = {
|
|
736
|
+
stamp,
|
|
737
|
+
text: `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd, sources)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`,
|
|
738
|
+
};
|
|
544
739
|
}
|
|
740
|
+
return catalogMemo.text;
|
|
545
741
|
};
|
|
742
|
+
const emitAgentCatalog = (ctx: ExtensionContext) => publishBlock("subagent-agents", agentCatalog(ctx));
|
|
743
|
+
|
|
744
|
+
const emitDelegationSteer = () => publishBlock("subagent-delegation", requestTier() === "tiny" ? DELEGATION_STEER : null);
|
|
546
745
|
|
|
547
746
|
let shuttingDown = false;
|
|
548
747
|
|
|
@@ -552,10 +751,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
552
751
|
* would get the reminder on every delegating turn for nothing, and the
|
|
553
752
|
* trigger fires on correct behaviour as readily as on the failure.
|
|
554
753
|
*/
|
|
555
|
-
const backstopApplies = (
|
|
556
|
-
const tier =
|
|
754
|
+
const backstopApplies = () => {
|
|
755
|
+
const tier = requestTier();
|
|
557
756
|
return tier === "cheap" || tier === "tiny";
|
|
558
757
|
};
|
|
758
|
+
/** A run that is still working: a live child, or a resident with a turn pending. */
|
|
759
|
+
const stillRunning = (taskId: string): boolean => {
|
|
760
|
+
const resident = residents.get(taskId);
|
|
761
|
+
return (resident !== undefined && residentPending(resident)) || runningIds.has(taskId);
|
|
762
|
+
};
|
|
559
763
|
pi.on("agent_start", () => {
|
|
560
764
|
spawnedThisLoop.clear();
|
|
561
765
|
parentBusy = true;
|
|
@@ -564,17 +768,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
564
768
|
pi.on("agent_settled", () => {
|
|
565
769
|
parentBusy = false;
|
|
566
770
|
});
|
|
567
|
-
pi.on("agent_end", (
|
|
771
|
+
pi.on("agent_end", () => {
|
|
568
772
|
const spawned = [...spawnedThisLoop];
|
|
569
773
|
spawnedThisLoop.clear();
|
|
570
774
|
// Tier check first: on a frontier session the backstop never fires, so the
|
|
571
775
|
// lookups below would be discarded work on every turn that delegated.
|
|
572
|
-
if (!backstopApplies(
|
|
776
|
+
if (!backstopApplies()) return undefined;
|
|
573
777
|
const pending = spawned
|
|
574
|
-
.filter(
|
|
575
|
-
const resident = residents.get(taskId);
|
|
576
|
-
return (resident !== undefined && !resident.handle.exited()) || runningIds.has(taskId);
|
|
577
|
-
})
|
|
778
|
+
.filter(stillRunning)
|
|
578
779
|
.map((taskId) => registry.resolve(taskId))
|
|
579
780
|
.filter((record): record is AgentRunRecord => record !== undefined)
|
|
580
781
|
.map((record) => ({ name: record.name, taskId: record.taskId }));
|
|
@@ -584,6 +785,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
584
785
|
});
|
|
585
786
|
|
|
586
787
|
pi.on("session_start", (_event, ctx) => {
|
|
788
|
+
// The account's refusals follow the branch: restore the selection floor
|
|
789
|
+
// before building the menu, or this process picks the refused model again.
|
|
790
|
+
for (const model of readStringsState(ctx, REFUSED_MODELS_STATE)) unusableModels.add(model);
|
|
587
791
|
lastCtx = ctx;
|
|
588
792
|
// A replaced session (/clear, /new, /resume) never reaches this instance
|
|
589
793
|
// with the old one's agents: pi emits `session_shutdown` (reason "new" /
|
|
@@ -594,12 +798,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
594
798
|
reconstructRuns(ctx);
|
|
595
799
|
emitModelStatus(ctx);
|
|
596
800
|
emitAgentCatalog(ctx);
|
|
597
|
-
emitDelegationSteer(
|
|
801
|
+
emitDelegationSteer();
|
|
598
802
|
registerPanelInputHook(ctx);
|
|
599
803
|
});
|
|
600
804
|
pi.on("model_select", (event, ctx) => {
|
|
601
805
|
emitModelStatus(ctx, event.model);
|
|
602
|
-
emitDelegationSteer(
|
|
806
|
+
emitDelegationSteer();
|
|
603
807
|
});
|
|
604
808
|
// Another extension wrote `subagentModel` on disk (`/doctor preset`): drop the
|
|
605
809
|
// cached automatic default and republish the reminder + banner status, the
|
|
@@ -609,7 +813,55 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
609
813
|
const live = liveUiCtx(lastCtx);
|
|
610
814
|
if (live) emitModelStatus(live);
|
|
611
815
|
});
|
|
612
|
-
|
|
816
|
+
/**
|
|
817
|
+
* The main conversation's runs still going at the last compaction, and the
|
|
818
|
+
* compaction entry they belong to: named on the next request, as far as
|
|
819
|
+
* they are still going then.
|
|
820
|
+
*/
|
|
821
|
+
let runningAtCompaction: { entryId: string | undefined; taskIds: string[] } | undefined;
|
|
822
|
+
// A compaction opens an epoch, which renders the three blocks from live state:
|
|
823
|
+
// refresh them, so a catalog that changed on disk since is current. The calls
|
|
824
|
+
// that started the background runs went into the summary, so the runs still
|
|
825
|
+
// going are named once on the new epoch's first request. Only the main
|
|
826
|
+
// conversation's own runs: a nested run reports to the agent that spawned it.
|
|
827
|
+
pi.on("session_compact", (event, ctx) => {
|
|
828
|
+
emitModelStatus(ctx);
|
|
829
|
+
emitAgentCatalog(ctx);
|
|
830
|
+
emitDelegationSteer();
|
|
831
|
+
const taskIds = [...registry.list()].filter((record) => (record.depth ?? 0) === 0 && stillRunning(record.taskId)).map((record) => record.taskId);
|
|
832
|
+
runningAtCompaction = taskIds.length > 0 ? { entryId: event?.compactionEntry?.id, taskIds } : undefined;
|
|
833
|
+
});
|
|
834
|
+
// Named when the request is built, not at the compaction: the next request can
|
|
835
|
+
// come much later (a manual /compact waits for the next prompt), and a run that
|
|
836
|
+
// finished meanwhile is not running. The run's own notification says it ended.
|
|
837
|
+
// A /tree to a branch without that compaction still shows the calls.
|
|
838
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
839
|
+
const owed = runningAtCompaction;
|
|
840
|
+
if (!owed) return;
|
|
841
|
+
runningAtCompaction = undefined;
|
|
842
|
+
if (owed.entryId !== undefined && !ctx.sessionManager.getBranch().some((entry) => entry.id === owed.entryId)) return;
|
|
843
|
+
const going = owed.taskIds
|
|
844
|
+
.filter(stillRunning)
|
|
845
|
+
.map((taskId) => registry.resolve(taskId))
|
|
846
|
+
.filter((record): record is AgentRunRecord => record !== undefined)
|
|
847
|
+
.map((record) => ({ name: record.name, taskId: record.taskId }));
|
|
848
|
+
const text = runningAfterCompactionReminder(going);
|
|
849
|
+
if (text) pi.events.emit(REMINDER_CHANNEL, { text, key: "subagents-running-after-compaction", untilCompaction: true });
|
|
850
|
+
});
|
|
851
|
+
// The catalog is read from disk at each run's start, so an agent file added
|
|
852
|
+
// or deleted mid-session reaches the model: the render step announces a
|
|
853
|
+
// changed catalog, live minus what the epoch told (R3-M28).
|
|
854
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
855
|
+
emitAgentCatalog(ctx);
|
|
856
|
+
});
|
|
857
|
+
// The selected branch's runs: the abandoned branch's finished ones are not
|
|
858
|
+
// addressable from it (SendMessage, list_agents), as on a resume. A run still
|
|
859
|
+
// going, a resident between turns and a nested run (recorded only in memory)
|
|
860
|
+
// stay reachable, and so do names held for spawns in flight.
|
|
861
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
862
|
+
registry.retain((record) => liveRuns.get(record.taskId)?.status === "running" || residents.has(record.taskId) || parentTaskOf.has(record.taskId));
|
|
863
|
+
reconstructRuns(ctx);
|
|
864
|
+
});
|
|
613
865
|
pi.on("session_shutdown", async () => {
|
|
614
866
|
// Teardown — at process exit AND on /clear, /new, /resume (pi replaces the
|
|
615
867
|
// session and re-runs this factory; afterwards every use of this
|
|
@@ -703,13 +955,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
703
955
|
* back.
|
|
704
956
|
*/
|
|
705
957
|
let view: { retarget(taskId: string): void; scrollBy(delta: number): void; close(): void } | undefined;
|
|
706
|
-
/** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + cwd/status(2) + one spare. */
|
|
958
|
+
/** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + other strips + cwd/status(2) + one spare. */
|
|
707
959
|
const viewReserve = () => {
|
|
708
960
|
const shellLines = panel.shellLineCount();
|
|
709
|
-
return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + 9;
|
|
961
|
+
return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + dock.others("subagents") + 9;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Tell the editor which agent it now addresses (lib/agent-view.ts), or that
|
|
966
|
+
* it is back on main. Viewing a run also puts it on `/tasks`' Completed list
|
|
967
|
+
* once it settles, as Claude Code keeps a viewed agent (findings §40).
|
|
968
|
+
*/
|
|
969
|
+
const announceView = (taskId: string | undefined) => {
|
|
970
|
+
const run = taskId === undefined ? undefined : liveRuns.get(taskId);
|
|
971
|
+
if (run) liveRuns.markViewed(run.taskId);
|
|
972
|
+
const announcement: AgentViewAnnouncement = run ? { target: agentViewTarget(run, run.agentType === FORK_AGENT) } : {};
|
|
973
|
+
pi.events.emit(AGENT_VIEW_CHANNEL, announcement);
|
|
710
974
|
};
|
|
711
975
|
|
|
712
976
|
const openView = (ctx: ExtensionContext, taskId: string) => {
|
|
977
|
+
if (ctx.mode !== "tui") {
|
|
978
|
+
ctx.ui.notify("The live subagent panel requires TUI mode. Use /tasks for task status; task_output and task_stop inspect or stop a task.", "warning");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
713
981
|
if (view) return view.retarget(taskId);
|
|
714
982
|
let currentId = taskId;
|
|
715
983
|
let scroll = 0;
|
|
@@ -717,12 +985,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
717
985
|
let closed = false;
|
|
718
986
|
let repaintFn: (() => void) | undefined;
|
|
719
987
|
let doneFn: ((result: null) => void) | undefined;
|
|
988
|
+
/** Fullscreen only: turns pi's own viewport scrolling into view scrolling. */
|
|
989
|
+
let mirror: ViewportMirror | undefined;
|
|
720
990
|
const entry = {
|
|
721
991
|
retarget(id: string) {
|
|
722
992
|
if (currentId === id) return;
|
|
723
993
|
currentId = id;
|
|
724
994
|
scroll = 0;
|
|
995
|
+
mirror?.park(0);
|
|
725
996
|
panel.setView(id);
|
|
997
|
+
announceView(id);
|
|
726
998
|
repaintFn?.();
|
|
727
999
|
},
|
|
728
1000
|
scrollBy(delta: number) {
|
|
@@ -733,22 +1005,26 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
733
1005
|
if (closed) return;
|
|
734
1006
|
closed = true;
|
|
735
1007
|
if (view === entry) view = undefined;
|
|
1008
|
+
mirror?.restore();
|
|
1009
|
+
mirror = undefined;
|
|
736
1010
|
panel.setView(undefined);
|
|
1011
|
+
announceView(undefined);
|
|
737
1012
|
doneFn?.(null);
|
|
738
1013
|
},
|
|
739
1014
|
};
|
|
740
1015
|
view = entry;
|
|
741
1016
|
panel.setView(currentId);
|
|
1017
|
+
announceView(currentId);
|
|
742
1018
|
void (async () => {
|
|
743
1019
|
const prose = await getProse(ctx);
|
|
744
1020
|
if (closed) return;
|
|
745
|
-
await ctx.ui.custom<null>(
|
|
1021
|
+
await panelUi(pi.events, ctx.ui).custom<null>(
|
|
746
1022
|
(tui, theme, _keybindings, done) => {
|
|
747
1023
|
doneFn = done;
|
|
748
1024
|
// close() raced ahead of the factory — dissolve as soon as we exist.
|
|
749
1025
|
if (closed) queueMicrotask(() => done(null));
|
|
750
1026
|
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
751
|
-
let cache: { width: number; lines: string[] } | undefined;
|
|
1027
|
+
let cache: { width: number; height: number; lines: string[] } | undefined;
|
|
752
1028
|
const live = liveRepaint(
|
|
753
1029
|
tui,
|
|
754
1030
|
() => {
|
|
@@ -758,19 +1034,32 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
758
1034
|
);
|
|
759
1035
|
repaintFn = live.repaint;
|
|
760
1036
|
const height = () => Math.max(8, tui.terminal.rows - viewReserve());
|
|
1037
|
+
const viewport = asScrollableViewport(tui);
|
|
1038
|
+
if (viewport && !closed) mirror = new ViewportMirror(viewport);
|
|
761
1039
|
return {
|
|
762
1040
|
render: (width: number) => {
|
|
763
|
-
|
|
1041
|
+
// The page keys and the wheel scroll pi's main viewport, under
|
|
1042
|
+
// this overlay; take the move as ours and park main again.
|
|
1043
|
+
if (mirror) {
|
|
1044
|
+
const next = mirror.absorb(scroll, maxScroll);
|
|
1045
|
+
if (next !== scroll) {
|
|
1046
|
+
scroll = next;
|
|
1047
|
+
cache = undefined;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Keyed by height too: a resize or another strip changing size moves the room.
|
|
1051
|
+
const viewHeight = height();
|
|
1052
|
+
if (cache?.width === width && cache.height === viewHeight) return cache.lines;
|
|
764
1053
|
const run = liveRuns.get(currentId);
|
|
765
1054
|
let lines: string[];
|
|
766
1055
|
if (run) {
|
|
767
|
-
const transcript = renderTranscript({ run, width, height:
|
|
1056
|
+
const transcript = renderTranscript({ run, width, height: viewHeight, scroll, now: Date.now(), prose }, paint);
|
|
768
1057
|
maxScroll = transcript.maxScroll;
|
|
769
1058
|
lines = transcript.lines;
|
|
770
1059
|
} else {
|
|
771
1060
|
lines = [paint.fg("dim", "agent gone")];
|
|
772
1061
|
}
|
|
773
|
-
cache = { width, lines: lines.map((line) => truncateLine(line, width)) };
|
|
1062
|
+
cache = { width, height: viewHeight, lines: lines.map((line) => truncateLine(line, width)) };
|
|
774
1063
|
return cache.lines;
|
|
775
1064
|
},
|
|
776
1065
|
invalidate: () => {
|
|
@@ -790,10 +1079,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
790
1079
|
if (panelHookRegistered || !registerCtx.hasUI) return;
|
|
791
1080
|
panelHookRegistered = true;
|
|
792
1081
|
let chordArmed = false;
|
|
1082
|
+
// Leaving the strip hands focus back to the editor and keeps any open view,
|
|
1083
|
+
// as Claude Code's Esc does: the editor then addresses the viewed agent.
|
|
793
1084
|
const leave = () => {
|
|
794
1085
|
panel.setFocus(undefined);
|
|
795
|
-
closeView();
|
|
796
1086
|
};
|
|
1087
|
+
const editorEmpty = () => (panel.editorBaseline as { getText?(): string } | undefined)?.getText?.() === "";
|
|
797
1088
|
type KeyResult = { consume: boolean } | undefined;
|
|
798
1089
|
// Actions shared by both key modes (browse and read), declared once so the
|
|
799
1090
|
// two switches below never drift. esc consumes only when idle — a streaming
|
|
@@ -814,16 +1105,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
814
1105
|
return { consume: true };
|
|
815
1106
|
};
|
|
816
1107
|
/** Unrecognized key: ctrl+x keeps the stop-all chord armed, else drop focus
|
|
817
|
-
* (
|
|
1108
|
+
* (any open view stays) and let the byte resume in the editor. */
|
|
818
1109
|
const closeAndPassthrough = (): KeyResult => {
|
|
819
1110
|
if (chordArmed) return { consume: true };
|
|
820
1111
|
leave();
|
|
821
1112
|
return undefined;
|
|
822
1113
|
};
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
*
|
|
826
|
-
* future StripKey addition is foreign here by default. */
|
|
1114
|
+
/** Keys the agents branch owns; any other decode (typing) drops focus and
|
|
1115
|
+
* passes through — stated positively so a future StripKey addition is
|
|
1116
|
+
* foreign here by default. */
|
|
827
1117
|
const AGENT_KEYS = new Set<StripKey>(["up", "down", "open", "leave", "stop", "stopAll", "pageUp", "pageDown"]);
|
|
828
1118
|
/** Row 0 exists only past the synthetic `main` row. */
|
|
829
1119
|
const focusFirstAgentRow = (): boolean => {
|
|
@@ -831,54 +1121,46 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
831
1121
|
panel.setFocus(0);
|
|
832
1122
|
return true;
|
|
833
1123
|
};
|
|
834
|
-
/** The
|
|
1124
|
+
/** The focused shells chip; agents keys untouched. */
|
|
835
1125
|
const handleShellKey = (data: string, ctx: ExtensionContext): KeyResult => {
|
|
836
|
-
// Focus moved to a dialog/overlay
|
|
837
|
-
|
|
838
|
-
|
|
1126
|
+
// Focus moved to a dialog/overlay, or the chip cleared under us: drop
|
|
1127
|
+
// soft focus and let the key through.
|
|
1128
|
+
if (!panel.editorFocused() || !panel.shellChipAvailable()) {
|
|
1129
|
+
panel.setShellChipFocus(false);
|
|
839
1130
|
return undefined;
|
|
840
1131
|
}
|
|
841
|
-
// Derive the row order once per keystroke; every step below reuses it.
|
|
842
|
-
const ids = panel.shellIds();
|
|
843
|
-
const focus = panel.anchoredShellFocus(ids);
|
|
844
|
-
if (!focus) return undefined;
|
|
845
1132
|
const decoded = decodeStripKey(data, false);
|
|
846
|
-
// ctrl+x (the agents stop-all chord arm) means nothing
|
|
847
|
-
//
|
|
1133
|
+
// ctrl+x (the agents stop-all chord arm) means nothing on the chip:
|
|
1134
|
+
// swallow it rather than leaking the raw \x18 into the editor.
|
|
848
1135
|
if (decoded.chordArmed) return { consume: true };
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
if (result.effect === "toAgents") {
|
|
855
|
-
panel.setShellFocus(undefined);
|
|
1136
|
+
const effect = reduceShellKey(decoded.key);
|
|
1137
|
+
panel.setShellChipFocus(false);
|
|
1138
|
+
if (effect === "passthrough") return undefined; // typing resumes in the editor, byte included
|
|
1139
|
+
if (effect === "toAgents") {
|
|
856
1140
|
if (focusFirstAgentRow()) return { consume: true };
|
|
857
1141
|
// No agent rows below the chip: stay on it. Dropping focus here handed
|
|
858
1142
|
// the NEXT key to the editor, where ↑ recalls the last prompt — the
|
|
859
1143
|
// "sometimes ↓ recalls history" seen on the Windows VM (2026-09-19):
|
|
860
1144
|
// ↓ chip, ↓ (nothing below, focus silently gone), ↑ → history.
|
|
861
|
-
panel.
|
|
1145
|
+
panel.setShellChipFocus(true);
|
|
862
1146
|
return { consume: true };
|
|
863
1147
|
}
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
// A dead process's stop() throwing must not break the panel.
|
|
869
|
-
}
|
|
870
|
-
panel.setShellFocus(result.focus, ids);
|
|
1148
|
+
if (effect === "openTasks") {
|
|
1149
|
+
void openTasksDialog(ctx).catch((error: unknown) => {
|
|
1150
|
+
ctx.ui.notify(`Could not open the tasks dialog: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1151
|
+
});
|
|
871
1152
|
return { consume: true };
|
|
872
1153
|
}
|
|
873
|
-
panel.setShellFocus(result.focus, ids);
|
|
874
1154
|
// While the model streams, esc must still interrupt it — consume only when idle.
|
|
875
|
-
if (decoded.key === "leave"
|
|
876
|
-
return ctx.isIdle() ? { consume: true } : undefined;
|
|
877
|
-
}
|
|
1155
|
+
if (decoded.key === "leave") return ctx.isIdle() ? { consume: true } : undefined;
|
|
878
1156
|
return { consume: true };
|
|
879
1157
|
};
|
|
880
1158
|
try {
|
|
881
1159
|
registerCtx.ui.onTerminalInput((data) => {
|
|
1160
|
+
// pi-tui calls input listeners before it filters key releases, and a
|
|
1161
|
+
// kitty terminal sends one after every press: a release is never a
|
|
1162
|
+
// key here (it used to read as typing and drop focus).
|
|
1163
|
+
if (isKeyRelease(data)) return undefined;
|
|
882
1164
|
const ctx = lastCtx ?? registerCtx;
|
|
883
1165
|
// Enter focus: down-arrow while the editor holds real focus. Unlike the
|
|
884
1166
|
// workflow strip we do NOT require the editor to be idle — the whole
|
|
@@ -887,15 +1169,31 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
887
1169
|
// O(rows) checks run only once a Down actually asks to focus, never on
|
|
888
1170
|
// plain typing. Claude Code's order: the FIRST ↓ lands on the shells
|
|
889
1171
|
// chip when shells are running; the next ↓ moves into the agent rows.
|
|
890
|
-
if (panel.focusIndex === undefined && panel.
|
|
891
|
-
|
|
1172
|
+
if (panel.focusIndex === undefined && !panel.shellChipFocused) {
|
|
1173
|
+
// Viewing an agent with the editor focused: the page keys scroll the
|
|
1174
|
+
// view, and Esc in an empty editor goes back to main.
|
|
1175
|
+
if (view && panel.editorFocused()) {
|
|
1176
|
+
const id = keyId(data);
|
|
1177
|
+
if (id === "pageUp" || id === "pageDown") {
|
|
1178
|
+
view.scrollBy(id === "pageUp" ? 10 : -10);
|
|
1179
|
+
return { consume: true };
|
|
1180
|
+
}
|
|
1181
|
+
if (id === "escape" && editorEmpty()) {
|
|
1182
|
+
closeView();
|
|
1183
|
+
return { consume: true };
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (!isStripEntryKey(data) || !panel.editorFocused()) return undefined;
|
|
1187
|
+
// In a draft, ↓ first moves the cursor, walks recalled history or the
|
|
1188
|
+
// autocomplete list; the strip takes it only past the draft's last line.
|
|
1189
|
+
if (!editorYieldsDown(panel.editorBaseline)) return undefined;
|
|
892
1190
|
if (panel.shellChipAvailable()) {
|
|
893
|
-
panel.
|
|
1191
|
+
panel.setShellChipFocus(true);
|
|
894
1192
|
return { consume: true };
|
|
895
1193
|
}
|
|
896
1194
|
return focusFirstAgentRow() ? { consume: true } : undefined;
|
|
897
1195
|
}
|
|
898
|
-
if (panel.
|
|
1196
|
+
if (panel.shellChipFocused) return handleShellKey(data, ctx);
|
|
899
1197
|
const focusIndex = panel.focusIndex;
|
|
900
1198
|
if (focusIndex === undefined) return undefined; // unreachable: one of the two focuses was set
|
|
901
1199
|
// The strip emptied under us (finished rows lingered out) — let go.
|
|
@@ -910,53 +1208,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
910
1208
|
}
|
|
911
1209
|
const decoded = decodeStripKey(data, chordArmed);
|
|
912
1210
|
chordArmed = decoded.chordArmed;
|
|
913
|
-
// Read mode: while a transcript view is open the arrows/page keys
|
|
914
|
-
// scroll IT (the natural gesture — pi's main screen has no mouse
|
|
915
|
-
// wheel, so PageUp/PageDown alone were the only path and few reach
|
|
916
|
-
// for them), Tab retargets to the next agent, Enter/esc close, and
|
|
917
|
-
// ← closes the transcript but keeps the panel focused on that agent
|
|
918
|
-
// (setView parked the highlight there) so ↑/↓ can pick a different
|
|
919
|
-
// agent and Enter reopens — Claude Code's back-to-selection gesture.
|
|
920
|
-
if (view) {
|
|
921
|
-
switch (decoded.key) {
|
|
922
|
-
case "up":
|
|
923
|
-
view.scrollBy(1);
|
|
924
|
-
return { consume: true };
|
|
925
|
-
case "down":
|
|
926
|
-
view.scrollBy(-1);
|
|
927
|
-
return { consume: true };
|
|
928
|
-
case "pageUp":
|
|
929
|
-
view.scrollBy(10);
|
|
930
|
-
return { consume: true };
|
|
931
|
-
case "pageDown":
|
|
932
|
-
view.scrollBy(-10);
|
|
933
|
-
return { consume: true };
|
|
934
|
-
case "left":
|
|
935
|
-
closeView();
|
|
936
|
-
return { consume: true };
|
|
937
|
-
case "switch": {
|
|
938
|
-
// Retarget to the next agent, anchored to the VIEWED run in the
|
|
939
|
-
// full row list (not the windowed selection) so it stays correct
|
|
940
|
-
// once the viewed run scrolls past MAX_STRIP_ROWS; setView then
|
|
941
|
-
// moves the strip highlight to it when it is in-window.
|
|
942
|
-
const nextId = panel.nextAgentAfter(panel.viewedTaskId());
|
|
943
|
-
if (nextId) openView(ctx, nextId);
|
|
944
|
-
return { consume: true };
|
|
945
|
-
}
|
|
946
|
-
case "open": // Enter toggles the view shut, mirroring Enter-to-open.
|
|
947
|
-
closeView();
|
|
948
|
-
return { consume: true };
|
|
949
|
-
case "leave":
|
|
950
|
-
return doLeave(ctx);
|
|
951
|
-
case "stop":
|
|
952
|
-
// Stop the agent whose transcript is open, not the strip selection.
|
|
953
|
-
return doStop(panel.viewedTaskId());
|
|
954
|
-
case "stopAll":
|
|
955
|
-
return doStopAll();
|
|
956
|
-
default:
|
|
957
|
-
return closeAndPassthrough(); // typing closes the view and resumes in the editor
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
1211
|
if (!decoded.key || !AGENT_KEYS.has(decoded.key)) return closeAndPassthrough();
|
|
961
1212
|
switch (decoded.key) {
|
|
962
1213
|
case "up":
|
|
@@ -964,7 +1215,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
964
1215
|
// Back up out of the agent rows: onto the shells chip when
|
|
965
1216
|
// shells are running, otherwise out of the panel entirely.
|
|
966
1217
|
leave();
|
|
967
|
-
if (panel.shellChipAvailable()) panel.
|
|
1218
|
+
if (panel.shellChipAvailable()) panel.setShellChipFocus(true);
|
|
968
1219
|
} else panel.setFocus(focusIndex - 1);
|
|
969
1220
|
return { consume: true };
|
|
970
1221
|
case "down":
|
|
@@ -987,8 +1238,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
987
1238
|
return doStopAll();
|
|
988
1239
|
case "pageUp":
|
|
989
1240
|
case "pageDown":
|
|
990
|
-
//
|
|
991
|
-
//
|
|
1241
|
+
// Scroll an open view; with none open, swallow so the page keys
|
|
1242
|
+
// never leak into the editor while the strip holds focus.
|
|
1243
|
+
view?.scrollBy(decoded.key === "pageUp" ? 10 : -10);
|
|
992
1244
|
return { consume: true };
|
|
993
1245
|
}
|
|
994
1246
|
});
|
|
@@ -999,10 +1251,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
999
1251
|
|
|
1000
1252
|
// Compact transcript rendering for the notifications this extension injects
|
|
1001
1253
|
// (full body on ctrl+o); the verbose framing stays model-only.
|
|
1254
|
+
// A message is addressed from the agent's task id; its headline names the
|
|
1255
|
+
// agent, from the live registry or the message's own details (a resumed
|
|
1256
|
+
// session has no live runs).
|
|
1002
1257
|
for (const customType of ["subagent-message", "subagent-result"]) {
|
|
1003
|
-
pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
|
|
1004
|
-
|
|
1005
|
-
|
|
1258
|
+
pi.registerMessageRenderer(customType, (message, { expanded }, theme) => {
|
|
1259
|
+
const details = (message as { details?: { taskId?: unknown; name?: unknown } }).details;
|
|
1260
|
+
const agentName = (from: string): string | undefined =>
|
|
1261
|
+
liveRuns.get(from)?.name ?? (details?.taskId === from && typeof details.name === "string" ? details.name : undefined);
|
|
1262
|
+
return notificationComponent(theme, customMessageText(message.content), expanded, agentName);
|
|
1263
|
+
});
|
|
1006
1264
|
}
|
|
1007
1265
|
|
|
1008
1266
|
// A hand-back the model already read through task_output (block: true on the
|
|
@@ -1010,7 +1268,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1010
1268
|
// so the message and its pointer would only repeat it (see decisions).
|
|
1011
1269
|
const notifier = createTaskNotifier(pi, { withdrawOnDelivery: true });
|
|
1012
1270
|
/** Every subagent notification; silent during shutdown (a teardown kill is not news). */
|
|
1013
|
-
const notify
|
|
1271
|
+
const notify = (customType: string, text: string, details?: Record<string, unknown>) => {
|
|
1014
1272
|
if (shuttingDown) return;
|
|
1015
1273
|
notifier(customType, text, details);
|
|
1016
1274
|
};
|
|
@@ -1066,15 +1324,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1066
1324
|
record.sessionSearchDir ? join(record.sessionSearchDir, "output.log") : undefined;
|
|
1067
1325
|
|
|
1068
1326
|
/**
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* `<
|
|
1072
|
-
* mode
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
* report
|
|
1327
|
+
* The hand-back for a resident agent's finished turn (its first report, an
|
|
1328
|
+
* update, a reply). Claude Code 2.1.283 sends one `<task-notification>` with
|
|
1329
|
+
* the report inline in `<result>` for a fork in every mode and for every agent
|
|
1330
|
+
* outside auto mode (findings §40). In auto mode a non-fork agent keeps the
|
|
1331
|
+
* two-message form: the report goes out as an `<agent-message>` from the
|
|
1332
|
+
* agent — the preamble, every line indented, auto mode's review flag above it
|
|
1333
|
+
* — and the agent's own `<task-notification>` follows, its `<result>` a
|
|
1334
|
+
* pointer at that message, never the report again. A turn that failed or was
|
|
1335
|
+
* stopped delivered no report, so it is always one notification with the
|
|
1336
|
+
* output (and any flag) inline. Both go through the same notifier, in this
|
|
1337
|
+
* order, so a coalesced round keeps the report ahead of the pointer.
|
|
1078
1338
|
*/
|
|
1079
1339
|
const notifyHandBack = (opts: {
|
|
1080
1340
|
/** The agent's persistent id: `from=` on the message, the SendMessage address. */
|
|
@@ -1097,6 +1357,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1097
1357
|
}) => {
|
|
1098
1358
|
const status: TaskStatus = opts.stopped ? "killed" : opts.outcome.failed ? "failed" : "completed";
|
|
1099
1359
|
const warning = opts.review ? handBackWarning(opts.review) : undefined;
|
|
1360
|
+
const record = registry.resolve(opts.from);
|
|
1100
1361
|
const details = { ...opts.details, taskId: opts.taskId, name: opts.name, failed: status === "failed", reviewed: opts.review !== undefined };
|
|
1101
1362
|
const envelope = (result: string) =>
|
|
1102
1363
|
taskNotification({
|
|
@@ -1105,12 +1366,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1105
1366
|
toolUseId: opts.toolUseId,
|
|
1106
1367
|
outputFile: opts.outputFile,
|
|
1107
1368
|
status,
|
|
1108
|
-
summary: agentSummary(opts.name, status),
|
|
1369
|
+
summary: agentSummary(record?.description ?? opts.name, status),
|
|
1109
1370
|
note: AGENT_NOTE,
|
|
1110
1371
|
result,
|
|
1111
1372
|
usage: { subagentTokens: opts.outcome.usage.total, toolUses: opts.outcome.toolCalls, durationMs: Date.now() - opts.startedAt },
|
|
1112
1373
|
});
|
|
1113
|
-
if (status !== "completed") {
|
|
1374
|
+
if (status !== "completed" || record?.agent === FORK_AGENT || permissionMode !== "auto") {
|
|
1114
1375
|
notify("subagent-result", envelope(withReview(opts.report, warning)), details);
|
|
1115
1376
|
return;
|
|
1116
1377
|
}
|
|
@@ -1120,6 +1381,68 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1120
1381
|
notify("subagent-result", envelope(handBackPointer(opts.from, opts.review !== undefined)), details);
|
|
1121
1382
|
};
|
|
1122
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* The prompt a fork inherits: the one this session's next request carries.
|
|
1386
|
+
* pi's `getSystemPrompt()` is its own stock prompt between turns, before the
|
|
1387
|
+
* first prompt and in a turn opened from idle, so it is only the fallback
|
|
1388
|
+
* for a prompt pi builds itself (a named agent's).
|
|
1389
|
+
*/
|
|
1390
|
+
const forkSystemPrompt = (ctx: ExtensionContext): string => requestSystemPrompt(pi.events, ctx) ?? ctx.getSystemPrompt();
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* The parent's last provider request (the compaction extension publishes
|
|
1394
|
+
* each one). A fork's requests are built from it so the fork reads the
|
|
1395
|
+
* parent's prompt cache (fork-cache.ts; decisions/caching.md). None before
|
|
1396
|
+
* the first request, or for another model: that fork has no cache to read.
|
|
1397
|
+
*/
|
|
1398
|
+
let lastRequest: RequestCapture | undefined;
|
|
1399
|
+
pi.events.on(LAST_REQUEST_CHANNEL, (data) => {
|
|
1400
|
+
lastRequest = data as RequestCapture | undefined;
|
|
1401
|
+
});
|
|
1402
|
+
const forkRequest = (ctx: ExtensionContext): RequestCapture | undefined =>
|
|
1403
|
+
lastRequest?.covers && captureMatches(lastRequest, ctx.model as { api?: string; provider?: string; id?: string } | undefined) ? lastRequest : undefined;
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* The capture is persisted too, so a resumed fork still reads the cache its
|
|
1407
|
+
* earlier turns wrote. One file per distinct capture (a fan-out of forks
|
|
1408
|
+
* shares one), named by its hash, beside the fork session directories.
|
|
1409
|
+
*/
|
|
1410
|
+
const FORK_REQUEST_FILE = "parent-request";
|
|
1411
|
+
const forkRequestsDir = (sessionSearchDir: string) => join(dirname(sessionSearchDir), "parent-requests");
|
|
1412
|
+
/** Each capture is serialized and hashed once, however many forks share it. */
|
|
1413
|
+
const persistedCaptures = new WeakMap<RequestCapture, string>();
|
|
1414
|
+
const persistForkRequest = (record: AgentRunRecord, capture: RequestCapture) => {
|
|
1415
|
+
if (!record.sessionSearchDir) return;
|
|
1416
|
+
try {
|
|
1417
|
+
const dir = forkRequestsDir(record.sessionSearchDir);
|
|
1418
|
+
let name = persistedCaptures.get(capture);
|
|
1419
|
+
if (!name || !existsSync(join(dir, name))) {
|
|
1420
|
+
const body = JSON.stringify(capture);
|
|
1421
|
+
name = `${createHash("sha256").update(body).digest("hex").slice(0, 24)}.json`;
|
|
1422
|
+
mkdirSync(dir, { recursive: true });
|
|
1423
|
+
if (!existsSync(join(dir, name))) {
|
|
1424
|
+
// Atomic: a partial file would be skipped as present by every later fork.
|
|
1425
|
+
const temp = join(dir, `${name}.${process.pid}.tmp`);
|
|
1426
|
+
writeFileSync(temp, body);
|
|
1427
|
+
renameSync(temp, join(dir, name));
|
|
1428
|
+
}
|
|
1429
|
+
persistedCaptures.set(capture, name);
|
|
1430
|
+
}
|
|
1431
|
+
writeFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), name);
|
|
1432
|
+
} catch {
|
|
1433
|
+
// The run still reads the cache; only a later resume re-reads uncached.
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
const readForkRequest = (record: AgentRunRecord): RequestCapture | undefined => {
|
|
1437
|
+
if (!record.sessionSearchDir) return undefined;
|
|
1438
|
+
try {
|
|
1439
|
+
const name = readFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), "utf-8").trim();
|
|
1440
|
+
return JSON.parse(readFileSync(join(forkRequestsDir(record.sessionSearchDir), name), "utf-8")) as RequestCapture;
|
|
1441
|
+
} catch {
|
|
1442
|
+
return undefined;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1123
1446
|
/** A fork's system prompt is persisted beside its session so a later resume can restore it (review S6). */
|
|
1124
1447
|
const FORK_PROMPT_FILE = "system-prompt.md";
|
|
1125
1448
|
const persistForkPrompt = (record: AgentRunRecord, prompt: string) => {
|
|
@@ -1130,6 +1453,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1130
1453
|
// The run still works; only a later resume loses the prompt (and says so).
|
|
1131
1454
|
}
|
|
1132
1455
|
};
|
|
1456
|
+
/**
|
|
1457
|
+
* A fork's inputs from this conversation (its system prompt and the parent's
|
|
1458
|
+
* last request, for the cache), saved beside its session so a SendMessage
|
|
1459
|
+
* can resume it later. Undefined for a named agent.
|
|
1460
|
+
*/
|
|
1461
|
+
const forkInputs = (request: RunRequest, record: AgentRunRecord, ctx: ExtensionContext) => {
|
|
1462
|
+
if (!request.fork) return { parentSystemPrompt: undefined, parentRequest: undefined };
|
|
1463
|
+
const parentSystemPrompt = forkSystemPrompt(ctx);
|
|
1464
|
+
persistForkPrompt(record, parentSystemPrompt);
|
|
1465
|
+
const parentRequest = forkRequest(ctx);
|
|
1466
|
+
if (parentRequest) persistForkRequest(record, parentRequest);
|
|
1467
|
+
return { parentSystemPrompt, parentRequest };
|
|
1468
|
+
};
|
|
1133
1469
|
const readForkPrompt = (record: AgentRunRecord): string | undefined => {
|
|
1134
1470
|
if (!record.sessionSearchDir) return undefined;
|
|
1135
1471
|
try {
|
|
@@ -1176,9 +1512,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1176
1512
|
const childCatalog = (cwd: string): string => {
|
|
1177
1513
|
let text = childCatalogCache.get(cwd);
|
|
1178
1514
|
if (text === undefined) {
|
|
1179
|
-
text = loadAgents(cwd)
|
|
1180
|
-
.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`)
|
|
1181
|
-
.join("\n");
|
|
1515
|
+
text = catalogLines(loadAgents(cwd)).join("\n");
|
|
1182
1516
|
childCatalogCache.set(cwd, text);
|
|
1183
1517
|
}
|
|
1184
1518
|
return text;
|
|
@@ -1205,16 +1539,18 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1205
1539
|
// accepted as `task`, `description` is tolerated, and the agent name is
|
|
1206
1540
|
// matched case-insensitively — a nested subagent is just as likely to be
|
|
1207
1541
|
// CC-trained.
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1542
|
+
// A fork inherits the parent's tools block byte for byte (its first request
|
|
1543
|
+
// extends the parent's recorded one, decisions/caching.md), so its spawn
|
|
1544
|
+
// tool takes the parent's Agent schema: the ledger keeps the recorded
|
|
1545
|
+
// declaration only while the live schema matches it, and pi validates the
|
|
1546
|
+
// call against the live one. The options a nested run lacks are refused in
|
|
1547
|
+
// execute() with the fix named.
|
|
1548
|
+
parameters: (parentRecord.agent === FORK_AGENT ? SubagentParams : NESTED_AGENT_PARAMS) as never,
|
|
1549
|
+
prepareArguments: prepareAgentArguments as never,
|
|
1215
1550
|
async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
|
|
1216
1551
|
const ctx = lastCtx;
|
|
1217
|
-
const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown };
|
|
1552
|
+
const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown; description?: unknown; model?: unknown; thinking?: unknown; isolation?: unknown; action?: unknown };
|
|
1553
|
+
const description = typeof p.description === "string" && p.description.trim() ? p.description.trim() : undefined;
|
|
1218
1554
|
const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
|
|
1219
1555
|
const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
|
|
1220
1556
|
const task = taskInput.trim();
|
|
@@ -1222,10 +1558,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1222
1558
|
if (!ctx) return err("The host session is not ready to spawn agents — retry shortly.");
|
|
1223
1559
|
if (agentName.toLowerCase() === FORK_AGENT) return err('"fork" is only available to the main conversation — pick a named agent instead.');
|
|
1224
1560
|
const agents = loadAgents(parentRecord.cwd);
|
|
1561
|
+
// Only a fork's schema (the parent's) offers these; a nested run has none of them.
|
|
1562
|
+
if (p.action === "list") return { content: [{ type: "text" as const, text: `Available agents:\n${catalogLines(agents).join("\n")}` }], details: {} };
|
|
1563
|
+
const unsupported = (["model", "thinking", "isolation"] as const).filter((key) => p[key] != null);
|
|
1564
|
+
if (unsupported.length > 0) return err(`${unsupported.map((key) => `\`${key}\``).join(", ")} ${unsupported.length === 1 ? "is" : "are"} not available to an Agent call made inside a subagent: a nested agent runs blocking, in this agent's directory, on its definition's model. Call again without ${unsupported.length === 1 ? "it" : "them"}.`);
|
|
1225
1565
|
const agentDef = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase());
|
|
1226
1566
|
if (!agentDef) {
|
|
1227
|
-
|
|
1228
|
-
return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalog}`);
|
|
1567
|
+
return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalogLines(agents).join("\n")}`);
|
|
1229
1568
|
}
|
|
1230
1569
|
if (!task) return err("`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted).");
|
|
1231
1570
|
|
|
@@ -1257,8 +1596,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1257
1596
|
model: resolved,
|
|
1258
1597
|
thinking: undefined,
|
|
1259
1598
|
depth: parentDepth + 1,
|
|
1599
|
+
...(description ? { description } : {}),
|
|
1260
1600
|
};
|
|
1261
1601
|
registry.add(record); // SendMessage from main can reach the nested run too
|
|
1602
|
+
parentTaskOf.set(taskId, parentRecord.taskId);
|
|
1262
1603
|
|
|
1263
1604
|
// The main conversation's Agent calls are classified as delegations
|
|
1264
1605
|
// (matcher DELEGATION_TOOLS); a child's nested spawn must be judged the
|
|
@@ -1269,7 +1610,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1269
1610
|
try {
|
|
1270
1611
|
const bridge = getPermissionBridge();
|
|
1271
1612
|
const verdict = bridge
|
|
1272
|
-
? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal })
|
|
1613
|
+
? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal, parentToolCallId: mainCallOf(parentRecord.taskId) })
|
|
1273
1614
|
: { block: true as const, reason: "no permission bridge is available to judge the delegation" };
|
|
1274
1615
|
if (verdict?.block) {
|
|
1275
1616
|
return {
|
|
@@ -1292,6 +1633,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1292
1633
|
agent: agentDef.name,
|
|
1293
1634
|
task,
|
|
1294
1635
|
name,
|
|
1636
|
+
description,
|
|
1295
1637
|
model: resolved,
|
|
1296
1638
|
fallbackModel: spawnFallbackModel(resolved, resolution.source, ctx.model),
|
|
1297
1639
|
},
|
|
@@ -1308,7 +1650,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1308
1650
|
content: [
|
|
1309
1651
|
{
|
|
1310
1652
|
type: "text" as const,
|
|
1311
|
-
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${result.output}\n\n(${formatStats(result.toolCalls, result.usage)})`,
|
|
1653
|
+
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${bounded(result.output, `${record.taskId}-report`, OUTPUT_CAP)}\n\n(${formatStats(result.toolCalls, result.usage)})`,
|
|
1312
1654
|
},
|
|
1313
1655
|
],
|
|
1314
1656
|
details: { agentRuns: [record] },
|
|
@@ -1321,13 +1663,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1321
1663
|
* The spawn tool a run is entitled to. THE one depth gate: it reads the
|
|
1322
1664
|
* record's own depth (set once at creation), so every handout site —
|
|
1323
1665
|
* fresh spawn, background resident, SendMessage resume — enforces
|
|
1324
|
-
* MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped.
|
|
1666
|
+
* MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped. Only a
|
|
1667
|
+
* `resident` run can receive a cron fire, so any other run's cron_create refuses.
|
|
1325
1668
|
*/
|
|
1326
|
-
const spawnToolsFor = (record: AgentRunRecord): ToolDefinition[] => {
|
|
1669
|
+
const spawnToolsFor = (record: AgentRunRecord, { resident = false } = {}): ToolDefinition[] => {
|
|
1327
1670
|
const depth = record.depth ?? 0;
|
|
1328
|
-
|
|
1671
|
+
// The child's cron tools reach this session's store (lib/agent-cron.ts).
|
|
1672
|
+
const cron = agentCronTools(pi.events, { agentId: record.taskId, cwd: record.cwd, resident }) as unknown as ToolDefinition[];
|
|
1673
|
+
return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth), ...cron] : cron;
|
|
1329
1674
|
};
|
|
1330
1675
|
|
|
1676
|
+
// A fired cron job of a subagent's: to that agent while it lives (Claude
|
|
1677
|
+
// Code's task-notification delivery); otherwise the background extension drops it.
|
|
1678
|
+
pi.events.on(AGENT_CRON_FIRE_CHANNEL, (data) => {
|
|
1679
|
+
const fire = data as AgentCronFire;
|
|
1680
|
+
const resident = residents.get(fire.agentId);
|
|
1681
|
+
if (!resident || resident.handle.exited()) return;
|
|
1682
|
+
fire.delivered = true;
|
|
1683
|
+
// A send that fails after all ends the job, as a fire to an ended agent does.
|
|
1684
|
+
void resident.handle.send(fire.text(resident.handle.conversation)).catch(() => {
|
|
1685
|
+
pi.events.emit(AGENT_CRON_CHANNEL, { op: "delete", agentId: fire.agentId, id: fire.jobId } satisfies AgentCronRequest);
|
|
1686
|
+
});
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1331
1689
|
/**
|
|
1332
1690
|
* The text a child's first turn is prompted with: a fork gets the inherited-
|
|
1333
1691
|
* context framing (plus, in a worktree, where its paths moved to); a named
|
|
@@ -1337,8 +1695,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1337
1695
|
request.fork ? forkTaskMessage(request.task, worktree ? { worktreePath: worktree.path, parentCwd } : undefined) : request.task;
|
|
1338
1696
|
|
|
1339
1697
|
/** Create a run's isolation worktree and point its record at it (both spawn paths). */
|
|
1340
|
-
const isolateInWorktree = async (
|
|
1341
|
-
const worktree = await createWorktree(
|
|
1698
|
+
const isolateInWorktree = async (base: string, record: AgentRunRecord, name: string): Promise<Worktree> => {
|
|
1699
|
+
const worktree = await createWorktree(base, name);
|
|
1342
1700
|
record.cwd = worktree.path;
|
|
1343
1701
|
record.worktree = true;
|
|
1344
1702
|
return worktree;
|
|
@@ -1362,11 +1720,20 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1362
1720
|
onProgress?: (toolCalls: number, text: string, usage: UsageTotals) => void,
|
|
1363
1721
|
): Promise<TaskResult> => {
|
|
1364
1722
|
const { request, record, agentDef } = prepared;
|
|
1723
|
+
// The session's working directory: the entered worktree, if any.
|
|
1724
|
+
const base = workCwd(ctx);
|
|
1725
|
+
|
|
1726
|
+
// Before anything marks the run as running: a failed build must not leave
|
|
1727
|
+
// the task id in runningIds, where SendMessage would call it "still running".
|
|
1728
|
+
const runtime = await runtimeOrError(ctx);
|
|
1729
|
+
if (typeof runtime === "string") {
|
|
1730
|
+
return { agent: request.agent, name: request.name, taskId: record.taskId, task: request.task, output: `Subagent failed: ${runtime}`, toolCalls: 0, usage: emptyUsage(), failed: true };
|
|
1731
|
+
}
|
|
1365
1732
|
|
|
1366
1733
|
let worktree: Worktree | undefined;
|
|
1367
1734
|
if (request.worktree) {
|
|
1368
1735
|
try {
|
|
1369
|
-
worktree = await isolateInWorktree(
|
|
1736
|
+
worktree = await isolateInWorktree(base, record, request.name);
|
|
1370
1737
|
} catch (error) {
|
|
1371
1738
|
return {
|
|
1372
1739
|
agent: request.agent,
|
|
@@ -1383,14 +1750,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1383
1750
|
|
|
1384
1751
|
runningIds.add(record.taskId);
|
|
1385
1752
|
const live = trackLiveRun(record, request, parent);
|
|
1386
|
-
const
|
|
1753
|
+
const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(request, record, ctx);
|
|
1387
1754
|
const handle = runtime.run({
|
|
1388
1755
|
name: request.name,
|
|
1389
1756
|
agent: agentDef,
|
|
1390
|
-
task: frameTask(request, worktree,
|
|
1757
|
+
task: frameTask(request, worktree, base),
|
|
1391
1758
|
cwd: record.cwd,
|
|
1392
1759
|
forkFrom: request.fork ? forkFrom : undefined,
|
|
1393
|
-
parentSystemPrompt:
|
|
1760
|
+
parentSystemPrompt: forkPrompt,
|
|
1761
|
+
parentRequest,
|
|
1394
1762
|
sessionDir: record.sessionSearchDir || undefined,
|
|
1395
1763
|
model: request.model,
|
|
1396
1764
|
fallbackModel: request.fallbackModel,
|
|
@@ -1404,6 +1772,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1404
1772
|
onMessageToMain: (message, summary) =>
|
|
1405
1773
|
parent ? relayToParent(parent.taskId, record.taskId, request.name, message, summary) : notifyAgentMessage(record.taskId, request.name, message),
|
|
1406
1774
|
extraTools: spawnToolsFor(record),
|
|
1775
|
+
mainToolCallId: () => mainCallOf(record.taskId),
|
|
1407
1776
|
});
|
|
1408
1777
|
liveHandles.set(record.taskId, handle);
|
|
1409
1778
|
|
|
@@ -1413,7 +1782,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1413
1782
|
live.finish(Boolean(outcome.failed));
|
|
1414
1783
|
let worktreeKept: boolean | undefined;
|
|
1415
1784
|
if (worktree) {
|
|
1416
|
-
worktreeKept = !(await cleanupWorktree(
|
|
1785
|
+
worktreeKept = !(await cleanupWorktree(base, worktree));
|
|
1417
1786
|
}
|
|
1418
1787
|
return {
|
|
1419
1788
|
agent: request.agent,
|
|
@@ -1422,11 +1791,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1422
1791
|
task: request.task,
|
|
1423
1792
|
...outcome,
|
|
1424
1793
|
worktreePath: worktreeKept ? worktree?.path : undefined,
|
|
1794
|
+
worktreeBranch: worktreeKept ? worktree?.branch : undefined,
|
|
1425
1795
|
worktreeKept,
|
|
1426
1796
|
};
|
|
1427
1797
|
} catch (error) {
|
|
1428
1798
|
live.finish(true);
|
|
1429
|
-
|
|
1799
|
+
// A failed run can still have committed or edited in its worktree:
|
|
1800
|
+
// a kept one is reported like a finished run's.
|
|
1801
|
+
const worktreeKept = worktree ? !(await cleanupWorktree(base, worktree)) : undefined;
|
|
1430
1802
|
return {
|
|
1431
1803
|
agent: request.agent,
|
|
1432
1804
|
name: request.name,
|
|
@@ -1436,40 +1808,321 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1436
1808
|
toolCalls: 0,
|
|
1437
1809
|
usage: emptyUsage(),
|
|
1438
1810
|
failed: true,
|
|
1811
|
+
worktreePath: worktreeKept ? worktree?.path : undefined,
|
|
1812
|
+
worktreeBranch: worktreeKept ? worktree?.branch : undefined,
|
|
1813
|
+
worktreeKept,
|
|
1439
1814
|
};
|
|
1440
1815
|
} finally {
|
|
1441
1816
|
runningIds.delete(record.taskId);
|
|
1442
1817
|
}
|
|
1443
1818
|
};
|
|
1444
1819
|
|
|
1445
|
-
|
|
1820
|
+
/**
|
|
1821
|
+
* Launch one prepared run as a background resident (Claude Code parity):
|
|
1822
|
+
* it returns as soon as the child starts, the report arrives as a steered
|
|
1823
|
+
* task notification, and the child stays resident so SendMessage can reach
|
|
1824
|
+
* it live (steer mid-turn, prompt when idle). `sessionFile` is the parent
|
|
1825
|
+
* transcript a fork clones; `toolCallId` links the notifications to the
|
|
1826
|
+
* spawning Agent call (none for a `/btw` fork); `forkMessages` are appended
|
|
1827
|
+
* to a fork's transcript. Returns the line reported for the run.
|
|
1828
|
+
*/
|
|
1829
|
+
const launchResident = async (
|
|
1830
|
+
p: PreparedRun,
|
|
1831
|
+
ctx: ExtensionContext,
|
|
1832
|
+
runtime: SubagentRuntime,
|
|
1833
|
+
{ sessionFile, toolCallId, forkMessages }: { sessionFile?: string; toolCallId?: string; forkMessages?: Message[] },
|
|
1834
|
+
): Promise<{ launched: boolean; line: string }> => {
|
|
1835
|
+
const ended = () => {
|
|
1836
|
+
registry.remove(p.record.taskId);
|
|
1837
|
+
return { launched: false, line: `✗ ${p.record.name}: the session ended before the agent started.` };
|
|
1838
|
+
};
|
|
1839
|
+
if (shuttingDown) return ended();
|
|
1840
|
+
if (toolCallId) mainCallFor.set(p.record.taskId, toolCallId);
|
|
1841
|
+
// Captured as a string: onExit runs from a `.finally` long after this
|
|
1842
|
+
// turn's ctx may be stale (review S5). The entered worktree, if any.
|
|
1843
|
+
const parentCwd = workCwd(ctx);
|
|
1844
|
+
let worktree: Worktree | undefined;
|
|
1845
|
+
if (p.request.worktree) {
|
|
1846
|
+
try {
|
|
1847
|
+
worktree = await isolateInWorktree(parentCwd, p.record, p.request.name);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
return { launched: false, line: `✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}` };
|
|
1850
|
+
}
|
|
1851
|
+
// Shutdown can begin while git creates the worktree: build no child
|
|
1852
|
+
// after its sweep, and remove the worktree within its grace.
|
|
1853
|
+
if (shuttingDown) {
|
|
1854
|
+
await cleanupWorktree(parentCwd, worktree);
|
|
1855
|
+
return ended();
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const logPath = outputLogPath(p.record);
|
|
1860
|
+
let lastLogWrite = 0;
|
|
1861
|
+
let finish!: () => void;
|
|
1862
|
+
const finished = new Promise<void>((resolve) => {
|
|
1863
|
+
finish = resolve;
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
// The child's final output. task.output() falls back to this
|
|
1867
|
+
// because handle.snapshot().text can be blank at a turn boundary —
|
|
1868
|
+
// the same reason the log write below uses `|| outcome.output`.
|
|
1869
|
+
// Keeps task_output consistent with the log and the completion
|
|
1870
|
+
// notification rather than showing an empty body in that case.
|
|
1871
|
+
let lastOutput = "";
|
|
1872
|
+
// The initial task's own report. task_output for THIS task must return the
|
|
1873
|
+
// first turn's reply — not the resident's growing multi-turn transcript —
|
|
1874
|
+
// the same way a SendMessage task returns one reply (review S12).
|
|
1875
|
+
let firstTurnOutput: string | undefined;
|
|
1876
|
+
const live = trackLiveRun(p.record, p.request);
|
|
1877
|
+
const resident: Resident = { handle: undefined as never, startedAt: Date.now(), turnHandlers: [], reviewing: 0 };
|
|
1878
|
+
const worktreeNote = worktree
|
|
1879
|
+
? `\n\n(Running in worktree ${worktree.path} on branch ${worktree.branch} — kept while the agent stays resident, and after it exits if it holds uncommitted changes or commits.)`
|
|
1880
|
+
: "";
|
|
1881
|
+
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
1882
|
+
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
1883
|
+
task.finishedAt = Date.now();
|
|
1884
|
+
firstTurnOutput = outcome.output;
|
|
1885
|
+
finish();
|
|
1886
|
+
notifyHandBack({
|
|
1887
|
+
from: p.record.taskId,
|
|
1888
|
+
taskId: p.record.taskId,
|
|
1889
|
+
name: p.record.name,
|
|
1890
|
+
toolUseId: toolCallId,
|
|
1891
|
+
outputFile: logPath,
|
|
1892
|
+
outcome,
|
|
1893
|
+
stopped,
|
|
1894
|
+
startedAt: task.startedAt,
|
|
1895
|
+
report: `${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
|
|
1896
|
+
review,
|
|
1897
|
+
});
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
// Idle reaper: every run is a resident now, so without this a long
|
|
1901
|
+
// session accumulates one live AgentSession (extension instances,
|
|
1902
|
+
// message array, MCP tool refs) per delegation for its whole life.
|
|
1903
|
+
// After RESIDENT_IDLE_MS idle the session is released quietly; the
|
|
1904
|
+
// agent stays reachable — SendMessage resumes it from its session
|
|
1905
|
+
// file. Worktree residents are exempt: release would remove the
|
|
1906
|
+
// worktree a resume still needs. So is one that owns a cron job, which
|
|
1907
|
+
// fires only to a live agent (lib/agent-cron.ts). Armed from every turn end.
|
|
1908
|
+
let reaper: ReturnType<typeof setTimeout> | undefined;
|
|
1909
|
+
const armReaper = () => {
|
|
1910
|
+
if (worktree) return;
|
|
1911
|
+
if (reaper) clearTimeout(reaper);
|
|
1912
|
+
reaper = setTimeout(() => {
|
|
1913
|
+
reaper = undefined;
|
|
1914
|
+
if (handle.exited()) return;
|
|
1915
|
+
if (handle.busy() || agentOwnsCronJobs(pi.events, p.record.taskId)) {
|
|
1916
|
+
armReaper();
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
handle.release();
|
|
1920
|
+
}, RESIDENT_IDLE_MS);
|
|
1921
|
+
reaper.unref?.();
|
|
1922
|
+
};
|
|
1923
|
+
const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(p.request, p.record, ctx);
|
|
1924
|
+
// No `signal` here on purpose: a resident outlives the spawning turn and
|
|
1925
|
+
// is stopped through task_stop / the panel, not by the turn ending (S15).
|
|
1926
|
+
const started = await runtime.runResident({
|
|
1927
|
+
name: p.record.name,
|
|
1928
|
+
agent: p.agentDef,
|
|
1929
|
+
cwd: p.record.cwd,
|
|
1930
|
+
forkFrom: p.request.fork ? sessionFile : undefined,
|
|
1931
|
+
forkMessages: p.request.fork ? forkMessages : undefined,
|
|
1932
|
+
parentSystemPrompt: forkPrompt,
|
|
1933
|
+
parentRequest,
|
|
1934
|
+
sessionDir: p.record.sessionSearchDir || undefined,
|
|
1935
|
+
model: p.request.model,
|
|
1936
|
+
fallbackModel: p.request.fallbackModel,
|
|
1937
|
+
thinking: p.request.thinking,
|
|
1938
|
+
onProgress: (toolCalls, text, usage) => {
|
|
1939
|
+
live.progress(toolCalls, usage);
|
|
1940
|
+
// Throttled: onProgress fires per tool call/message with the whole
|
|
1941
|
+
// turn text so far, and this path now carries EVERY run — an
|
|
1942
|
+
// unthrottled sync rewrite would be O(n²) bytes on the hot path.
|
|
1943
|
+
// onTurnEnd below flushes the final state, so the tail is never lost.
|
|
1944
|
+
if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
|
|
1945
|
+
lastLogWrite = Date.now();
|
|
1946
|
+
writeFileSync(logPath, text);
|
|
1947
|
+
}
|
|
1948
|
+
},
|
|
1949
|
+
sink: live.sink,
|
|
1950
|
+
onMessageToMain: (message) => notifyAgentMessage(p.record.taskId, p.record.name, message),
|
|
1951
|
+
extraTools: spawnToolsFor(p.record, { resident: true }),
|
|
1952
|
+
mainToolCallId: () => mainCallOf(p.record.taskId),
|
|
1953
|
+
onTurnEnd: (outcome) => {
|
|
1954
|
+
registry.sessionFileFor(p.record);
|
|
1955
|
+
live.settle();
|
|
1956
|
+
lastOutput = resident.handle.snapshot().text || outcome.output;
|
|
1957
|
+
if (logPath) writeFileSync(logPath, lastOutput);
|
|
1958
|
+
// The handler is claimed now (so a message arriving mid-review pairs
|
|
1959
|
+
// with the NEXT turn), but runs only once auto mode's hand-back
|
|
1960
|
+
// review of this turn's action sequence has answered, so the
|
|
1961
|
+
// verdict rides in the same notification as the report instead of
|
|
1962
|
+
// trailing it. The wait is bounded (hand-back-review.ts) and
|
|
1963
|
+
// answered synchronously when auto mode is off.
|
|
1964
|
+
const handler = resident.turnHandlers.shift();
|
|
1965
|
+
// A stop or resume during review must not change this turn's outcome.
|
|
1966
|
+
const stopped = stoppedTaskIds.has(p.record.taskId);
|
|
1967
|
+
armReaper();
|
|
1968
|
+
resident.reviewing++;
|
|
1969
|
+
void awaitHandBackReview(pi.events, p.record, outcome.actions, undefined, mainCallOf(p.record.taskId)).then((review) => {
|
|
1970
|
+
resident.reviewing--;
|
|
1971
|
+
if (handler) {
|
|
1972
|
+
handler(outcome, review, stopped);
|
|
1973
|
+
} else {
|
|
1974
|
+
// A turn nobody is waiting on (e.g. a steer that raced past its
|
|
1975
|
+
// target turn and ran on its own) must still surface.
|
|
1976
|
+
// Only the launch's notification names its tool call; a later
|
|
1977
|
+
// turn's notifies without one, as Claude Code's do (findings §40).
|
|
1978
|
+
notifyHandBack({
|
|
1979
|
+
from: p.record.taskId,
|
|
1980
|
+
taskId: p.record.taskId,
|
|
1981
|
+
name: p.record.name,
|
|
1982
|
+
outputFile: logPath,
|
|
1983
|
+
outcome,
|
|
1984
|
+
stopped,
|
|
1985
|
+
startedAt: resident.startedAt,
|
|
1986
|
+
report: bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP),
|
|
1987
|
+
review,
|
|
1988
|
+
details: { update: true },
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
},
|
|
1993
|
+
onExit: () => {
|
|
1994
|
+
if (reaper) clearTimeout(reaper);
|
|
1995
|
+
live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
|
|
1996
|
+
if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
|
|
1997
|
+
liveHandles.delete(p.record.taskId);
|
|
1998
|
+
// Returned, not voided: kill() settles after the cleanup, so the
|
|
1999
|
+
// session_shutdown grace covers it.
|
|
2000
|
+
if (worktree) return cleanupWorktree(parentCwd, worktree);
|
|
2001
|
+
return undefined;
|
|
2002
|
+
},
|
|
2003
|
+
}).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
|
2004
|
+
if (started instanceof Error) {
|
|
2005
|
+
// The child never started: settle its panel row and forget the run, so
|
|
2006
|
+
// neither the panel nor list_agents offers it.
|
|
2007
|
+
live.finish(true);
|
|
2008
|
+
registry.remove(p.record.taskId);
|
|
2009
|
+
if (worktree) void cleanupWorktree(parentCwd, worktree);
|
|
2010
|
+
return { launched: false, line: `✗ ${p.record.name}: could not start: ${started.message}` };
|
|
2011
|
+
}
|
|
2012
|
+
const handle = started;
|
|
2013
|
+
resident.handle = handle;
|
|
2014
|
+
// Construction can finish after shutdown's stop-all sweep. The handle
|
|
2015
|
+
// was not registered then, so dispose it before sending any task now.
|
|
2016
|
+
if (shuttingDown) {
|
|
2017
|
+
await handle.kill();
|
|
2018
|
+
return ended();
|
|
2019
|
+
}
|
|
2020
|
+
residents.set(p.record.taskId, resident);
|
|
2021
|
+
liveHandles.set(p.record.taskId, handle);
|
|
2022
|
+
|
|
2023
|
+
const task: BackgroundTask = {
|
|
2024
|
+
id: p.record.taskId,
|
|
2025
|
+
kind: "subagent",
|
|
2026
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
2027
|
+
description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
|
|
2028
|
+
status: "running",
|
|
2029
|
+
startedAt: Date.now(),
|
|
2030
|
+
logPath,
|
|
2031
|
+
output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
|
|
2032
|
+
stop: () => stopAgent(p.record.taskId, handle),
|
|
2033
|
+
resident: () => !handle.exited(),
|
|
2034
|
+
finished,
|
|
2035
|
+
};
|
|
2036
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2037
|
+
void handle.send(frameTask(p.request, worktree, parentCwd));
|
|
2038
|
+
|
|
2039
|
+
return {
|
|
2040
|
+
launched: true,
|
|
2041
|
+
line: `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
|
|
2042
|
+
};
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* `/btw`'s `f to fork` (lib/btw-fork.ts): a background fork that inherits
|
|
2047
|
+
* the conversation (if the session has one yet), then the side question and
|
|
2048
|
+
* its answer, and takes the question as its task. It reports back like any
|
|
2049
|
+
* spawned fork.
|
|
2050
|
+
*/
|
|
2051
|
+
const forkFromBtw = async (request: BtwForkRequest): Promise<BtwForkResult> => {
|
|
2052
|
+
const ctx = request.ctx as ExtensionContext;
|
|
2053
|
+
// Before the first turn the file is not written yet and the fork starts from
|
|
2054
|
+
// the side exchange alone, as Claude Code's does (newChildSessionManager).
|
|
2055
|
+
// Without a session file at all the conversation cannot be cloned, and a
|
|
2056
|
+
// fork framed as inheriting it would confabulate (the Agent tool refuses too).
|
|
2057
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
2058
|
+
if (!sessionFile) return { error: "Cannot fork: this session is not persisted (started with --no-session), so there is no conversation to clone." };
|
|
2059
|
+
// Named from the question, as Claude Code names a /btw fork.
|
|
2060
|
+
const name = freeRunName(registry.names(), btwForkName(request.question));
|
|
2061
|
+
const taskId = btwForkTaskId(name);
|
|
2062
|
+
// The session's current model and thinking, explicitly: before the first
|
|
2063
|
+
// turn there is no transcript for the fork to restore them from.
|
|
2064
|
+
const prepared: PreparedRun = {
|
|
2065
|
+
request: {
|
|
2066
|
+
agent: FORK_AGENT,
|
|
2067
|
+
task: request.question,
|
|
2068
|
+
name,
|
|
2069
|
+
description: btwForkDescription(request.question),
|
|
2070
|
+
fork: true,
|
|
2071
|
+
model: ctx.model ? modelSpec(ctx.model) : undefined,
|
|
2072
|
+
thinking: pi.getThinkingLevel(),
|
|
2073
|
+
},
|
|
2074
|
+
agentDef: undefined,
|
|
2075
|
+
record: {
|
|
2076
|
+
name,
|
|
2077
|
+
agent: FORK_AGENT,
|
|
2078
|
+
taskId,
|
|
2079
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
2080
|
+
cwd: workCwd(ctx),
|
|
2081
|
+
depth: 0,
|
|
2082
|
+
description: btwForkDescription(request.question),
|
|
2083
|
+
},
|
|
2084
|
+
};
|
|
2085
|
+
registry.add(prepared.record);
|
|
2086
|
+
// The session can be torn down while the runtime or the child starts; a
|
|
2087
|
+
// fork launched after shutdown's stop-all sweep would outlive it.
|
|
2088
|
+
const runtime = await runtimeOrError(ctx);
|
|
2089
|
+
if (typeof runtime === "string") {
|
|
2090
|
+
registry.remove(taskId);
|
|
2091
|
+
return { error: `Cannot fork: ${runtime}` };
|
|
2092
|
+
}
|
|
2093
|
+
if (shuttingDown) return { error: "The session ended before the fork started." };
|
|
2094
|
+
const { launched, line } = await launchResident(prepared, ctx, runtime, { sessionFile, forkMessages: request.messages });
|
|
2095
|
+
if (shuttingDown) {
|
|
2096
|
+
void stopAgent(taskId);
|
|
2097
|
+
return { error: "The session ended before the fork started." };
|
|
2098
|
+
}
|
|
2099
|
+
if (!launched) return { error: line };
|
|
2100
|
+
for (const text of btwForkRecord(name, taskId, request.question)) {
|
|
2101
|
+
pi.events.emit(REMINDER_CHANNEL, { text, placement: "user-prepend", raw: true } satisfies ReminderPayload);
|
|
2102
|
+
}
|
|
2103
|
+
return { name, taskId };
|
|
2104
|
+
};
|
|
2105
|
+
pi.events.on(BTW_FORK_CHANNEL, (data) => {
|
|
2106
|
+
const request = data as BtwForkRequest;
|
|
2107
|
+
request.handled = true;
|
|
2108
|
+
void forkFromBtw(request).then(request.respond, (error: unknown) =>
|
|
2109
|
+
request.respond({ error: `Failed to fork: ${error instanceof Error ? error.message : String(error)}` }),
|
|
2110
|
+
);
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
// Claude Code's Agent text, short or long by the model's tier
|
|
2114
|
+
// (agent-description.ts, lib/tool-variants.ts); registered again when the
|
|
2115
|
+
// form changes.
|
|
2116
|
+
const agentTool = defineTool({
|
|
1446
2117
|
name: "Agent",
|
|
1447
2118
|
label: "Agent",
|
|
1448
2119
|
...ccToolRenderers<{ subagent_type?: string; task?: string; prompt?: string; description?: string; action?: string }>("Agent", {
|
|
1449
2120
|
title: (a) => (a ? [a.subagent_type, a.description ?? a.task ?? a.prompt ?? a.action].filter(Boolean).join(": ") || undefined : undefined),
|
|
1450
2121
|
}),
|
|
1451
|
-
description:
|
|
1452
|
-
'Delegate a task to a specialist agent that runs in its own context window and reports back. The available agents are listed in the "Available agents" system reminder.\n' +
|
|
1453
|
-
"\n" +
|
|
1454
|
-
"## When to use\n" +
|
|
1455
|
-
"- Broad codebase searches or exploration where you need the conclusion, not the file dumps.\n" +
|
|
1456
|
-
"- Self-contained research, reviews, or verification whose intermediate output you won't need again.\n" +
|
|
1457
|
-
"- Independent questions that can run at once — issue multiple Agent tool calls in one message to run them in parallel.\n" +
|
|
1458
|
-
"\n" +
|
|
1459
|
-
"For a single-fact lookup you already know how to run, search directly instead. Once you've delegated work, don't also run it yourself — wait for the result.\n" +
|
|
1460
|
-
"\n" +
|
|
1461
|
-
"## How agents run\n" +
|
|
1462
|
-
"Agents run in the background: the call returns immediately with a task id, and you'll be notified when one completes — the agent's report arrives as a task notification while you keep working, or on its own if you are idle. Never fabricate or predict a pending agent's results — the notification is never something you write yourself; if the user asks before it arrives, say it's still running. Call task_output only if your next step cannot proceed without the result (block=true waits); stop a run with task_stop. Both are deferred — load them with tool_search first. (Exception: in a one-shot print session the call blocks and returns the report directly — no notification follows.)\n" +
|
|
1463
|
-
"\n" +
|
|
1464
|
-
"## Usage notes\n" +
|
|
1465
|
-
"- Give a complete, self-contained task: the agent cannot ask follow-up questions.\n" +
|
|
1466
|
-
"- If an agent's description says it should be used proactively, try your best to use it without the user having to ask first.\n" +
|
|
1467
|
-
'- `subagent_type: "fork"` clones this conversation instead of starting fresh; a fork always runs on this conversation\'s model and reasoning settings. If you are the fork, execute your assigned task directly — don\'t re-delegate.\n' +
|
|
1468
|
-
'- `isolation: "worktree"` gives the agent its own git worktree when it will edit files.\n' +
|
|
1469
|
-
"- Each run gets a name — SendMessage reaches it live while it runs and continues it after it finishes. `action: \"list\"` re-prints the agent catalog.\n" +
|
|
1470
|
-
"- The agent's final report is not shown to the user, so relay what matters.",
|
|
2122
|
+
description: agentDescription("short", projectConfigDirName()),
|
|
1471
2123
|
promptSnippet: "Delegate scoped work to a specialist agent in its own context",
|
|
1472
2124
|
parameters: SubagentParams,
|
|
2125
|
+
prepareArguments: prepareAgentArguments as (args: unknown) => Static<typeof SubagentParams>,
|
|
1473
2126
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1474
2127
|
const agents = loadAgents(ctx.cwd);
|
|
1475
2128
|
|
|
@@ -1479,11 +2132,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1479
2132
|
const taskText = params.task ?? params.prompt;
|
|
1480
2133
|
|
|
1481
2134
|
// A call carrying run options (a task, a name, a model/thinking/isolation
|
|
1482
|
-
// override, or action:"run")
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
// omitted `subagent_type`.
|
|
2135
|
+
// override, or action:"run") is a run; prepareAgentArguments gave one that
|
|
2136
|
+
// named no agent general-purpose, as Claude Code does (a tiny model
|
|
2137
|
+
// repeated such a call 30 times against an error naming the fix, battery
|
|
2138
|
+
// round 7). A call with neither lists the catalog.
|
|
1487
2139
|
const wantsRun =
|
|
1488
2140
|
params.action === "run" ||
|
|
1489
2141
|
taskText != null ||
|
|
@@ -1499,19 +2151,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1499
2151
|
};
|
|
1500
2152
|
}
|
|
1501
2153
|
|
|
1502
|
-
if (!params.subagent_type) {
|
|
1503
|
-
return {
|
|
1504
|
-
content: [
|
|
1505
|
-
{
|
|
1506
|
-
type: "text",
|
|
1507
|
-
text: `No \`subagent_type\` given, but you passed run options — this looks like a run that forgot to name its agent. Set \`subagent_type\` to one of the names below (or "fork" to clone this conversation). To only browse the catalog, call with action:"list".\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
1508
|
-
},
|
|
1509
|
-
],
|
|
1510
|
-
details: {},
|
|
1511
|
-
isError: true,
|
|
1512
|
-
};
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
2154
|
// A run needs a task. A CC-shaped call that put its instruction in an
|
|
1516
2155
|
// unrecognized key, or none at all, must fail loud rather than start a
|
|
1517
2156
|
// child prompted with "" (H1). Fork included — cloning to do nothing is
|
|
@@ -1544,6 +2183,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1544
2183
|
agent: entry.agent,
|
|
1545
2184
|
task: entry.task,
|
|
1546
2185
|
name,
|
|
2186
|
+
description: params.description?.trim() || undefined,
|
|
1547
2187
|
fork: entry.agent === FORK_AGENT,
|
|
1548
2188
|
model: params.model,
|
|
1549
2189
|
thinking: params.thinking,
|
|
@@ -1551,86 +2191,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1551
2191
|
};
|
|
1552
2192
|
});
|
|
1553
2193
|
|
|
1554
|
-
// A fork continues this conversation; running it on a different model or
|
|
1555
|
-
// reasoning effort is how the fork-confabulation incident happened (a
|
|
1556
|
-
// minimal-thinking fork resumed the inherited topic instead of its task).
|
|
1557
|
-
// Claude Code silently ignores `model` for forks; we fail loud instead.
|
|
1558
|
-
if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
|
|
1559
|
-
return {
|
|
1560
|
-
content: [
|
|
1561
|
-
{
|
|
1562
|
-
type: "text",
|
|
1563
|
-
text: '`model`/`thinking` overrides are not applied to fork runs — a fork continues this conversation and keeps its exact model and reasoning settings. Drop the override, or use a named agent (e.g. "general-purpose") to run the task with different settings.',
|
|
1564
|
-
},
|
|
1565
|
-
],
|
|
1566
|
-
details: {},
|
|
1567
|
-
isError: true,
|
|
1568
|
-
};
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
2194
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1572
|
-
|
|
1573
|
-
return {
|
|
1574
|
-
content: [
|
|
1575
|
-
{
|
|
1576
|
-
type: "text",
|
|
1577
|
-
text: "Cannot fork: this session is not persisted (started with --no-session), so there is no transcript to clone. Use a named agent instead.",
|
|
1578
|
-
},
|
|
1579
|
-
],
|
|
1580
|
-
details: {},
|
|
1581
|
-
isError: true,
|
|
1582
|
-
};
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
|
|
1586
|
-
if (unknown.length > 0) {
|
|
1587
|
-
return {
|
|
1588
|
-
content: [
|
|
1589
|
-
{
|
|
1590
|
-
type: "text",
|
|
1591
|
-
text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
1592
|
-
},
|
|
1593
|
-
],
|
|
1594
|
-
details: {},
|
|
1595
|
-
isError: true,
|
|
1596
|
-
};
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
if (requested.some((r) => r.worktree) && !(await isGitRepo(ctx.cwd))) {
|
|
1600
|
-
return {
|
|
1601
|
-
content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
|
|
1602
|
-
details: {},
|
|
1603
|
-
isError: true,
|
|
1604
|
-
};
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
const prepared: PreparedRun[] = requested.map((request) => {
|
|
1608
|
-
const taskId = generateTaskId();
|
|
1609
|
-
return {
|
|
1610
|
-
request,
|
|
1611
|
-
agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
|
|
1612
|
-
record: {
|
|
1613
|
-
name: request.name,
|
|
1614
|
-
agent: request.agent,
|
|
1615
|
-
taskId,
|
|
1616
|
-
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
1617
|
-
cwd: ctx.cwd,
|
|
1618
|
-
model: request.model,
|
|
1619
|
-
thinking: request.thinking,
|
|
1620
|
-
depth: 0,
|
|
1621
|
-
},
|
|
1622
|
-
};
|
|
1623
|
-
});
|
|
1624
|
-
|
|
1625
|
-
/**
|
|
1626
|
-
* Model resolution happens here, in the parent, against the real
|
|
1627
|
-
* registry — never in the child, whose `--model` fuzzy-matches across
|
|
1628
|
-
* every configured provider. The child is spawned with a concrete
|
|
1629
|
-
* `provider/id`; anything surprising (a fallback, a provider crossing)
|
|
1630
|
-
* is said out loud rather than happening silently.
|
|
1631
|
-
*/
|
|
1632
|
-
const available = usableModels(ctx);
|
|
1633
|
-
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
2195
|
+
let prepared: PreparedRun[] = [];
|
|
1634
2196
|
/**
|
|
1635
2197
|
* What the model is told about its own model choice: the same sentences
|
|
1636
2198
|
* the user gets, plus the model each child actually runs on. Deduped —
|
|
@@ -1638,94 +2200,182 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1638
2200
|
* once. See `subagentModelNotes` for why the model needs this.
|
|
1639
2201
|
*/
|
|
1640
2202
|
const modelNotes = new Set<string>();
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
// which is stable for the session, so reuse the cached resolution
|
|
1651
|
-
// instead of re-ranking the catalog per spawn. An override varies per
|
|
1652
|
-
// call and always resolves fresh.
|
|
1653
|
-
const resolution =
|
|
1654
|
-
p.request.model || p.agentDef?.model
|
|
1655
|
-
? resolveSubagentModel({
|
|
1656
|
-
requested: p.request.model,
|
|
1657
|
-
agentModel: p.agentDef?.model,
|
|
1658
|
-
configuredDefault: configuredDefault,
|
|
1659
|
-
sessionModel: ctx.model,
|
|
1660
|
-
available,
|
|
1661
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1662
|
-
})
|
|
1663
|
-
: resolveAutoDefault(ctx.model, available, configuredDefault);
|
|
1664
|
-
if (resolution.unresolved) {
|
|
1665
|
-
// The main model chose this string; the menu lets it retry.
|
|
1666
|
-
const fallback = resolveSubagentModel({
|
|
1667
|
-
configuredDefault: configuredDefault,
|
|
1668
|
-
sessionModel: ctx.model,
|
|
1669
|
-
available,
|
|
1670
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1671
|
-
});
|
|
2203
|
+
// Every check before the records land: a rejection, or a throw on the way,
|
|
2204
|
+
// frees the spawn's names for the retry.
|
|
2205
|
+
let landed = false;
|
|
2206
|
+
try {
|
|
2207
|
+
// A fork continues this conversation; running it on a different model or
|
|
2208
|
+
// reasoning effort is how the fork-confabulation incident happened (a
|
|
2209
|
+
// minimal-thinking fork resumed the inherited topic instead of its task).
|
|
2210
|
+
// Claude Code silently ignores `model` for forks; we fail loud instead.
|
|
2211
|
+
if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
|
|
1672
2212
|
return {
|
|
1673
2213
|
content: [
|
|
1674
2214
|
{
|
|
1675
2215
|
type: "text",
|
|
1676
|
-
text:
|
|
1677
|
-
`${resolution.unresolvedReason ?? `Unknown model "${resolution.unresolved}" — no available model matches it.`}\n\n` +
|
|
1678
|
-
subagentModelsReminder({
|
|
1679
|
-
available,
|
|
1680
|
-
sessionModel: ctx.model,
|
|
1681
|
-
defaultModel: fallback.model,
|
|
1682
|
-
defaultSource: fallback.source,
|
|
1683
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1684
|
-
}),
|
|
2216
|
+
text: '`model`/`thinking` overrides are not applied to fork runs — a fork continues this conversation and keeps its exact model and reasoning settings. Drop the override, or use a named agent (e.g. "general-purpose") to run the task with different settings.',
|
|
1685
2217
|
},
|
|
1686
2218
|
],
|
|
1687
2219
|
details: {},
|
|
1688
2220
|
isError: true,
|
|
1689
2221
|
};
|
|
1690
2222
|
}
|
|
1691
|
-
|
|
1692
|
-
if (
|
|
1693
|
-
const fallback = resolveSubagentModel({
|
|
1694
|
-
configuredDefault: configuredDefault,
|
|
1695
|
-
sessionModel: ctx.model,
|
|
1696
|
-
available,
|
|
1697
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1698
|
-
});
|
|
2223
|
+
|
|
2224
|
+
if (requested.some((r) => r.fork) && !sessionFile) {
|
|
1699
2225
|
return {
|
|
1700
2226
|
content: [
|
|
1701
2227
|
{
|
|
1702
2228
|
type: "text",
|
|
1703
|
-
text:
|
|
1704
|
-
`${gate}\n` +
|
|
1705
|
-
"If the user explicitly asked for this model, retry with allow_expensive: true; " +
|
|
1706
|
-
"otherwise pick a cheaper model or omit the field.\n\n" +
|
|
1707
|
-
subagentModelsReminder({
|
|
1708
|
-
available,
|
|
1709
|
-
sessionModel: ctx.model,
|
|
1710
|
-
defaultModel: fallback.model,
|
|
1711
|
-
defaultSource: fallback.source,
|
|
1712
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1713
|
-
}),
|
|
2229
|
+
text: "Cannot fork: this session is not persisted (started with --no-session), so there is no transcript to clone. Use a named agent instead.",
|
|
1714
2230
|
},
|
|
1715
2231
|
],
|
|
1716
2232
|
details: {},
|
|
1717
2233
|
isError: true,
|
|
1718
2234
|
};
|
|
1719
2235
|
}
|
|
1720
|
-
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
1721
|
-
for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
|
|
1722
|
-
const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
|
|
1723
|
-
p.request.model = resolved;
|
|
1724
|
-
p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
|
|
1725
|
-
p.record.model = resolved;
|
|
1726
|
-
}
|
|
1727
2236
|
|
|
1728
|
-
|
|
2237
|
+
const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
|
|
2238
|
+
if (unknown.length > 0) {
|
|
2239
|
+
return {
|
|
2240
|
+
content: [
|
|
2241
|
+
{
|
|
2242
|
+
type: "text",
|
|
2243
|
+
text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
2244
|
+
},
|
|
2245
|
+
],
|
|
2246
|
+
details: {},
|
|
2247
|
+
isError: true,
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
if (requested.some((r) => r.worktree) && !(await isGitRepo(workCwd(ctx)))) {
|
|
2252
|
+
return {
|
|
2253
|
+
content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
|
|
2254
|
+
details: {},
|
|
2255
|
+
isError: true,
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
prepared = requested.map((request) => {
|
|
2260
|
+
const taskId = generateTaskId();
|
|
2261
|
+
return {
|
|
2262
|
+
request,
|
|
2263
|
+
agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
|
|
2264
|
+
record: {
|
|
2265
|
+
name: request.name,
|
|
2266
|
+
agent: request.agent,
|
|
2267
|
+
taskId,
|
|
2268
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
2269
|
+
cwd: workCwd(ctx),
|
|
2270
|
+
model: request.model,
|
|
2271
|
+
thinking: request.thinking,
|
|
2272
|
+
depth: 0,
|
|
2273
|
+
...(request.description ? { description: request.description } : {}),
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Model resolution happens here, in the parent, against the real
|
|
2280
|
+
* registry — never in the child, whose `--model` fuzzy-matches across
|
|
2281
|
+
* every configured provider. The child is spawned with a concrete
|
|
2282
|
+
* `provider/id`; anything surprising (a fallback, a provider crossing)
|
|
2283
|
+
* is said out loud rather than happening silently.
|
|
2284
|
+
*/
|
|
2285
|
+
const available = usableModels(ctx);
|
|
2286
|
+
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
2287
|
+
for (const p of prepared) {
|
|
2288
|
+
// A fork inherits the parent transcript, so it must continue on THIS
|
|
2289
|
+
// conversation's exact model — never a configured default or the
|
|
2290
|
+
// automatic same-provider pick, which would move the inherited context
|
|
2291
|
+
// onto a different (often weaker) model. Leaving model undefined makes
|
|
2292
|
+
// the forked session restore its own model. (Per-call `model` on a fork
|
|
2293
|
+
// is already rejected above.)
|
|
2294
|
+
if (p.request.fork) continue;
|
|
2295
|
+
// No per-call or agent-frontmatter override → the automatic default,
|
|
2296
|
+
// which is stable for the session, so reuse the cached resolution
|
|
2297
|
+
// instead of re-ranking the catalog per spawn. An override varies per
|
|
2298
|
+
// call and always resolves fresh.
|
|
2299
|
+
const resolution =
|
|
2300
|
+
p.request.model || p.agentDef?.model
|
|
2301
|
+
? resolveSubagentModel({
|
|
2302
|
+
requested: p.request.model,
|
|
2303
|
+
agentModel: p.agentDef?.model,
|
|
2304
|
+
configuredDefault: configuredDefault,
|
|
2305
|
+
sessionModel: ctx.model,
|
|
2306
|
+
available,
|
|
2307
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2308
|
+
})
|
|
2309
|
+
: resolveAutoDefault(ctx.model, available, configuredDefault);
|
|
2310
|
+
if (resolution.unresolved) {
|
|
2311
|
+
// The main model chose this string; the menu lets it retry.
|
|
2312
|
+
const fallback = resolveSubagentModel({
|
|
2313
|
+
configuredDefault: configuredDefault,
|
|
2314
|
+
sessionModel: ctx.model,
|
|
2315
|
+
available,
|
|
2316
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2317
|
+
});
|
|
2318
|
+
return {
|
|
2319
|
+
content: [
|
|
2320
|
+
{
|
|
2321
|
+
type: "text",
|
|
2322
|
+
text:
|
|
2323
|
+
`${resolution.unresolvedReason ?? `Unknown model "${resolution.unresolved}" — no available model matches it.`}\n\n` +
|
|
2324
|
+
subagentModelsReminder({
|
|
2325
|
+
available,
|
|
2326
|
+
sessionModel: ctx.model,
|
|
2327
|
+
defaultModel: fallback.model,
|
|
2328
|
+
defaultSource: fallback.source,
|
|
2329
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2330
|
+
}),
|
|
2331
|
+
},
|
|
2332
|
+
],
|
|
2333
|
+
details: {},
|
|
2334
|
+
isError: true,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
|
|
2338
|
+
if (gate) {
|
|
2339
|
+
const fallback = resolveSubagentModel({
|
|
2340
|
+
configuredDefault: configuredDefault,
|
|
2341
|
+
sessionModel: ctx.model,
|
|
2342
|
+
available,
|
|
2343
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2344
|
+
});
|
|
2345
|
+
return {
|
|
2346
|
+
content: [
|
|
2347
|
+
{
|
|
2348
|
+
type: "text",
|
|
2349
|
+
text:
|
|
2350
|
+
`${gate}\n` +
|
|
2351
|
+
"If the user explicitly asked for this model, retry with allow_expensive: true; " +
|
|
2352
|
+
"otherwise pick a cheaper model or omit the field.\n\n" +
|
|
2353
|
+
subagentModelsReminder({
|
|
2354
|
+
available,
|
|
2355
|
+
sessionModel: ctx.model,
|
|
2356
|
+
defaultModel: fallback.model,
|
|
2357
|
+
defaultSource: fallback.source,
|
|
2358
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2359
|
+
}),
|
|
2360
|
+
},
|
|
2361
|
+
],
|
|
2362
|
+
details: {},
|
|
2363
|
+
isError: true,
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
2367
|
+
for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
|
|
2368
|
+
const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
|
|
2369
|
+
p.request.model = resolved;
|
|
2370
|
+
p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
|
|
2371
|
+
p.record.model = resolved;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
for (const p of prepared) registry.add(p.record);
|
|
2375
|
+
landed = true;
|
|
2376
|
+
} finally {
|
|
2377
|
+
if (!landed) for (const request of requested) registry.release(request.name);
|
|
2378
|
+
}
|
|
1729
2379
|
const records = prepared.map((p) => p.record);
|
|
1730
2380
|
|
|
1731
2381
|
// One-shot modes (`-p` / `--mode json`) exit when the turn settles, so
|
|
@@ -1742,6 +2392,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1742
2392
|
onUpdate?.({ content: [{ type: "text", text: line }], details: {} });
|
|
1743
2393
|
};
|
|
1744
2394
|
report();
|
|
2395
|
+
mainCallFor.set(prepared[0].record.taskId, toolCallId);
|
|
1745
2396
|
const result = await executeRun(prepared[0], ctx, signal, sessionFile ?? undefined, undefined, (toolCalls, text, usage) => {
|
|
1746
2397
|
progress = { toolCalls, text, usage };
|
|
1747
2398
|
report();
|
|
@@ -1753,7 +2404,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1753
2404
|
actions: result.actions ?? [],
|
|
1754
2405
|
} satisfies SubagentActionsPayload);
|
|
1755
2406
|
const stats = formatStats(result.toolCalls, result.usage);
|
|
1756
|
-
const worktreeNote = result.worktreePath ? `\n\n
|
|
2407
|
+
const worktreeNote = result.worktreePath ? `\n\n${keptWorktreeNote(result.worktreePath, result.worktreeBranch)}` : "";
|
|
1757
2408
|
// The report arrives inline with no frame, no task id and no note, so a
|
|
1758
2409
|
// model told to "retrieve the result with task_output" chased an id that
|
|
1759
2410
|
// was never registered (WEAK-MODEL-REVIEW-2026-09-06 M3). Say it up
|
|
@@ -1770,7 +2421,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1770
2421
|
content: [
|
|
1771
2422
|
{
|
|
1772
2423
|
type: "text",
|
|
1773
|
-
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${result.output}${worktreeNote}\n\n(${stats})`,
|
|
2424
|
+
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${bounded(result.output, `${prepared[0].record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}\n\n(${stats})`,
|
|
1774
2425
|
},
|
|
1775
2426
|
],
|
|
1776
2427
|
details: { results: [result], agentRuns: records },
|
|
@@ -1783,181 +2434,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1783
2434
|
// steered system notification, and the child stays resident so
|
|
1784
2435
|
// SendMessage can reach it live (steer mid-turn, prompt when idle).
|
|
1785
2436
|
const lines: string[] = [...renamed, ...modelNotes];
|
|
1786
|
-
const runtime = await
|
|
2437
|
+
const runtime = await runtimeOrError(ctx);
|
|
2438
|
+
if (typeof runtime === "string") {
|
|
2439
|
+
// Nothing launched: forget the runs so list_agents and SendMessage never offer them.
|
|
2440
|
+
for (const p of prepared) registry.remove(p.record.taskId);
|
|
2441
|
+
return { content: [{ type: "text", text: `Could not start the agent: ${runtime}` }], details: {}, isError: true };
|
|
2442
|
+
}
|
|
1787
2443
|
for (const p of prepared) {
|
|
1788
|
-
|
|
1789
|
-
if (p.
|
|
1790
|
-
|
|
1791
|
-
worktree = await isolateInWorktree(ctx, p.record, p.request.name);
|
|
1792
|
-
} catch (error) {
|
|
1793
|
-
lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
|
|
1794
|
-
continue;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
const logPath = outputLogPath(p.record);
|
|
1799
|
-
let lastLogWrite = 0;
|
|
1800
|
-
let finish!: () => void;
|
|
1801
|
-
const finished = new Promise<void>((resolve) => {
|
|
1802
|
-
finish = resolve;
|
|
1803
|
-
});
|
|
1804
|
-
|
|
1805
|
-
// The child's final output. task.output() falls back to this
|
|
1806
|
-
// because handle.snapshot().text can be blank at a turn boundary —
|
|
1807
|
-
// the same reason the log write below uses `|| outcome.output`.
|
|
1808
|
-
// Keeps task_output consistent with the log and the completion
|
|
1809
|
-
// notification rather than showing an empty body in that case.
|
|
1810
|
-
let lastOutput = "";
|
|
1811
|
-
// The initial task's own report. task_output for THIS task must return the
|
|
1812
|
-
// first turn's reply — not the resident's growing multi-turn transcript —
|
|
1813
|
-
// the same way a SendMessage task returns one reply (review S12).
|
|
1814
|
-
let firstTurnOutput: string | undefined;
|
|
1815
|
-
const live = trackLiveRun(p.record, p.request);
|
|
1816
|
-
const resident: Resident = { handle: undefined as never, startedAt: Date.now(), turnHandlers: [] };
|
|
1817
|
-
const worktreeNote = worktree
|
|
1818
|
-
? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
|
|
1819
|
-
: "";
|
|
1820
|
-
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
1821
|
-
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
1822
|
-
task.finishedAt = Date.now();
|
|
1823
|
-
firstTurnOutput = outcome.output;
|
|
1824
|
-
finish();
|
|
1825
|
-
notifyHandBack({
|
|
1826
|
-
from: p.record.taskId,
|
|
1827
|
-
taskId: p.record.taskId,
|
|
1828
|
-
name: p.record.name,
|
|
1829
|
-
toolUseId: toolCallId,
|
|
1830
|
-
outputFile: logPath,
|
|
1831
|
-
outcome,
|
|
1832
|
-
stopped,
|
|
1833
|
-
startedAt: task.startedAt,
|
|
1834
|
-
report: `${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
|
|
1835
|
-
review,
|
|
1836
|
-
});
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
// Idle reaper: every run is a resident now, so without this a long
|
|
1840
|
-
// session accumulates one live AgentSession (extension instances,
|
|
1841
|
-
// message array, MCP tool refs) per delegation for its whole life.
|
|
1842
|
-
// After RESIDENT_IDLE_MS idle the session is released quietly; the
|
|
1843
|
-
// agent stays reachable — SendMessage resumes it from its session
|
|
1844
|
-
// file. Worktree residents are exempt: release would remove the
|
|
1845
|
-
// worktree a resume still needs. Armed from every turn end.
|
|
1846
|
-
let reaper: ReturnType<typeof setTimeout> | undefined;
|
|
1847
|
-
const armReaper = () => {
|
|
1848
|
-
if (worktree) return;
|
|
1849
|
-
if (reaper) clearTimeout(reaper);
|
|
1850
|
-
reaper = setTimeout(() => {
|
|
1851
|
-
reaper = undefined;
|
|
1852
|
-
if (handle.exited()) return;
|
|
1853
|
-
if (handle.busy()) {
|
|
1854
|
-
armReaper();
|
|
1855
|
-
return;
|
|
1856
|
-
}
|
|
1857
|
-
handle.release();
|
|
1858
|
-
}, RESIDENT_IDLE_MS);
|
|
1859
|
-
reaper.unref?.();
|
|
1860
|
-
};
|
|
1861
|
-
// Captured as a string: onExit runs from a `.finally` long after this
|
|
1862
|
-
// turn's ctx may be stale (review S5).
|
|
1863
|
-
const parentCwd = ctx.cwd;
|
|
1864
|
-
const forkPrompt = p.request.fork ? ctx.getSystemPrompt() : undefined;
|
|
1865
|
-
if (forkPrompt !== undefined) persistForkPrompt(p.record, forkPrompt);
|
|
1866
|
-
// No `signal` here on purpose: a resident outlives the spawning turn and
|
|
1867
|
-
// is stopped through task_stop / the panel, not by the turn ending (S15).
|
|
1868
|
-
const handle = await runtime.runResident({
|
|
1869
|
-
name: p.record.name,
|
|
1870
|
-
agent: p.agentDef,
|
|
1871
|
-
cwd: p.record.cwd,
|
|
1872
|
-
forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
|
|
1873
|
-
parentSystemPrompt: forkPrompt,
|
|
1874
|
-
sessionDir: p.record.sessionSearchDir || undefined,
|
|
1875
|
-
model: p.request.model,
|
|
1876
|
-
fallbackModel: p.request.fallbackModel,
|
|
1877
|
-
thinking: p.request.thinking,
|
|
1878
|
-
onProgress: (toolCalls, text, usage) => {
|
|
1879
|
-
live.progress(toolCalls, usage);
|
|
1880
|
-
// Throttled: onProgress fires per tool call/message with the whole
|
|
1881
|
-
// turn text so far, and this path now carries EVERY run — an
|
|
1882
|
-
// unthrottled sync rewrite would be O(n²) bytes on the hot path.
|
|
1883
|
-
// onTurnEnd below flushes the final state, so the tail is never lost.
|
|
1884
|
-
if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
|
|
1885
|
-
lastLogWrite = Date.now();
|
|
1886
|
-
writeFileSync(logPath, text);
|
|
1887
|
-
}
|
|
1888
|
-
},
|
|
1889
|
-
sink: live.sink,
|
|
1890
|
-
onMessageToMain: (message) => notifyAgentMessage(p.record.taskId, p.record.name, message),
|
|
1891
|
-
extraTools: spawnToolsFor(p.record),
|
|
1892
|
-
onTurnEnd: (outcome) => {
|
|
1893
|
-
registry.sessionFileFor(p.record);
|
|
1894
|
-
live.settle();
|
|
1895
|
-
lastOutput = resident.handle.snapshot().text || outcome.output;
|
|
1896
|
-
if (logPath) writeFileSync(logPath, lastOutput);
|
|
1897
|
-
// The handler is claimed now (so a message arriving mid-review pairs
|
|
1898
|
-
// with the NEXT turn), but runs only once auto mode's hand-back
|
|
1899
|
-
// review of this turn's action sequence has answered, so the
|
|
1900
|
-
// verdict rides in the same notification as the report instead of
|
|
1901
|
-
// trailing it. The wait is bounded (hand-back-review.ts) and
|
|
1902
|
-
// answered synchronously when auto mode is off.
|
|
1903
|
-
const handler = resident.turnHandlers.shift();
|
|
1904
|
-
// A stop or resume during review must not change this turn's outcome.
|
|
1905
|
-
const stopped = stoppedTaskIds.has(p.record.taskId);
|
|
1906
|
-
armReaper();
|
|
1907
|
-
void awaitHandBackReview(pi.events, p.record, outcome.actions).then((review) => {
|
|
1908
|
-
if (handler) {
|
|
1909
|
-
handler(outcome, review, stopped);
|
|
1910
|
-
} else {
|
|
1911
|
-
// A turn nobody is waiting on (e.g. a steer that raced past its
|
|
1912
|
-
// target turn and ran on its own) must still surface.
|
|
1913
|
-
notifyHandBack({
|
|
1914
|
-
from: p.record.taskId,
|
|
1915
|
-
taskId: p.record.taskId,
|
|
1916
|
-
name: p.record.name,
|
|
1917
|
-
toolUseId: toolCallId,
|
|
1918
|
-
outputFile: logPath,
|
|
1919
|
-
outcome,
|
|
1920
|
-
stopped,
|
|
1921
|
-
startedAt: resident.startedAt,
|
|
1922
|
-
report: bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP),
|
|
1923
|
-
review,
|
|
1924
|
-
details: { update: true },
|
|
1925
|
-
});
|
|
1926
|
-
}
|
|
1927
|
-
});
|
|
1928
|
-
},
|
|
1929
|
-
onExit: () => {
|
|
1930
|
-
if (reaper) clearTimeout(reaper);
|
|
1931
|
-
live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
|
|
1932
|
-
if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
|
|
1933
|
-
liveHandles.delete(p.record.taskId);
|
|
1934
|
-
if (worktree) void cleanupWorktree(parentCwd, worktree);
|
|
1935
|
-
},
|
|
1936
|
-
});
|
|
1937
|
-
resident.handle = handle;
|
|
1938
|
-
residents.set(p.record.taskId, resident);
|
|
1939
|
-
liveHandles.set(p.record.taskId, handle);
|
|
1940
|
-
|
|
1941
|
-
const task: BackgroundTask = {
|
|
1942
|
-
id: p.record.taskId,
|
|
1943
|
-
kind: "subagent",
|
|
1944
|
-
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
1945
|
-
description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
|
|
1946
|
-
status: "running",
|
|
1947
|
-
startedAt: Date.now(),
|
|
1948
|
-
logPath,
|
|
1949
|
-
output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
|
|
1950
|
-
stop: () => stopAgent(p.record.taskId, handle),
|
|
1951
|
-
resident: () => !handle.exited(),
|
|
1952
|
-
finished,
|
|
1953
|
-
};
|
|
1954
|
-
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
1955
|
-
void handle.send(frameTask(p.request, worktree, parentCwd));
|
|
1956
|
-
|
|
1957
|
-
spawnedThisLoop.add(p.record.taskId);
|
|
1958
|
-
lines.push(
|
|
1959
|
-
`⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
|
|
1960
|
-
);
|
|
2444
|
+
const { launched, line } = await launchResident(p, ctx, runtime, { sessionFile: sessionFile ?? undefined, toolCallId });
|
|
2445
|
+
if (launched) spawnedThisLoop.add(p.record.taskId);
|
|
2446
|
+
lines.push(line);
|
|
1961
2447
|
}
|
|
1962
2448
|
return {
|
|
1963
2449
|
content: [
|
|
@@ -1970,231 +2456,97 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1970
2456
|
};
|
|
1971
2457
|
},
|
|
1972
2458
|
});
|
|
2459
|
+
const setAgentForm = registerVariantTool<DescriptionForm>(pi, "short", (form) => ({ ...agentTool, description: agentDescription(form, projectConfigDirName()) }));
|
|
2460
|
+
followDescriptionForm(pi, setAgentForm);
|
|
1973
2461
|
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
}),
|
|
1980
|
-
description:
|
|
1981
|
-
'Send a message to a previously spawned agent, addressed by the name from its spawn result (or its task id). To discover the names of running or finished agents, use list_agents (deferred — load it with tool_search select:list_agents). A resident background agent is reached live (mid-turn the message is steered into its current work; when idle it starts a new turn); a finished agent is resumed from its session with full context. Replies arrive as task notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
|
|
1982
|
-
parameters: Type.Object({
|
|
1983
|
-
to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
|
|
1984
|
-
message: Type.String({ description: "Plain text message for the agent" }),
|
|
1985
|
-
summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
|
|
1986
|
-
}),
|
|
1987
|
-
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1988
|
-
if (params.to === "main") {
|
|
1989
|
-
// The main conversation's SendMessage only addresses spawned agents; the
|
|
1990
|
-
// "main" recipient exists only on a subagent's own injected SendMessage.
|
|
1991
|
-
return {
|
|
1992
|
-
content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
|
|
1993
|
-
details: {},
|
|
1994
|
-
isError: true,
|
|
1995
|
-
};
|
|
1996
|
-
}
|
|
2462
|
+
const SendMessageParams = Type.Object({
|
|
2463
|
+
to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
|
|
2464
|
+
message: Type.String({ description: "Plain text message for the agent" }),
|
|
2465
|
+
summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
|
|
2466
|
+
});
|
|
1997
2467
|
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2468
|
+
/**
|
|
2469
|
+
* SendMessage's body, typed on the plain extension context: the agent view
|
|
2470
|
+
* also sends from an input handler, which has no tool context (pi 0.99's
|
|
2471
|
+
* `ExtensionToolContext` adds nested tool calls this never makes).
|
|
2472
|
+
*/
|
|
2473
|
+
const sendMessage = async (toolCallId: string, params: Static<typeof SendMessageParams>, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> => {
|
|
2474
|
+
if (params.to === "main") {
|
|
2475
|
+
// The main conversation's SendMessage only addresses spawned agents; the
|
|
2476
|
+
// "main" recipient exists only on a subagent's own injected SendMessage.
|
|
2477
|
+
return {
|
|
2478
|
+
content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
|
|
2479
|
+
details: {},
|
|
2480
|
+
isError: true,
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
const record = registry.resolve(params.to);
|
|
2485
|
+
if (!record) {
|
|
2486
|
+
// Recorded runs only: a name reserved by a spawn that then failed validation is not addressable.
|
|
2487
|
+
const known = registry.list().map((run) => run.name).join(", ") || "(none)";
|
|
2488
|
+
return {
|
|
2489
|
+
content: [
|
|
2490
|
+
{
|
|
2491
|
+
type: "text",
|
|
2492
|
+
text: `No run named "${params.to}". SendMessage only reaches agent runs already started this session — address them by their run name or task id, not by a catalog agent name. Start one with the Agent tool first if you haven't. Known runs: ${known}.`,
|
|
2493
|
+
},
|
|
2494
|
+
],
|
|
2495
|
+
details: {},
|
|
2496
|
+
isError: true,
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
// Resident background agent: reach its in-process session live.
|
|
2500
|
+
const resident = residents.get(record.taskId);
|
|
2501
|
+
if (resident && !resident.handle.exited()) {
|
|
2502
|
+
// This call now starts (or joins) the agent's turn.
|
|
2503
|
+
mainCallFor.set(record.taskId, toolCallId);
|
|
2504
|
+
if (resident.handle.busy()) {
|
|
2505
|
+
let delivery: "started" | "steered";
|
|
2506
|
+
try {
|
|
2507
|
+
delivery = await resident.handle.send(params.message);
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
return {
|
|
2510
|
+
content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
|
|
2511
|
+
details: {},
|
|
2512
|
+
isError: true,
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
if (delivery === "steered") {
|
|
2040
2516
|
return {
|
|
2041
2517
|
content: [
|
|
2042
2518
|
{
|
|
2043
2519
|
type: "text",
|
|
2044
|
-
text:
|
|
2520
|
+
text: `Message steered into ${record.name}'s running turn — it will be taken into account before the turn completes, and the turn's completion notification will reflect it.`,
|
|
2045
2521
|
},
|
|
2046
2522
|
],
|
|
2047
|
-
details: { agentRuns: [record] },
|
|
2523
|
+
details: { agentRuns: [record], steered: true },
|
|
2048
2524
|
};
|
|
2049
2525
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
const finished = new Promise<void>((resolve) => {
|
|
2054
|
-
finish = resolve;
|
|
2055
|
-
});
|
|
2056
|
-
// This task is ONE turn of the resident agent. Its output must be that
|
|
2057
|
-
// turn's reply — the same text the reply notification carries — not the
|
|
2058
|
-
// resident's whole multi-turn transcript, or task_output and the
|
|
2059
|
-
// notification disagree (superset), which weak models conflate.
|
|
2060
|
-
let replyOutput = "";
|
|
2061
|
-
const task: BackgroundTask = {
|
|
2062
|
-
id: taskId,
|
|
2063
|
-
kind: "subagent",
|
|
2064
|
-
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
2065
|
-
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
2066
|
-
status: "running",
|
|
2067
|
-
startedAt: Date.now(),
|
|
2068
|
-
output: () => replyOutput || resident.handle.snapshot().text,
|
|
2069
|
-
stop: () => stopAgent(record.taskId, resident.handle),
|
|
2070
|
-
resident: () => !resident.handle.exited(),
|
|
2071
|
-
finished,
|
|
2072
|
-
};
|
|
2073
|
-
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
2074
|
-
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
2075
|
-
task.finishedAt = Date.now();
|
|
2076
|
-
replyOutput = outcome.output;
|
|
2077
|
-
finish();
|
|
2078
|
-
// `<task-id>` is this message's task (what task_output resolves); `from=`
|
|
2079
|
-
// stays the agent's id, the address a further SendMessage uses. The
|
|
2080
|
-
// tracker's tokens and tool uses span the resident's life, so the
|
|
2081
|
-
// duration does too.
|
|
2082
|
-
notifyHandBack({
|
|
2083
|
-
from: record.taskId,
|
|
2084
|
-
taskId,
|
|
2085
|
-
name: record.name,
|
|
2086
|
-
toolUseId: toolCallId,
|
|
2087
|
-
outputFile: outputLogPath(record),
|
|
2088
|
-
outcome,
|
|
2089
|
-
stopped,
|
|
2090
|
-
startedAt: resident.startedAt,
|
|
2091
|
-
report: bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP),
|
|
2092
|
-
review,
|
|
2093
|
-
});
|
|
2094
|
-
});
|
|
2095
|
-
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2096
|
-
void resident.handle.send(params.message);
|
|
2097
|
-
// Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
|
|
2098
|
-
// the reply arrives as a notification, so forbid predicting it and arm
|
|
2099
|
-
// the agent_end backstop (keyed by the agent's persistent id).
|
|
2100
|
-
spawnedThisLoop.add(record.taskId);
|
|
2526
|
+
// The turn had just ended (settle window): the message rides the next
|
|
2527
|
+
// turn, whose reply nobody has claimed yet — announce it as an update.
|
|
2528
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
2101
2529
|
return {
|
|
2102
2530
|
content: [
|
|
2103
2531
|
{
|
|
2104
2532
|
type: "text",
|
|
2105
|
-
text:
|
|
2533
|
+
text: `${record.name}'s turn had just finished; the message starts its next turn. The reply will arrive as a task notification from ${record.name}. ${PENDING_RESULT_PROHIBITION}`,
|
|
2106
2534
|
},
|
|
2107
2535
|
],
|
|
2108
|
-
details: { agentRuns: [record]
|
|
2109
|
-
};
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
if (runningIds.has(record.taskId)) {
|
|
2113
|
-
return {
|
|
2114
|
-
content: [
|
|
2115
|
-
{ type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
|
|
2116
|
-
],
|
|
2117
|
-
details: {},
|
|
2118
|
-
isError: true,
|
|
2119
|
-
};
|
|
2120
|
-
}
|
|
2121
|
-
const sessionFile = registry.sessionFileFor(record);
|
|
2122
|
-
// A fork has no agent definition: its identity is the parent's prompt at
|
|
2123
|
-
// spawn time, persisted beside its session. Without it a resume would run
|
|
2124
|
-
// on pi's stock prompt and default tools — refuse instead (review S6).
|
|
2125
|
-
const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
|
|
2126
|
-
if (record.agent === FORK_AGENT && forkPrompt === undefined) {
|
|
2127
|
-
return {
|
|
2128
|
-
content: [
|
|
2129
|
-
{
|
|
2130
|
-
type: "text",
|
|
2131
|
-
text: `Agent ${record.name} is a fork whose system prompt was not persisted (it ran before fork resume was supported, or its files were removed), so it cannot be resumed faithfully. Start a fresh run instead.`,
|
|
2132
|
-
},
|
|
2133
|
-
],
|
|
2134
|
-
details: {},
|
|
2135
|
-
isError: true,
|
|
2136
|
-
};
|
|
2137
|
-
}
|
|
2138
|
-
if (!sessionFile) {
|
|
2139
|
-
return {
|
|
2140
|
-
content: [
|
|
2141
|
-
{ type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
|
|
2142
|
-
],
|
|
2143
|
-
details: {},
|
|
2144
|
-
isError: true,
|
|
2536
|
+
details: { agentRuns: [record] },
|
|
2145
2537
|
};
|
|
2146
2538
|
}
|
|
2147
2539
|
|
|
2148
|
-
// A worktree run whose agent left no changes had its worktree removed at
|
|
2149
|
-
// exit, but the record (persisted in the tool result) still points there.
|
|
2150
|
-
// Resume in the parent cwd instead of a deleted directory, say so in the
|
|
2151
|
-
// reply, and fix the record so later messages don't repeat the note (M2).
|
|
2152
|
-
let relocationNote = "";
|
|
2153
|
-
if (!existsSync(record.cwd)) {
|
|
2154
|
-
relocationNote = `[${record.name}'s working directory ${record.cwd} no longer exists${record.worktree ? " (its isolation worktree was removed when the run left no changes)" : ""}; this turn ran in ${ctx.cwd}.]\n\n`;
|
|
2155
|
-
record.cwd = ctx.cwd;
|
|
2156
|
-
record.worktree = undefined;
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
2540
|
const taskId = generateTaskId();
|
|
2160
2541
|
let finish!: () => void;
|
|
2161
2542
|
const finished = new Promise<void>((resolve) => {
|
|
2162
2543
|
finish = resolve;
|
|
2163
2544
|
});
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
|
|
2169
|
-
agent: record.agent,
|
|
2170
|
-
name: record.name,
|
|
2171
|
-
task: params.message,
|
|
2172
|
-
model: record.model,
|
|
2173
|
-
thinking: record.thinking,
|
|
2174
|
-
});
|
|
2175
|
-
const runtime = await getRuntime(ctx);
|
|
2176
|
-
const handle = runtime.run({
|
|
2177
|
-
name: record.name,
|
|
2178
|
-
agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
|
|
2179
|
-
task: params.message,
|
|
2180
|
-
cwd: record.cwd,
|
|
2181
|
-
sessionFile,
|
|
2182
|
-
parentSystemPrompt: forkPrompt,
|
|
2183
|
-
model: record.model,
|
|
2184
|
-
// Resume degrades to the session model if the recorded model has become
|
|
2185
|
-
// unavailable since the original run, rather than failing the resume
|
|
2186
|
-
// outright (the note surfaces the swap). Undefined for a fork (record.model
|
|
2187
|
-
// unset — it inherits) or when the record already is the session model.
|
|
2188
|
-
fallbackModel:
|
|
2189
|
-
record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
|
|
2190
|
-
thinking: record.thinking,
|
|
2191
|
-
onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
|
|
2192
|
-
sink: live.sink,
|
|
2193
|
-
onMessageToMain: (message) => notifyAgentMessage(record.taskId, record.name, message),
|
|
2194
|
-
extraTools: spawnToolsFor(record),
|
|
2195
|
-
});
|
|
2196
|
-
liveHandles.set(record.taskId, handle);
|
|
2197
|
-
|
|
2545
|
+
// This task is ONE turn of the resident agent. Its output must be that
|
|
2546
|
+
// turn's reply — the same text the reply notification carries — not the
|
|
2547
|
+
// resident's whole multi-turn transcript, or task_output and the
|
|
2548
|
+
// notification disagree (superset), which weak models conflate.
|
|
2549
|
+
let replyOutput = "";
|
|
2198
2550
|
const task: BackgroundTask = {
|
|
2199
2551
|
id: taskId,
|
|
2200
2552
|
kind: "subagent",
|
|
@@ -2202,43 +2554,415 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2202
2554
|
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
2203
2555
|
status: "running",
|
|
2204
2556
|
startedAt: Date.now(),
|
|
2205
|
-
output: () => handle.snapshot().text,
|
|
2206
|
-
stop: () => stopAgent(record.taskId, handle),
|
|
2557
|
+
output: () => replyOutput || resident.handle.snapshot().text,
|
|
2558
|
+
stop: () => stopAgent(record.taskId, resident.handle),
|
|
2559
|
+
resident: () => !resident.handle.exited(),
|
|
2207
2560
|
finished,
|
|
2208
2561
|
};
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
void handle.result.then((outcome) => {
|
|
2212
|
-
runningIds.delete(record.taskId);
|
|
2213
|
-
const stopped = stoppedTaskIds.has(record.taskId);
|
|
2214
|
-
live.finish(stopped ? "stopped" : Boolean(outcome.failed));
|
|
2562
|
+
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
2215
2563
|
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
2216
2564
|
task.finishedAt = Date.now();
|
|
2565
|
+
replyOutput = outcome.output;
|
|
2217
2566
|
finish();
|
|
2567
|
+
// `<task-id>` is this message's task (what task_output resolves); `from=`
|
|
2568
|
+
// stays the agent's id, the address a further SendMessage uses. The
|
|
2569
|
+
// tracker's tokens and tool uses span the resident's life, so the
|
|
2570
|
+
// duration does too.
|
|
2218
2571
|
notifyHandBack({
|
|
2219
2572
|
from: record.taskId,
|
|
2220
2573
|
taskId,
|
|
2221
2574
|
name: record.name,
|
|
2222
2575
|
toolUseId: toolCallId,
|
|
2576
|
+
outputFile: outputLogPath(record),
|
|
2223
2577
|
outcome,
|
|
2224
2578
|
stopped,
|
|
2225
|
-
startedAt:
|
|
2226
|
-
report:
|
|
2227
|
-
review
|
|
2579
|
+
startedAt: resident.startedAt,
|
|
2580
|
+
report: bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP),
|
|
2581
|
+
review,
|
|
2228
2582
|
});
|
|
2229
2583
|
});
|
|
2230
|
-
|
|
2231
|
-
|
|
2584
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2585
|
+
void resident.handle.send(params.message);
|
|
2586
|
+
// Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
|
|
2587
|
+
// the reply arrives as a notification, so forbid predicting it and arm
|
|
2588
|
+
// the agent_end backstop (keyed by the agent's persistent id).
|
|
2589
|
+
spawnedThisLoop.add(record.taskId);
|
|
2232
2590
|
return {
|
|
2233
2591
|
content: [
|
|
2234
2592
|
{
|
|
2235
2593
|
type: "text",
|
|
2236
|
-
text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
|
|
2594
|
+
text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
|
|
2237
2595
|
},
|
|
2238
2596
|
],
|
|
2239
2597
|
details: { agentRuns: [record], taskId },
|
|
2240
2598
|
};
|
|
2241
|
-
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
if (runningIds.has(record.taskId)) {
|
|
2602
|
+
return {
|
|
2603
|
+
content: [
|
|
2604
|
+
{ type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
|
|
2605
|
+
],
|
|
2606
|
+
details: {},
|
|
2607
|
+
isError: true,
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
const sessionFile = registry.sessionFileFor(record);
|
|
2611
|
+
// A fork has no agent definition: its identity is the parent's prompt at
|
|
2612
|
+
// spawn time, persisted beside its session. Without it a resume would run
|
|
2613
|
+
// on pi's stock prompt and default tools — refuse instead (review S6).
|
|
2614
|
+
const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
|
|
2615
|
+
if (record.agent === FORK_AGENT && forkPrompt === undefined) {
|
|
2616
|
+
return {
|
|
2617
|
+
content: [
|
|
2618
|
+
{
|
|
2619
|
+
type: "text",
|
|
2620
|
+
text: `Agent ${record.name} is a fork whose system prompt was not persisted (it ran before fork resume was supported, or its files were removed), so it cannot be resumed faithfully. Start a fresh run instead.`,
|
|
2621
|
+
},
|
|
2622
|
+
],
|
|
2623
|
+
details: {},
|
|
2624
|
+
isError: true,
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
if (!sessionFile) {
|
|
2628
|
+
return {
|
|
2629
|
+
content: [
|
|
2630
|
+
{ type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
|
|
2631
|
+
],
|
|
2632
|
+
details: {},
|
|
2633
|
+
isError: true,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// A worktree run whose agent left no changes had its worktree removed at
|
|
2638
|
+
// exit, but the record (persisted in the tool result) still points there.
|
|
2639
|
+
// Resume in the parent cwd instead of a deleted directory, say so in the
|
|
2640
|
+
// reply, and fix the record so later messages don't repeat the note (M2).
|
|
2641
|
+
let relocationNote = "";
|
|
2642
|
+
if (!existsSync(record.cwd)) {
|
|
2643
|
+
const cwd = workCwd(ctx);
|
|
2644
|
+
relocationNote = `[${record.name}'s working directory ${record.cwd} no longer exists${record.worktree ? " (its isolation worktree was removed when the run left no changes)" : ""}; this turn ran in ${cwd}.]\n\n`;
|
|
2645
|
+
record.cwd = cwd;
|
|
2646
|
+
record.worktree = undefined;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// Before the run is marked running (L8, as in executeRun).
|
|
2650
|
+
const runtime = await runtimeOrError(ctx);
|
|
2651
|
+
if (typeof runtime === "string") {
|
|
2652
|
+
return { content: [{ type: "text", text: `Could not resume ${record.name}: ${runtime}` }], details: {}, isError: true };
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const taskId = generateTaskId();
|
|
2656
|
+
let finish!: () => void;
|
|
2657
|
+
const finished = new Promise<void>((resolve) => {
|
|
2658
|
+
finish = resolve;
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
runningIds.add(record.taskId);
|
|
2662
|
+
// Re-enter the panel: the finished run's entry flips back to running (or
|
|
2663
|
+
// registers fresh after a session resume) and streams this turn live.
|
|
2664
|
+
const live = trackLiveRun(record, {
|
|
2665
|
+
agent: record.agent,
|
|
2666
|
+
name: record.name,
|
|
2667
|
+
task: params.message,
|
|
2668
|
+
model: record.model,
|
|
2669
|
+
thinking: record.thinking,
|
|
2670
|
+
});
|
|
2671
|
+
// One-shot modes (`-p`, `--mode json`) exit when the turn settles, so the
|
|
2672
|
+
// resumed turn runs blocking there and the reply is the tool result, as a
|
|
2673
|
+
// spawn's report is (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M3).
|
|
2674
|
+
const oneShot = !sessionOutlivesTurn(ctx.mode);
|
|
2675
|
+
mainCallFor.set(record.taskId, toolCallId);
|
|
2676
|
+
const handle = runtime.run({
|
|
2677
|
+
name: record.name,
|
|
2678
|
+
agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
|
|
2679
|
+
task: params.message,
|
|
2680
|
+
cwd: record.cwd,
|
|
2681
|
+
sessionFile,
|
|
2682
|
+
parentSystemPrompt: forkPrompt,
|
|
2683
|
+
parentRequest: record.agent === FORK_AGENT ? readForkRequest(record) : undefined,
|
|
2684
|
+
// Blocking, so the call's own abort stops it; a background turn is stopped by task_stop.
|
|
2685
|
+
signal: oneShot ? signal : undefined,
|
|
2686
|
+
model: record.model,
|
|
2687
|
+
// Resume degrades to the session model if the recorded model has become
|
|
2688
|
+
// unavailable since the original run, rather than failing the resume
|
|
2689
|
+
// outright (the note surfaces the swap). Undefined for a fork (record.model
|
|
2690
|
+
// unset — it inherits) or when the record already is the session model.
|
|
2691
|
+
fallbackModel:
|
|
2692
|
+
record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
|
|
2693
|
+
thinking: record.thinking,
|
|
2694
|
+
onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
|
|
2695
|
+
sink: live.sink,
|
|
2696
|
+
onMessageToMain: (message) => notifyAgentMessage(record.taskId, record.name, message),
|
|
2697
|
+
extraTools: spawnToolsFor(record),
|
|
2698
|
+
mainToolCallId: () => mainCallOf(record.taskId),
|
|
2699
|
+
});
|
|
2700
|
+
liveHandles.set(record.taskId, handle);
|
|
2701
|
+
|
|
2702
|
+
if (oneShot) {
|
|
2703
|
+
const outcome = await handle.result;
|
|
2704
|
+
runningIds.delete(record.taskId);
|
|
2705
|
+
live.finish(stoppedTaskIds.has(record.taskId) ? "stopped" : Boolean(outcome.failed));
|
|
2706
|
+
// Auto mode reviews the turn's actions; the gate attaches the verdict to this tool result.
|
|
2707
|
+
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, { toolCallId, actions: outcome.actions } satisfies SubagentActionsPayload);
|
|
2708
|
+
return {
|
|
2709
|
+
content: [
|
|
2710
|
+
{
|
|
2711
|
+
type: "text",
|
|
2712
|
+
text:
|
|
2713
|
+
`${record.name}: ${oneShotNote("agent's turn")} Its reply follows here; there is no background task to poll.\n\n` +
|
|
2714
|
+
`${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}\n\n(${formatStats(outcome.toolCalls, outcome.usage)})`,
|
|
2715
|
+
},
|
|
2716
|
+
],
|
|
2717
|
+
details: { agentRuns: [record] },
|
|
2718
|
+
isError: outcome.failed ?? false,
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
let settledOutput: string | undefined;
|
|
2723
|
+
const task: BackgroundTask = {
|
|
2724
|
+
id: taskId,
|
|
2725
|
+
kind: "subagent",
|
|
2726
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
2727
|
+
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
2728
|
+
status: "running",
|
|
2729
|
+
startedAt: Date.now(),
|
|
2730
|
+
output: () => settledOutput ?? handle.snapshot().text,
|
|
2731
|
+
stop: () => stopAgent(record.taskId, handle),
|
|
2732
|
+
finished,
|
|
2733
|
+
};
|
|
2734
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2735
|
+
|
|
2736
|
+
void handle.result.then(async (outcome) => {
|
|
2737
|
+
runningIds.delete(record.taskId);
|
|
2738
|
+
// Read before the review: a stop or resume during it must not change this turn's outcome.
|
|
2739
|
+
const stopped = stoppedTaskIds.has(record.taskId);
|
|
2740
|
+
live.finish(stopped ? "stopped" : Boolean(outcome.failed));
|
|
2741
|
+
// Auto mode reviews this turn's actions before the reply goes out, as it
|
|
2742
|
+
// does for a resident's turn; a resumed turn once skipped it
|
|
2743
|
+
// (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M2). The task settles after the
|
|
2744
|
+
// review, so task_output never returns the reply ahead of its verdict.
|
|
2745
|
+
const review = await awaitHandBackReview(pi.events, record, outcome.actions, undefined, mainCallOf(record.taskId));
|
|
2746
|
+
// The outcome includes SubagentHandback and startup/abort errors;
|
|
2747
|
+
// the live snapshot only contains assistant text, not the final report.
|
|
2748
|
+
settledOutput = `${relocationNote}${outcome.output}`;
|
|
2749
|
+
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
2750
|
+
task.finishedAt = Date.now();
|
|
2751
|
+
finish();
|
|
2752
|
+
notifyHandBack({
|
|
2753
|
+
from: record.taskId,
|
|
2754
|
+
taskId,
|
|
2755
|
+
name: record.name,
|
|
2756
|
+
toolUseId: toolCallId,
|
|
2757
|
+
outcome,
|
|
2758
|
+
stopped,
|
|
2759
|
+
startedAt: task.startedAt,
|
|
2760
|
+
report: `${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`,
|
|
2761
|
+
review,
|
|
2762
|
+
});
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
2766
|
+
return {
|
|
2767
|
+
content: [
|
|
2768
|
+
{
|
|
2769
|
+
type: "text",
|
|
2770
|
+
text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
|
|
2771
|
+
},
|
|
2772
|
+
],
|
|
2773
|
+
details: { agentRuns: [record], taskId },
|
|
2774
|
+
};
|
|
2775
|
+
};
|
|
2776
|
+
|
|
2777
|
+
const sendMessageTool = defineTool({
|
|
2778
|
+
name: "SendMessage",
|
|
2779
|
+
label: "Send Message",
|
|
2780
|
+
...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
|
|
2781
|
+
title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
|
|
2782
|
+
}),
|
|
2783
|
+
description:
|
|
2784
|
+
'Send a message to a previously spawned agent, addressed by the name from its spawn result (or its task id). To discover the names of running or finished agents, use list_agents. A resident background agent is reached live (mid-turn the message is steered into its current work; when idle it starts a new turn); a finished agent is resumed from its session with full context. Replies arrive as task notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
|
|
2785
|
+
parameters: SendMessageParams,
|
|
2786
|
+
execute: (toolCallId, params, signal, _onUpdate, ctx) => sendMessage(toolCallId, params, signal, ctx),
|
|
2787
|
+
});
|
|
2788
|
+
pi.registerTool(sendMessageTool);
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* What the user submits while viewing an agent goes to that agent, as in
|
|
2792
|
+
* Claude Code (findings §40): Claude Code's mid-turn wrapper, steered into a
|
|
2793
|
+
* running turn or starting one on an idle resident, whose reply notifies
|
|
2794
|
+
* under the agent's own task id. A released or finished agent resumes
|
|
2795
|
+
* through SendMessage's path. Slash commands, skills and `!` shell lines
|
|
2796
|
+
* keep their main-conversation meaning.
|
|
2797
|
+
*/
|
|
2798
|
+
const deliverFromView = async (ctx: ExtensionContext, taskId: string, text: string) => {
|
|
2799
|
+
const record = registry.resolve(taskId);
|
|
2800
|
+
const fail = (reason: string) => ctx.ui.notify(`Could not send the message to ${record?.name ?? "the agent"}: ${reason}`, "error");
|
|
2801
|
+
if (!record) return fail("the agent is gone.");
|
|
2802
|
+
const wrapped = userMessageForAgent(text);
|
|
2803
|
+
const resident = residents.get(record.taskId);
|
|
2804
|
+
if (resident && !resident.handle.exited()) {
|
|
2805
|
+
liveRuns.block(record.taskId, { kind: "user", text });
|
|
2806
|
+
try {
|
|
2807
|
+
await resident.handle.send(wrapped);
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
fail((error as Error).message);
|
|
2810
|
+
}
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const result = await sendMessage(`view-${Date.now()}`, { to: record.taskId, message: wrapped }, undefined, ctx);
|
|
2814
|
+
if (result.isError) fail(result.content.map((block) => (block.type === "text" ? block.text : "")).join(" "));
|
|
2815
|
+
};
|
|
2816
|
+
pi.on("input", (event, ctx) => {
|
|
2817
|
+
const taskId = view ? panel.viewedTaskId() : undefined;
|
|
2818
|
+
if (!taskId || event.source !== "interactive") return undefined;
|
|
2819
|
+
const text = event.text.trim();
|
|
2820
|
+
if (!text || text.startsWith("/") || text.startsWith("!")) return undefined;
|
|
2821
|
+
void deliverFromView(ctx, taskId, event.text).catch((error: unknown) => {
|
|
2822
|
+
ctx.ui.notify(`Could not send the message: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2823
|
+
});
|
|
2824
|
+
return { action: "handled" as const };
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
/** A run's model as Claude Code names it in `/tasks` (`Sonnet 5`), else its spec. */
|
|
2828
|
+
const modelLabel = (ctx: ExtensionContext, spec: string | undefined): string | undefined => {
|
|
2829
|
+
if (!spec) return undefined;
|
|
2830
|
+
try {
|
|
2831
|
+
return findConfigured(ctx.modelRegistry.getAvailable(), spec)?.name ?? spec;
|
|
2832
|
+
} catch {
|
|
2833
|
+
return spec; // no registry (a torn-down session)
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* `/tasks`: Claude Code's Background dialog (findings §40): running shells,
|
|
2839
|
+
* running agents, and the finished agents the user viewed. Enter views an
|
|
2840
|
+
* agent or shows a shell's details in the dialog, x stops the selected
|
|
2841
|
+
* running task, Esc closes. Enter on the shells chip below the editor opens
|
|
2842
|
+
* it too.
|
|
2843
|
+
*/
|
|
2844
|
+
const openTasksDialog = async (ctx: ExtensionContext) => {
|
|
2845
|
+
if (!canShowCustomUi(ctx)) {
|
|
2846
|
+
if (ctx.mode !== "rpc") return;
|
|
2847
|
+
const lines = [
|
|
2848
|
+
...shellRows(shellTasks.list(), Date.now()).map((task) => `- ${task.id} [shell, ${task.status}] ${task.command ?? task.description}`),
|
|
2849
|
+
...liveRuns.list().filter((run) => run.depth === 0).map((run) => `- ${run.taskId} [${run.name}, ${run.status}] ${run.label}`),
|
|
2850
|
+
];
|
|
2851
|
+
ctx.ui.notify([
|
|
2852
|
+
"The interactive task viewer requires TUI mode; showing a task snapshot instead.",
|
|
2853
|
+
...(lines.length ? lines : ["No background tasks in this session."]),
|
|
2854
|
+
"Use task_output to inspect a task and task_stop to stop it.",
|
|
2855
|
+
].join("\n"), "info");
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
const build = () => {
|
|
2859
|
+
const runs = liveRuns.list().filter((run) => run.depth === 0);
|
|
2860
|
+
// The shells the chip counts, so Enter on a chip whose shell just finished finds it.
|
|
2861
|
+
const shells = shellRows(shellTasks.list(), Date.now()).map((task) => ({
|
|
2862
|
+
kind: "shell" as const,
|
|
2863
|
+
id: task.id,
|
|
2864
|
+
text: task.command ?? task.description,
|
|
2865
|
+
running: task.status === "running",
|
|
2866
|
+
status: task.status,
|
|
2867
|
+
}));
|
|
2868
|
+
const agents = [
|
|
2869
|
+
...runs.filter((run) => run.status === "running"),
|
|
2870
|
+
...runs.filter((run) => run.status !== "running" && run.viewed),
|
|
2871
|
+
].map((run) => ({ kind: "agent" as const, taskId: run.taskId, text: run.label, model: modelLabel(ctx, run.model), running: run.status === "running" }));
|
|
2872
|
+
return { shells, agents };
|
|
2873
|
+
};
|
|
2874
|
+
let selected = 0;
|
|
2875
|
+
// Rebuilt when a run or shell changes, not per frame or keystroke.
|
|
2876
|
+
let snapshot = build();
|
|
2877
|
+
// The rows on screen: keys act on what the user sees, even when a change
|
|
2878
|
+
// has rebuilt the snapshot and its render is still pending.
|
|
2879
|
+
let shown = tasksItems(snapshot);
|
|
2880
|
+
// The list, or one shell's details inside the same dialog.
|
|
2881
|
+
let dialogView: TasksView = { mode: "list" };
|
|
2882
|
+
const shellById = (id: string) => shellTasks.list().find((task) => task.id === id);
|
|
2883
|
+
const stopShell = (task: { stop(): void } | undefined) => {
|
|
2884
|
+
try {
|
|
2885
|
+
task?.stop();
|
|
2886
|
+
} catch {
|
|
2887
|
+
// A dead process's stop() throwing must not break the dialog.
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<string | null>((tui, theme, _keybindings, done) => {
|
|
2891
|
+
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
2892
|
+
const refresh = () => {
|
|
2893
|
+
snapshot = build();
|
|
2894
|
+
tui.requestRender();
|
|
2895
|
+
};
|
|
2896
|
+
const unsubscribe = liveRuns.subscribe(refresh);
|
|
2897
|
+
const unsubscribeShells = shellTasks.subscribe(refresh);
|
|
2898
|
+
// A running shell's details follow its runtime and output once a second,
|
|
2899
|
+
// and the list drops a finished shell once its linger window ends, as
|
|
2900
|
+
// the chip does (a rebuild only when that row set changes).
|
|
2901
|
+
const ticker = setInterval(() => {
|
|
2902
|
+
if (dialogView.mode === "shell") {
|
|
2903
|
+
if (shellById(dialogView.id)?.status === "running") tui.requestRender();
|
|
2904
|
+
} else if (snapshot.shells.some((shell) => !shell.running) && shellRows(shellTasks.list(), Date.now()).length !== snapshot.shells.length) {
|
|
2905
|
+
refresh();
|
|
2906
|
+
}
|
|
2907
|
+
}, 1000);
|
|
2908
|
+
ticker.unref?.();
|
|
2909
|
+
const finish = (value: string | null) => {
|
|
2910
|
+
clearInterval(ticker);
|
|
2911
|
+
unsubscribe();
|
|
2912
|
+
unsubscribeShells();
|
|
2913
|
+
done(value);
|
|
2914
|
+
};
|
|
2915
|
+
return {
|
|
2916
|
+
render: (width: number) => {
|
|
2917
|
+
const task = dialogView.mode === "shell" ? shellById(dialogView.id) : undefined;
|
|
2918
|
+
dialogView = settleTasksView(dialogView, task !== undefined);
|
|
2919
|
+
if (dialogView.mode === "shell" && task) {
|
|
2920
|
+
return ["", ...renderShellDetail(task, width - 2, Date.now(), paint).map((line) => ` ${line}`), ""].map((line) => truncateLine(line, width));
|
|
2921
|
+
}
|
|
2922
|
+
shown = tasksItems(snapshot);
|
|
2923
|
+
selected = Math.max(0, Math.min(selected, shown.length - 1));
|
|
2924
|
+
return ["", ...renderTasksDialog({ ...snapshot, selected, width }, paint), ""].map((line) => truncateLine(line, width));
|
|
2925
|
+
},
|
|
2926
|
+
handleInput: (data: string) => {
|
|
2927
|
+
if (isKeyRelease(data)) return;
|
|
2928
|
+
const id = keyId(data);
|
|
2929
|
+
if (dialogView.mode === "shell") {
|
|
2930
|
+
const task = shellById(dialogView.id);
|
|
2931
|
+
const action = shellDetailKey(id, keyText(data), task?.status === "running");
|
|
2932
|
+
if (action === "close") return finish(null);
|
|
2933
|
+
if (action === "back") dialogView = { mode: "list" };
|
|
2934
|
+
else if (action === "stop") stopShell(task);
|
|
2935
|
+
tui.requestRender();
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
const items = shown;
|
|
2939
|
+
if (id === "escape") return finish(null);
|
|
2940
|
+
if (id === "up" || id === "down") {
|
|
2941
|
+
selected = moveTasksSelection(selected, id, items.length);
|
|
2942
|
+
tui.requestRender();
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
const item = items[selected];
|
|
2946
|
+
if (id === "enter" && item?.kind === "agent") return finish(item.taskId);
|
|
2947
|
+
if (id === "enter" && item?.kind === "shell") {
|
|
2948
|
+
dialogView = { mode: "shell", id: item.id };
|
|
2949
|
+
tui.requestRender();
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
if (keyText(data)?.toLowerCase() === "x" && item?.running) {
|
|
2953
|
+
if (item.kind === "agent") void stopAgent(item.taskId);
|
|
2954
|
+
else stopShell(shellById(item.id));
|
|
2955
|
+
tui.requestRender();
|
|
2956
|
+
}
|
|
2957
|
+
},
|
|
2958
|
+
invalidate: () => {},
|
|
2959
|
+
};
|
|
2960
|
+
});
|
|
2961
|
+
if (chosen) openView(ctx, chosen);
|
|
2962
|
+
};
|
|
2963
|
+
registerLocalCommand(pi, "tasks", {
|
|
2964
|
+
description: "List background tasks: shells, running agents, and the agents you viewed",
|
|
2965
|
+
handler: async (_args, ctx) => openTasksDialog(ctx as ExtensionContext),
|
|
2242
2966
|
});
|
|
2243
2967
|
pi.events.emit(DEFER_CHANNEL, { name: "SendMessage", keywords: ["message", "agent", "resume", "continue", "teammate"] });
|
|
2244
2968
|
|
|
@@ -2279,7 +3003,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2279
3003
|
};
|
|
2280
3004
|
},
|
|
2281
3005
|
});
|
|
2282
|
-
|
|
3006
|
+
// Eager, as Claude Code's ListAgents is: not emitted on the deferral channel.
|
|
2283
3007
|
|
|
2284
3008
|
registerLocalCommand(pi, "agents", {
|
|
2285
3009
|
description: "Open the live subagent panel, or list available agents",
|
|
@@ -2287,7 +3011,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2287
3011
|
// With live children, focus the strip on the newest and open its view
|
|
2288
3012
|
// (Claude Code's agent panel); otherwise there is nothing running, so
|
|
2289
3013
|
// list the catalog the way the tool's action:"list" does.
|
|
2290
|
-
if (ctx.hasUI && panel.rowCount() > 1) {
|
|
3014
|
+
if (ctx.hasUI && ctx.mode === "tui" && panel.rowCount() > 1) {
|
|
2291
3015
|
panel.setFocus(1); // row 0 is `main`; 1 is the newest live child
|
|
2292
3016
|
const run = panel.selectedRun();
|
|
2293
3017
|
if (run) {
|
|
@@ -2295,6 +3019,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2295
3019
|
return;
|
|
2296
3020
|
}
|
|
2297
3021
|
}
|
|
3022
|
+
if (ctx.mode === "rpc" && registry.list().length) {
|
|
3023
|
+
ctx.ui.notify([
|
|
3024
|
+
"The live subagent panel requires TUI mode; showing spawned agents instead.",
|
|
3025
|
+
...registry.list().map((run) => `- ${run.name} [${run.agent}] — ${agentStatus(run)} (task ${run.taskId})`),
|
|
3026
|
+
"Use /tasks for task status; task_output and task_stop inspect or stop a task.",
|
|
3027
|
+
].join("\n"), "info");
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
2298
3030
|
ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
|
|
2299
3031
|
},
|
|
2300
3032
|
});
|
|
@@ -2317,6 +3049,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2317
3049
|
: "subagentModel: (not set)",
|
|
2318
3050
|
`effective: ${resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : "none"} (${resolution.source})`,
|
|
2319
3051
|
...(resolution.notices.length ? [resolution.notices.join("\n")] : []),
|
|
3052
|
+
...(resolution.source === "default" && resolution.model
|
|
3053
|
+
? chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) }).map((warning) => `! ${warning.text} ${warning.fix}`)
|
|
3054
|
+
: []),
|
|
2320
3055
|
"Set it with /subagent <provider/model-id|sonnet|opus|haiku|fable|inherit>, or clear with /subagent clear.",
|
|
2321
3056
|
].join("\n"),
|
|
2322
3057
|
"info",
|
|
@@ -2388,16 +3123,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2388
3123
|
ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
|
|
2389
3124
|
return;
|
|
2390
3125
|
}
|
|
2391
|
-
emitModelStatus(ctx);
|
|
2392
3126
|
ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.onecode/settings.json).`, "info");
|
|
3127
|
+
// What the choice costs, shown now even if an earlier choice already
|
|
3128
|
+
// raised the same line; marked noticed so the status refresh is quiet.
|
|
3129
|
+
if (resolved) {
|
|
3130
|
+
for (const warning of chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolved, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
|
|
3131
|
+
const message = `${warning.text} ${warning.fix}`;
|
|
3132
|
+
noticedModels.add(message);
|
|
3133
|
+
ctx.ui.notify(message, "warning");
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
emitModelStatus(ctx);
|
|
2393
3137
|
};
|
|
2394
3138
|
|
|
2395
3139
|
registerLocalCommand(pi, "subagent", {
|
|
2396
3140
|
description: "Set the default model for subagent/workflow runs: /subagent [provider/model-id|inherit|status|clear]",
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
2400
|
-
.map((value) => ({ value, label: value })),
|
|
3141
|
+
argumentHint: "[provider/model-id|inherit|status|clear]",
|
|
3142
|
+
getArgumentCompletions: (prefix) => extendingCompletions(prefix, ["inherit", "status", "clear"].map((value) => ({ value, label: value }))),
|
|
2401
3143
|
handler: async (args, ctx) => {
|
|
2402
3144
|
const typed = args.trim();
|
|
2403
3145
|
if (typed === "clear") {
|
|
@@ -2441,7 +3183,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2441
3183
|
const current = configured && configured.spec.includes("/") ? configured.spec : undefined;
|
|
2442
3184
|
const entries = toPickerEntries(available);
|
|
2443
3185
|
|
|
2444
|
-
const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
3186
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
2445
3187
|
modelPickerComponent(
|
|
2446
3188
|
{
|
|
2447
3189
|
entries,
|