one-code-extension 0.4.1 → 0.5.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 +32 -23
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +27 -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 +123 -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 +515 -0
- package/extensions/background/index.ts +514 -236
- package/extensions/background/loop-fire.ts +136 -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 +52 -0
- package/extensions/background/wakeup.ts +159 -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 +88 -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 +229 -71
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +446 -58
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +216 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/index.ts +24 -5
- package/extensions/context-management/index.ts +25 -0
- 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 +72 -19
- 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 +337 -26
- package/extensions/file-tracker/replay.ts +96 -10
- package/extensions/file-tracker/restore.ts +63 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +32 -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 +198 -31
- package/extensions/hooks/matcher.ts +3 -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 +13 -5
- 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/lib/agent-cron.ts +130 -0
- package/extensions/lib/agent-loader.ts +15 -1
- package/extensions/lib/agent-view.ts +85 -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 +42 -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 +78 -0
- package/extensions/lib/context-stack.ts +81 -0
- package/extensions/lib/deferred.ts +219 -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/local-command.ts +47 -13
- 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 +174 -0
- package/extensions/lib/model-catalog/models-dev.json +18297 -0
- package/extensions/lib/model-catalog/openrouter.json +5252 -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 +308 -64
- 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 +21 -2
- package/extensions/lib/plan-mode-channels.ts +20 -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/process-tree.ts +131 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +364 -58
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +439 -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 +13 -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 +113 -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 +149 -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 +143 -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 +70 -4
- 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 +87 -5
- 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 +209 -71
- 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 +151 -67
- 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/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 +1300 -381
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +6 -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 +156 -69
- package/extensions/plan-mode/reminder.ts +89 -21
- 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 +61 -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 +12 -7
- package/extensions/recap/index.ts +65 -60
- 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/code-review.ts +97 -0
- package/extensions/skill/index.ts +421 -101
- package/extensions/skill/invoke.ts +128 -18
- 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 +116 -23
- package/extensions/subagents/announce.ts +69 -0
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +76 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1493 -824
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +2 -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/runner.ts +108 -80
- package/extensions/subagents/runs.ts +39 -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 +131 -47
- package/extensions/system-prompt/template.ts +24 -50
- 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 +175 -13
- 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/announce.ts +16 -0
- package/extensions/tool-search/index.ts +189 -41
- 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 +105 -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 +145 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +29 -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 +121 -39
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +18 -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
|
@@ -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, 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";
|
|
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
|
+
|
|
43
56
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
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";
|
|
46
66
|
import { pendingClaimReminder } 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, type ReminderPayload } 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,33 @@ 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 { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
|
|
116
|
+
import { emptySubagentBaseline, planSubagentAnnouncement, subagentBaseline, subagentBaselineFromStack, type SubagentBaseline } from "./announce.ts";
|
|
117
|
+
import { panelUi } from "../lib/panel-focus.ts";
|
|
118
|
+
import { dockRowsReporter, trackDockRows } from "../lib/dock-rows.ts";
|
|
119
|
+
|
|
120
|
+
/** How the guide's setup list names a server that is not connected. */
|
|
121
|
+
const MCP_STATUS_LABELS: Record<McpStatusKind, string | undefined> = {
|
|
122
|
+
connected: undefined,
|
|
123
|
+
failed: "failed to connect",
|
|
124
|
+
authNeeded: "needs authentication",
|
|
125
|
+
connecting: "connecting",
|
|
126
|
+
disabled: "disabled",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** This package's root (the `one-code-extension` directory). */
|
|
130
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
91
131
|
|
|
92
132
|
interface RunRequest {
|
|
93
133
|
agent: string;
|
|
@@ -105,6 +145,8 @@ interface RunRequest {
|
|
|
105
145
|
fallbackModel?: string;
|
|
106
146
|
thinking?: string;
|
|
107
147
|
worktree?: boolean;
|
|
148
|
+
/** Claude Code's `description`: the run's title (AgentRunRecord.description). */
|
|
149
|
+
description?: string;
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
/**
|
|
@@ -134,6 +176,8 @@ interface TaskResult {
|
|
|
134
176
|
usage: UsageTotals;
|
|
135
177
|
failed?: boolean;
|
|
136
178
|
worktreePath?: string;
|
|
179
|
+
/** The kept worktree's branch, which holds any commits the agent made. */
|
|
180
|
+
worktreeBranch?: string;
|
|
137
181
|
worktreeKept?: boolean;
|
|
138
182
|
/** What the child did, for auto mode's return review. */
|
|
139
183
|
actions?: ChildAction[];
|
|
@@ -143,12 +187,10 @@ const SubagentParams = Type.Object({
|
|
|
143
187
|
subagent_type: Type.Optional(
|
|
144
188
|
Type.String({
|
|
145
189
|
description:
|
|
146
|
-
'The agent to run — a name from the "Available agents" system reminder, or "fork" to clone this conversation.
|
|
190
|
+
'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
191
|
}),
|
|
148
192
|
),
|
|
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
|
-
),
|
|
193
|
+
task: Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups)." }),
|
|
152
194
|
// `prompt` is Claude Code's name for the task text and `description` its short
|
|
153
195
|
// title. A CC-trained model writes `{subagent_type, description, prompt}`; pi
|
|
154
196
|
// does not reject unknown keys, so without these the task silently became ""
|
|
@@ -186,7 +228,6 @@ const SubagentParams = Type.Object({
|
|
|
186
228
|
),
|
|
187
229
|
});
|
|
188
230
|
|
|
189
|
-
export const FORK_AGENT = "fork";
|
|
190
231
|
|
|
191
232
|
/**
|
|
192
233
|
* The anti-fabrication sentence, carried by the tool RESULT of a background
|
|
@@ -218,10 +259,34 @@ interface Resident {
|
|
|
218
259
|
* ahead of the report in its notification.
|
|
219
260
|
*/
|
|
220
261
|
turnHandlers: Array<(outcome: ChildOutcome, review: HandBackVerdict | undefined, stopped: boolean) => void>;
|
|
262
|
+
/** Finished turns whose hand-back is still waiting on auto mode's review, so not yet delivered. */
|
|
263
|
+
reviewing: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Whether a resident has a report still to deliver: a turn is running, a turn's
|
|
268
|
+
* handler is queued, or a finished turn waits on its review. A resident that
|
|
269
|
+
* has delivered stays alive and idle for SendMessage; counting it as pending
|
|
270
|
+
* told a cheap-tier model to retract an answer the agent really gave
|
|
271
|
+
* (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M4).
|
|
272
|
+
*/
|
|
273
|
+
function residentPending(resident: Resident): boolean {
|
|
274
|
+
return !resident.handle.exited() && (resident.handle.busy() || resident.turnHandlers.length > 0 || resident.reviewing > 0);
|
|
221
275
|
}
|
|
222
276
|
|
|
223
277
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
278
|
+
const requestTier = sessionModelTier(pi);
|
|
224
279
|
const registry = new RunRegistry();
|
|
280
|
+
/**
|
|
281
|
+
* The session's permission mode as the permissions extension last announced
|
|
282
|
+
* it; auto (the shipped default) until the first announcement. It picks the
|
|
283
|
+
* hand-back's shape (notifyHandBack).
|
|
284
|
+
*/
|
|
285
|
+
let permissionMode = "auto";
|
|
286
|
+
pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
|
|
287
|
+
const mode = (data as Partial<PermissionStatus> | undefined)?.mode;
|
|
288
|
+
if (typeof mode === "string") permissionMode = mode;
|
|
289
|
+
});
|
|
225
290
|
/**
|
|
226
291
|
* Task ids with a blocking child (nested spawn or SendMessage resume) currently
|
|
227
292
|
* running (SendMessage must wait for these). Keyed by task id, like `residents`:
|
|
@@ -231,6 +296,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
231
296
|
const runningIds = new Set<string>();
|
|
232
297
|
/** Task id → live resident. */
|
|
233
298
|
const residents = new Map<string, Resident>();
|
|
299
|
+
/**
|
|
300
|
+
* Task id → the main session's tool call that started the run's current
|
|
301
|
+
* turn (its Agent call, or the latest SendMessage to it); a nested run
|
|
302
|
+
* answers with its parent's (`parentTaskOf`). The permission gate reads the
|
|
303
|
+
* main session only as far as that call (auto-mode/history.ts
|
|
304
|
+
* childHistoryCursor). Ids only, a few per run.
|
|
305
|
+
*/
|
|
306
|
+
const mainCallFor = new Map<string, string>();
|
|
307
|
+
const parentTaskOf = new Map<string, string>();
|
|
308
|
+
const mainCallOf = (taskId: string): string | undefined => {
|
|
309
|
+
// Bounded: nesting is capped far below this, and a cycle must not hang the gate.
|
|
310
|
+
for (let id: string | undefined = taskId, depth = 0; id !== undefined && depth < 8; id = parentTaskOf.get(id), depth++) {
|
|
311
|
+
const call = mainCallFor.get(id);
|
|
312
|
+
if (call) return call;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
};
|
|
234
316
|
/**
|
|
235
317
|
* Task ids of background runs spawned during the agent loop now in flight.
|
|
236
318
|
* Cleared at `agent_start`; read at `agent_end` to notice a turn that ended
|
|
@@ -256,7 +338,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
256
338
|
const stoppedTaskIds = new Set<string>();
|
|
257
339
|
let lastCtx: ExtensionContext | undefined;
|
|
258
340
|
const shellTasks = trackShellTasks(pi);
|
|
259
|
-
const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks);
|
|
341
|
+
const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks, dockRowsReporter(pi.events, "subagents"));
|
|
342
|
+
// The other strips below the editor (workflow status rows), so the agent view leaves their rows free.
|
|
343
|
+
const dock = trackDockRows(pi.events);
|
|
260
344
|
|
|
261
345
|
/**
|
|
262
346
|
* Register a child in the live panel and return the wiring the spawn sites
|
|
@@ -273,6 +357,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
273
357
|
liveRuns.register({
|
|
274
358
|
taskId: record.taskId,
|
|
275
359
|
name: record.name,
|
|
360
|
+
description: record.description,
|
|
276
361
|
agentType: request.agent,
|
|
277
362
|
model: request.model,
|
|
278
363
|
thinking: request.thinking,
|
|
@@ -301,37 +386,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
301
386
|
|
|
302
387
|
// Live MCP tool definitions published by the mcp extension; injected into
|
|
303
388
|
// 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
|
-
};
|
|
389
|
+
const awaitMcpTools = watchMcpTools(pi.events);
|
|
335
390
|
|
|
336
391
|
// The parent permissions extension's decision closure, used to gate a child's
|
|
337
392
|
// tool calls through the real pipeline (mode inheritance, classifier, prompts
|
|
@@ -361,10 +416,21 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
361
416
|
// The default may have been that model: recompute and republish the
|
|
362
417
|
// reminder/banner so the next request already names the replacement.
|
|
363
418
|
const event = data as ModelUnusableEvent;
|
|
364
|
-
if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx);
|
|
419
|
+
if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx, lastCtx.model, event.model);
|
|
365
420
|
});
|
|
366
421
|
|
|
367
|
-
|
|
422
|
+
// After `enter_worktree` the session works in the worktree, but pi keeps
|
|
423
|
+
// ctx.cwd at the original checkout: spawns start where the session works.
|
|
424
|
+
const enteredWorktree = followEnteredWorktree(pi.events);
|
|
425
|
+
const workCwd = (ctx: ExtensionContext) => sessionWorkCwd(enteredWorktree(), ctx.cwd);
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The in-process runner, built lazily on first run and shared across all runs.
|
|
429
|
+
* A failed build (a malformed models.json or auth.json) is forgotten, so the
|
|
430
|
+
* next spawn tries again instead of every later spawn failing on the same
|
|
431
|
+
* rejected promise; callers await it before they mark a run as running
|
|
432
|
+
* (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 L8).
|
|
433
|
+
*/
|
|
368
434
|
let runtimePromise: Promise<SubagentRuntime> | undefined;
|
|
369
435
|
const getRuntime = (ctx: ExtensionContext) =>
|
|
370
436
|
(runtimePromise ??= SubagentRuntime.create(
|
|
@@ -386,19 +452,85 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
386
452
|
pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
|
|
387
453
|
const live = liveUiCtx(lastCtx);
|
|
388
454
|
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);
|
|
455
|
+
if (lastCtx) emitModelStatus(lastCtx, lastCtx.model, model);
|
|
456
|
+
},
|
|
457
|
+
(path, agent) => pi.events.emit(CHILD_WROTE_CHANNEL, { path, ...(agent ? { agent } : {}) } satisfies ChildWrote),
|
|
458
|
+
).catch((error: unknown) => {
|
|
459
|
+
runtimePromise = undefined;
|
|
460
|
+
throw error;
|
|
461
|
+
}));
|
|
462
|
+
/** The runtime, or the startup error as a message a failed run can report. */
|
|
463
|
+
const runtimeOrError = (ctx: ExtensionContext): Promise<SubagentRuntime | string> =>
|
|
464
|
+
getRuntime(ctx)
|
|
465
|
+
.then((runtime) => shuttingDown ? "the session ended before the agent started" : runtime)
|
|
466
|
+
.catch((error: unknown) => `could not start the subagent runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
467
|
+
|
|
468
|
+
// The mcp extension answers a status request synchronously on the bus.
|
|
469
|
+
let mcpStatus: McpServerStatus[] | undefined;
|
|
470
|
+
pi.events.on(MCP_STATUS_CHANNEL, (data) => {
|
|
471
|
+
mcpStatus = (data as McpStatusEvent).servers;
|
|
472
|
+
});
|
|
473
|
+
/** The configured servers now; empty when the mcp extension is not loaded. */
|
|
474
|
+
const requestMcpStatus = (): McpServerStatus[] => {
|
|
475
|
+
mcpStatus = undefined;
|
|
476
|
+
pi.events.emit(MCP_STATUS_REQUEST_CHANNEL, {});
|
|
477
|
+
return (mcpStatus as McpServerStatus[] | undefined) ?? [];
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* What the code-defined `one-code-guide`'s prompt lists (guide-agent.ts): the
|
|
482
|
+
* user's custom skills and agents, enabled plugins, MCP servers, installed pi
|
|
483
|
+
* packages and extensions, and pi's settings keys. Read only when a spawn
|
|
484
|
+
* reads the guide's prompt.
|
|
485
|
+
*/
|
|
486
|
+
const guideInput = (cwd: string, catalog: readonly AgentDefinition[], plugins: ReturnType<typeof discoverPlugins>): GuideInput => {
|
|
487
|
+
const agentDir = getAgentDir();
|
|
488
|
+
let settingsText: string | undefined;
|
|
489
|
+
try {
|
|
490
|
+
settingsText = readFileSync(join(agentDir, "settings.json"), "utf-8");
|
|
491
|
+
} catch {
|
|
492
|
+
// No settings file yet: no packages or keys to list.
|
|
493
|
+
}
|
|
494
|
+
let extensions: string[] = [];
|
|
495
|
+
try {
|
|
496
|
+
extensions = readdirSync(join(agentDir, "extensions")).filter((entry) => !entry.startsWith("."));
|
|
497
|
+
} catch {
|
|
498
|
+
// No user extensions directory.
|
|
499
|
+
}
|
|
500
|
+
// Every configured server, from the mcp extension's status snapshot: a
|
|
501
|
+
// failed, disabled or resource-only server registers no tools.
|
|
502
|
+
const mcpServers = requestMcpStatus().map((server) => ({ name: server.name, status: MCP_STATUS_LABELS[server.status] }));
|
|
503
|
+
return {
|
|
504
|
+
docs: guideDocs(),
|
|
505
|
+
install: {
|
|
506
|
+
shape: process.env.ONECODE_INSTALL_METHOD ? "app" : "extension",
|
|
507
|
+
method: process.env.ONECODE_INSTALL_METHOD,
|
|
508
|
+
agentDir,
|
|
509
|
+
version: process.env.CC_VERSION ?? extensionVersion(),
|
|
510
|
+
},
|
|
511
|
+
setup: {
|
|
512
|
+
skills: scanSkills(cwd, os.homedir(), agentDir, plugins.skills).map((skill) => skill.name),
|
|
513
|
+
agents: catalog.filter((agent) => agent.source !== "built-in" && !agent.source.startsWith(BUNDLED_AGENTS_DIR)).map((agent) => agent.name),
|
|
514
|
+
plugins: plugins.enabledPlugins.map((plugin) => plugin.name),
|
|
515
|
+
mcpServers,
|
|
516
|
+
extensions,
|
|
517
|
+
...settingsSetup(settingsText, PACKAGE_ROOT),
|
|
390
518
|
},
|
|
391
|
-
|
|
519
|
+
};
|
|
520
|
+
};
|
|
392
521
|
|
|
393
522
|
const loadAgents = (cwd: string) => {
|
|
394
523
|
// Plugin agents sit between bundled and user definitions, and are exposed
|
|
395
524
|
// namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
525
|
+
// The code-defined guide ranks below every agent file, so one of the same
|
|
526
|
+
// name replaces it.
|
|
527
|
+
const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd));
|
|
528
|
+
const fileSources = agentFileSources(cwd, os.homedir(), plugins.agentDirs);
|
|
529
|
+
// The prompt is built from the catalog it sits in, when a spawn reads it.
|
|
530
|
+
let catalog: AgentDefinition[] = [];
|
|
531
|
+
const guide = guideAgentDefinition(() => guideInput(cwd, catalog, plugins));
|
|
532
|
+
catalog = discoverAgents([{ agents: [guide] }, ...fileSources]);
|
|
533
|
+
return catalog;
|
|
402
534
|
};
|
|
403
535
|
|
|
404
536
|
// Claude Code closes every catalog row with a tools clause — "(Tools: *)",
|
|
@@ -408,7 +540,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
408
540
|
const toolsClause = (a: AgentDefinition): string => {
|
|
409
541
|
if (a.tools && a.tools.length > 0) return ` (Tools: ${a.tools.join(", ")})`;
|
|
410
542
|
if (a.excludeTools && a.excludeTools.length > 0) return ` (Tools: All tools except ${a.excludeTools.join(", ")})`;
|
|
411
|
-
|
|
543
|
+
// Claude Code writes "*" for its built-in agents and "All tools" for an agent file that lists none.
|
|
544
|
+
return a.source === "built-in" || a.source.startsWith(BUNDLED_AGENTS_DIR) ? " (Tools: *)" : " (Tools: All tools)";
|
|
412
545
|
};
|
|
413
546
|
|
|
414
547
|
const describeAgents = (cwd: string) => {
|
|
@@ -435,14 +568,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
435
568
|
// A kept worktree can host a resumed run in a NEW process, where
|
|
436
569
|
// createWorktree's registration no longer exists — re-register so the
|
|
437
570
|
// git-isolation guard survives the restart. A removed worktree's entry
|
|
438
|
-
// is inert (no session runs there any more).
|
|
571
|
+
// is inert (no session runs there any more). findProjectRoot instead of
|
|
439
572
|
// `git rev-parse` because session_start handlers must stay fast
|
|
440
573
|
// (findings §15); a slightly-off sharedRoot only softens one message —
|
|
441
574
|
// the worktree containment check itself uses the exact record.cwd.
|
|
442
575
|
if (record.worktree && !worktreesSeen.has(record.cwd)) {
|
|
443
576
|
worktreesSeen.add(record.cwd);
|
|
444
577
|
if (!existsSync(record.cwd)) continue;
|
|
445
|
-
sharedRoot ??=
|
|
578
|
+
sharedRoot ??= findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
446
579
|
registerWorktreeIsolation(record.cwd, sharedRoot);
|
|
447
580
|
}
|
|
448
581
|
}
|
|
@@ -486,30 +619,67 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
486
619
|
return resolution;
|
|
487
620
|
};
|
|
488
621
|
|
|
622
|
+
// Message 1 is frozen once a request has gone out (decisions/caching.md):
|
|
623
|
+
// after that, a model the account refused is corrected by a one-shot, not by
|
|
624
|
+
// rewriting the menu and re-caching the conversation. A user's /model or
|
|
625
|
+
// /subagent still rewrites it.
|
|
626
|
+
let requestSent = false;
|
|
627
|
+
let restoredSession = false;
|
|
628
|
+
let capabilityBaseline: SubagentBaseline = emptySubagentBaseline();
|
|
629
|
+
const publishCapabilityBaseline = (baseline: SubagentBaseline) => {
|
|
630
|
+
pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "subagents", value: baseline });
|
|
631
|
+
};
|
|
632
|
+
pi.on("context", () => {
|
|
633
|
+
requestSent = true;
|
|
634
|
+
});
|
|
635
|
+
|
|
489
636
|
/**
|
|
490
637
|
* The every-turn menu reminder and the banner's subagent-default status.
|
|
491
638
|
* Every-turn because reminders are transient per-request injections — that
|
|
492
639
|
* scope survives compaction by construction — and keyed so a model change
|
|
493
640
|
* replaces it: the very next LLM call, even mid-turn, carries the update.
|
|
641
|
+
* `refused` names a model the account just refused: once a request has gone
|
|
642
|
+
* out, that change rides a one-shot instead.
|
|
494
643
|
*/
|
|
495
|
-
const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model) => {
|
|
644
|
+
const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model, refused?: string) => {
|
|
496
645
|
const available = usableModels(ctx);
|
|
497
646
|
const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
|
|
498
647
|
const resolution = resolveAutoDefault(sessionModel, available, configured);
|
|
648
|
+
const text = subagentModelsReminder({
|
|
649
|
+
available, sessionModel, defaultModel: resolution.model, defaultSource: resolution.source, configured,
|
|
650
|
+
requireImageInput: supportsImageInput(sessionModel),
|
|
651
|
+
});
|
|
499
652
|
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
653
|
+
// The user's own subagentModel setting is honoured, so say what it costs:
|
|
654
|
+
// a newer model in its line, or a model far weaker than the session's.
|
|
655
|
+
if (resolution.source === "default" && resolution.model) {
|
|
656
|
+
for (const warning of chosenModelWarnings({ available, sessionModel, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
|
|
657
|
+
notifyModelOnce(ctx, `${warning.text} ${warning.fix}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Keep the established narrow refusal correction: it does not claim the
|
|
661
|
+
// entire menu was reprinted, and it remains useful on restored sessions.
|
|
662
|
+
if (refused !== undefined && requestSent) {
|
|
663
|
+
// The narrow correction is all the model needs now, but persist the
|
|
664
|
+
// corrected complete menu as announcement history. Otherwise the next
|
|
665
|
+
// process would compare the stale menu with the same live catalog and
|
|
666
|
+
// append a spurious full-menu correction on every resume.
|
|
667
|
+
capabilityBaseline = {
|
|
668
|
+
...capabilityBaseline,
|
|
669
|
+
models: text,
|
|
670
|
+
refusedModels: [...new Set([...capabilityBaseline.refusedModels, refused])],
|
|
671
|
+
};
|
|
672
|
+
publishCapabilityBaseline(capabilityBaseline);
|
|
673
|
+
pi.events.emit(REMINDER_CHANNEL, { text: refusedModelNote(refused, resolution.model) });
|
|
674
|
+
pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "models", text });
|
|
678
|
+
capabilityBaseline = plan.baseline;
|
|
679
|
+
if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
|
|
680
|
+
if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
|
|
681
|
+
else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, {
|
|
682
|
+
text, scope: "every-turn", key: "subagent-models", placement: "first-prepend", order: CONTEXT_ORDER.subagentModels,
|
|
513
683
|
});
|
|
514
684
|
pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
|
|
515
685
|
};
|
|
@@ -521,27 +691,26 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
521
691
|
* byte-stable within a session, so it costs nothing in prompt-cache terms.
|
|
522
692
|
*/
|
|
523
693
|
const emitAgentCatalog = (ctx: ExtensionContext) => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
694
|
+
const text = `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`;
|
|
695
|
+
const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "agents", text });
|
|
696
|
+
capabilityBaseline = plan.baseline;
|
|
697
|
+
if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
|
|
698
|
+
if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
|
|
699
|
+
else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, {
|
|
700
|
+
scope: "every-turn", key: "subagent-agents", text, placement: "first-prepend", order: CONTEXT_ORDER.agents,
|
|
530
701
|
});
|
|
531
702
|
};
|
|
532
703
|
|
|
533
|
-
const emitDelegationSteer = (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
|
|
544
|
-
}
|
|
704
|
+
const emitDelegationSteer = () => {
|
|
705
|
+
const text = requestTier() === "tiny" ? DELEGATION_STEER : null;
|
|
706
|
+
const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "delegation", text });
|
|
707
|
+
capabilityBaseline = plan.baseline;
|
|
708
|
+
if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
|
|
709
|
+
if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
|
|
710
|
+
else if (!restoredSession && text !== null) pi.events.emit(REMINDER_CHANNEL, {
|
|
711
|
+
scope: "every-turn", key: "subagent-delegation", text, placement: "first-prepend", order: CONTEXT_ORDER.delegation,
|
|
712
|
+
});
|
|
713
|
+
else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
|
|
545
714
|
};
|
|
546
715
|
|
|
547
716
|
let shuttingDown = false;
|
|
@@ -552,8 +721,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
552
721
|
* would get the reminder on every delegating turn for nothing, and the
|
|
553
722
|
* trigger fires on correct behaviour as readily as on the failure.
|
|
554
723
|
*/
|
|
555
|
-
const backstopApplies = (
|
|
556
|
-
const tier =
|
|
724
|
+
const backstopApplies = () => {
|
|
725
|
+
const tier = requestTier();
|
|
557
726
|
return tier === "cheap" || tier === "tiny";
|
|
558
727
|
};
|
|
559
728
|
pi.on("agent_start", () => {
|
|
@@ -564,16 +733,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
564
733
|
pi.on("agent_settled", () => {
|
|
565
734
|
parentBusy = false;
|
|
566
735
|
});
|
|
567
|
-
pi.on("agent_end", (
|
|
736
|
+
pi.on("agent_end", () => {
|
|
568
737
|
const spawned = [...spawnedThisLoop];
|
|
569
738
|
spawnedThisLoop.clear();
|
|
570
739
|
// Tier check first: on a frontier session the backstop never fires, so the
|
|
571
740
|
// lookups below would be discarded work on every turn that delegated.
|
|
572
|
-
if (!backstopApplies(
|
|
741
|
+
if (!backstopApplies()) return undefined;
|
|
573
742
|
const pending = spawned
|
|
574
743
|
.filter((taskId) => {
|
|
575
744
|
const resident = residents.get(taskId);
|
|
576
|
-
return (resident !== undefined &&
|
|
745
|
+
return (resident !== undefined && residentPending(resident)) || runningIds.has(taskId);
|
|
577
746
|
})
|
|
578
747
|
.map((taskId) => registry.resolve(taskId))
|
|
579
748
|
.filter((record): record is AgentRunRecord => record !== undefined)
|
|
@@ -584,6 +753,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
584
753
|
});
|
|
585
754
|
|
|
586
755
|
pi.on("session_start", (_event, ctx) => {
|
|
756
|
+
const restored = restoredContext(pi.events);
|
|
757
|
+
restoredSession = restored !== undefined;
|
|
758
|
+
requestSent = restoredSession;
|
|
759
|
+
// New snapshots carry exact structured text; old v1 snapshots can still
|
|
760
|
+
// seed from their locked first-prepend blocks without rewriting them.
|
|
761
|
+
capabilityBaseline = subagentBaseline(restored?.baselines.subagents)
|
|
762
|
+
?? subagentBaselineFromStack(restored?.stack ?? []);
|
|
763
|
+
// A refusal correction is a real capability fact, not merely UI prose.
|
|
764
|
+
// Restore its selection floor before rebuilding the menu, otherwise this
|
|
765
|
+
// process can choose the same refused model and reissue a false correction.
|
|
766
|
+
for (const model of capabilityBaseline.refusedModels) unusableModels.add(model);
|
|
587
767
|
lastCtx = ctx;
|
|
588
768
|
// A replaced session (/clear, /new, /resume) never reaches this instance
|
|
589
769
|
// with the old one's agents: pi emits `session_shutdown` (reason "new" /
|
|
@@ -594,12 +774,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
594
774
|
reconstructRuns(ctx);
|
|
595
775
|
emitModelStatus(ctx);
|
|
596
776
|
emitAgentCatalog(ctx);
|
|
597
|
-
emitDelegationSteer(
|
|
777
|
+
emitDelegationSteer();
|
|
598
778
|
registerPanelInputHook(ctx);
|
|
599
779
|
});
|
|
600
780
|
pi.on("model_select", (event, ctx) => {
|
|
601
781
|
emitModelStatus(ctx, event.model);
|
|
602
|
-
emitDelegationSteer(
|
|
782
|
+
emitDelegationSteer();
|
|
603
783
|
});
|
|
604
784
|
// Another extension wrote `subagentModel` on disk (`/doctor preset`): drop the
|
|
605
785
|
// cached automatic default and republish the reminder + banner status, the
|
|
@@ -703,13 +883,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
703
883
|
* back.
|
|
704
884
|
*/
|
|
705
885
|
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. */
|
|
886
|
+
/** 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
887
|
const viewReserve = () => {
|
|
708
888
|
const shellLines = panel.shellLineCount();
|
|
709
|
-
return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + 9;
|
|
889
|
+
return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + dock.others("subagents") + 9;
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Tell the editor which agent it now addresses (lib/agent-view.ts), or that
|
|
894
|
+
* it is back on main. Viewing a run also puts it on `/tasks`' Completed list
|
|
895
|
+
* once it settles, as Claude Code keeps a viewed agent (findings §40).
|
|
896
|
+
*/
|
|
897
|
+
const announceView = (taskId: string | undefined) => {
|
|
898
|
+
const run = taskId === undefined ? undefined : liveRuns.get(taskId);
|
|
899
|
+
if (run) liveRuns.markViewed(run.taskId);
|
|
900
|
+
const announcement: AgentViewAnnouncement = run ? { target: agentViewTarget(run, run.agentType === FORK_AGENT) } : {};
|
|
901
|
+
pi.events.emit(AGENT_VIEW_CHANNEL, announcement);
|
|
710
902
|
};
|
|
711
903
|
|
|
712
904
|
const openView = (ctx: ExtensionContext, taskId: string) => {
|
|
905
|
+
if (ctx.mode !== "tui") {
|
|
906
|
+
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");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
713
909
|
if (view) return view.retarget(taskId);
|
|
714
910
|
let currentId = taskId;
|
|
715
911
|
let scroll = 0;
|
|
@@ -717,12 +913,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
717
913
|
let closed = false;
|
|
718
914
|
let repaintFn: (() => void) | undefined;
|
|
719
915
|
let doneFn: ((result: null) => void) | undefined;
|
|
916
|
+
/** Fullscreen only: turns pi's own viewport scrolling into view scrolling. */
|
|
917
|
+
let mirror: ViewportMirror | undefined;
|
|
720
918
|
const entry = {
|
|
721
919
|
retarget(id: string) {
|
|
722
920
|
if (currentId === id) return;
|
|
723
921
|
currentId = id;
|
|
724
922
|
scroll = 0;
|
|
923
|
+
mirror?.park(0);
|
|
725
924
|
panel.setView(id);
|
|
925
|
+
announceView(id);
|
|
726
926
|
repaintFn?.();
|
|
727
927
|
},
|
|
728
928
|
scrollBy(delta: number) {
|
|
@@ -733,22 +933,26 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
733
933
|
if (closed) return;
|
|
734
934
|
closed = true;
|
|
735
935
|
if (view === entry) view = undefined;
|
|
936
|
+
mirror?.restore();
|
|
937
|
+
mirror = undefined;
|
|
736
938
|
panel.setView(undefined);
|
|
939
|
+
announceView(undefined);
|
|
737
940
|
doneFn?.(null);
|
|
738
941
|
},
|
|
739
942
|
};
|
|
740
943
|
view = entry;
|
|
741
944
|
panel.setView(currentId);
|
|
945
|
+
announceView(currentId);
|
|
742
946
|
void (async () => {
|
|
743
947
|
const prose = await getProse(ctx);
|
|
744
948
|
if (closed) return;
|
|
745
|
-
await ctx.ui.custom<null>(
|
|
949
|
+
await panelUi(pi.events, ctx.ui).custom<null>(
|
|
746
950
|
(tui, theme, _keybindings, done) => {
|
|
747
951
|
doneFn = done;
|
|
748
952
|
// close() raced ahead of the factory — dissolve as soon as we exist.
|
|
749
953
|
if (closed) queueMicrotask(() => done(null));
|
|
750
954
|
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
751
|
-
let cache: { width: number; lines: string[] } | undefined;
|
|
955
|
+
let cache: { width: number; height: number; lines: string[] } | undefined;
|
|
752
956
|
const live = liveRepaint(
|
|
753
957
|
tui,
|
|
754
958
|
() => {
|
|
@@ -758,19 +962,32 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
758
962
|
);
|
|
759
963
|
repaintFn = live.repaint;
|
|
760
964
|
const height = () => Math.max(8, tui.terminal.rows - viewReserve());
|
|
965
|
+
const viewport = asScrollableViewport(tui);
|
|
966
|
+
if (viewport && !closed) mirror = new ViewportMirror(viewport);
|
|
761
967
|
return {
|
|
762
968
|
render: (width: number) => {
|
|
763
|
-
|
|
969
|
+
// The page keys and the wheel scroll pi's main viewport, under
|
|
970
|
+
// this overlay; take the move as ours and park main again.
|
|
971
|
+
if (mirror) {
|
|
972
|
+
const next = mirror.absorb(scroll, maxScroll);
|
|
973
|
+
if (next !== scroll) {
|
|
974
|
+
scroll = next;
|
|
975
|
+
cache = undefined;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// Keyed by height too: a resize or another strip changing size moves the room.
|
|
979
|
+
const viewHeight = height();
|
|
980
|
+
if (cache?.width === width && cache.height === viewHeight) return cache.lines;
|
|
764
981
|
const run = liveRuns.get(currentId);
|
|
765
982
|
let lines: string[];
|
|
766
983
|
if (run) {
|
|
767
|
-
const transcript = renderTranscript({ run, width, height:
|
|
984
|
+
const transcript = renderTranscript({ run, width, height: viewHeight, scroll, now: Date.now(), prose }, paint);
|
|
768
985
|
maxScroll = transcript.maxScroll;
|
|
769
986
|
lines = transcript.lines;
|
|
770
987
|
} else {
|
|
771
988
|
lines = [paint.fg("dim", "agent gone")];
|
|
772
989
|
}
|
|
773
|
-
cache = { width, lines: lines.map((line) => truncateLine(line, width)) };
|
|
990
|
+
cache = { width, height: viewHeight, lines: lines.map((line) => truncateLine(line, width)) };
|
|
774
991
|
return cache.lines;
|
|
775
992
|
},
|
|
776
993
|
invalidate: () => {
|
|
@@ -790,10 +1007,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
790
1007
|
if (panelHookRegistered || !registerCtx.hasUI) return;
|
|
791
1008
|
panelHookRegistered = true;
|
|
792
1009
|
let chordArmed = false;
|
|
1010
|
+
// Leaving the strip hands focus back to the editor and keeps any open view,
|
|
1011
|
+
// as Claude Code's Esc does: the editor then addresses the viewed agent.
|
|
793
1012
|
const leave = () => {
|
|
794
1013
|
panel.setFocus(undefined);
|
|
795
|
-
closeView();
|
|
796
1014
|
};
|
|
1015
|
+
const editorEmpty = () => (panel.editorBaseline as { getText?(): string } | undefined)?.getText?.() === "";
|
|
797
1016
|
type KeyResult = { consume: boolean } | undefined;
|
|
798
1017
|
// Actions shared by both key modes (browse and read), declared once so the
|
|
799
1018
|
// two switches below never drift. esc consumes only when idle — a streaming
|
|
@@ -814,16 +1033,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
814
1033
|
return { consume: true };
|
|
815
1034
|
};
|
|
816
1035
|
/** Unrecognized key: ctrl+x keeps the stop-all chord armed, else drop focus
|
|
817
|
-
* (
|
|
1036
|
+
* (any open view stays) and let the byte resume in the editor. */
|
|
818
1037
|
const closeAndPassthrough = (): KeyResult => {
|
|
819
1038
|
if (chordArmed) return { consume: true };
|
|
820
1039
|
leave();
|
|
821
1040
|
return undefined;
|
|
822
1041
|
};
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
*
|
|
826
|
-
* future StripKey addition is foreign here by default. */
|
|
1042
|
+
/** Keys the agents branch owns; any other decode (typing) drops focus and
|
|
1043
|
+
* passes through — stated positively so a future StripKey addition is
|
|
1044
|
+
* foreign here by default. */
|
|
827
1045
|
const AGENT_KEYS = new Set<StripKey>(["up", "down", "open", "leave", "stop", "stopAll", "pageUp", "pageDown"]);
|
|
828
1046
|
/** Row 0 exists only past the synthetic `main` row. */
|
|
829
1047
|
const focusFirstAgentRow = (): boolean => {
|
|
@@ -831,54 +1049,46 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
831
1049
|
panel.setFocus(0);
|
|
832
1050
|
return true;
|
|
833
1051
|
};
|
|
834
|
-
/** The
|
|
1052
|
+
/** The focused shells chip; agents keys untouched. */
|
|
835
1053
|
const handleShellKey = (data: string, ctx: ExtensionContext): KeyResult => {
|
|
836
|
-
// Focus moved to a dialog/overlay
|
|
837
|
-
|
|
838
|
-
|
|
1054
|
+
// Focus moved to a dialog/overlay, or the chip cleared under us: drop
|
|
1055
|
+
// soft focus and let the key through.
|
|
1056
|
+
if (!panel.editorFocused() || !panel.shellChipAvailable()) {
|
|
1057
|
+
panel.setShellChipFocus(false);
|
|
839
1058
|
return undefined;
|
|
840
1059
|
}
|
|
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
1060
|
const decoded = decodeStripKey(data, false);
|
|
846
|
-
// ctrl+x (the agents stop-all chord arm) means nothing
|
|
847
|
-
//
|
|
1061
|
+
// ctrl+x (the agents stop-all chord arm) means nothing on the chip:
|
|
1062
|
+
// swallow it rather than leaking the raw \x18 into the editor.
|
|
848
1063
|
if (decoded.chordArmed) return { consume: true };
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
if (result.effect === "toAgents") {
|
|
855
|
-
panel.setShellFocus(undefined);
|
|
1064
|
+
const effect = reduceShellKey(decoded.key);
|
|
1065
|
+
panel.setShellChipFocus(false);
|
|
1066
|
+
if (effect === "passthrough") return undefined; // typing resumes in the editor, byte included
|
|
1067
|
+
if (effect === "toAgents") {
|
|
856
1068
|
if (focusFirstAgentRow()) return { consume: true };
|
|
857
1069
|
// No agent rows below the chip: stay on it. Dropping focus here handed
|
|
858
1070
|
// the NEXT key to the editor, where ↑ recalls the last prompt — the
|
|
859
1071
|
// "sometimes ↓ recalls history" seen on the Windows VM (2026-09-19):
|
|
860
1072
|
// ↓ chip, ↓ (nothing below, focus silently gone), ↑ → history.
|
|
861
|
-
panel.
|
|
1073
|
+
panel.setShellChipFocus(true);
|
|
862
1074
|
return { consume: true };
|
|
863
1075
|
}
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
// A dead process's stop() throwing must not break the panel.
|
|
869
|
-
}
|
|
870
|
-
panel.setShellFocus(result.focus, ids);
|
|
1076
|
+
if (effect === "openTasks") {
|
|
1077
|
+
void openTasksDialog(ctx).catch((error: unknown) => {
|
|
1078
|
+
ctx.ui.notify(`Could not open the tasks dialog: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1079
|
+
});
|
|
871
1080
|
return { consume: true };
|
|
872
1081
|
}
|
|
873
|
-
panel.setShellFocus(result.focus, ids);
|
|
874
1082
|
// 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
|
-
}
|
|
1083
|
+
if (decoded.key === "leave") return ctx.isIdle() ? { consume: true } : undefined;
|
|
878
1084
|
return { consume: true };
|
|
879
1085
|
};
|
|
880
1086
|
try {
|
|
881
1087
|
registerCtx.ui.onTerminalInput((data) => {
|
|
1088
|
+
// pi-tui calls input listeners before it filters key releases, and a
|
|
1089
|
+
// kitty terminal sends one after every press: a release is never a
|
|
1090
|
+
// key here (it used to read as typing and drop focus).
|
|
1091
|
+
if (isKeyRelease(data)) return undefined;
|
|
882
1092
|
const ctx = lastCtx ?? registerCtx;
|
|
883
1093
|
// Enter focus: down-arrow while the editor holds real focus. Unlike the
|
|
884
1094
|
// workflow strip we do NOT require the editor to be idle — the whole
|
|
@@ -887,15 +1097,31 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
887
1097
|
// O(rows) checks run only once a Down actually asks to focus, never on
|
|
888
1098
|
// plain typing. Claude Code's order: the FIRST ↓ lands on the shells
|
|
889
1099
|
// chip when shells are running; the next ↓ moves into the agent rows.
|
|
890
|
-
if (panel.focusIndex === undefined && panel.
|
|
891
|
-
|
|
1100
|
+
if (panel.focusIndex === undefined && !panel.shellChipFocused) {
|
|
1101
|
+
// Viewing an agent with the editor focused: the page keys scroll the
|
|
1102
|
+
// view, and Esc in an empty editor goes back to main.
|
|
1103
|
+
if (view && panel.editorFocused()) {
|
|
1104
|
+
const id = keyId(data);
|
|
1105
|
+
if (id === "pageUp" || id === "pageDown") {
|
|
1106
|
+
view.scrollBy(id === "pageUp" ? 10 : -10);
|
|
1107
|
+
return { consume: true };
|
|
1108
|
+
}
|
|
1109
|
+
if (id === "escape" && editorEmpty()) {
|
|
1110
|
+
closeView();
|
|
1111
|
+
return { consume: true };
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (!isStripEntryKey(data) || !panel.editorFocused()) return undefined;
|
|
1115
|
+
// In a draft, ↓ first moves the cursor, walks recalled history or the
|
|
1116
|
+
// autocomplete list; the strip takes it only past the draft's last line.
|
|
1117
|
+
if (!editorYieldsDown(panel.editorBaseline)) return undefined;
|
|
892
1118
|
if (panel.shellChipAvailable()) {
|
|
893
|
-
panel.
|
|
1119
|
+
panel.setShellChipFocus(true);
|
|
894
1120
|
return { consume: true };
|
|
895
1121
|
}
|
|
896
1122
|
return focusFirstAgentRow() ? { consume: true } : undefined;
|
|
897
1123
|
}
|
|
898
|
-
if (panel.
|
|
1124
|
+
if (panel.shellChipFocused) return handleShellKey(data, ctx);
|
|
899
1125
|
const focusIndex = panel.focusIndex;
|
|
900
1126
|
if (focusIndex === undefined) return undefined; // unreachable: one of the two focuses was set
|
|
901
1127
|
// The strip emptied under us (finished rows lingered out) — let go.
|
|
@@ -910,53 +1136,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
910
1136
|
}
|
|
911
1137
|
const decoded = decodeStripKey(data, chordArmed);
|
|
912
1138
|
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
1139
|
if (!decoded.key || !AGENT_KEYS.has(decoded.key)) return closeAndPassthrough();
|
|
961
1140
|
switch (decoded.key) {
|
|
962
1141
|
case "up":
|
|
@@ -964,7 +1143,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
964
1143
|
// Back up out of the agent rows: onto the shells chip when
|
|
965
1144
|
// shells are running, otherwise out of the panel entirely.
|
|
966
1145
|
leave();
|
|
967
|
-
if (panel.shellChipAvailable()) panel.
|
|
1146
|
+
if (panel.shellChipAvailable()) panel.setShellChipFocus(true);
|
|
968
1147
|
} else panel.setFocus(focusIndex - 1);
|
|
969
1148
|
return { consume: true };
|
|
970
1149
|
case "down":
|
|
@@ -987,8 +1166,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
987
1166
|
return doStopAll();
|
|
988
1167
|
case "pageUp":
|
|
989
1168
|
case "pageDown":
|
|
990
|
-
//
|
|
991
|
-
//
|
|
1169
|
+
// Scroll an open view; with none open, swallow so the page keys
|
|
1170
|
+
// never leak into the editor while the strip holds focus.
|
|
1171
|
+
view?.scrollBy(decoded.key === "pageUp" ? 10 : -10);
|
|
992
1172
|
return { consume: true };
|
|
993
1173
|
}
|
|
994
1174
|
});
|
|
@@ -999,10 +1179,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
999
1179
|
|
|
1000
1180
|
// Compact transcript rendering for the notifications this extension injects
|
|
1001
1181
|
// (full body on ctrl+o); the verbose framing stays model-only.
|
|
1182
|
+
// A message is addressed from the agent's task id; its headline names the
|
|
1183
|
+
// agent, from the live registry or the message's own details (a resumed
|
|
1184
|
+
// session has no live runs).
|
|
1002
1185
|
for (const customType of ["subagent-message", "subagent-result"]) {
|
|
1003
|
-
pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
|
|
1004
|
-
|
|
1005
|
-
|
|
1186
|
+
pi.registerMessageRenderer(customType, (message, { expanded }, theme) => {
|
|
1187
|
+
const details = (message as { details?: { taskId?: unknown; name?: unknown } }).details;
|
|
1188
|
+
const agentName = (from: string): string | undefined =>
|
|
1189
|
+
liveRuns.get(from)?.name ?? (details?.taskId === from && typeof details.name === "string" ? details.name : undefined);
|
|
1190
|
+
return notificationComponent(theme, customMessageText(message.content), expanded, agentName);
|
|
1191
|
+
});
|
|
1006
1192
|
}
|
|
1007
1193
|
|
|
1008
1194
|
// A hand-back the model already read through task_output (block: true on the
|
|
@@ -1010,7 +1196,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1010
1196
|
// so the message and its pointer would only repeat it (see decisions).
|
|
1011
1197
|
const notifier = createTaskNotifier(pi, { withdrawOnDelivery: true });
|
|
1012
1198
|
/** Every subagent notification; silent during shutdown (a teardown kill is not news). */
|
|
1013
|
-
const notify
|
|
1199
|
+
const notify = (customType: string, text: string, details?: Record<string, unknown>) => {
|
|
1014
1200
|
if (shuttingDown) return;
|
|
1015
1201
|
notifier(customType, text, details);
|
|
1016
1202
|
};
|
|
@@ -1066,15 +1252,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1066
1252
|
record.sessionSearchDir ? join(record.sessionSearchDir, "output.log") : undefined;
|
|
1067
1253
|
|
|
1068
1254
|
/**
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* `<
|
|
1072
|
-
* mode
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
* report
|
|
1255
|
+
* The hand-back for a resident agent's finished turn (its first report, an
|
|
1256
|
+
* update, a reply). Claude Code 2.1.283 sends one `<task-notification>` with
|
|
1257
|
+
* the report inline in `<result>` for a fork in every mode and for every agent
|
|
1258
|
+
* outside auto mode (findings §40). In auto mode a non-fork agent keeps the
|
|
1259
|
+
* two-message form: the report goes out as an `<agent-message>` from the
|
|
1260
|
+
* agent — the preamble, every line indented, auto mode's review flag above it
|
|
1261
|
+
* — and the agent's own `<task-notification>` follows, its `<result>` a
|
|
1262
|
+
* pointer at that message, never the report again. A turn that failed or was
|
|
1263
|
+
* stopped delivered no report, so it is always one notification with the
|
|
1264
|
+
* output (and any flag) inline. Both go through the same notifier, in this
|
|
1265
|
+
* order, so a coalesced round keeps the report ahead of the pointer.
|
|
1078
1266
|
*/
|
|
1079
1267
|
const notifyHandBack = (opts: {
|
|
1080
1268
|
/** The agent's persistent id: `from=` on the message, the SendMessage address. */
|
|
@@ -1097,6 +1285,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1097
1285
|
}) => {
|
|
1098
1286
|
const status: TaskStatus = opts.stopped ? "killed" : opts.outcome.failed ? "failed" : "completed";
|
|
1099
1287
|
const warning = opts.review ? handBackWarning(opts.review) : undefined;
|
|
1288
|
+
const record = registry.resolve(opts.from);
|
|
1100
1289
|
const details = { ...opts.details, taskId: opts.taskId, name: opts.name, failed: status === "failed", reviewed: opts.review !== undefined };
|
|
1101
1290
|
const envelope = (result: string) =>
|
|
1102
1291
|
taskNotification({
|
|
@@ -1105,12 +1294,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1105
1294
|
toolUseId: opts.toolUseId,
|
|
1106
1295
|
outputFile: opts.outputFile,
|
|
1107
1296
|
status,
|
|
1108
|
-
summary: agentSummary(opts.name, status),
|
|
1297
|
+
summary: agentSummary(record?.description ?? opts.name, status),
|
|
1109
1298
|
note: AGENT_NOTE,
|
|
1110
1299
|
result,
|
|
1111
1300
|
usage: { subagentTokens: opts.outcome.usage.total, toolUses: opts.outcome.toolCalls, durationMs: Date.now() - opts.startedAt },
|
|
1112
1301
|
});
|
|
1113
|
-
if (status !== "completed") {
|
|
1302
|
+
if (status !== "completed" || record?.agent === FORK_AGENT || permissionMode !== "auto") {
|
|
1114
1303
|
notify("subagent-result", envelope(withReview(opts.report, warning)), details);
|
|
1115
1304
|
return;
|
|
1116
1305
|
}
|
|
@@ -1120,6 +1309,68 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1120
1309
|
notify("subagent-result", envelope(handBackPointer(opts.from, opts.review !== undefined)), details);
|
|
1121
1310
|
};
|
|
1122
1311
|
|
|
1312
|
+
/**
|
|
1313
|
+
* The prompt a fork inherits: the one this session's next request carries.
|
|
1314
|
+
* pi's `getSystemPrompt()` is its own stock prompt between turns, before the
|
|
1315
|
+
* first prompt and in a turn opened from idle, so it is only the fallback
|
|
1316
|
+
* for a prompt pi builds itself (a named agent's).
|
|
1317
|
+
*/
|
|
1318
|
+
const forkSystemPrompt = (ctx: ExtensionContext): string => requestSystemPrompt(pi.events, ctx) ?? ctx.getSystemPrompt();
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* The parent's last provider request (the compaction extension publishes
|
|
1322
|
+
* each one). A fork's requests are built from it so the fork reads the
|
|
1323
|
+
* parent's prompt cache (fork-cache.ts; decisions/caching.md). None before
|
|
1324
|
+
* the first request, or for another model: that fork has no cache to read.
|
|
1325
|
+
*/
|
|
1326
|
+
let lastRequest: RequestCapture | undefined;
|
|
1327
|
+
pi.events.on(LAST_REQUEST_CHANNEL, (data) => {
|
|
1328
|
+
lastRequest = data as RequestCapture | undefined;
|
|
1329
|
+
});
|
|
1330
|
+
const forkRequest = (ctx: ExtensionContext): RequestCapture | undefined =>
|
|
1331
|
+
lastRequest?.covers && captureMatches(lastRequest, ctx.model as { api?: string; provider?: string; id?: string } | undefined) ? lastRequest : undefined;
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* The capture is persisted too, so a resumed fork still reads the cache its
|
|
1335
|
+
* earlier turns wrote. One file per distinct capture (a fan-out of forks
|
|
1336
|
+
* shares one), named by its hash, beside the fork session directories.
|
|
1337
|
+
*/
|
|
1338
|
+
const FORK_REQUEST_FILE = "parent-request";
|
|
1339
|
+
const forkRequestsDir = (sessionSearchDir: string) => join(dirname(sessionSearchDir), "parent-requests");
|
|
1340
|
+
/** Each capture is serialized and hashed once, however many forks share it. */
|
|
1341
|
+
const persistedCaptures = new WeakMap<RequestCapture, string>();
|
|
1342
|
+
const persistForkRequest = (record: AgentRunRecord, capture: RequestCapture) => {
|
|
1343
|
+
if (!record.sessionSearchDir) return;
|
|
1344
|
+
try {
|
|
1345
|
+
const dir = forkRequestsDir(record.sessionSearchDir);
|
|
1346
|
+
let name = persistedCaptures.get(capture);
|
|
1347
|
+
if (!name || !existsSync(join(dir, name))) {
|
|
1348
|
+
const body = JSON.stringify(capture);
|
|
1349
|
+
name = `${createHash("sha256").update(body).digest("hex").slice(0, 24)}.json`;
|
|
1350
|
+
mkdirSync(dir, { recursive: true });
|
|
1351
|
+
if (!existsSync(join(dir, name))) {
|
|
1352
|
+
// Atomic: a partial file would be skipped as present by every later fork.
|
|
1353
|
+
const temp = join(dir, `${name}.${process.pid}.tmp`);
|
|
1354
|
+
writeFileSync(temp, body);
|
|
1355
|
+
renameSync(temp, join(dir, name));
|
|
1356
|
+
}
|
|
1357
|
+
persistedCaptures.set(capture, name);
|
|
1358
|
+
}
|
|
1359
|
+
writeFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), name);
|
|
1360
|
+
} catch {
|
|
1361
|
+
// The run still reads the cache; only a later resume re-reads uncached.
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
const readForkRequest = (record: AgentRunRecord): RequestCapture | undefined => {
|
|
1365
|
+
if (!record.sessionSearchDir) return undefined;
|
|
1366
|
+
try {
|
|
1367
|
+
const name = readFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), "utf-8").trim();
|
|
1368
|
+
return JSON.parse(readFileSync(join(forkRequestsDir(record.sessionSearchDir), name), "utf-8")) as RequestCapture;
|
|
1369
|
+
} catch {
|
|
1370
|
+
return undefined;
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1123
1374
|
/** A fork's system prompt is persisted beside its session so a later resume can restore it (review S6). */
|
|
1124
1375
|
const FORK_PROMPT_FILE = "system-prompt.md";
|
|
1125
1376
|
const persistForkPrompt = (record: AgentRunRecord, prompt: string) => {
|
|
@@ -1130,6 +1381,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1130
1381
|
// The run still works; only a later resume loses the prompt (and says so).
|
|
1131
1382
|
}
|
|
1132
1383
|
};
|
|
1384
|
+
/**
|
|
1385
|
+
* A fork's inputs from this conversation (its system prompt and the parent's
|
|
1386
|
+
* last request, for the cache), saved beside its session so a SendMessage
|
|
1387
|
+
* can resume it later. Undefined for a named agent.
|
|
1388
|
+
*/
|
|
1389
|
+
const forkInputs = (request: RunRequest, record: AgentRunRecord, ctx: ExtensionContext) => {
|
|
1390
|
+
if (!request.fork) return { parentSystemPrompt: undefined, parentRequest: undefined };
|
|
1391
|
+
const parentSystemPrompt = forkSystemPrompt(ctx);
|
|
1392
|
+
persistForkPrompt(record, parentSystemPrompt);
|
|
1393
|
+
const parentRequest = forkRequest(ctx);
|
|
1394
|
+
if (parentRequest) persistForkRequest(record, parentRequest);
|
|
1395
|
+
return { parentSystemPrompt, parentRequest };
|
|
1396
|
+
};
|
|
1133
1397
|
const readForkPrompt = (record: AgentRunRecord): string | undefined => {
|
|
1134
1398
|
if (!record.sessionSearchDir) return undefined;
|
|
1135
1399
|
try {
|
|
@@ -1206,15 +1470,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1206
1470
|
// matched case-insensitively — a nested subagent is just as likely to be
|
|
1207
1471
|
// CC-trained.
|
|
1208
1472
|
parameters: Type.Object({
|
|
1209
|
-
subagent_type: Type.String({ description: "An agent name from the list in this tool's description" }),
|
|
1210
|
-
task: Type.
|
|
1473
|
+
subagent_type: Type.Optional(Type.String({ description: "An agent name from the list in this tool's description (default: general-purpose)" })),
|
|
1474
|
+
task: Type.String({ description: "The task — a complete, self-contained instruction" }),
|
|
1211
1475
|
prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
|
|
1212
1476
|
description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
|
|
1213
1477
|
name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
|
|
1214
1478
|
}) as never,
|
|
1479
|
+
prepareArguments: prepareAgentArguments as never,
|
|
1215
1480
|
async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
|
|
1216
1481
|
const ctx = lastCtx;
|
|
1217
|
-
const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown };
|
|
1482
|
+
const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown; description?: unknown };
|
|
1483
|
+
const description = typeof p.description === "string" && p.description.trim() ? p.description.trim() : undefined;
|
|
1218
1484
|
const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
|
|
1219
1485
|
const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
|
|
1220
1486
|
const task = taskInput.trim();
|
|
@@ -1257,8 +1523,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1257
1523
|
model: resolved,
|
|
1258
1524
|
thinking: undefined,
|
|
1259
1525
|
depth: parentDepth + 1,
|
|
1526
|
+
...(description ? { description } : {}),
|
|
1260
1527
|
};
|
|
1261
1528
|
registry.add(record); // SendMessage from main can reach the nested run too
|
|
1529
|
+
parentTaskOf.set(taskId, parentRecord.taskId);
|
|
1262
1530
|
|
|
1263
1531
|
// The main conversation's Agent calls are classified as delegations
|
|
1264
1532
|
// (matcher DELEGATION_TOOLS); a child's nested spawn must be judged the
|
|
@@ -1269,7 +1537,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1269
1537
|
try {
|
|
1270
1538
|
const bridge = getPermissionBridge();
|
|
1271
1539
|
const verdict = bridge
|
|
1272
|
-
? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal })
|
|
1540
|
+
? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal, parentToolCallId: mainCallOf(parentRecord.taskId) })
|
|
1273
1541
|
: { block: true as const, reason: "no permission bridge is available to judge the delegation" };
|
|
1274
1542
|
if (verdict?.block) {
|
|
1275
1543
|
return {
|
|
@@ -1292,6 +1560,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1292
1560
|
agent: agentDef.name,
|
|
1293
1561
|
task,
|
|
1294
1562
|
name,
|
|
1563
|
+
description,
|
|
1295
1564
|
model: resolved,
|
|
1296
1565
|
fallbackModel: spawnFallbackModel(resolved, resolution.source, ctx.model),
|
|
1297
1566
|
},
|
|
@@ -1308,7 +1577,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1308
1577
|
content: [
|
|
1309
1578
|
{
|
|
1310
1579
|
type: "text" as const,
|
|
1311
|
-
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${result.output}\n\n(${formatStats(result.toolCalls, result.usage)})`,
|
|
1580
|
+
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${bounded(result.output, `${record.taskId}-report`, OUTPUT_CAP)}\n\n(${formatStats(result.toolCalls, result.usage)})`,
|
|
1312
1581
|
},
|
|
1313
1582
|
],
|
|
1314
1583
|
details: { agentRuns: [record] },
|
|
@@ -1321,13 +1590,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1321
1590
|
* The spawn tool a run is entitled to. THE one depth gate: it reads the
|
|
1322
1591
|
* record's own depth (set once at creation), so every handout site —
|
|
1323
1592
|
* fresh spawn, background resident, SendMessage resume — enforces
|
|
1324
|
-
* MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped.
|
|
1593
|
+
* MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped. Only a
|
|
1594
|
+
* `resident` run can receive a cron fire, so any other run's cron_create refuses.
|
|
1325
1595
|
*/
|
|
1326
|
-
const spawnToolsFor = (record: AgentRunRecord): ToolDefinition[] => {
|
|
1596
|
+
const spawnToolsFor = (record: AgentRunRecord, { resident = false } = {}): ToolDefinition[] => {
|
|
1327
1597
|
const depth = record.depth ?? 0;
|
|
1328
|
-
|
|
1598
|
+
// The child's cron tools reach this session's store (lib/agent-cron.ts).
|
|
1599
|
+
const cron = agentCronTools(pi.events, { agentId: record.taskId, cwd: record.cwd, resident }) as unknown as ToolDefinition[];
|
|
1600
|
+
return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth), ...cron] : cron;
|
|
1329
1601
|
};
|
|
1330
1602
|
|
|
1603
|
+
// A fired cron job of a subagent's: to that agent while it lives (Claude
|
|
1604
|
+
// Code's task-notification delivery); otherwise the background extension drops it.
|
|
1605
|
+
pi.events.on(AGENT_CRON_FIRE_CHANNEL, (data) => {
|
|
1606
|
+
const fire = data as AgentCronFire;
|
|
1607
|
+
const resident = residents.get(fire.agentId);
|
|
1608
|
+
if (!resident || resident.handle.exited()) return;
|
|
1609
|
+
fire.delivered = true;
|
|
1610
|
+
// A send that fails after all ends the job, as a fire to an ended agent does.
|
|
1611
|
+
void resident.handle.send(fire.prompt).catch(() => {
|
|
1612
|
+
pi.events.emit(AGENT_CRON_CHANNEL, { op: "delete", agentId: fire.agentId, id: fire.jobId } satisfies AgentCronRequest);
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1331
1616
|
/**
|
|
1332
1617
|
* The text a child's first turn is prompted with: a fork gets the inherited-
|
|
1333
1618
|
* context framing (plus, in a worktree, where its paths moved to); a named
|
|
@@ -1337,8 +1622,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1337
1622
|
request.fork ? forkTaskMessage(request.task, worktree ? { worktreePath: worktree.path, parentCwd } : undefined) : request.task;
|
|
1338
1623
|
|
|
1339
1624
|
/** Create a run's isolation worktree and point its record at it (both spawn paths). */
|
|
1340
|
-
const isolateInWorktree = async (
|
|
1341
|
-
const worktree = await createWorktree(
|
|
1625
|
+
const isolateInWorktree = async (base: string, record: AgentRunRecord, name: string): Promise<Worktree> => {
|
|
1626
|
+
const worktree = await createWorktree(base, name);
|
|
1342
1627
|
record.cwd = worktree.path;
|
|
1343
1628
|
record.worktree = true;
|
|
1344
1629
|
return worktree;
|
|
@@ -1362,11 +1647,20 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1362
1647
|
onProgress?: (toolCalls: number, text: string, usage: UsageTotals) => void,
|
|
1363
1648
|
): Promise<TaskResult> => {
|
|
1364
1649
|
const { request, record, agentDef } = prepared;
|
|
1650
|
+
// The session's working directory: the entered worktree, if any.
|
|
1651
|
+
const base = workCwd(ctx);
|
|
1652
|
+
|
|
1653
|
+
// Before anything marks the run as running: a failed build must not leave
|
|
1654
|
+
// the task id in runningIds, where SendMessage would call it "still running".
|
|
1655
|
+
const runtime = await runtimeOrError(ctx);
|
|
1656
|
+
if (typeof runtime === "string") {
|
|
1657
|
+
return { agent: request.agent, name: request.name, taskId: record.taskId, task: request.task, output: `Subagent failed: ${runtime}`, toolCalls: 0, usage: emptyUsage(), failed: true };
|
|
1658
|
+
}
|
|
1365
1659
|
|
|
1366
1660
|
let worktree: Worktree | undefined;
|
|
1367
1661
|
if (request.worktree) {
|
|
1368
1662
|
try {
|
|
1369
|
-
worktree = await isolateInWorktree(
|
|
1663
|
+
worktree = await isolateInWorktree(base, record, request.name);
|
|
1370
1664
|
} catch (error) {
|
|
1371
1665
|
return {
|
|
1372
1666
|
agent: request.agent,
|
|
@@ -1383,14 +1677,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1383
1677
|
|
|
1384
1678
|
runningIds.add(record.taskId);
|
|
1385
1679
|
const live = trackLiveRun(record, request, parent);
|
|
1386
|
-
const
|
|
1680
|
+
const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(request, record, ctx);
|
|
1387
1681
|
const handle = runtime.run({
|
|
1388
1682
|
name: request.name,
|
|
1389
1683
|
agent: agentDef,
|
|
1390
|
-
task: frameTask(request, worktree,
|
|
1684
|
+
task: frameTask(request, worktree, base),
|
|
1391
1685
|
cwd: record.cwd,
|
|
1392
1686
|
forkFrom: request.fork ? forkFrom : undefined,
|
|
1393
|
-
parentSystemPrompt:
|
|
1687
|
+
parentSystemPrompt: forkPrompt,
|
|
1688
|
+
parentRequest,
|
|
1394
1689
|
sessionDir: record.sessionSearchDir || undefined,
|
|
1395
1690
|
model: request.model,
|
|
1396
1691
|
fallbackModel: request.fallbackModel,
|
|
@@ -1404,6 +1699,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1404
1699
|
onMessageToMain: (message, summary) =>
|
|
1405
1700
|
parent ? relayToParent(parent.taskId, record.taskId, request.name, message, summary) : notifyAgentMessage(record.taskId, request.name, message),
|
|
1406
1701
|
extraTools: spawnToolsFor(record),
|
|
1702
|
+
mainToolCallId: () => mainCallOf(record.taskId),
|
|
1407
1703
|
});
|
|
1408
1704
|
liveHandles.set(record.taskId, handle);
|
|
1409
1705
|
|
|
@@ -1413,7 +1709,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1413
1709
|
live.finish(Boolean(outcome.failed));
|
|
1414
1710
|
let worktreeKept: boolean | undefined;
|
|
1415
1711
|
if (worktree) {
|
|
1416
|
-
worktreeKept = !(await cleanupWorktree(
|
|
1712
|
+
worktreeKept = !(await cleanupWorktree(base, worktree));
|
|
1417
1713
|
}
|
|
1418
1714
|
return {
|
|
1419
1715
|
agent: request.agent,
|
|
@@ -1422,11 +1718,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1422
1718
|
task: request.task,
|
|
1423
1719
|
...outcome,
|
|
1424
1720
|
worktreePath: worktreeKept ? worktree?.path : undefined,
|
|
1721
|
+
worktreeBranch: worktreeKept ? worktree?.branch : undefined,
|
|
1425
1722
|
worktreeKept,
|
|
1426
1723
|
};
|
|
1427
1724
|
} catch (error) {
|
|
1428
1725
|
live.finish(true);
|
|
1429
|
-
|
|
1726
|
+
// A failed run can still have committed or edited in its worktree:
|
|
1727
|
+
// a kept one is reported like a finished run's.
|
|
1728
|
+
const worktreeKept = worktree ? !(await cleanupWorktree(base, worktree)) : undefined;
|
|
1430
1729
|
return {
|
|
1431
1730
|
agent: request.agent,
|
|
1432
1731
|
name: request.name,
|
|
@@ -1436,40 +1735,321 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1436
1735
|
toolCalls: 0,
|
|
1437
1736
|
usage: emptyUsage(),
|
|
1438
1737
|
failed: true,
|
|
1738
|
+
worktreePath: worktreeKept ? worktree?.path : undefined,
|
|
1739
|
+
worktreeBranch: worktreeKept ? worktree?.branch : undefined,
|
|
1740
|
+
worktreeKept,
|
|
1439
1741
|
};
|
|
1440
1742
|
} finally {
|
|
1441
1743
|
runningIds.delete(record.taskId);
|
|
1442
1744
|
}
|
|
1443
1745
|
};
|
|
1444
1746
|
|
|
1445
|
-
|
|
1747
|
+
/**
|
|
1748
|
+
* Launch one prepared run as a background resident (Claude Code parity):
|
|
1749
|
+
* it returns as soon as the child starts, the report arrives as a steered
|
|
1750
|
+
* task notification, and the child stays resident so SendMessage can reach
|
|
1751
|
+
* it live (steer mid-turn, prompt when idle). `sessionFile` is the parent
|
|
1752
|
+
* transcript a fork clones; `toolCallId` links the notifications to the
|
|
1753
|
+
* spawning Agent call (none for a `/btw` fork); `forkMessages` are appended
|
|
1754
|
+
* to a fork's transcript. Returns the line reported for the run.
|
|
1755
|
+
*/
|
|
1756
|
+
const launchResident = async (
|
|
1757
|
+
p: PreparedRun,
|
|
1758
|
+
ctx: ExtensionContext,
|
|
1759
|
+
runtime: SubagentRuntime,
|
|
1760
|
+
{ sessionFile, toolCallId, forkMessages }: { sessionFile?: string; toolCallId?: string; forkMessages?: Message[] },
|
|
1761
|
+
): Promise<{ launched: boolean; line: string }> => {
|
|
1762
|
+
const ended = () => {
|
|
1763
|
+
registry.remove(p.record.taskId);
|
|
1764
|
+
return { launched: false, line: `✗ ${p.record.name}: the session ended before the agent started.` };
|
|
1765
|
+
};
|
|
1766
|
+
if (shuttingDown) return ended();
|
|
1767
|
+
if (toolCallId) mainCallFor.set(p.record.taskId, toolCallId);
|
|
1768
|
+
// Captured as a string: onExit runs from a `.finally` long after this
|
|
1769
|
+
// turn's ctx may be stale (review S5). The entered worktree, if any.
|
|
1770
|
+
const parentCwd = workCwd(ctx);
|
|
1771
|
+
let worktree: Worktree | undefined;
|
|
1772
|
+
if (p.request.worktree) {
|
|
1773
|
+
try {
|
|
1774
|
+
worktree = await isolateInWorktree(parentCwd, p.record, p.request.name);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
return { launched: false, line: `✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}` };
|
|
1777
|
+
}
|
|
1778
|
+
// Shutdown can begin while git creates the worktree: build no child
|
|
1779
|
+
// after its sweep, and remove the worktree within its grace.
|
|
1780
|
+
if (shuttingDown) {
|
|
1781
|
+
await cleanupWorktree(parentCwd, worktree);
|
|
1782
|
+
return ended();
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const logPath = outputLogPath(p.record);
|
|
1787
|
+
let lastLogWrite = 0;
|
|
1788
|
+
let finish!: () => void;
|
|
1789
|
+
const finished = new Promise<void>((resolve) => {
|
|
1790
|
+
finish = resolve;
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
// The child's final output. task.output() falls back to this
|
|
1794
|
+
// because handle.snapshot().text can be blank at a turn boundary —
|
|
1795
|
+
// the same reason the log write below uses `|| outcome.output`.
|
|
1796
|
+
// Keeps task_output consistent with the log and the completion
|
|
1797
|
+
// notification rather than showing an empty body in that case.
|
|
1798
|
+
let lastOutput = "";
|
|
1799
|
+
// The initial task's own report. task_output for THIS task must return the
|
|
1800
|
+
// first turn's reply — not the resident's growing multi-turn transcript —
|
|
1801
|
+
// the same way a SendMessage task returns one reply (review S12).
|
|
1802
|
+
let firstTurnOutput: string | undefined;
|
|
1803
|
+
const live = trackLiveRun(p.record, p.request);
|
|
1804
|
+
const resident: Resident = { handle: undefined as never, startedAt: Date.now(), turnHandlers: [], reviewing: 0 };
|
|
1805
|
+
const worktreeNote = worktree
|
|
1806
|
+
? `\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.)`
|
|
1807
|
+
: "";
|
|
1808
|
+
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
1809
|
+
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
1810
|
+
task.finishedAt = Date.now();
|
|
1811
|
+
firstTurnOutput = outcome.output;
|
|
1812
|
+
finish();
|
|
1813
|
+
notifyHandBack({
|
|
1814
|
+
from: p.record.taskId,
|
|
1815
|
+
taskId: p.record.taskId,
|
|
1816
|
+
name: p.record.name,
|
|
1817
|
+
toolUseId: toolCallId,
|
|
1818
|
+
outputFile: logPath,
|
|
1819
|
+
outcome,
|
|
1820
|
+
stopped,
|
|
1821
|
+
startedAt: task.startedAt,
|
|
1822
|
+
report: `${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
|
|
1823
|
+
review,
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
// Idle reaper: every run is a resident now, so without this a long
|
|
1828
|
+
// session accumulates one live AgentSession (extension instances,
|
|
1829
|
+
// message array, MCP tool refs) per delegation for its whole life.
|
|
1830
|
+
// After RESIDENT_IDLE_MS idle the session is released quietly; the
|
|
1831
|
+
// agent stays reachable — SendMessage resumes it from its session
|
|
1832
|
+
// file. Worktree residents are exempt: release would remove the
|
|
1833
|
+
// worktree a resume still needs. So is one that owns a cron job, which
|
|
1834
|
+
// fires only to a live agent (lib/agent-cron.ts). Armed from every turn end.
|
|
1835
|
+
let reaper: ReturnType<typeof setTimeout> | undefined;
|
|
1836
|
+
const armReaper = () => {
|
|
1837
|
+
if (worktree) return;
|
|
1838
|
+
if (reaper) clearTimeout(reaper);
|
|
1839
|
+
reaper = setTimeout(() => {
|
|
1840
|
+
reaper = undefined;
|
|
1841
|
+
if (handle.exited()) return;
|
|
1842
|
+
if (handle.busy() || agentOwnsCronJobs(pi.events, p.record.taskId)) {
|
|
1843
|
+
armReaper();
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
handle.release();
|
|
1847
|
+
}, RESIDENT_IDLE_MS);
|
|
1848
|
+
reaper.unref?.();
|
|
1849
|
+
};
|
|
1850
|
+
const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(p.request, p.record, ctx);
|
|
1851
|
+
// No `signal` here on purpose: a resident outlives the spawning turn and
|
|
1852
|
+
// is stopped through task_stop / the panel, not by the turn ending (S15).
|
|
1853
|
+
const started = await runtime.runResident({
|
|
1854
|
+
name: p.record.name,
|
|
1855
|
+
agent: p.agentDef,
|
|
1856
|
+
cwd: p.record.cwd,
|
|
1857
|
+
forkFrom: p.request.fork ? sessionFile : undefined,
|
|
1858
|
+
forkMessages: p.request.fork ? forkMessages : undefined,
|
|
1859
|
+
parentSystemPrompt: forkPrompt,
|
|
1860
|
+
parentRequest,
|
|
1861
|
+
sessionDir: p.record.sessionSearchDir || undefined,
|
|
1862
|
+
model: p.request.model,
|
|
1863
|
+
fallbackModel: p.request.fallbackModel,
|
|
1864
|
+
thinking: p.request.thinking,
|
|
1865
|
+
onProgress: (toolCalls, text, usage) => {
|
|
1866
|
+
live.progress(toolCalls, usage);
|
|
1867
|
+
// Throttled: onProgress fires per tool call/message with the whole
|
|
1868
|
+
// turn text so far, and this path now carries EVERY run — an
|
|
1869
|
+
// unthrottled sync rewrite would be O(n²) bytes on the hot path.
|
|
1870
|
+
// onTurnEnd below flushes the final state, so the tail is never lost.
|
|
1871
|
+
if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
|
|
1872
|
+
lastLogWrite = Date.now();
|
|
1873
|
+
writeFileSync(logPath, text);
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
sink: live.sink,
|
|
1877
|
+
onMessageToMain: (message) => notifyAgentMessage(p.record.taskId, p.record.name, message),
|
|
1878
|
+
extraTools: spawnToolsFor(p.record, { resident: true }),
|
|
1879
|
+
mainToolCallId: () => mainCallOf(p.record.taskId),
|
|
1880
|
+
onTurnEnd: (outcome) => {
|
|
1881
|
+
registry.sessionFileFor(p.record);
|
|
1882
|
+
live.settle();
|
|
1883
|
+
lastOutput = resident.handle.snapshot().text || outcome.output;
|
|
1884
|
+
if (logPath) writeFileSync(logPath, lastOutput);
|
|
1885
|
+
// The handler is claimed now (so a message arriving mid-review pairs
|
|
1886
|
+
// with the NEXT turn), but runs only once auto mode's hand-back
|
|
1887
|
+
// review of this turn's action sequence has answered, so the
|
|
1888
|
+
// verdict rides in the same notification as the report instead of
|
|
1889
|
+
// trailing it. The wait is bounded (hand-back-review.ts) and
|
|
1890
|
+
// answered synchronously when auto mode is off.
|
|
1891
|
+
const handler = resident.turnHandlers.shift();
|
|
1892
|
+
// A stop or resume during review must not change this turn's outcome.
|
|
1893
|
+
const stopped = stoppedTaskIds.has(p.record.taskId);
|
|
1894
|
+
armReaper();
|
|
1895
|
+
resident.reviewing++;
|
|
1896
|
+
void awaitHandBackReview(pi.events, p.record, outcome.actions, undefined, mainCallOf(p.record.taskId)).then((review) => {
|
|
1897
|
+
resident.reviewing--;
|
|
1898
|
+
if (handler) {
|
|
1899
|
+
handler(outcome, review, stopped);
|
|
1900
|
+
} else {
|
|
1901
|
+
// A turn nobody is waiting on (e.g. a steer that raced past its
|
|
1902
|
+
// target turn and ran on its own) must still surface.
|
|
1903
|
+
// Only the launch's notification names its tool call; a later
|
|
1904
|
+
// turn's notifies without one, as Claude Code's do (findings §40).
|
|
1905
|
+
notifyHandBack({
|
|
1906
|
+
from: p.record.taskId,
|
|
1907
|
+
taskId: p.record.taskId,
|
|
1908
|
+
name: p.record.name,
|
|
1909
|
+
outputFile: logPath,
|
|
1910
|
+
outcome,
|
|
1911
|
+
stopped,
|
|
1912
|
+
startedAt: resident.startedAt,
|
|
1913
|
+
report: bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP),
|
|
1914
|
+
review,
|
|
1915
|
+
details: { update: true },
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
},
|
|
1920
|
+
onExit: () => {
|
|
1921
|
+
if (reaper) clearTimeout(reaper);
|
|
1922
|
+
live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
|
|
1923
|
+
if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
|
|
1924
|
+
liveHandles.delete(p.record.taskId);
|
|
1925
|
+
// Returned, not voided: kill() settles after the cleanup, so the
|
|
1926
|
+
// session_shutdown grace covers it.
|
|
1927
|
+
if (worktree) return cleanupWorktree(parentCwd, worktree);
|
|
1928
|
+
return undefined;
|
|
1929
|
+
},
|
|
1930
|
+
}).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
|
1931
|
+
if (started instanceof Error) {
|
|
1932
|
+
// The child never started: settle its panel row and forget the run, so
|
|
1933
|
+
// neither the panel nor list_agents offers it.
|
|
1934
|
+
live.finish(true);
|
|
1935
|
+
registry.remove(p.record.taskId);
|
|
1936
|
+
if (worktree) void cleanupWorktree(parentCwd, worktree);
|
|
1937
|
+
return { launched: false, line: `✗ ${p.record.name}: could not start: ${started.message}` };
|
|
1938
|
+
}
|
|
1939
|
+
const handle = started;
|
|
1940
|
+
resident.handle = handle;
|
|
1941
|
+
// Construction can finish after shutdown's stop-all sweep. The handle
|
|
1942
|
+
// was not registered then, so dispose it before sending any task now.
|
|
1943
|
+
if (shuttingDown) {
|
|
1944
|
+
await handle.kill();
|
|
1945
|
+
return ended();
|
|
1946
|
+
}
|
|
1947
|
+
residents.set(p.record.taskId, resident);
|
|
1948
|
+
liveHandles.set(p.record.taskId, handle);
|
|
1949
|
+
|
|
1950
|
+
const task: BackgroundTask = {
|
|
1951
|
+
id: p.record.taskId,
|
|
1952
|
+
kind: "subagent",
|
|
1953
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
1954
|
+
description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
|
|
1955
|
+
status: "running",
|
|
1956
|
+
startedAt: Date.now(),
|
|
1957
|
+
logPath,
|
|
1958
|
+
output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
|
|
1959
|
+
stop: () => stopAgent(p.record.taskId, handle),
|
|
1960
|
+
resident: () => !handle.exited(),
|
|
1961
|
+
finished,
|
|
1962
|
+
};
|
|
1963
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
1964
|
+
void handle.send(frameTask(p.request, worktree, parentCwd));
|
|
1965
|
+
|
|
1966
|
+
return {
|
|
1967
|
+
launched: true,
|
|
1968
|
+
line: `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
|
|
1969
|
+
};
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* `/btw`'s `f to fork` (lib/btw-fork.ts): a background fork that inherits
|
|
1974
|
+
* the conversation (if the session has one yet), then the side question and
|
|
1975
|
+
* its answer, and takes the question as its task. It reports back like any
|
|
1976
|
+
* spawned fork.
|
|
1977
|
+
*/
|
|
1978
|
+
const forkFromBtw = async (request: BtwForkRequest): Promise<BtwForkResult> => {
|
|
1979
|
+
const ctx = request.ctx as ExtensionContext;
|
|
1980
|
+
// Before the first turn the file is not written yet and the fork starts from
|
|
1981
|
+
// the side exchange alone, as Claude Code's does (newChildSessionManager).
|
|
1982
|
+
// Without a session file at all the conversation cannot be cloned, and a
|
|
1983
|
+
// fork framed as inheriting it would confabulate (the Agent tool refuses too).
|
|
1984
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1985
|
+
if (!sessionFile) return { error: "Cannot fork: this session is not persisted (started with --no-session), so there is no conversation to clone." };
|
|
1986
|
+
// Named from the question, as Claude Code names a /btw fork.
|
|
1987
|
+
const name = freeRunName(registry.names(), btwForkName(request.question));
|
|
1988
|
+
const taskId = btwForkTaskId(name);
|
|
1989
|
+
// The session's current model and thinking, explicitly: before the first
|
|
1990
|
+
// turn there is no transcript for the fork to restore them from.
|
|
1991
|
+
const prepared: PreparedRun = {
|
|
1992
|
+
request: {
|
|
1993
|
+
agent: FORK_AGENT,
|
|
1994
|
+
task: request.question,
|
|
1995
|
+
name,
|
|
1996
|
+
description: btwForkDescription(request.question),
|
|
1997
|
+
fork: true,
|
|
1998
|
+
model: ctx.model ? modelSpec(ctx.model) : undefined,
|
|
1999
|
+
thinking: pi.getThinkingLevel(),
|
|
2000
|
+
},
|
|
2001
|
+
agentDef: undefined,
|
|
2002
|
+
record: {
|
|
2003
|
+
name,
|
|
2004
|
+
agent: FORK_AGENT,
|
|
2005
|
+
taskId,
|
|
2006
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
2007
|
+
cwd: workCwd(ctx),
|
|
2008
|
+
depth: 0,
|
|
2009
|
+
description: btwForkDescription(request.question),
|
|
2010
|
+
},
|
|
2011
|
+
};
|
|
2012
|
+
registry.add(prepared.record);
|
|
2013
|
+
// The session can be torn down while the runtime or the child starts; a
|
|
2014
|
+
// fork launched after shutdown's stop-all sweep would outlive it.
|
|
2015
|
+
const runtime = await runtimeOrError(ctx);
|
|
2016
|
+
if (typeof runtime === "string") {
|
|
2017
|
+
registry.remove(taskId);
|
|
2018
|
+
return { error: `Cannot fork: ${runtime}` };
|
|
2019
|
+
}
|
|
2020
|
+
if (shuttingDown) return { error: "The session ended before the fork started." };
|
|
2021
|
+
const { launched, line } = await launchResident(prepared, ctx, runtime, { sessionFile, forkMessages: request.messages });
|
|
2022
|
+
if (shuttingDown) {
|
|
2023
|
+
void stopAgent(taskId);
|
|
2024
|
+
return { error: "The session ended before the fork started." };
|
|
2025
|
+
}
|
|
2026
|
+
if (!launched) return { error: line };
|
|
2027
|
+
for (const text of btwForkRecord(name, taskId, request.question)) {
|
|
2028
|
+
pi.events.emit(REMINDER_CHANNEL, { text, placement: "user-prepend", raw: true } satisfies ReminderPayload);
|
|
2029
|
+
}
|
|
2030
|
+
return { name, taskId };
|
|
2031
|
+
};
|
|
2032
|
+
pi.events.on(BTW_FORK_CHANNEL, (data) => {
|
|
2033
|
+
const request = data as BtwForkRequest;
|
|
2034
|
+
request.handled = true;
|
|
2035
|
+
void forkFromBtw(request).then(request.respond, (error: unknown) =>
|
|
2036
|
+
request.respond({ error: `Failed to fork: ${error instanceof Error ? error.message : String(error)}` }),
|
|
2037
|
+
);
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
// Claude Code's Agent text, short or long by the model's tier
|
|
2041
|
+
// (agent-description.ts, lib/tool-variants.ts); registered again when the
|
|
2042
|
+
// form changes.
|
|
2043
|
+
const agentTool = defineTool({
|
|
1446
2044
|
name: "Agent",
|
|
1447
2045
|
label: "Agent",
|
|
1448
2046
|
...ccToolRenderers<{ subagent_type?: string; task?: string; prompt?: string; description?: string; action?: string }>("Agent", {
|
|
1449
2047
|
title: (a) => (a ? [a.subagent_type, a.description ?? a.task ?? a.prompt ?? a.action].filter(Boolean).join(": ") || undefined : undefined),
|
|
1450
2048
|
}),
|
|
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.",
|
|
2049
|
+
description: agentDescription("short", projectConfigDirName()),
|
|
1471
2050
|
promptSnippet: "Delegate scoped work to a specialist agent in its own context",
|
|
1472
2051
|
parameters: SubagentParams,
|
|
2052
|
+
prepareArguments: prepareAgentArguments as (args: unknown) => Static<typeof SubagentParams>,
|
|
1473
2053
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1474
2054
|
const agents = loadAgents(ctx.cwd);
|
|
1475
2055
|
|
|
@@ -1479,11 +2059,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1479
2059
|
const taskText = params.task ?? params.prompt;
|
|
1480
2060
|
|
|
1481
2061
|
// 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`.
|
|
2062
|
+
// override, or action:"run") is a run; prepareAgentArguments gave one that
|
|
2063
|
+
// named no agent general-purpose, as Claude Code does (a tiny model
|
|
2064
|
+
// repeated such a call 30 times against an error naming the fix, battery
|
|
2065
|
+
// round 7). A call with neither lists the catalog.
|
|
1487
2066
|
const wantsRun =
|
|
1488
2067
|
params.action === "run" ||
|
|
1489
2068
|
taskText != null ||
|
|
@@ -1499,19 +2078,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1499
2078
|
};
|
|
1500
2079
|
}
|
|
1501
2080
|
|
|
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
2081
|
// A run needs a task. A CC-shaped call that put its instruction in an
|
|
1516
2082
|
// unrecognized key, or none at all, must fail loud rather than start a
|
|
1517
2083
|
// child prompted with "" (H1). Fork included — cloning to do nothing is
|
|
@@ -1544,6 +2110,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1544
2110
|
agent: entry.agent,
|
|
1545
2111
|
task: entry.task,
|
|
1546
2112
|
name,
|
|
2113
|
+
description: params.description?.trim() || undefined,
|
|
1547
2114
|
fork: entry.agent === FORK_AGENT,
|
|
1548
2115
|
model: params.model,
|
|
1549
2116
|
thinking: params.thinking,
|
|
@@ -1551,86 +2118,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1551
2118
|
};
|
|
1552
2119
|
});
|
|
1553
2120
|
|
|
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
2121
|
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);
|
|
2122
|
+
let prepared: PreparedRun[] = [];
|
|
1634
2123
|
/**
|
|
1635
2124
|
* What the model is told about its own model choice: the same sentences
|
|
1636
2125
|
* the user gets, plus the model each child actually runs on. Deduped —
|
|
@@ -1638,99 +2127,187 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1638
2127
|
* once. See `subagentModelNotes` for why the model needs this.
|
|
1639
2128
|
*/
|
|
1640
2129
|
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
|
-
});
|
|
2130
|
+
// Every check before the records land: a rejection, or a throw on the way,
|
|
2131
|
+
// frees the spawn's names for the retry.
|
|
2132
|
+
let landed = false;
|
|
2133
|
+
try {
|
|
2134
|
+
// A fork continues this conversation; running it on a different model or
|
|
2135
|
+
// reasoning effort is how the fork-confabulation incident happened (a
|
|
2136
|
+
// minimal-thinking fork resumed the inherited topic instead of its task).
|
|
2137
|
+
// Claude Code silently ignores `model` for forks; we fail loud instead.
|
|
2138
|
+
if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
|
|
1672
2139
|
return {
|
|
1673
2140
|
content: [
|
|
1674
2141
|
{
|
|
1675
2142
|
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
|
-
}),
|
|
2143
|
+
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
2144
|
},
|
|
1686
2145
|
],
|
|
1687
2146
|
details: {},
|
|
1688
2147
|
isError: true,
|
|
1689
2148
|
};
|
|
1690
2149
|
}
|
|
1691
|
-
|
|
1692
|
-
if (
|
|
1693
|
-
const fallback = resolveSubagentModel({
|
|
1694
|
-
configuredDefault: configuredDefault,
|
|
1695
|
-
sessionModel: ctx.model,
|
|
1696
|
-
available,
|
|
1697
|
-
requireImageInput: supportsImageInput(ctx.model),
|
|
1698
|
-
});
|
|
2150
|
+
|
|
2151
|
+
if (requested.some((r) => r.fork) && !sessionFile) {
|
|
1699
2152
|
return {
|
|
1700
2153
|
content: [
|
|
1701
2154
|
{
|
|
1702
2155
|
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
|
-
}),
|
|
2156
|
+
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
2157
|
},
|
|
1715
2158
|
],
|
|
1716
2159
|
details: {},
|
|
1717
2160
|
isError: true,
|
|
1718
2161
|
};
|
|
1719
2162
|
}
|
|
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
2163
|
|
|
1728
|
-
|
|
1729
|
-
|
|
2164
|
+
const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
|
|
2165
|
+
if (unknown.length > 0) {
|
|
2166
|
+
return {
|
|
2167
|
+
content: [
|
|
2168
|
+
{
|
|
2169
|
+
type: "text",
|
|
2170
|
+
text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
2171
|
+
},
|
|
2172
|
+
],
|
|
2173
|
+
details: {},
|
|
2174
|
+
isError: true,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
1730
2177
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2178
|
+
if (requested.some((r) => r.worktree) && !(await isGitRepo(workCwd(ctx)))) {
|
|
2179
|
+
return {
|
|
2180
|
+
content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
|
|
2181
|
+
details: {},
|
|
2182
|
+
isError: true,
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
prepared = requested.map((request) => {
|
|
2187
|
+
const taskId = generateTaskId();
|
|
2188
|
+
return {
|
|
2189
|
+
request,
|
|
2190
|
+
agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
|
|
2191
|
+
record: {
|
|
2192
|
+
name: request.name,
|
|
2193
|
+
agent: request.agent,
|
|
2194
|
+
taskId,
|
|
2195
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
2196
|
+
cwd: workCwd(ctx),
|
|
2197
|
+
model: request.model,
|
|
2198
|
+
thinking: request.thinking,
|
|
2199
|
+
depth: 0,
|
|
2200
|
+
...(request.description ? { description: request.description } : {}),
|
|
2201
|
+
},
|
|
2202
|
+
};
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Model resolution happens here, in the parent, against the real
|
|
2207
|
+
* registry — never in the child, whose `--model` fuzzy-matches across
|
|
2208
|
+
* every configured provider. The child is spawned with a concrete
|
|
2209
|
+
* `provider/id`; anything surprising (a fallback, a provider crossing)
|
|
2210
|
+
* is said out loud rather than happening silently.
|
|
2211
|
+
*/
|
|
2212
|
+
const available = usableModels(ctx);
|
|
2213
|
+
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
2214
|
+
for (const p of prepared) {
|
|
2215
|
+
// A fork inherits the parent transcript, so it must continue on THIS
|
|
2216
|
+
// conversation's exact model — never a configured default or the
|
|
2217
|
+
// automatic same-provider pick, which would move the inherited context
|
|
2218
|
+
// onto a different (often weaker) model. Leaving model undefined makes
|
|
2219
|
+
// the forked session restore its own model. (Per-call `model` on a fork
|
|
2220
|
+
// is already rejected above.)
|
|
2221
|
+
if (p.request.fork) continue;
|
|
2222
|
+
// No per-call or agent-frontmatter override → the automatic default,
|
|
2223
|
+
// which is stable for the session, so reuse the cached resolution
|
|
2224
|
+
// instead of re-ranking the catalog per spawn. An override varies per
|
|
2225
|
+
// call and always resolves fresh.
|
|
2226
|
+
const resolution =
|
|
2227
|
+
p.request.model || p.agentDef?.model
|
|
2228
|
+
? resolveSubagentModel({
|
|
2229
|
+
requested: p.request.model,
|
|
2230
|
+
agentModel: p.agentDef?.model,
|
|
2231
|
+
configuredDefault: configuredDefault,
|
|
2232
|
+
sessionModel: ctx.model,
|
|
2233
|
+
available,
|
|
2234
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2235
|
+
})
|
|
2236
|
+
: resolveAutoDefault(ctx.model, available, configuredDefault);
|
|
2237
|
+
if (resolution.unresolved) {
|
|
2238
|
+
// The main model chose this string; the menu lets it retry.
|
|
2239
|
+
const fallback = resolveSubagentModel({
|
|
2240
|
+
configuredDefault: configuredDefault,
|
|
2241
|
+
sessionModel: ctx.model,
|
|
2242
|
+
available,
|
|
2243
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2244
|
+
});
|
|
2245
|
+
return {
|
|
2246
|
+
content: [
|
|
2247
|
+
{
|
|
2248
|
+
type: "text",
|
|
2249
|
+
text:
|
|
2250
|
+
`${resolution.unresolvedReason ?? `Unknown model "${resolution.unresolved}" — no available model matches it.`}\n\n` +
|
|
2251
|
+
subagentModelsReminder({
|
|
2252
|
+
available,
|
|
2253
|
+
sessionModel: ctx.model,
|
|
2254
|
+
defaultModel: fallback.model,
|
|
2255
|
+
defaultSource: fallback.source,
|
|
2256
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2257
|
+
}),
|
|
2258
|
+
},
|
|
2259
|
+
],
|
|
2260
|
+
details: {},
|
|
2261
|
+
isError: true,
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
|
|
2265
|
+
if (gate) {
|
|
2266
|
+
const fallback = resolveSubagentModel({
|
|
2267
|
+
configuredDefault: configuredDefault,
|
|
2268
|
+
sessionModel: ctx.model,
|
|
2269
|
+
available,
|
|
2270
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2271
|
+
});
|
|
2272
|
+
return {
|
|
2273
|
+
content: [
|
|
2274
|
+
{
|
|
2275
|
+
type: "text",
|
|
2276
|
+
text:
|
|
2277
|
+
`${gate}\n` +
|
|
2278
|
+
"If the user explicitly asked for this model, retry with allow_expensive: true; " +
|
|
2279
|
+
"otherwise pick a cheaper model or omit the field.\n\n" +
|
|
2280
|
+
subagentModelsReminder({
|
|
2281
|
+
available,
|
|
2282
|
+
sessionModel: ctx.model,
|
|
2283
|
+
defaultModel: fallback.model,
|
|
2284
|
+
defaultSource: fallback.source,
|
|
2285
|
+
requireImageInput: supportsImageInput(ctx.model),
|
|
2286
|
+
}),
|
|
2287
|
+
},
|
|
2288
|
+
],
|
|
2289
|
+
details: {},
|
|
2290
|
+
isError: true,
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
2294
|
+
for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
|
|
2295
|
+
const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
|
|
2296
|
+
p.request.model = resolved;
|
|
2297
|
+
p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
|
|
2298
|
+
p.record.model = resolved;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
for (const p of prepared) registry.add(p.record);
|
|
2302
|
+
landed = true;
|
|
2303
|
+
} finally {
|
|
2304
|
+
if (!landed) for (const request of requested) registry.release(request.name);
|
|
2305
|
+
}
|
|
2306
|
+
const records = prepared.map((p) => p.record);
|
|
2307
|
+
|
|
2308
|
+
// One-shot modes (`-p` / `--mode json`) exit when the turn settles, so
|
|
2309
|
+
// a background run would be orphaned with its report undelivered — run
|
|
2310
|
+
// blocking there and return the report in the tool result instead.
|
|
1734
2311
|
if (!sessionOutlivesTurn(ctx.mode)) {
|
|
1735
2312
|
// Stream progress through onUpdate (tool_execution_update) like the
|
|
1736
2313
|
// old foreground path — a headless JSON/RPC consumer has no panel,
|
|
@@ -1742,6 +2319,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1742
2319
|
onUpdate?.({ content: [{ type: "text", text: line }], details: {} });
|
|
1743
2320
|
};
|
|
1744
2321
|
report();
|
|
2322
|
+
mainCallFor.set(prepared[0].record.taskId, toolCallId);
|
|
1745
2323
|
const result = await executeRun(prepared[0], ctx, signal, sessionFile ?? undefined, undefined, (toolCalls, text, usage) => {
|
|
1746
2324
|
progress = { toolCalls, text, usage };
|
|
1747
2325
|
report();
|
|
@@ -1753,7 +2331,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1753
2331
|
actions: result.actions ?? [],
|
|
1754
2332
|
} satisfies SubagentActionsPayload);
|
|
1755
2333
|
const stats = formatStats(result.toolCalls, result.usage);
|
|
1756
|
-
const worktreeNote = result.worktreePath ? `\n\n
|
|
2334
|
+
const worktreeNote = result.worktreePath ? `\n\n${keptWorktreeNote(result.worktreePath, result.worktreeBranch)}` : "";
|
|
1757
2335
|
// The report arrives inline with no frame, no task id and no note, so a
|
|
1758
2336
|
// model told to "retrieve the result with task_output" chased an id that
|
|
1759
2337
|
// was never registered (WEAK-MODEL-REVIEW-2026-09-06 M3). Say it up
|
|
@@ -1770,7 +2348,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1770
2348
|
content: [
|
|
1771
2349
|
{
|
|
1772
2350
|
type: "text",
|
|
1773
|
-
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${result.output}${worktreeNote}\n\n(${stats})`,
|
|
2351
|
+
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
2352
|
},
|
|
1775
2353
|
],
|
|
1776
2354
|
details: { results: [result], agentRuns: records },
|
|
@@ -1783,181 +2361,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1783
2361
|
// steered system notification, and the child stays resident so
|
|
1784
2362
|
// SendMessage can reach it live (steer mid-turn, prompt when idle).
|
|
1785
2363
|
const lines: string[] = [...renamed, ...modelNotes];
|
|
1786
|
-
const runtime = await
|
|
2364
|
+
const runtime = await runtimeOrError(ctx);
|
|
2365
|
+
if (typeof runtime === "string") {
|
|
2366
|
+
// Nothing launched: forget the runs so list_agents and SendMessage never offer them.
|
|
2367
|
+
for (const p of prepared) registry.remove(p.record.taskId);
|
|
2368
|
+
return { content: [{ type: "text", text: `Could not start the agent: ${runtime}` }], details: {}, isError: true };
|
|
2369
|
+
}
|
|
1787
2370
|
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
|
-
);
|
|
2371
|
+
const { launched, line } = await launchResident(p, ctx, runtime, { sessionFile: sessionFile ?? undefined, toolCallId });
|
|
2372
|
+
if (launched) spawnedThisLoop.add(p.record.taskId);
|
|
2373
|
+
lines.push(line);
|
|
1961
2374
|
}
|
|
1962
2375
|
return {
|
|
1963
2376
|
content: [
|
|
@@ -1970,231 +2383,97 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1970
2383
|
};
|
|
1971
2384
|
},
|
|
1972
2385
|
});
|
|
2386
|
+
const setAgentForm = registerVariantTool<DescriptionForm>(pi, "short", (form) => ({ ...agentTool, description: agentDescription(form, projectConfigDirName()) }));
|
|
2387
|
+
followDescriptionForm(pi, setAgentForm);
|
|
1973
2388
|
|
|
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
|
-
}
|
|
2389
|
+
const SendMessageParams = Type.Object({
|
|
2390
|
+
to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
|
|
2391
|
+
message: Type.String({ description: "Plain text message for the agent" }),
|
|
2392
|
+
summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
|
|
2393
|
+
});
|
|
1997
2394
|
|
|
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
|
-
|
|
2395
|
+
/**
|
|
2396
|
+
* SendMessage's body, typed on the plain extension context: the agent view
|
|
2397
|
+
* also sends from an input handler, which has no tool context (pi 0.99's
|
|
2398
|
+
* `ExtensionToolContext` adds nested tool calls this never makes).
|
|
2399
|
+
*/
|
|
2400
|
+
const sendMessage = async (toolCallId: string, params: Static<typeof SendMessageParams>, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> => {
|
|
2401
|
+
if (params.to === "main") {
|
|
2402
|
+
// The main conversation's SendMessage only addresses spawned agents; the
|
|
2403
|
+
// "main" recipient exists only on a subagent's own injected SendMessage.
|
|
2404
|
+
return {
|
|
2405
|
+
content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
|
|
2406
|
+
details: {},
|
|
2407
|
+
isError: true,
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const record = registry.resolve(params.to);
|
|
2412
|
+
if (!record) {
|
|
2413
|
+
// Recorded runs only: a name reserved by a spawn that then failed validation is not addressable.
|
|
2414
|
+
const known = registry.list().map((run) => run.name).join(", ") || "(none)";
|
|
2415
|
+
return {
|
|
2416
|
+
content: [
|
|
2417
|
+
{
|
|
2418
|
+
type: "text",
|
|
2419
|
+
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}.`,
|
|
2420
|
+
},
|
|
2421
|
+
],
|
|
2422
|
+
details: {},
|
|
2423
|
+
isError: true,
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
// Resident background agent: reach its in-process session live.
|
|
2427
|
+
const resident = residents.get(record.taskId);
|
|
2428
|
+
if (resident && !resident.handle.exited()) {
|
|
2429
|
+
// This call now starts (or joins) the agent's turn.
|
|
2430
|
+
mainCallFor.set(record.taskId, toolCallId);
|
|
2431
|
+
if (resident.handle.busy()) {
|
|
2432
|
+
let delivery: "started" | "steered";
|
|
2433
|
+
try {
|
|
2434
|
+
delivery = await resident.handle.send(params.message);
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
return {
|
|
2437
|
+
content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
|
|
2438
|
+
details: {},
|
|
2439
|
+
isError: true,
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
if (delivery === "steered") {
|
|
2040
2443
|
return {
|
|
2041
2444
|
content: [
|
|
2042
2445
|
{
|
|
2043
2446
|
type: "text",
|
|
2044
|
-
text:
|
|
2447
|
+
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
2448
|
},
|
|
2046
2449
|
],
|
|
2047
|
-
details: { agentRuns: [record] },
|
|
2450
|
+
details: { agentRuns: [record], steered: true },
|
|
2048
2451
|
};
|
|
2049
2452
|
}
|
|
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);
|
|
2453
|
+
// The turn had just ended (settle window): the message rides the next
|
|
2454
|
+
// turn, whose reply nobody has claimed yet — announce it as an update.
|
|
2455
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
2101
2456
|
return {
|
|
2102
2457
|
content: [
|
|
2103
2458
|
{
|
|
2104
2459
|
type: "text",
|
|
2105
|
-
text:
|
|
2460
|
+
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
2461
|
},
|
|
2107
2462
|
],
|
|
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,
|
|
2463
|
+
details: { agentRuns: [record] },
|
|
2145
2464
|
};
|
|
2146
2465
|
}
|
|
2147
2466
|
|
|
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
2467
|
const taskId = generateTaskId();
|
|
2160
2468
|
let finish!: () => void;
|
|
2161
2469
|
const finished = new Promise<void>((resolve) => {
|
|
2162
2470
|
finish = resolve;
|
|
2163
2471
|
});
|
|
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
|
-
|
|
2472
|
+
// This task is ONE turn of the resident agent. Its output must be that
|
|
2473
|
+
// turn's reply — the same text the reply notification carries — not the
|
|
2474
|
+
// resident's whole multi-turn transcript, or task_output and the
|
|
2475
|
+
// notification disagree (superset), which weak models conflate.
|
|
2476
|
+
let replyOutput = "";
|
|
2198
2477
|
const task: BackgroundTask = {
|
|
2199
2478
|
id: taskId,
|
|
2200
2479
|
kind: "subagent",
|
|
@@ -2202,43 +2481,415 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2202
2481
|
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
2203
2482
|
status: "running",
|
|
2204
2483
|
startedAt: Date.now(),
|
|
2205
|
-
output: () => handle.snapshot().text,
|
|
2206
|
-
stop: () => stopAgent(record.taskId, handle),
|
|
2484
|
+
output: () => replyOutput || resident.handle.snapshot().text,
|
|
2485
|
+
stop: () => stopAgent(record.taskId, resident.handle),
|
|
2486
|
+
resident: () => !resident.handle.exited(),
|
|
2207
2487
|
finished,
|
|
2208
2488
|
};
|
|
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));
|
|
2489
|
+
resident.turnHandlers.push((outcome, review, stopped) => {
|
|
2215
2490
|
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
2216
2491
|
task.finishedAt = Date.now();
|
|
2492
|
+
replyOutput = outcome.output;
|
|
2217
2493
|
finish();
|
|
2494
|
+
// `<task-id>` is this message's task (what task_output resolves); `from=`
|
|
2495
|
+
// stays the agent's id, the address a further SendMessage uses. The
|
|
2496
|
+
// tracker's tokens and tool uses span the resident's life, so the
|
|
2497
|
+
// duration does too.
|
|
2218
2498
|
notifyHandBack({
|
|
2219
2499
|
from: record.taskId,
|
|
2220
2500
|
taskId,
|
|
2221
2501
|
name: record.name,
|
|
2222
2502
|
toolUseId: toolCallId,
|
|
2503
|
+
outputFile: outputLogPath(record),
|
|
2223
2504
|
outcome,
|
|
2224
2505
|
stopped,
|
|
2225
|
-
startedAt:
|
|
2226
|
-
report:
|
|
2227
|
-
review
|
|
2506
|
+
startedAt: resident.startedAt,
|
|
2507
|
+
report: bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP),
|
|
2508
|
+
review,
|
|
2228
2509
|
});
|
|
2229
2510
|
});
|
|
2230
|
-
|
|
2231
|
-
|
|
2511
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2512
|
+
void resident.handle.send(params.message);
|
|
2513
|
+
// Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
|
|
2514
|
+
// the reply arrives as a notification, so forbid predicting it and arm
|
|
2515
|
+
// the agent_end backstop (keyed by the agent's persistent id).
|
|
2516
|
+
spawnedThisLoop.add(record.taskId);
|
|
2232
2517
|
return {
|
|
2233
2518
|
content: [
|
|
2234
2519
|
{
|
|
2235
2520
|
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}`,
|
|
2521
|
+
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
2522
|
},
|
|
2238
2523
|
],
|
|
2239
2524
|
details: { agentRuns: [record], taskId },
|
|
2240
2525
|
};
|
|
2241
|
-
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (runningIds.has(record.taskId)) {
|
|
2529
|
+
return {
|
|
2530
|
+
content: [
|
|
2531
|
+
{ type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
|
|
2532
|
+
],
|
|
2533
|
+
details: {},
|
|
2534
|
+
isError: true,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
const sessionFile = registry.sessionFileFor(record);
|
|
2538
|
+
// A fork has no agent definition: its identity is the parent's prompt at
|
|
2539
|
+
// spawn time, persisted beside its session. Without it a resume would run
|
|
2540
|
+
// on pi's stock prompt and default tools — refuse instead (review S6).
|
|
2541
|
+
const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
|
|
2542
|
+
if (record.agent === FORK_AGENT && forkPrompt === undefined) {
|
|
2543
|
+
return {
|
|
2544
|
+
content: [
|
|
2545
|
+
{
|
|
2546
|
+
type: "text",
|
|
2547
|
+
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.`,
|
|
2548
|
+
},
|
|
2549
|
+
],
|
|
2550
|
+
details: {},
|
|
2551
|
+
isError: true,
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
if (!sessionFile) {
|
|
2555
|
+
return {
|
|
2556
|
+
content: [
|
|
2557
|
+
{ type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
|
|
2558
|
+
],
|
|
2559
|
+
details: {},
|
|
2560
|
+
isError: true,
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// A worktree run whose agent left no changes had its worktree removed at
|
|
2565
|
+
// exit, but the record (persisted in the tool result) still points there.
|
|
2566
|
+
// Resume in the parent cwd instead of a deleted directory, say so in the
|
|
2567
|
+
// reply, and fix the record so later messages don't repeat the note (M2).
|
|
2568
|
+
let relocationNote = "";
|
|
2569
|
+
if (!existsSync(record.cwd)) {
|
|
2570
|
+
const cwd = workCwd(ctx);
|
|
2571
|
+
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`;
|
|
2572
|
+
record.cwd = cwd;
|
|
2573
|
+
record.worktree = undefined;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// Before the run is marked running (L8, as in executeRun).
|
|
2577
|
+
const runtime = await runtimeOrError(ctx);
|
|
2578
|
+
if (typeof runtime === "string") {
|
|
2579
|
+
return { content: [{ type: "text", text: `Could not resume ${record.name}: ${runtime}` }], details: {}, isError: true };
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
const taskId = generateTaskId();
|
|
2583
|
+
let finish!: () => void;
|
|
2584
|
+
const finished = new Promise<void>((resolve) => {
|
|
2585
|
+
finish = resolve;
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
runningIds.add(record.taskId);
|
|
2589
|
+
// Re-enter the panel: the finished run's entry flips back to running (or
|
|
2590
|
+
// registers fresh after a session resume) and streams this turn live.
|
|
2591
|
+
const live = trackLiveRun(record, {
|
|
2592
|
+
agent: record.agent,
|
|
2593
|
+
name: record.name,
|
|
2594
|
+
task: params.message,
|
|
2595
|
+
model: record.model,
|
|
2596
|
+
thinking: record.thinking,
|
|
2597
|
+
});
|
|
2598
|
+
// One-shot modes (`-p`, `--mode json`) exit when the turn settles, so the
|
|
2599
|
+
// resumed turn runs blocking there and the reply is the tool result, as a
|
|
2600
|
+
// spawn's report is (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M3).
|
|
2601
|
+
const oneShot = !sessionOutlivesTurn(ctx.mode);
|
|
2602
|
+
mainCallFor.set(record.taskId, toolCallId);
|
|
2603
|
+
const handle = runtime.run({
|
|
2604
|
+
name: record.name,
|
|
2605
|
+
agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
|
|
2606
|
+
task: params.message,
|
|
2607
|
+
cwd: record.cwd,
|
|
2608
|
+
sessionFile,
|
|
2609
|
+
parentSystemPrompt: forkPrompt,
|
|
2610
|
+
parentRequest: record.agent === FORK_AGENT ? readForkRequest(record) : undefined,
|
|
2611
|
+
// Blocking, so the call's own abort stops it; a background turn is stopped by task_stop.
|
|
2612
|
+
signal: oneShot ? signal : undefined,
|
|
2613
|
+
model: record.model,
|
|
2614
|
+
// Resume degrades to the session model if the recorded model has become
|
|
2615
|
+
// unavailable since the original run, rather than failing the resume
|
|
2616
|
+
// outright (the note surfaces the swap). Undefined for a fork (record.model
|
|
2617
|
+
// unset — it inherits) or when the record already is the session model.
|
|
2618
|
+
fallbackModel:
|
|
2619
|
+
record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
|
|
2620
|
+
thinking: record.thinking,
|
|
2621
|
+
onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
|
|
2622
|
+
sink: live.sink,
|
|
2623
|
+
onMessageToMain: (message) => notifyAgentMessage(record.taskId, record.name, message),
|
|
2624
|
+
extraTools: spawnToolsFor(record),
|
|
2625
|
+
mainToolCallId: () => mainCallOf(record.taskId),
|
|
2626
|
+
});
|
|
2627
|
+
liveHandles.set(record.taskId, handle);
|
|
2628
|
+
|
|
2629
|
+
if (oneShot) {
|
|
2630
|
+
const outcome = await handle.result;
|
|
2631
|
+
runningIds.delete(record.taskId);
|
|
2632
|
+
live.finish(stoppedTaskIds.has(record.taskId) ? "stopped" : Boolean(outcome.failed));
|
|
2633
|
+
// Auto mode reviews the turn's actions; the gate attaches the verdict to this tool result.
|
|
2634
|
+
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, { toolCallId, actions: outcome.actions } satisfies SubagentActionsPayload);
|
|
2635
|
+
return {
|
|
2636
|
+
content: [
|
|
2637
|
+
{
|
|
2638
|
+
type: "text",
|
|
2639
|
+
text:
|
|
2640
|
+
`${record.name}: ${oneShotNote("agent's turn")} Its reply follows here; there is no background task to poll.\n\n` +
|
|
2641
|
+
`${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}\n\n(${formatStats(outcome.toolCalls, outcome.usage)})`,
|
|
2642
|
+
},
|
|
2643
|
+
],
|
|
2644
|
+
details: { agentRuns: [record] },
|
|
2645
|
+
isError: outcome.failed ?? false,
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
let settledOutput: string | undefined;
|
|
2650
|
+
const task: BackgroundTask = {
|
|
2651
|
+
id: taskId,
|
|
2652
|
+
kind: "subagent",
|
|
2653
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
2654
|
+
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
2655
|
+
status: "running",
|
|
2656
|
+
startedAt: Date.now(),
|
|
2657
|
+
output: () => settledOutput ?? handle.snapshot().text,
|
|
2658
|
+
stop: () => stopAgent(record.taskId, handle),
|
|
2659
|
+
finished,
|
|
2660
|
+
};
|
|
2661
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
2662
|
+
|
|
2663
|
+
void handle.result.then(async (outcome) => {
|
|
2664
|
+
runningIds.delete(record.taskId);
|
|
2665
|
+
// Read before the review: a stop or resume during it must not change this turn's outcome.
|
|
2666
|
+
const stopped = stoppedTaskIds.has(record.taskId);
|
|
2667
|
+
live.finish(stopped ? "stopped" : Boolean(outcome.failed));
|
|
2668
|
+
// Auto mode reviews this turn's actions before the reply goes out, as it
|
|
2669
|
+
// does for a resident's turn; a resumed turn once skipped it
|
|
2670
|
+
// (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M2). The task settles after the
|
|
2671
|
+
// review, so task_output never returns the reply ahead of its verdict.
|
|
2672
|
+
const review = await awaitHandBackReview(pi.events, record, outcome.actions, undefined, mainCallOf(record.taskId));
|
|
2673
|
+
// The outcome includes SubagentHandback and startup/abort errors;
|
|
2674
|
+
// the live snapshot only contains assistant text, not the final report.
|
|
2675
|
+
settledOutput = `${relocationNote}${outcome.output}`;
|
|
2676
|
+
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
2677
|
+
task.finishedAt = Date.now();
|
|
2678
|
+
finish();
|
|
2679
|
+
notifyHandBack({
|
|
2680
|
+
from: record.taskId,
|
|
2681
|
+
taskId,
|
|
2682
|
+
name: record.name,
|
|
2683
|
+
toolUseId: toolCallId,
|
|
2684
|
+
outcome,
|
|
2685
|
+
stopped,
|
|
2686
|
+
startedAt: task.startedAt,
|
|
2687
|
+
report: `${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`,
|
|
2688
|
+
review,
|
|
2689
|
+
});
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
2693
|
+
return {
|
|
2694
|
+
content: [
|
|
2695
|
+
{
|
|
2696
|
+
type: "text",
|
|
2697
|
+
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}`,
|
|
2698
|
+
},
|
|
2699
|
+
],
|
|
2700
|
+
details: { agentRuns: [record], taskId },
|
|
2701
|
+
};
|
|
2702
|
+
};
|
|
2703
|
+
|
|
2704
|
+
const sendMessageTool = defineTool({
|
|
2705
|
+
name: "SendMessage",
|
|
2706
|
+
label: "Send Message",
|
|
2707
|
+
...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
|
|
2708
|
+
title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
|
|
2709
|
+
}),
|
|
2710
|
+
description:
|
|
2711
|
+
'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".)',
|
|
2712
|
+
parameters: SendMessageParams,
|
|
2713
|
+
execute: (toolCallId, params, signal, _onUpdate, ctx) => sendMessage(toolCallId, params, signal, ctx),
|
|
2714
|
+
});
|
|
2715
|
+
pi.registerTool(sendMessageTool);
|
|
2716
|
+
|
|
2717
|
+
/**
|
|
2718
|
+
* What the user submits while viewing an agent goes to that agent, as in
|
|
2719
|
+
* Claude Code (findings §40): Claude Code's mid-turn wrapper, steered into a
|
|
2720
|
+
* running turn or starting one on an idle resident, whose reply notifies
|
|
2721
|
+
* under the agent's own task id. A released or finished agent resumes
|
|
2722
|
+
* through SendMessage's path. Slash commands, skills and `!` shell lines
|
|
2723
|
+
* keep their main-conversation meaning.
|
|
2724
|
+
*/
|
|
2725
|
+
const deliverFromView = async (ctx: ExtensionContext, taskId: string, text: string) => {
|
|
2726
|
+
const record = registry.resolve(taskId);
|
|
2727
|
+
const fail = (reason: string) => ctx.ui.notify(`Could not send the message to ${record?.name ?? "the agent"}: ${reason}`, "error");
|
|
2728
|
+
if (!record) return fail("the agent is gone.");
|
|
2729
|
+
const wrapped = userMessageForAgent(text);
|
|
2730
|
+
const resident = residents.get(record.taskId);
|
|
2731
|
+
if (resident && !resident.handle.exited()) {
|
|
2732
|
+
liveRuns.block(record.taskId, { kind: "user", text });
|
|
2733
|
+
try {
|
|
2734
|
+
await resident.handle.send(wrapped);
|
|
2735
|
+
} catch (error) {
|
|
2736
|
+
fail((error as Error).message);
|
|
2737
|
+
}
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
const result = await sendMessage(`view-${Date.now()}`, { to: record.taskId, message: wrapped }, undefined, ctx);
|
|
2741
|
+
if (result.isError) fail(result.content.map((block) => (block.type === "text" ? block.text : "")).join(" "));
|
|
2742
|
+
};
|
|
2743
|
+
pi.on("input", (event, ctx) => {
|
|
2744
|
+
const taskId = view ? panel.viewedTaskId() : undefined;
|
|
2745
|
+
if (!taskId || event.source !== "interactive") return undefined;
|
|
2746
|
+
const text = event.text.trim();
|
|
2747
|
+
if (!text || text.startsWith("/") || text.startsWith("!")) return undefined;
|
|
2748
|
+
void deliverFromView(ctx, taskId, event.text).catch((error: unknown) => {
|
|
2749
|
+
ctx.ui.notify(`Could not send the message: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2750
|
+
});
|
|
2751
|
+
return { action: "handled" as const };
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
/** A run's model as Claude Code names it in `/tasks` (`Sonnet 5`), else its spec. */
|
|
2755
|
+
const modelLabel = (ctx: ExtensionContext, spec: string | undefined): string | undefined => {
|
|
2756
|
+
if (!spec) return undefined;
|
|
2757
|
+
try {
|
|
2758
|
+
return findConfigured(ctx.modelRegistry.getAvailable(), spec)?.name ?? spec;
|
|
2759
|
+
} catch {
|
|
2760
|
+
return spec; // no registry (a torn-down session)
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* `/tasks`: Claude Code's Background dialog (findings §40): running shells,
|
|
2766
|
+
* running agents, and the finished agents the user viewed. Enter views an
|
|
2767
|
+
* agent or shows a shell's details in the dialog, x stops the selected
|
|
2768
|
+
* running task, Esc closes. Enter on the shells chip below the editor opens
|
|
2769
|
+
* it too.
|
|
2770
|
+
*/
|
|
2771
|
+
const openTasksDialog = async (ctx: ExtensionContext) => {
|
|
2772
|
+
if (!canShowCustomUi(ctx)) {
|
|
2773
|
+
if (ctx.mode !== "rpc") return;
|
|
2774
|
+
const lines = [
|
|
2775
|
+
...shellRows(shellTasks.list(), Date.now()).map((task) => `- ${task.id} [shell, ${task.status}] ${task.command ?? task.description}`),
|
|
2776
|
+
...liveRuns.list().filter((run) => run.depth === 0).map((run) => `- ${run.taskId} [${run.name}, ${run.status}] ${run.label}`),
|
|
2777
|
+
];
|
|
2778
|
+
ctx.ui.notify([
|
|
2779
|
+
"The interactive task viewer requires TUI mode; showing a task snapshot instead.",
|
|
2780
|
+
...(lines.length ? lines : ["No background tasks in this session."]),
|
|
2781
|
+
"Use task_output to inspect a task and task_stop to stop it.",
|
|
2782
|
+
].join("\n"), "info");
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const build = () => {
|
|
2786
|
+
const runs = liveRuns.list().filter((run) => run.depth === 0);
|
|
2787
|
+
// The shells the chip counts, so Enter on a chip whose shell just finished finds it.
|
|
2788
|
+
const shells = shellRows(shellTasks.list(), Date.now()).map((task) => ({
|
|
2789
|
+
kind: "shell" as const,
|
|
2790
|
+
id: task.id,
|
|
2791
|
+
text: task.command ?? task.description,
|
|
2792
|
+
running: task.status === "running",
|
|
2793
|
+
status: task.status,
|
|
2794
|
+
}));
|
|
2795
|
+
const agents = [
|
|
2796
|
+
...runs.filter((run) => run.status === "running"),
|
|
2797
|
+
...runs.filter((run) => run.status !== "running" && run.viewed),
|
|
2798
|
+
].map((run) => ({ kind: "agent" as const, taskId: run.taskId, text: run.label, model: modelLabel(ctx, run.model), running: run.status === "running" }));
|
|
2799
|
+
return { shells, agents };
|
|
2800
|
+
};
|
|
2801
|
+
let selected = 0;
|
|
2802
|
+
// Rebuilt when a run or shell changes, not per frame or keystroke.
|
|
2803
|
+
let snapshot = build();
|
|
2804
|
+
// The rows on screen: keys act on what the user sees, even when a change
|
|
2805
|
+
// has rebuilt the snapshot and its render is still pending.
|
|
2806
|
+
let shown = tasksItems(snapshot);
|
|
2807
|
+
// The list, or one shell's details inside the same dialog.
|
|
2808
|
+
let dialogView: TasksView = { mode: "list" };
|
|
2809
|
+
const shellById = (id: string) => shellTasks.list().find((task) => task.id === id);
|
|
2810
|
+
const stopShell = (task: { stop(): void } | undefined) => {
|
|
2811
|
+
try {
|
|
2812
|
+
task?.stop();
|
|
2813
|
+
} catch {
|
|
2814
|
+
// A dead process's stop() throwing must not break the dialog.
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2817
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<string | null>((tui, theme, _keybindings, done) => {
|
|
2818
|
+
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
2819
|
+
const refresh = () => {
|
|
2820
|
+
snapshot = build();
|
|
2821
|
+
tui.requestRender();
|
|
2822
|
+
};
|
|
2823
|
+
const unsubscribe = liveRuns.subscribe(refresh);
|
|
2824
|
+
const unsubscribeShells = shellTasks.subscribe(refresh);
|
|
2825
|
+
// A running shell's details follow its runtime and output once a second,
|
|
2826
|
+
// and the list drops a finished shell once its linger window ends, as
|
|
2827
|
+
// the chip does (a rebuild only when that row set changes).
|
|
2828
|
+
const ticker = setInterval(() => {
|
|
2829
|
+
if (dialogView.mode === "shell") {
|
|
2830
|
+
if (shellById(dialogView.id)?.status === "running") tui.requestRender();
|
|
2831
|
+
} else if (snapshot.shells.some((shell) => !shell.running) && shellRows(shellTasks.list(), Date.now()).length !== snapshot.shells.length) {
|
|
2832
|
+
refresh();
|
|
2833
|
+
}
|
|
2834
|
+
}, 1000);
|
|
2835
|
+
ticker.unref?.();
|
|
2836
|
+
const finish = (value: string | null) => {
|
|
2837
|
+
clearInterval(ticker);
|
|
2838
|
+
unsubscribe();
|
|
2839
|
+
unsubscribeShells();
|
|
2840
|
+
done(value);
|
|
2841
|
+
};
|
|
2842
|
+
return {
|
|
2843
|
+
render: (width: number) => {
|
|
2844
|
+
const task = dialogView.mode === "shell" ? shellById(dialogView.id) : undefined;
|
|
2845
|
+
dialogView = settleTasksView(dialogView, task !== undefined);
|
|
2846
|
+
if (dialogView.mode === "shell" && task) {
|
|
2847
|
+
return ["", ...renderShellDetail(task, width - 2, Date.now(), paint).map((line) => ` ${line}`), ""].map((line) => truncateLine(line, width));
|
|
2848
|
+
}
|
|
2849
|
+
shown = tasksItems(snapshot);
|
|
2850
|
+
selected = Math.max(0, Math.min(selected, shown.length - 1));
|
|
2851
|
+
return ["", ...renderTasksDialog({ ...snapshot, selected, width }, paint), ""].map((line) => truncateLine(line, width));
|
|
2852
|
+
},
|
|
2853
|
+
handleInput: (data: string) => {
|
|
2854
|
+
if (isKeyRelease(data)) return;
|
|
2855
|
+
const id = keyId(data);
|
|
2856
|
+
if (dialogView.mode === "shell") {
|
|
2857
|
+
const task = shellById(dialogView.id);
|
|
2858
|
+
const action = shellDetailKey(id, keyText(data), task?.status === "running");
|
|
2859
|
+
if (action === "close") return finish(null);
|
|
2860
|
+
if (action === "back") dialogView = { mode: "list" };
|
|
2861
|
+
else if (action === "stop") stopShell(task);
|
|
2862
|
+
tui.requestRender();
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
const items = shown;
|
|
2866
|
+
if (id === "escape") return finish(null);
|
|
2867
|
+
if (id === "up" || id === "down") {
|
|
2868
|
+
selected = moveTasksSelection(selected, id, items.length);
|
|
2869
|
+
tui.requestRender();
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const item = items[selected];
|
|
2873
|
+
if (id === "enter" && item?.kind === "agent") return finish(item.taskId);
|
|
2874
|
+
if (id === "enter" && item?.kind === "shell") {
|
|
2875
|
+
dialogView = { mode: "shell", id: item.id };
|
|
2876
|
+
tui.requestRender();
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
if (keyText(data)?.toLowerCase() === "x" && item?.running) {
|
|
2880
|
+
if (item.kind === "agent") void stopAgent(item.taskId);
|
|
2881
|
+
else stopShell(shellById(item.id));
|
|
2882
|
+
tui.requestRender();
|
|
2883
|
+
}
|
|
2884
|
+
},
|
|
2885
|
+
invalidate: () => {},
|
|
2886
|
+
};
|
|
2887
|
+
});
|
|
2888
|
+
if (chosen) openView(ctx, chosen);
|
|
2889
|
+
};
|
|
2890
|
+
registerLocalCommand(pi, "tasks", {
|
|
2891
|
+
description: "List background tasks: shells, running agents, and the agents you viewed",
|
|
2892
|
+
handler: async (_args, ctx) => openTasksDialog(ctx as ExtensionContext),
|
|
2242
2893
|
});
|
|
2243
2894
|
pi.events.emit(DEFER_CHANNEL, { name: "SendMessage", keywords: ["message", "agent", "resume", "continue", "teammate"] });
|
|
2244
2895
|
|
|
@@ -2279,7 +2930,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2279
2930
|
};
|
|
2280
2931
|
},
|
|
2281
2932
|
});
|
|
2282
|
-
|
|
2933
|
+
// Eager, as Claude Code's ListAgents is: not emitted on the deferral channel.
|
|
2283
2934
|
|
|
2284
2935
|
registerLocalCommand(pi, "agents", {
|
|
2285
2936
|
description: "Open the live subagent panel, or list available agents",
|
|
@@ -2287,7 +2938,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2287
2938
|
// With live children, focus the strip on the newest and open its view
|
|
2288
2939
|
// (Claude Code's agent panel); otherwise there is nothing running, so
|
|
2289
2940
|
// list the catalog the way the tool's action:"list" does.
|
|
2290
|
-
if (ctx.hasUI && panel.rowCount() > 1) {
|
|
2941
|
+
if (ctx.hasUI && ctx.mode === "tui" && panel.rowCount() > 1) {
|
|
2291
2942
|
panel.setFocus(1); // row 0 is `main`; 1 is the newest live child
|
|
2292
2943
|
const run = panel.selectedRun();
|
|
2293
2944
|
if (run) {
|
|
@@ -2295,6 +2946,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2295
2946
|
return;
|
|
2296
2947
|
}
|
|
2297
2948
|
}
|
|
2949
|
+
if (ctx.mode === "rpc" && registry.list().length) {
|
|
2950
|
+
ctx.ui.notify([
|
|
2951
|
+
"The live subagent panel requires TUI mode; showing spawned agents instead.",
|
|
2952
|
+
...registry.list().map((run) => `- ${run.name} [${run.agent}] — ${agentStatus(run)} (task ${run.taskId})`),
|
|
2953
|
+
"Use /tasks for task status; task_output and task_stop inspect or stop a task.",
|
|
2954
|
+
].join("\n"), "info");
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2298
2957
|
ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
|
|
2299
2958
|
},
|
|
2300
2959
|
});
|
|
@@ -2317,6 +2976,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2317
2976
|
: "subagentModel: (not set)",
|
|
2318
2977
|
`effective: ${resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : "none"} (${resolution.source})`,
|
|
2319
2978
|
...(resolution.notices.length ? [resolution.notices.join("\n")] : []),
|
|
2979
|
+
...(resolution.source === "default" && resolution.model
|
|
2980
|
+
? chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) }).map((warning) => `! ${warning.text} ${warning.fix}`)
|
|
2981
|
+
: []),
|
|
2320
2982
|
"Set it with /subagent <provider/model-id|sonnet|opus|haiku|fable|inherit>, or clear with /subagent clear.",
|
|
2321
2983
|
].join("\n"),
|
|
2322
2984
|
"info",
|
|
@@ -2388,16 +3050,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2388
3050
|
ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
|
|
2389
3051
|
return;
|
|
2390
3052
|
}
|
|
2391
|
-
emitModelStatus(ctx);
|
|
2392
3053
|
ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.onecode/settings.json).`, "info");
|
|
3054
|
+
// What the choice costs, shown now even if an earlier choice already
|
|
3055
|
+
// raised the same line; marked noticed so the status refresh is quiet.
|
|
3056
|
+
if (resolved) {
|
|
3057
|
+
for (const warning of chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolved, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
|
|
3058
|
+
const message = `${warning.text} ${warning.fix}`;
|
|
3059
|
+
noticedModels.add(message);
|
|
3060
|
+
ctx.ui.notify(message, "warning");
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
emitModelStatus(ctx);
|
|
2393
3064
|
};
|
|
2394
3065
|
|
|
2395
3066
|
registerLocalCommand(pi, "subagent", {
|
|
2396
3067
|
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 })),
|
|
3068
|
+
argumentHint: "[provider/model-id|inherit|status|clear]",
|
|
3069
|
+
getArgumentCompletions: (prefix) => extendingCompletions(prefix, ["inherit", "status", "clear"].map((value) => ({ value, label: value }))),
|
|
2401
3070
|
handler: async (args, ctx) => {
|
|
2402
3071
|
const typed = args.trim();
|
|
2403
3072
|
if (typed === "clear") {
|
|
@@ -2441,7 +3110,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
2441
3110
|
const current = configured && configured.spec.includes("/") ? configured.spec : undefined;
|
|
2442
3111
|
const entries = toPickerEntries(available);
|
|
2443
3112
|
|
|
2444
|
-
const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
3113
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
2445
3114
|
modelPickerComponent(
|
|
2446
3115
|
{
|
|
2447
3116
|
entries,
|