one-code-extension 0.1.4 → 0.3.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/LICENSE +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -1,58 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* subagents extension — Claude Code's Agent/Task tool, plus send_message.
|
|
3
3
|
*
|
|
4
|
-
* Delegates a task to a specialist agent running in
|
|
5
|
-
* its own context window. Agent definitions
|
|
4
|
+
* Delegates a task to a specialist agent running in-process (its own
|
|
5
|
+
* AgentSession, see runner.ts) with its own context window. Agent definitions
|
|
6
|
+
* come from `.claude/agents/*.md`
|
|
6
7
|
* (project), `~/.claude/agents/*.md` (user), and the catalog bundled with this
|
|
7
8
|
* package — the same markdown + frontmatter format Claude Code uses.
|
|
8
9
|
*
|
|
9
10
|
* Claude Code features covered: parallel tasks, per-call `model`/`thinking`
|
|
10
11
|
* overrides, `fork` (a child inheriting this conversation), `isolation:
|
|
11
|
-
* "worktree"`,
|
|
12
|
-
* task_output/task_stop,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* "worktree"`, background execution (every run returns immediately — CC parity
|
|
13
|
+
* — stays addressable via task_output/task_stop, and steers its completion
|
|
14
|
+
* into the conversation as a system notification), and SendMessage (reach a
|
|
15
|
+
* running agent live, or resume a finished one with its context intact —
|
|
16
|
+
* children persist their sessions per run to make that possible).
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
18
20
|
import os from "node:os";
|
|
19
21
|
import { dirname, join } from "node:path";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
21
23
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
22
|
-
import type {
|
|
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";
|
|
23
26
|
import { Type } from "typebox";
|
|
24
27
|
import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
|
|
25
28
|
import { type AgentDefinition, type AgentSource, agentDirs, discoverAgents } from "./agents.ts";
|
|
26
|
-
import {
|
|
29
|
+
import { modelIdentity, modelSpec } from "../lib/model-policy.ts";
|
|
30
|
+
import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent, withoutUnusable } from "../lib/model-unusable.ts";
|
|
31
|
+
import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel, type SubagentDefault } from "./default-model.ts";
|
|
27
32
|
import {
|
|
28
33
|
expensiveModelGate,
|
|
29
34
|
resolveSubagentModel,
|
|
35
|
+
type SubagentModelResolution,
|
|
30
36
|
SUBAGENT_STATUS_CHANNEL,
|
|
37
|
+
subagentModelNotes,
|
|
31
38
|
subagentModelsReminder,
|
|
32
39
|
subagentStatusModel,
|
|
33
40
|
} from "./model-select.ts";
|
|
34
41
|
import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../auto-mode/model-picker.ts";
|
|
35
|
-
import { discoverPlugins } from "../lib/plugins.ts";
|
|
42
|
+
import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
|
|
36
43
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
44
|
+
import { MCP_TOOLS_CHANNEL, type McpToolsPayload } from "../lib/mcp-share.ts";
|
|
45
|
+
import { resolveModelTier } from "../lib/model-tier.ts";
|
|
46
|
+
import { pendingClaimReminder } from "./pending-claim.ts";
|
|
47
|
+
import { watchPermissionBridge } from "../permissions/subagent-gate.ts";
|
|
48
|
+
import { watchHookBridge } from "../hooks/subagent-bridge.ts";
|
|
37
49
|
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
38
50
|
import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
|
|
39
51
|
import { type ChildAction } from "../auto-mode/actions.ts";
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
forkTaskMessage,
|
|
44
|
-
OUTPUT_CAP,
|
|
45
|
-
type RpcChildHandle,
|
|
46
|
-
startChild,
|
|
47
|
-
startRpcChild,
|
|
48
|
-
} from "./child.ts";
|
|
49
|
-
import { type AgentRunRecord, nextRunName, RunRegistry } from "./runs.ts";
|
|
52
|
+
import { type ChildOutcome, forkTaskMessage, OUTPUT_CAP, type RpcChildHandle } from "./outcome.ts";
|
|
53
|
+
import { type AgentRunRecord, resolveRunName, RunRegistry } from "./runs.ts";
|
|
54
|
+
import { SubagentRuntime } from "./runner.ts";
|
|
50
55
|
import { emptyUsage, formatStats, type UsageTotals } from "./usage.ts";
|
|
51
56
|
import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "./worktree.ts";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
import { findGitRoot } from "../lib/git.ts";
|
|
58
|
+
import { registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
|
|
59
|
+
import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, systemNotification } from "../lib/notifications.ts";
|
|
60
|
+
import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
|
|
61
|
+
import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
|
|
62
|
+
import { deriveActivity, type FinishOutcome, LiveRunRegistry } from "./live-runs.ts";
|
|
63
|
+
import { DELEGATION_STEER } from "./delegation-steer.ts";
|
|
64
|
+
import { awaitHandBackReview, withReview } from "./hand-back-review.ts";
|
|
65
|
+
import type { LiveSink } from "./runner.ts";
|
|
66
|
+
import { recordUsage } from "../lib/usage-bus.ts";
|
|
67
|
+
import { SUBAGENT_DEFAULT_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
|
|
68
|
+
import { SubagentWidget } from "./panel-widget.ts";
|
|
69
|
+
import { type ProseRenderer, renderTranscript } from "./panel-render.ts";
|
|
70
|
+
import { decodeStripKey, type StripKey } from "./panel-keys.ts";
|
|
71
|
+
import { reduceShellKey } from "./shell-panel.ts";
|
|
72
|
+
import { trackShellTasks } from "../lib/shell-tasks.ts";
|
|
73
|
+
import { createMarkdownProse } from "./prose.ts";
|
|
56
74
|
|
|
57
75
|
/** The catalog shipped in this package: <package>/agents. */
|
|
58
76
|
const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
|
|
@@ -64,10 +82,34 @@ interface RunRequest {
|
|
|
64
82
|
/** Inherit the caller's conversation instead of starting from an agent prompt. */
|
|
65
83
|
fork?: boolean;
|
|
66
84
|
model?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Session model to fall back to if `model` (a non-per-call automatic/default
|
|
87
|
+
* pick) cannot spawn. Unset for a per-call model, which surfaces its error to
|
|
88
|
+
* the main model to retry rather than being silently swapped. See the runner's
|
|
89
|
+
* buildChildSession.
|
|
90
|
+
*/
|
|
91
|
+
fallbackModel?: string;
|
|
67
92
|
thinking?: string;
|
|
68
93
|
worktree?: boolean;
|
|
69
94
|
}
|
|
70
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The session model a broken *automatic* pick should fall back to at spawn.
|
|
98
|
+
* Undefined for a per-call model (source "call") — that surfaces its error to
|
|
99
|
+
* the main model to retry rather than being silently swapped — and when the
|
|
100
|
+
* pick already IS the session model. `resolvedSpec` is the caller's already-
|
|
101
|
+
* computed `provider/id` for the resolved model.
|
|
102
|
+
*/
|
|
103
|
+
function spawnFallbackModel(
|
|
104
|
+
resolvedSpec: string | undefined,
|
|
105
|
+
source: SubagentModelResolution["source"],
|
|
106
|
+
sessionModel: { provider: string; id: string } | undefined,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
if (!sessionModel || source === "call" || !resolvedSpec) return undefined;
|
|
109
|
+
const sessionSpec = modelSpec(sessionModel);
|
|
110
|
+
return resolvedSpec !== sessionSpec ? sessionSpec : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
interface TaskResult {
|
|
72
114
|
agent: string;
|
|
73
115
|
name: string;
|
|
@@ -83,28 +125,23 @@ interface TaskResult {
|
|
|
83
125
|
actions?: ChildAction[];
|
|
84
126
|
}
|
|
85
127
|
|
|
86
|
-
const TaskShape = Type.Object({
|
|
87
|
-
agent: Type.String({
|
|
88
|
-
description: 'Agent name from the "Available agents" system reminder, or "fork" to clone this conversation',
|
|
89
|
-
}),
|
|
90
|
-
task: Type.String({ description: "Complete, self-contained instruction — the agent cannot ask follow-ups" }),
|
|
91
|
-
name: Type.Optional(Type.String({ description: "Name for this run, usable later with send_message (default: <agent>-<n>)" })),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
128
|
const SubagentParams = Type.Object({
|
|
95
|
-
|
|
129
|
+
subagent_type: Type.Optional(
|
|
96
130
|
Type.String({
|
|
97
131
|
description:
|
|
98
|
-
'
|
|
132
|
+
'The agent to run — a name from the "Available agents" system reminder, or "fork" to clone this conversation. Required unless action:"list". To run several in parallel, issue multiple Agent tool calls in one turn.',
|
|
99
133
|
}),
|
|
100
134
|
),
|
|
101
135
|
task: Type.Optional(
|
|
102
|
-
Type.String({ description:
|
|
103
|
-
),
|
|
104
|
-
name: Type.Optional(Type.String({ description: "Name for a single run, usable later with send_message" })),
|
|
105
|
-
tasks: Type.Optional(
|
|
106
|
-
Type.Array(TaskShape, { description: `Run several agents in parallel (max ${MAX_PARALLEL} at a time)` }),
|
|
136
|
+
Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups). Required with subagent_type." }),
|
|
107
137
|
),
|
|
138
|
+
// `prompt` is Claude Code's name for the task text and `description` its short
|
|
139
|
+
// title. A CC-trained model writes `{subagent_type, description, prompt}`; pi
|
|
140
|
+
// does not reject unknown keys, so without these the task silently became ""
|
|
141
|
+
// (TOOL-FIDELITY-REVIEW-2026-09-07 H1). Accept both as aliases.
|
|
142
|
+
prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it) — the task for the agent to perform." })),
|
|
143
|
+
description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task, shown as the run's title." })),
|
|
144
|
+
name: Type.Optional(Type.String({ description: "Name for this run, usable later with SendMessage (default: <agent>-<n>)" })),
|
|
108
145
|
model: Type.Optional(
|
|
109
146
|
Type.String({
|
|
110
147
|
description:
|
|
@@ -125,13 +162,7 @@ const SubagentParams = Type.Object({
|
|
|
125
162
|
),
|
|
126
163
|
isolation: Type.Optional(
|
|
127
164
|
StringEnum(["worktree"] as const, {
|
|
128
|
-
description: "Run
|
|
129
|
-
}),
|
|
130
|
-
),
|
|
131
|
-
run_in_background: Type.Optional(
|
|
132
|
-
Type.Boolean({
|
|
133
|
-
description:
|
|
134
|
-
"Return immediately with a task id instead of waiting. Completion arrives as a system notification; inspect with task_output, stop with task_stop",
|
|
165
|
+
description: "Run the agent in its own git worktree so its file edits are isolated from the main checkout",
|
|
135
166
|
}),
|
|
136
167
|
),
|
|
137
168
|
action: Type.Optional(
|
|
@@ -141,60 +172,264 @@ const SubagentParams = Type.Object({
|
|
|
141
172
|
),
|
|
142
173
|
});
|
|
143
174
|
|
|
144
|
-
async function runPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
145
|
-
const results: R[] = new Array(items.length);
|
|
146
|
-
let next = 0;
|
|
147
|
-
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
148
|
-
while (next < items.length) {
|
|
149
|
-
const index = next++;
|
|
150
|
-
results[index] = await fn(items[index]);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
await Promise.all(workers);
|
|
154
|
-
return results;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
175
|
export const FORK_AGENT = "fork";
|
|
158
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The anti-fabrication sentence, carried by the tool RESULT of a background
|
|
179
|
+
* spawn and not only by the Agent tool's description. Claude Code puts it in the
|
|
180
|
+
* result ("You know nothing about its results until that notification
|
|
181
|
+
* arrives…"), and a cheap-tier model that had the description in every request
|
|
182
|
+
* still wrote a pending agent's answer in the message before the notification
|
|
183
|
+
* arrived, then read the real notification as confirmation of what it had
|
|
184
|
+
* invented (WEAK-MODEL-REVIEW-2026-09-06 H1). The description keeps its own
|
|
185
|
+
* copy; this is the one the model reads at the point of action.
|
|
186
|
+
*/
|
|
187
|
+
const PENDING_RESULT_PROHIBITION =
|
|
188
|
+
"You know nothing about the agent's result until that notification arrives: do not report, assume, or predict it, in prose or otherwise. If the request asks you to report what the agent found, that IS a step that cannot proceed without the result — call task_output with block=true and wait, rather than ending your turn with an answer you do not have yet.";
|
|
189
|
+
|
|
190
|
+
/** Floor between interim output.log rewrites (onProgress fires per tool call/message). */
|
|
191
|
+
const LOG_WRITE_INTERVAL_MS = 250;
|
|
192
|
+
/** Idle time after which a resident agent session is released (its session file keeps it reachable). */
|
|
193
|
+
const RESIDENT_IDLE_MS = 15 * 60_000;
|
|
194
|
+
|
|
159
195
|
/** A background agent's process, kept alive after its run so it can be messaged. */
|
|
160
196
|
interface Resident {
|
|
161
197
|
handle: RpcChildHandle;
|
|
162
|
-
/**
|
|
163
|
-
|
|
198
|
+
/**
|
|
199
|
+
* FIFO — the head entry handles the next turn_end (initial task, then one per
|
|
200
|
+
* idle-time message). `review` is auto mode's rendered hand-back flag for
|
|
201
|
+
* that turn (undefined when clean or auto mode is off); the handler puts it
|
|
202
|
+
* ahead of the report in its notification.
|
|
203
|
+
*/
|
|
204
|
+
turnHandlers: Array<(outcome: ChildOutcome, review: string | undefined) => void>;
|
|
164
205
|
}
|
|
165
206
|
|
|
166
207
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
167
208
|
const registry = new RunRegistry();
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Task ids with a blocking child (nested spawn or SendMessage resume) currently
|
|
211
|
+
* running (SendMessage must wait for these). Keyed by task id, like `residents`:
|
|
212
|
+
* run NAMES repeat across a session (the model reuses "reviewer"), and a name key
|
|
213
|
+
* routed a message addressed to the older task id to the newer agent (M1).
|
|
214
|
+
*/
|
|
215
|
+
const runningIds = new Set<string>();
|
|
216
|
+
/** Task id → live resident. */
|
|
171
217
|
const residents = new Map<string, Resident>();
|
|
218
|
+
/**
|
|
219
|
+
* Task ids of background runs spawned during the agent loop now in flight.
|
|
220
|
+
* Cleared at `agent_start`; read at `agent_end` to notice a turn that ended
|
|
221
|
+
* with an agent still pending, which is the shape of the fabricated-result
|
|
222
|
+
* failure (pending-claim.ts, WEAK-MODEL-REVIEW-2026-09-06 H1).
|
|
223
|
+
*/
|
|
224
|
+
const spawnedThisLoop = new Set<string>();
|
|
225
|
+
|
|
226
|
+
// The live subagent panel (Claude Code's below-editor agent tree): a registry
|
|
227
|
+
// of every in-process child fed by the runner's live sink, a below-editor
|
|
228
|
+
// strip, and taskId→kill handles for the viewer's stop/stop-all. The same
|
|
229
|
+
// widget carries the background-shell manager (chip → list → details), fed
|
|
230
|
+
// by the bash tasks that cross TASK_REGISTER_CHANNEL.
|
|
231
|
+
const liveRuns = new LiveRunRegistry();
|
|
232
|
+
/** taskId → kill handle: a resident's kill resolves when it has exited; a blocking run exposes `result`. */
|
|
233
|
+
const liveHandles = new Map<string, { kill(): void | Promise<void>; result?: Promise<unknown> }>();
|
|
234
|
+
/**
|
|
235
|
+
* Task ids the user asked to stop (x / ctrl+x ctrl+k / session teardown). A
|
|
236
|
+
* kill still surfaces as an exit, but it neither completed nor failed — so the
|
|
237
|
+
* strip and the notification read this to say "Stopped" instead of the
|
|
238
|
+
* misleading "Completed"/"failed" (TUI-REVIEW L1). Cleared when a run (re)starts.
|
|
239
|
+
*/
|
|
240
|
+
const stoppedTaskIds = new Set<string>();
|
|
241
|
+
let lastCtx: ExtensionContext | undefined;
|
|
242
|
+
const shellTasks = trackShellTasks(pi);
|
|
243
|
+
const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Register a child in the live panel and return the wiring the spawn sites
|
|
247
|
+
* need: a runner `sink` (activity + transcript blocks), a `progress` hook to
|
|
248
|
+
* fold into the existing onProgress, and settle/finish terminal marks.
|
|
249
|
+
*/
|
|
250
|
+
const trackLiveRun = (record: AgentRunRecord, request: RunRequest, parent?: { taskId: string; depth: number }) => {
|
|
251
|
+
// A (re)starting run is no longer stopped — a resumed agent that was killed
|
|
252
|
+
// earlier must not inherit the "Stopped" mark.
|
|
253
|
+
stoppedTaskIds.delete(record.taskId);
|
|
254
|
+
// A resumed run (SendMessage to a finished agent) re-enters its existing
|
|
255
|
+
// panel entry; only a never-seen taskId registers fresh.
|
|
256
|
+
if (!liveRuns.reactivate(record.taskId, request.task, Date.now())) {
|
|
257
|
+
liveRuns.register({
|
|
258
|
+
taskId: record.taskId,
|
|
259
|
+
name: record.name,
|
|
260
|
+
agentType: request.agent,
|
|
261
|
+
model: request.model,
|
|
262
|
+
thinking: request.thinking,
|
|
263
|
+
task: request.task,
|
|
264
|
+
startedAt: Date.now(),
|
|
265
|
+
parentTaskId: parent?.taskId,
|
|
266
|
+
depth: record.depth ?? 0,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const sink: LiveSink = {
|
|
270
|
+
onActivity: (tool, args, text) => liveRuns.setActivity(record.taskId, deriveActivity(tool, args, text)),
|
|
271
|
+
onBlock: (block) => liveRuns.block(record.taskId, block),
|
|
272
|
+
onStreaming: (message) => liveRuns.setStreaming(record.taskId, message),
|
|
273
|
+
onUsage: (usage) => recordUsage(pi, "subagent", usage),
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
sink,
|
|
277
|
+
progress: (toolCalls: number, usage: UsageTotals) => liveRuns.stats(record.taskId, toolCalls, usage),
|
|
278
|
+
settle: () => liveRuns.settle(record.taskId),
|
|
279
|
+
finish: (outcome: FinishOutcome) => {
|
|
280
|
+
liveRuns.finish(record.taskId, outcome);
|
|
281
|
+
liveHandles.delete(record.taskId);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Live MCP tool definitions published by the mcp extension; injected into
|
|
287
|
+
// child sessions so subagents share the parent's open connections (no reconnect).
|
|
288
|
+
// Empty when no MCP servers are configured, so this is a no-op for most sessions.
|
|
289
|
+
// The parent's MCP connect runs in the background, so a spawn in the first
|
|
290
|
+
// seconds of a session waits for the settled publish (capped, so a hung server
|
|
291
|
+
// can't stall spawns) instead of baking in a still-connecting snapshot.
|
|
292
|
+
const MCP_SETTLE_CAP_MS = 10_000;
|
|
293
|
+
let mcpTools: ToolDefinition[] = [];
|
|
294
|
+
let mcpSettled = false;
|
|
295
|
+
let resolveMcpSettled: (() => void) | undefined;
|
|
296
|
+
const mcpSettledPromise = new Promise<void>((resolve) => {
|
|
297
|
+
resolveMcpSettled = resolve;
|
|
298
|
+
});
|
|
299
|
+
pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
|
|
300
|
+
const payload = data as McpToolsPayload | undefined;
|
|
301
|
+
mcpTools = payload?.tools ?? [];
|
|
302
|
+
if (payload?.settled) {
|
|
303
|
+
mcpSettled = true;
|
|
304
|
+
resolveMcpSettled?.();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
const awaitMcpTools = async (): Promise<ToolDefinition[]> => {
|
|
308
|
+
if (!mcpSettled) {
|
|
309
|
+
await Promise.race([
|
|
310
|
+
mcpSettledPromise,
|
|
311
|
+
new Promise<void>((resolve) => {
|
|
312
|
+
const timer = setTimeout(resolve, MCP_SETTLE_CAP_MS);
|
|
313
|
+
timer.unref?.();
|
|
314
|
+
}),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
return mcpTools;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// The parent permissions extension's decision closure, used to gate a child's
|
|
321
|
+
// tool calls through the real pipeline (mode inheritance, classifier, prompts
|
|
322
|
+
// bubbled to the user). Undefined until permissions emits it at session start;
|
|
323
|
+
// a child built before then falls back to the fail-closed local gate.
|
|
324
|
+
const getPermissionBridge = watchPermissionBridge(pi);
|
|
325
|
+
// Likewise the hooks extension's bridge: the user's PreToolUse/PostToolUse
|
|
326
|
+
// hooks run for a child's tool calls too (hooks/subagent-bridge.ts).
|
|
327
|
+
const getHookBridge = watchHookBridge(pi);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Models this account cannot run, learned during the session: the auto-mode
|
|
331
|
+
* classifier's rejections arrive on the shared channel, a child's own
|
|
332
|
+
* provider refusal is reported by the runtime. Both roles pick from one
|
|
333
|
+
* floor, so both would otherwise keep landing on the same refused model
|
|
334
|
+
* (lib/model-unusable.ts). `usableModels` is the catalog every resolution
|
|
335
|
+
* and menu here reads.
|
|
336
|
+
*/
|
|
337
|
+
const unusableModels = new Set<string>();
|
|
338
|
+
const usableModels = (ctx: ExtensionContext) => withoutUnusable(ctx.modelRegistry.getAvailable(), unusableModels);
|
|
339
|
+
const rememberUnusable = (model: string) => {
|
|
340
|
+
if (unusableModels.has(model)) return false;
|
|
341
|
+
unusableModels.add(model);
|
|
342
|
+
return true;
|
|
343
|
+
};
|
|
344
|
+
pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
|
|
345
|
+
// The default may have been that model: recompute and republish the
|
|
346
|
+
// reminder/banner so the next request already names the replacement.
|
|
347
|
+
const event = data as ModelUnusableEvent;
|
|
348
|
+
if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** The in-process runner, built lazily on first run and shared across all runs. */
|
|
352
|
+
let runtimePromise: Promise<SubagentRuntime> | undefined;
|
|
353
|
+
const getRuntime = (ctx: ExtensionContext) =>
|
|
354
|
+
(runtimePromise ??= SubagentRuntime.create(
|
|
355
|
+
ctx.cwd,
|
|
356
|
+
awaitMcpTools,
|
|
357
|
+
getPermissionBridge,
|
|
358
|
+
getHookBridge,
|
|
359
|
+
// A child extension handler threw. pi's wrapper swallows it inside the
|
|
360
|
+
// child, so this is the only trace; shown in the parent's UI (stderr
|
|
361
|
+
// when headless), through the live ctx so a late one cannot throw.
|
|
362
|
+
(runName, error) => {
|
|
363
|
+
const text = `Subagent ${runName ?? "(unnamed)"}: extension error in ${error.event} (${error.extensionPath}): ${error.error}`;
|
|
364
|
+
const live = liveUiCtx(lastCtx);
|
|
365
|
+
if (live) live.ui.notify(text, "warning");
|
|
366
|
+
else process.stderr.write(`${text}\n`);
|
|
367
|
+
},
|
|
368
|
+
(model, reason) => {
|
|
369
|
+
if (!rememberUnusable(model)) return;
|
|
370
|
+
pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
|
|
371
|
+
const live = liveUiCtx(lastCtx);
|
|
372
|
+
live?.ui.notify(`Subagent model ${model} is not usable on this account (${reason}); automatic selection skips it from now on.`, "warning");
|
|
373
|
+
if (lastCtx) emitModelStatus(lastCtx);
|
|
374
|
+
},
|
|
375
|
+
));
|
|
172
376
|
|
|
173
377
|
const loadAgents = (cwd: string) => {
|
|
174
378
|
// Plugin agents sit between bundled and user definitions, and are exposed
|
|
175
379
|
// namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
|
|
176
380
|
const sources: Array<string | AgentSource> = [
|
|
177
381
|
BUNDLED_AGENTS_DIR,
|
|
178
|
-
...discoverPlugins(
|
|
382
|
+
...discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd)).agentDirs,
|
|
179
383
|
...agentDirs(cwd, os.homedir()),
|
|
180
384
|
];
|
|
181
385
|
return discoverAgents(sources);
|
|
182
386
|
};
|
|
183
387
|
|
|
388
|
+
// Claude Code closes every catalog row with a tools clause — "(Tools: *)",
|
|
389
|
+
// "(Tools: All tools except Edit, Write)", "(Tools: Bash, Read)" — the one
|
|
390
|
+
// place it tells the model an agent cannot edit. Our frontmatter enforces
|
|
391
|
+
// `tools`/`excludeTools` but never showed them (TOOL-FIDELITY-REVIEW M4).
|
|
392
|
+
const toolsClause = (a: AgentDefinition): string => {
|
|
393
|
+
if (a.tools && a.tools.length > 0) return ` (Tools: ${a.tools.join(", ")})`;
|
|
394
|
+
if (a.excludeTools && a.excludeTools.length > 0) return ` (Tools: All tools except ${a.excludeTools.join(", ")})`;
|
|
395
|
+
return " (Tools: *)";
|
|
396
|
+
};
|
|
397
|
+
|
|
184
398
|
const describeAgents = (cwd: string) => {
|
|
185
399
|
const agents = loadAgents(cwd);
|
|
186
|
-
const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}`);
|
|
400
|
+
const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
|
|
187
401
|
lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
|
|
188
402
|
return lines.join("\n");
|
|
189
403
|
};
|
|
190
404
|
|
|
191
405
|
const reconstructRuns = (ctx: ExtensionContext) => {
|
|
406
|
+
/** Loop-invariant; computed lazily so worktree-free histories pay nothing. */
|
|
407
|
+
let sharedRoot: string | undefined;
|
|
408
|
+
/** A resident messaged N times embeds its record N times; stat and register each worktree once. */
|
|
409
|
+
const worktreesSeen = new Set<string>();
|
|
192
410
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
193
411
|
if (entry.type !== "message") continue;
|
|
194
412
|
const msg = entry.message;
|
|
195
|
-
|
|
413
|
+
// Both the current names and the pre-rename ones, so runs recorded before the
|
|
414
|
+
// Agent/SendMessage rename still reconstruct on resume.
|
|
415
|
+
if (msg.role !== "toolResult" || !["Agent", "SendMessage", "subagent", "send_message"].includes(msg.toolName ?? "")) continue;
|
|
196
416
|
const records = (msg.details as { agentRuns?: AgentRunRecord[] } | undefined)?.agentRuns;
|
|
197
|
-
for (const record of records ?? [])
|
|
417
|
+
for (const record of records ?? []) {
|
|
418
|
+
registry.add(record);
|
|
419
|
+
// A kept worktree can host a resumed run in a NEW process, where
|
|
420
|
+
// createWorktree's registration no longer exists — re-register so the
|
|
421
|
+
// git-isolation guard survives the restart. A removed worktree's entry
|
|
422
|
+
// is inert (no session runs there any more). findGitRoot instead of
|
|
423
|
+
// `git rev-parse` because session_start handlers must stay fast
|
|
424
|
+
// (findings §15); a slightly-off sharedRoot only softens one message —
|
|
425
|
+
// the worktree containment check itself uses the exact record.cwd.
|
|
426
|
+
if (record.worktree && !worktreesSeen.has(record.cwd)) {
|
|
427
|
+
worktreesSeen.add(record.cwd);
|
|
428
|
+
if (!existsSync(record.cwd)) continue;
|
|
429
|
+
sharedRoot ??= findGitRoot(ctx.cwd) ?? ctx.cwd;
|
|
430
|
+
registerWorktreeIsolation(record.cwd, sharedRoot);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
198
433
|
}
|
|
199
434
|
};
|
|
200
435
|
/** Notices about model fallbacks/crossings, shown once per distinct message. */
|
|
@@ -205,6 +440,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
205
440
|
ctx.ui.notify(message, "warning");
|
|
206
441
|
};
|
|
207
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Session-scoped cache of the AUTOMATIC subagent default (no per-call or agent
|
|
445
|
+
* override), so the O(catalog) ranking is not rebuilt on every turn (the
|
|
446
|
+
* every-turn reminder/banner) or every no-override spawn. Keyed on the session
|
|
447
|
+
* model plus the configured default; catalog churn is deliberately ignored,
|
|
448
|
+
* matching the classifier's pin — the session commits to one default worker,
|
|
449
|
+
* and /model or /subagent both change the signature and recompute. A per-call
|
|
450
|
+
* `model` or an agent's frontmatter model still resolves fresh (they vary).
|
|
451
|
+
*/
|
|
452
|
+
let autoDefaultCache: { signature: string; resolution: SubagentModelResolution } | undefined;
|
|
453
|
+
const resolveAutoDefault = (
|
|
454
|
+
sessionModel: Model<Api> | undefined,
|
|
455
|
+
available: Model<Api>[],
|
|
456
|
+
configured: SubagentDefault | undefined,
|
|
457
|
+
): SubagentModelResolution => {
|
|
458
|
+
const session = sessionModel ? modelSpec(sessionModel) : "(none)";
|
|
459
|
+
const signature = `${session}|${configured?.spec ?? ""}|${configured?.setForContainment ?? ""}|${configured?.source ?? ""}|${unusableModels.size}`;
|
|
460
|
+
if (autoDefaultCache?.signature === signature) return autoDefaultCache.resolution;
|
|
461
|
+
const resolution = resolveSubagentModel({ configuredDefault: configured, sessionModel, available });
|
|
462
|
+
autoDefaultCache = { signature, resolution };
|
|
463
|
+
return resolution;
|
|
464
|
+
};
|
|
465
|
+
|
|
208
466
|
/**
|
|
209
467
|
* The every-turn menu reminder and the banner's subagent-default status.
|
|
210
468
|
* Every-turn because reminders are transient per-request injections — that
|
|
@@ -212,9 +470,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
212
470
|
* replaces it: the very next LLM call, even mid-turn, carries the update.
|
|
213
471
|
*/
|
|
214
472
|
const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model) => {
|
|
215
|
-
const available = ctx
|
|
473
|
+
const available = usableModels(ctx);
|
|
216
474
|
const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
|
|
217
|
-
const resolution =
|
|
475
|
+
const resolution = resolveAutoDefault(sessionModel, available, configured);
|
|
218
476
|
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
219
477
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
220
478
|
text: subagentModelsReminder({
|
|
@@ -222,6 +480,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
222
480
|
sessionModel,
|
|
223
481
|
defaultModel: resolution.model,
|
|
224
482
|
defaultSource: resolution.source,
|
|
483
|
+
configured,
|
|
225
484
|
}),
|
|
226
485
|
scope: "every-turn",
|
|
227
486
|
key: "subagent-models",
|
|
@@ -241,23 +500,470 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
241
500
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
242
501
|
scope: "every-turn",
|
|
243
502
|
key: "subagent-agents",
|
|
244
|
-
text: `Available
|
|
503
|
+
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.`,
|
|
245
504
|
placement: "first-prepend",
|
|
246
505
|
order: CONTEXT_ORDER.agents,
|
|
247
506
|
});
|
|
248
507
|
};
|
|
249
508
|
|
|
509
|
+
const emitDelegationSteer = (sessionModel = lastCtx?.model) => {
|
|
510
|
+
if (resolveModelTier(sessionModel) === "tiny") {
|
|
511
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
512
|
+
scope: "every-turn",
|
|
513
|
+
key: "subagent-delegation",
|
|
514
|
+
text: DELEGATION_STEER,
|
|
515
|
+
placement: "first-prepend",
|
|
516
|
+
order: CONTEXT_ORDER.delegation,
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
let shuttingDown = false;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The fabricated-result backstop (pending-claim.ts). Only the tiers that were
|
|
527
|
+
* measured to need it: a frontier model that correctly said "I'm waiting"
|
|
528
|
+
* would get the reminder on every delegating turn for nothing, and the
|
|
529
|
+
* trigger fires on correct behaviour as readily as on the failure.
|
|
530
|
+
*/
|
|
531
|
+
const backstopApplies = (ctx: ExtensionContext | undefined) => {
|
|
532
|
+
const tier = resolveModelTier(ctx?.model);
|
|
533
|
+
return tier === "cheap" || tier === "tiny";
|
|
534
|
+
};
|
|
535
|
+
pi.on("agent_start", () => {
|
|
536
|
+
spawnedThisLoop.clear();
|
|
537
|
+
return undefined;
|
|
538
|
+
});
|
|
539
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
540
|
+
const spawned = [...spawnedThisLoop];
|
|
541
|
+
spawnedThisLoop.clear();
|
|
542
|
+
// Tier check first: on a frontier session the backstop never fires, so the
|
|
543
|
+
// lookups below would be discarded work on every turn that delegated.
|
|
544
|
+
if (!backstopApplies(ctx)) return undefined;
|
|
545
|
+
const pending = spawned
|
|
546
|
+
.filter((taskId) => {
|
|
547
|
+
const resident = residents.get(taskId);
|
|
548
|
+
return (resident !== undefined && !resident.handle.exited()) || runningIds.has(taskId);
|
|
549
|
+
})
|
|
550
|
+
.map((taskId) => registry.resolve(taskId))
|
|
551
|
+
.filter((record): record is AgentRunRecord => record !== undefined)
|
|
552
|
+
.map((record) => ({ name: record.name, taskId: record.taskId }));
|
|
553
|
+
const text = pendingClaimReminder(pending);
|
|
554
|
+
if (text) pi.events.emit(REMINDER_CHANNEL, { text });
|
|
555
|
+
return undefined;
|
|
556
|
+
});
|
|
557
|
+
|
|
250
558
|
pi.on("session_start", (_event, ctx) => {
|
|
559
|
+
lastCtx = ctx;
|
|
560
|
+
// A replaced session (/clear, /new, /resume) never reaches this instance
|
|
561
|
+
// with the old one's agents: pi emits `session_shutdown` (reason "new" /
|
|
562
|
+
// "resume") to the OLD extension instance, which stops every agent below,
|
|
563
|
+
// then re-runs this factory for the new session (verified live on pi
|
|
564
|
+
// 0.84.1, SUBAGENT-REVIEW-2026-09-04 appendix B). So the state here is
|
|
565
|
+
// always fresh; only a resumed session's persisted run records are rebuilt.
|
|
251
566
|
reconstructRuns(ctx);
|
|
252
567
|
emitModelStatus(ctx);
|
|
253
568
|
emitAgentCatalog(ctx);
|
|
569
|
+
emitDelegationSteer(ctx.model);
|
|
570
|
+
registerPanelInputHook(ctx);
|
|
571
|
+
});
|
|
572
|
+
pi.on("model_select", (event, ctx) => {
|
|
573
|
+
emitModelStatus(ctx, event.model);
|
|
574
|
+
emitDelegationSteer(event.model);
|
|
575
|
+
});
|
|
576
|
+
// Another extension wrote `subagentModel` on disk (`/doctor preset`): drop the
|
|
577
|
+
// cached automatic default and republish the reminder + banner status, the
|
|
578
|
+
// same refresh `/subagent` itself performs after its own write.
|
|
579
|
+
pi.events.on(SUBAGENT_DEFAULT_CHANGED_CHANNEL, () => {
|
|
580
|
+
autoDefaultCache = undefined;
|
|
581
|
+
const live = liveUiCtx(lastCtx);
|
|
582
|
+
if (live) emitModelStatus(live);
|
|
254
583
|
});
|
|
255
|
-
pi.on("model_select", (event, ctx) => emitModelStatus(ctx, event.model));
|
|
256
584
|
pi.on("session_tree", (_event, ctx) => reconstructRuns(ctx));
|
|
257
|
-
pi.on("session_shutdown", () => {
|
|
258
|
-
|
|
585
|
+
pi.on("session_shutdown", async () => {
|
|
586
|
+
// Teardown — at process exit AND on /clear, /new, /resume (pi replaces the
|
|
587
|
+
// session and re-runs this factory; afterwards every use of this
|
|
588
|
+
// instance's ctx or pi throws). Kills must not surface as "terminated"
|
|
589
|
+
// notifications; the panel and the transcript view must stop painting
|
|
590
|
+
// before the ctx goes stale (a timer painting on the dead ctx took the
|
|
591
|
+
// whole process down — SUBAGENT-REVIEW H1); and the exits — including the
|
|
592
|
+
// worktree cleanups they trigger — get a bounded moment to finish (S14).
|
|
593
|
+
shuttingDown = true;
|
|
594
|
+
closeView();
|
|
595
|
+
const exits = stopAllAgents();
|
|
596
|
+
panel.dispose();
|
|
597
|
+
lastCtx = undefined;
|
|
598
|
+
await Promise.race([Promise.allSettled(exits), new Promise((resolve) => setTimeout(resolve, 1_500))]);
|
|
259
599
|
});
|
|
260
600
|
|
|
601
|
+
// --- The subagent panel: strip soft focus + Enter-to-view transcript swap ---
|
|
602
|
+
|
|
603
|
+
/** Stop one live agent by task id (blocking-run handle or resident task). */
|
|
604
|
+
const stopAgent = (taskId: string) => {
|
|
605
|
+
stoppedTaskIds.add(taskId); // the coming exit is a user stop, not a completion (L1)
|
|
606
|
+
void liveHandles.get(taskId)?.kill();
|
|
607
|
+
};
|
|
608
|
+
/** Stop every live agent; returns one settle promise per agent (a resident's exit, a blocking run's result). */
|
|
609
|
+
const stopAllAgents = (): Promise<unknown>[] => {
|
|
610
|
+
const exits: Promise<unknown>[] = [];
|
|
611
|
+
for (const [taskId, handle] of liveHandles) {
|
|
612
|
+
stoppedTaskIds.add(taskId);
|
|
613
|
+
const killed = handle.kill();
|
|
614
|
+
const settled = handle.result ?? killed;
|
|
615
|
+
if (settled) exits.push(settled);
|
|
616
|
+
}
|
|
617
|
+
return exits;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Markdown prose for the transcript views — pi-tui's real renderer, loaded
|
|
622
|
+
* lazily on first view; falls back to plain wrapped text with one warning.
|
|
623
|
+
*/
|
|
624
|
+
let prosePromise: Promise<ProseRenderer | undefined> | undefined;
|
|
625
|
+
const getProse = (ctx: ExtensionContext): Promise<ProseRenderer | undefined> =>
|
|
626
|
+
(prosePromise ??= createMarkdownProse().catch((error) => {
|
|
627
|
+
try {
|
|
628
|
+
ctx.ui.notify(`Subagent viewer: markdown rendering unavailable (${(error as Error).message}) — using plain text.`, "warning");
|
|
629
|
+
} catch {
|
|
630
|
+
// no UI — plain text is fine
|
|
631
|
+
}
|
|
632
|
+
return undefined;
|
|
633
|
+
}));
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Liveness for the transcript view: change-driven repaints (one registry
|
|
637
|
+
* event per provider delta, coalesced to ~80ms) plus a 1s ticker for elapsed
|
|
638
|
+
* time. `relevant` filters by the event's taskId so unrelated concurrent
|
|
639
|
+
* children's deltas don't invalidate the view's cache.
|
|
640
|
+
*/
|
|
641
|
+
const liveRepaint = (tui: { requestRender(): void }, invalidate: () => void, relevant: (taskId?: string) => boolean = () => true) => {
|
|
642
|
+
let coalesce: ReturnType<typeof setTimeout> | undefined;
|
|
643
|
+
const repaint = () => {
|
|
644
|
+
invalidate();
|
|
645
|
+
tui.requestRender();
|
|
646
|
+
};
|
|
647
|
+
const unsubscribe = liveRuns.subscribe((taskId) => {
|
|
648
|
+
if (!relevant(taskId) || coalesce) return;
|
|
649
|
+
coalesce = setTimeout(() => {
|
|
650
|
+
coalesce = undefined;
|
|
651
|
+
repaint();
|
|
652
|
+
}, 80);
|
|
653
|
+
coalesce.unref?.();
|
|
654
|
+
});
|
|
655
|
+
const ticker = setInterval(repaint, 1000);
|
|
656
|
+
ticker.unref?.();
|
|
657
|
+
return {
|
|
658
|
+
repaint,
|
|
659
|
+
cleanup: () => {
|
|
660
|
+
clearInterval(ticker);
|
|
661
|
+
if (coalesce) clearTimeout(coalesce);
|
|
662
|
+
unsubscribe();
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Claude Code's agent view: with no view open, arrows move the strip
|
|
669
|
+
* selection and Enter swaps the transcript region to the selected child (a
|
|
670
|
+
* non-capturing full-width overlay composited over the main transcript; the
|
|
671
|
+
* editor keeps focus, strip keys keep working). Once a view IS open the panel
|
|
672
|
+
* switches to read mode (see the input hook): arrows/page keys scroll it, Tab
|
|
673
|
+
* retargets to the next agent, Enter/esc close it and the main transcript is
|
|
674
|
+
* back.
|
|
675
|
+
*/
|
|
676
|
+
let view: { retarget(taskId: string): void; scrollBy(delta: number): void; close(): void } | undefined;
|
|
677
|
+
/** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + cwd/status(2) + one spare. */
|
|
678
|
+
const viewReserve = () => {
|
|
679
|
+
const shellLines = panel.shellLineCount();
|
|
680
|
+
return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + 9;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const openView = (ctx: ExtensionContext, taskId: string) => {
|
|
684
|
+
if (view) return view.retarget(taskId);
|
|
685
|
+
let currentId = taskId;
|
|
686
|
+
let scroll = 0;
|
|
687
|
+
let maxScroll = 0;
|
|
688
|
+
let closed = false;
|
|
689
|
+
let repaintFn: (() => void) | undefined;
|
|
690
|
+
let doneFn: ((result: null) => void) | undefined;
|
|
691
|
+
const entry = {
|
|
692
|
+
retarget(id: string) {
|
|
693
|
+
if (currentId === id) return;
|
|
694
|
+
currentId = id;
|
|
695
|
+
scroll = 0;
|
|
696
|
+
panel.setView(id);
|
|
697
|
+
repaintFn?.();
|
|
698
|
+
},
|
|
699
|
+
scrollBy(delta: number) {
|
|
700
|
+
scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
|
|
701
|
+
repaintFn?.();
|
|
702
|
+
},
|
|
703
|
+
close() {
|
|
704
|
+
if (closed) return;
|
|
705
|
+
closed = true;
|
|
706
|
+
if (view === entry) view = undefined;
|
|
707
|
+
panel.setView(undefined);
|
|
708
|
+
doneFn?.(null);
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
view = entry;
|
|
712
|
+
panel.setView(currentId);
|
|
713
|
+
void (async () => {
|
|
714
|
+
const prose = await getProse(ctx);
|
|
715
|
+
if (closed) return;
|
|
716
|
+
await ctx.ui.custom<null>(
|
|
717
|
+
(tui, theme, _keybindings, done) => {
|
|
718
|
+
doneFn = done;
|
|
719
|
+
// close() raced ahead of the factory — dissolve as soon as we exist.
|
|
720
|
+
if (closed) queueMicrotask(() => done(null));
|
|
721
|
+
const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
722
|
+
let cache: { width: number; lines: string[] } | undefined;
|
|
723
|
+
const live = liveRepaint(
|
|
724
|
+
tui,
|
|
725
|
+
() => {
|
|
726
|
+
cache = undefined;
|
|
727
|
+
},
|
|
728
|
+
(taskId) => taskId === undefined || taskId === currentId,
|
|
729
|
+
);
|
|
730
|
+
repaintFn = live.repaint;
|
|
731
|
+
const height = () => Math.max(8, tui.terminal.rows - viewReserve());
|
|
732
|
+
return {
|
|
733
|
+
render: (width: number) => {
|
|
734
|
+
if (cache?.width === width) return cache.lines;
|
|
735
|
+
const run = liveRuns.get(currentId);
|
|
736
|
+
let lines: string[];
|
|
737
|
+
if (run) {
|
|
738
|
+
const transcript = renderTranscript({ run, width, height: height(), scroll, now: Date.now(), prose }, paint);
|
|
739
|
+
maxScroll = transcript.maxScroll;
|
|
740
|
+
lines = transcript.lines;
|
|
741
|
+
} else {
|
|
742
|
+
lines = [paint.fg("dim", "agent gone")];
|
|
743
|
+
}
|
|
744
|
+
cache = { width, lines: lines.map((line) => truncateLine(line, width)) };
|
|
745
|
+
return cache.lines;
|
|
746
|
+
},
|
|
747
|
+
invalidate: () => {
|
|
748
|
+
cache = undefined;
|
|
749
|
+
},
|
|
750
|
+
dispose: () => live.cleanup(),
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
{ overlay: true, overlayOptions: { row: 0, col: 0, width: "100%", nonCapturing: true } },
|
|
754
|
+
);
|
|
755
|
+
})().catch(() => entry.close()); // full teardown (clears view + panel.setView) so state can't desync
|
|
756
|
+
};
|
|
757
|
+
const closeView = () => view?.close();
|
|
758
|
+
|
|
759
|
+
let panelHookRegistered = false;
|
|
760
|
+
const registerPanelInputHook = (registerCtx: ExtensionContext) => {
|
|
761
|
+
if (panelHookRegistered || !registerCtx.hasUI) return;
|
|
762
|
+
panelHookRegistered = true;
|
|
763
|
+
let chordArmed = false;
|
|
764
|
+
const leave = () => {
|
|
765
|
+
panel.setFocus(undefined);
|
|
766
|
+
closeView();
|
|
767
|
+
};
|
|
768
|
+
type KeyResult = { consume: boolean } | undefined;
|
|
769
|
+
// Actions shared by both key modes (browse and read), declared once so the
|
|
770
|
+
// two switches below never drift. esc consumes only when idle — a streaming
|
|
771
|
+
// turn must stay interruptible.
|
|
772
|
+
const doLeave = (ctx: ExtensionContext): KeyResult => {
|
|
773
|
+
leave();
|
|
774
|
+
return ctx.isIdle() ? { consume: true } : undefined;
|
|
775
|
+
};
|
|
776
|
+
// `taskId` targets a specific run (read mode stops the VIEWED agent, which
|
|
777
|
+
// can differ from the windowed strip selection); default is the selection.
|
|
778
|
+
const doStop = (taskId?: string): KeyResult => {
|
|
779
|
+
const id = taskId ?? panel.selectedRun()?.taskId;
|
|
780
|
+
if (id) stopAgent(id);
|
|
781
|
+
return { consume: true };
|
|
782
|
+
};
|
|
783
|
+
const doStopAll = (): KeyResult => {
|
|
784
|
+
stopAllAgents();
|
|
785
|
+
return { consume: true };
|
|
786
|
+
};
|
|
787
|
+
/** Unrecognized key: ctrl+x keeps the stop-all chord armed, else drop focus
|
|
788
|
+
* (closing any open view) and let the byte resume in the editor. */
|
|
789
|
+
const closeAndPassthrough = (): KeyResult => {
|
|
790
|
+
if (chordArmed) return { consume: true };
|
|
791
|
+
leave();
|
|
792
|
+
return undefined;
|
|
793
|
+
};
|
|
794
|
+
const DOWN_KEYS = new Set(["\x1b[B", "\x1bOB"]);
|
|
795
|
+
/** Keys the agents branch owns; any other decode (typing, shell-only keys
|
|
796
|
+
* like left/space) drops focus and passes through — stated positively so a
|
|
797
|
+
* future StripKey addition is foreign here by default. */
|
|
798
|
+
const AGENT_KEYS = new Set<StripKey>(["up", "down", "open", "leave", "stop", "stopAll", "pageUp", "pageDown"]);
|
|
799
|
+
/** Row 0 exists only past the synthetic `main` row. */
|
|
800
|
+
const focusFirstAgentRow = (): boolean => {
|
|
801
|
+
if (panel.rowCount() <= 1) return false;
|
|
802
|
+
panel.setFocus(0);
|
|
803
|
+
return true;
|
|
804
|
+
};
|
|
805
|
+
/** The shell-manager stages (chip → list → details); agents keys untouched. */
|
|
806
|
+
const handleShellKey = (data: string, ctx: ExtensionContext): KeyResult => {
|
|
807
|
+
// Focus moved to a dialog/overlay — drop soft focus, let it have the key.
|
|
808
|
+
if (!panel.editorFocused()) {
|
|
809
|
+
panel.setShellFocus(undefined);
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
// Derive the row order once per keystroke; every step below reuses it.
|
|
813
|
+
const ids = panel.shellIds();
|
|
814
|
+
const focus = panel.anchoredShellFocus(ids);
|
|
815
|
+
if (!focus) return undefined;
|
|
816
|
+
const decoded = decodeStripKey(data, false);
|
|
817
|
+
// ctrl+x (the agents stop-all chord arm) means nothing in the shell
|
|
818
|
+
// stages — swallow it rather than leaking the raw \x18 into the editor.
|
|
819
|
+
if (decoded.chordArmed) return { consume: true };
|
|
820
|
+
const result = reduceShellKey(focus, decoded.key, ids);
|
|
821
|
+
if (result.effect === "passthrough") {
|
|
822
|
+
panel.setShellFocus(undefined);
|
|
823
|
+
return undefined; // typing resumes in the editor, byte included
|
|
824
|
+
}
|
|
825
|
+
if (result.effect === "toAgents") {
|
|
826
|
+
// No agent rows to land on → the byte passes through to the editor,
|
|
827
|
+
// consistent with the initial-entry branch.
|
|
828
|
+
panel.setShellFocus(undefined);
|
|
829
|
+
return focusFirstAgentRow() ? { consume: true } : undefined;
|
|
830
|
+
}
|
|
831
|
+
if (result.effect === "stopSelected") {
|
|
832
|
+
try {
|
|
833
|
+
panel.selectedShellTask()?.stop();
|
|
834
|
+
} catch {
|
|
835
|
+
// A dead process's stop() throwing must not break the panel.
|
|
836
|
+
}
|
|
837
|
+
panel.setShellFocus(result.focus, ids);
|
|
838
|
+
return { consume: true };
|
|
839
|
+
}
|
|
840
|
+
panel.setShellFocus(result.focus, ids);
|
|
841
|
+
// While the model streams, esc must still interrupt it — consume only when idle.
|
|
842
|
+
if (decoded.key === "leave" && result.focus === undefined) {
|
|
843
|
+
return ctx.isIdle() ? { consume: true } : undefined;
|
|
844
|
+
}
|
|
845
|
+
return { consume: true };
|
|
846
|
+
};
|
|
847
|
+
try {
|
|
848
|
+
registerCtx.ui.onTerminalInput((data) => {
|
|
849
|
+
const ctx = lastCtx ?? registerCtx;
|
|
850
|
+
// Enter focus: down-arrow while the editor holds real focus. Unlike the
|
|
851
|
+
// workflow strip we do NOT require the editor to be idle — the whole
|
|
852
|
+
// point is inspecting agents (and shells) that run while the main turn
|
|
853
|
+
// is in flight. This branch is the per-keystroke hot path — the
|
|
854
|
+
// O(rows) checks run only once a Down actually asks to focus, never on
|
|
855
|
+
// plain typing. Claude Code's order: the FIRST ↓ lands on the shells
|
|
856
|
+
// chip when shells are running; the next ↓ moves into the agent rows.
|
|
857
|
+
if (panel.focusIndex === undefined && panel.shellFocus === undefined) {
|
|
858
|
+
if (!DOWN_KEYS.has(data) || !panel.editorFocused()) return undefined;
|
|
859
|
+
if (panel.shellChipAvailable()) {
|
|
860
|
+
panel.setShellFocus({ stage: "chip" });
|
|
861
|
+
return { consume: true };
|
|
862
|
+
}
|
|
863
|
+
return focusFirstAgentRow() ? { consume: true } : undefined;
|
|
864
|
+
}
|
|
865
|
+
if (panel.shellFocus !== undefined) return handleShellKey(data, ctx);
|
|
866
|
+
const focusIndex = panel.focusIndex;
|
|
867
|
+
if (focusIndex === undefined) return undefined; // unreachable: one of the two focuses was set
|
|
868
|
+
// The strip emptied under us (finished rows lingered out) — let go.
|
|
869
|
+
if (panel.rowCount() <= 1) {
|
|
870
|
+
leave();
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
// Focus moved to a dialog/overlay — drop soft focus, let it have the key.
|
|
874
|
+
if (!panel.editorFocused()) {
|
|
875
|
+
leave();
|
|
876
|
+
return undefined;
|
|
877
|
+
}
|
|
878
|
+
const decoded = decodeStripKey(data, chordArmed);
|
|
879
|
+
chordArmed = decoded.chordArmed;
|
|
880
|
+
// Read mode: while a transcript view is open the arrows/page keys
|
|
881
|
+
// scroll IT (the natural gesture — pi's main screen has no mouse
|
|
882
|
+
// wheel, so PageUp/PageDown alone were the only path and few reach
|
|
883
|
+
// for them), Tab retargets to the next agent, Enter/esc close, and
|
|
884
|
+
// ← closes the transcript but keeps the panel focused on that agent
|
|
885
|
+
// (setView parked the highlight there) so ↑/↓ can pick a different
|
|
886
|
+
// agent and Enter reopens — Claude Code's back-to-selection gesture.
|
|
887
|
+
if (view) {
|
|
888
|
+
switch (decoded.key) {
|
|
889
|
+
case "up":
|
|
890
|
+
view.scrollBy(1);
|
|
891
|
+
return { consume: true };
|
|
892
|
+
case "down":
|
|
893
|
+
view.scrollBy(-1);
|
|
894
|
+
return { consume: true };
|
|
895
|
+
case "pageUp":
|
|
896
|
+
view.scrollBy(10);
|
|
897
|
+
return { consume: true };
|
|
898
|
+
case "pageDown":
|
|
899
|
+
view.scrollBy(-10);
|
|
900
|
+
return { consume: true };
|
|
901
|
+
case "left":
|
|
902
|
+
closeView();
|
|
903
|
+
return { consume: true };
|
|
904
|
+
case "switch": {
|
|
905
|
+
// Retarget to the next agent, anchored to the VIEWED run in the
|
|
906
|
+
// full row list (not the windowed selection) so it stays correct
|
|
907
|
+
// once the viewed run scrolls past MAX_STRIP_ROWS; setView then
|
|
908
|
+
// moves the strip highlight to it when it is in-window.
|
|
909
|
+
const nextId = panel.nextAgentAfter(panel.viewedTaskId());
|
|
910
|
+
if (nextId) openView(ctx, nextId);
|
|
911
|
+
return { consume: true };
|
|
912
|
+
}
|
|
913
|
+
case "open": // Enter toggles the view shut, mirroring Enter-to-open.
|
|
914
|
+
closeView();
|
|
915
|
+
return { consume: true };
|
|
916
|
+
case "leave":
|
|
917
|
+
return doLeave(ctx);
|
|
918
|
+
case "stop":
|
|
919
|
+
// Stop the agent whose transcript is open, not the strip selection.
|
|
920
|
+
return doStop(panel.viewedTaskId());
|
|
921
|
+
case "stopAll":
|
|
922
|
+
return doStopAll();
|
|
923
|
+
default:
|
|
924
|
+
return closeAndPassthrough(); // typing closes the view and resumes in the editor
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (!decoded.key || !AGENT_KEYS.has(decoded.key)) return closeAndPassthrough();
|
|
928
|
+
switch (decoded.key) {
|
|
929
|
+
case "up":
|
|
930
|
+
if (focusIndex === 0) {
|
|
931
|
+
// Back up out of the agent rows: onto the shells chip when
|
|
932
|
+
// shells are running, otherwise out of the panel entirely.
|
|
933
|
+
leave();
|
|
934
|
+
if (panel.shellChipAvailable()) panel.setShellFocus({ stage: "chip" });
|
|
935
|
+
} else panel.setFocus(focusIndex - 1);
|
|
936
|
+
return { consume: true };
|
|
937
|
+
case "down":
|
|
938
|
+
panel.setFocus(Math.min(panel.rowCount() - 1, focusIndex + 1));
|
|
939
|
+
return { consume: true };
|
|
940
|
+
case "leave":
|
|
941
|
+
return doLeave(ctx);
|
|
942
|
+
case "open": {
|
|
943
|
+
// Claude Code's model: Enter swaps the view to the selection (and
|
|
944
|
+
// switches an open view); Enter on `main` restores the main
|
|
945
|
+
// transcript. Focus stays in the strip for the next switch.
|
|
946
|
+
const run = panel.selectedRun();
|
|
947
|
+
if (run) openView(ctx, run.taskId);
|
|
948
|
+
else closeView();
|
|
949
|
+
return { consume: true };
|
|
950
|
+
}
|
|
951
|
+
case "stop":
|
|
952
|
+
return doStop();
|
|
953
|
+
case "stopAll":
|
|
954
|
+
return doStopAll();
|
|
955
|
+
case "pageUp":
|
|
956
|
+
case "pageDown":
|
|
957
|
+
// No transcript open → nothing to scroll; swallow so the page
|
|
958
|
+
// keys never leak into the editor while the strip holds focus.
|
|
959
|
+
return { consume: true };
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
} catch {
|
|
963
|
+
// Mode without raw terminal input (print/RPC) — the strip is view-only.
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
|
|
261
967
|
// Compact transcript rendering for the notifications this extension injects
|
|
262
968
|
// (full body on ctrl+o); the verbose framing stays model-only.
|
|
263
969
|
for (const customType of ["subagent-message", "subagent-result"]) {
|
|
@@ -266,20 +972,68 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
266
972
|
);
|
|
267
973
|
}
|
|
268
974
|
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
975
|
+
const notifier = createTaskNotifier(pi);
|
|
976
|
+
/** Every subagent notification; silent during shutdown (a teardown kill is not news). */
|
|
977
|
+
const notify: typeof notifier = (customType, text, details) => {
|
|
978
|
+
if (shuttingDown) return;
|
|
979
|
+
notifier(customType, text, details);
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Anything a child sends the parent — a mid-run message or its final report —
|
|
984
|
+
* is unmetered model output landing in the parent's context, N children at
|
|
985
|
+
* once (review S4). Past the cap it is PERSISTED, not cut: the full text goes
|
|
986
|
+
* to `<session-dir>/tool-results/<id>.txt` (Claude Code's convention, the
|
|
987
|
+
* same block MCP results use) and the notification carries a preview plus
|
|
988
|
+
* the path, so nothing is lost — the parent reads the file when it needs it.
|
|
989
|
+
*/
|
|
990
|
+
const MESSAGE_TO_MAIN_CAP = 10_000;
|
|
991
|
+
const bounded = (text: string, id: string, maxBytes: number): string =>
|
|
992
|
+
persistIfLarge(text, { dir: sessionResultsDir(lastCtx), id, maxBytes });
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Route a nested run's SendMessage {to: "main"}: to the CHILD that spawned it
|
|
996
|
+
* (steered into the turn that is blocking on the grandchild's result) when
|
|
997
|
+
* that child is a live resident, else to the main conversation. Without this
|
|
998
|
+
* the spawning child never saw the message and the main model was told about
|
|
999
|
+
* work it did not delegate (SUBAGENT-REVIEW L4).
|
|
1000
|
+
*/
|
|
1001
|
+
const relayToParent = (parentTaskId: string, name: string, message: string, summary?: string) => {
|
|
1002
|
+
// Bounded once: the fallback reuses the same body (and persisted file).
|
|
1003
|
+
const body = boundedMessage(name, message);
|
|
1004
|
+
const toMain = () => announceAgentMessage(name, body, summary);
|
|
1005
|
+
const parent = residents.get(parentTaskId);
|
|
1006
|
+
if (!parent || parent.handle.exited()) {
|
|
1007
|
+
toMain();
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
void parent.handle.send(`Message from your subagent ${name}${summary ? ` (${summary})` : ""}:\n\n${body}`).catch(toMain);
|
|
274
1011
|
};
|
|
275
1012
|
|
|
1013
|
+
const boundedMessage = (name: string, message: string) => bounded(message, `message-${name}-${Date.now()}`, MESSAGE_TO_MAIN_CAP);
|
|
1014
|
+
const announceAgentMessage = (name: string, body: string, summary?: string) =>
|
|
1015
|
+
notify("subagent-message", systemNotification(`Message from agent ${name}${summary ? ` (${summary})` : ""}:\n\n${body}`), { name, summary });
|
|
276
1016
|
/** Relay a child's send_message {to: "main"} into this conversation. */
|
|
277
|
-
const notifyAgentMessage = (name: string, message: string, summary?: string) =>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
1017
|
+
const notifyAgentMessage = (name: string, message: string, summary?: string) => announceAgentMessage(name, boundedMessage(name, message), summary);
|
|
1018
|
+
|
|
1019
|
+
/** A fork's system prompt is persisted beside its session so a later resume can restore it (review S6). */
|
|
1020
|
+
const FORK_PROMPT_FILE = "system-prompt.md";
|
|
1021
|
+
const persistForkPrompt = (record: AgentRunRecord, prompt: string) => {
|
|
1022
|
+
if (!record.sessionSearchDir) return;
|
|
1023
|
+
try {
|
|
1024
|
+
writeFileSync(join(record.sessionSearchDir, FORK_PROMPT_FILE), prompt);
|
|
1025
|
+
} catch {
|
|
1026
|
+
// The run still works; only a later resume loses the prompt (and says so).
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const readForkPrompt = (record: AgentRunRecord): string | undefined => {
|
|
1030
|
+
if (!record.sessionSearchDir) return undefined;
|
|
1031
|
+
try {
|
|
1032
|
+
return readFileSync(join(record.sessionSearchDir, FORK_PROMPT_FILE), "utf-8");
|
|
1033
|
+
} catch {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
283
1037
|
|
|
284
1038
|
/** Session dir for a run's persisted child session; undefined → child runs --no-session. */
|
|
285
1039
|
const runSessionDir = (ctx: ExtensionContext, taskId: string): string | undefined => {
|
|
@@ -298,22 +1052,216 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
298
1052
|
agentDef?: AgentDefinition;
|
|
299
1053
|
}
|
|
300
1054
|
|
|
301
|
-
/**
|
|
1055
|
+
/**
|
|
1056
|
+
* How deep Agent-in-Agent nesting goes: a run at depth < MAX_SPAWN_DEPTH
|
|
1057
|
+
* gets the spawn tool, so main(–) → child(0) → grandchild(1) and no further.
|
|
1058
|
+
* Matches CC (its subagents/forks spawn their own agents) while bounding
|
|
1059
|
+
* runaway fan-out — every session shares one in-process event loop.
|
|
1060
|
+
*/
|
|
1061
|
+
const MAX_SPAWN_DEPTH = 1;
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Catalog text for the injected spawn tool, cached per cwd: building a
|
|
1065
|
+
* child's toolset must not re-scan the agent dirs on every spawn — a wide
|
|
1066
|
+
* fan-out would pay the filesystem walk N times for a description most
|
|
1067
|
+
* children never use. Fork is not listed (unavailable inside a child).
|
|
1068
|
+
* Validation in execute() still loads fresh; only this string is pinned,
|
|
1069
|
+
* which is what tool descriptions are anyway — static for the session.
|
|
1070
|
+
*/
|
|
1071
|
+
const childCatalogCache = new Map<string, string>();
|
|
1072
|
+
const childCatalog = (cwd: string): string => {
|
|
1073
|
+
let text = childCatalogCache.get(cwd);
|
|
1074
|
+
if (text === undefined) {
|
|
1075
|
+
text = loadAgents(cwd)
|
|
1076
|
+
.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`)
|
|
1077
|
+
.join("\n");
|
|
1078
|
+
childCatalogCache.set(cwd, text);
|
|
1079
|
+
}
|
|
1080
|
+
return text;
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The Agent tool injected into a child session (CC parity: subagents can
|
|
1085
|
+
* spawn subagents). It delegates to THIS extension's runtime and registries,
|
|
1086
|
+
* so a nested run streams into the same panel (as a `└` row under its
|
|
1087
|
+
* parent), is gated through the parent's real permission pipeline, and is
|
|
1088
|
+
* addressable by SendMessage like any other run. Foreground-only: the call
|
|
1089
|
+
* returns when the nested agent finishes — no fork, worktree, or background
|
|
1090
|
+
* from inside a child.
|
|
1091
|
+
*/
|
|
1092
|
+
const childAgentTool = (parentRecord: AgentRunRecord, parentDepth: number): ToolDefinition =>
|
|
1093
|
+
({
|
|
1094
|
+
name: "Agent",
|
|
1095
|
+
label: "Agent",
|
|
1096
|
+
description:
|
|
1097
|
+
"Delegate a scoped task to a specialist subagent running in its own context window. The call BLOCKS until the agent finishes and returns its report — use it for well-scoped work whose intermediate output you don't need (broad searches, focused verification, independent research). Give a complete, self-contained task: the agent cannot ask follow-ups. If an agent's description says it should be used proactively, use it without being asked. Available agents:\n" +
|
|
1098
|
+
`${childCatalog(parentRecord.cwd)}\n` +
|
|
1099
|
+
'(No "fork" here — forking is only available to the main conversation.)',
|
|
1100
|
+
// Same CC-parity surface as the main Agent tool (H1): `prompt` is
|
|
1101
|
+
// accepted as `task`, `description` is tolerated, and the agent name is
|
|
1102
|
+
// matched case-insensitively — a nested subagent is just as likely to be
|
|
1103
|
+
// CC-trained.
|
|
1104
|
+
parameters: Type.Object({
|
|
1105
|
+
subagent_type: Type.String({ description: "An agent name from the list in this tool's description" }),
|
|
1106
|
+
task: Type.Optional(Type.String({ description: "The task — a complete, self-contained instruction" })),
|
|
1107
|
+
prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
|
|
1108
|
+
description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
|
|
1109
|
+
name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
|
|
1110
|
+
}) as never,
|
|
1111
|
+
async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
|
|
1112
|
+
const ctx = lastCtx;
|
|
1113
|
+
const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown };
|
|
1114
|
+
const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
|
|
1115
|
+
const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
|
|
1116
|
+
const task = taskInput.trim();
|
|
1117
|
+
const err = (text: string) => ({ content: [{ type: "text" as const, text }], details: {}, isError: true });
|
|
1118
|
+
if (!ctx) return err("The host session is not ready to spawn agents — retry shortly.");
|
|
1119
|
+
if (agentName.toLowerCase() === FORK_AGENT) return err('"fork" is only available to the main conversation — pick a named agent instead.');
|
|
1120
|
+
const agents = loadAgents(parentRecord.cwd);
|
|
1121
|
+
const agentDef = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase());
|
|
1122
|
+
if (!agentDef) {
|
|
1123
|
+
const catalog = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`).join("\n");
|
|
1124
|
+
return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalog}`);
|
|
1125
|
+
}
|
|
1126
|
+
if (!task) return err("`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted).");
|
|
1127
|
+
|
|
1128
|
+
const { name, note: renamedNote } = resolveRunName(registry, agentDef.name, typeof p.name === "string" ? p.name : undefined);
|
|
1129
|
+
// Same parent-side model resolution as the main Agent tool, minus
|
|
1130
|
+
// per-call overrides: the agent's own model, else the configured
|
|
1131
|
+
// default, else the session model.
|
|
1132
|
+
const available = usableModels(ctx);
|
|
1133
|
+
const resolution = resolveSubagentModel({
|
|
1134
|
+
agentModel: agentDef.model,
|
|
1135
|
+
configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model),
|
|
1136
|
+
sessionModel: ctx.model,
|
|
1137
|
+
available,
|
|
1138
|
+
});
|
|
1139
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
1140
|
+
const modelNotes = subagentModelNotes(resolution);
|
|
1141
|
+
const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
|
|
1142
|
+
|
|
1143
|
+
const taskId = generateTaskId();
|
|
1144
|
+
const record: AgentRunRecord = {
|
|
1145
|
+
name,
|
|
1146
|
+
agent: agentDef.name,
|
|
1147
|
+
taskId,
|
|
1148
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
1149
|
+
// The SPAWNING agent's cwd, not the top-level session's: a worktree-
|
|
1150
|
+
// isolated parent's nested spawns must work in the same worktree.
|
|
1151
|
+
cwd: parentRecord.cwd,
|
|
1152
|
+
model: resolved,
|
|
1153
|
+
thinking: undefined,
|
|
1154
|
+
depth: parentDepth + 1,
|
|
1155
|
+
};
|
|
1156
|
+
registry.add(record); // SendMessage from main can reach the nested run too
|
|
1157
|
+
|
|
1158
|
+
// The main conversation's Agent calls are classified as delegations
|
|
1159
|
+
// (matcher DELEGATION_TOOLS); a child's nested spawn must be judged the
|
|
1160
|
+
// same way — a prompt-injected child could otherwise hand a task the
|
|
1161
|
+
// parent would never be allowed to a grandchild unclassified (review
|
|
1162
|
+
// S8). The child's own gate never sees this call (Agent is NEVER_GATE
|
|
1163
|
+
// there), so route it through the parent's bridge here. Fails closed.
|
|
1164
|
+
try {
|
|
1165
|
+
const bridge = getPermissionBridge();
|
|
1166
|
+
const verdict = bridge
|
|
1167
|
+
? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal })
|
|
1168
|
+
: { block: true as const, reason: "no permission bridge is available to judge the delegation" };
|
|
1169
|
+
if (verdict?.block) {
|
|
1170
|
+
return {
|
|
1171
|
+
content: [{ type: "text" as const, text: `Nested Agent call refused: ${verdict.reason}` }],
|
|
1172
|
+
details: { agentRuns: [] as AgentRunRecord[] },
|
|
1173
|
+
isError: true,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
return {
|
|
1178
|
+
content: [{ type: "text" as const, text: `Nested Agent call refused: permission check failed (${(error as Error).message}).` }],
|
|
1179
|
+
details: { agentRuns: [] as AgentRunRecord[] },
|
|
1180
|
+
isError: true,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const result = await executeRun(
|
|
1185
|
+
{
|
|
1186
|
+
request: {
|
|
1187
|
+
agent: agentDef.name,
|
|
1188
|
+
task,
|
|
1189
|
+
name,
|
|
1190
|
+
model: resolved,
|
|
1191
|
+
fallbackModel: spawnFallbackModel(resolved, resolution.source, ctx.model),
|
|
1192
|
+
},
|
|
1193
|
+
record,
|
|
1194
|
+
agentDef,
|
|
1195
|
+
},
|
|
1196
|
+
ctx,
|
|
1197
|
+
signal, // the spawning child aborting (stop/esc) kills the nested run
|
|
1198
|
+
undefined,
|
|
1199
|
+
{ taskId: parentRecord.taskId, depth: parentDepth },
|
|
1200
|
+
);
|
|
1201
|
+
const notes = [...(renamedNote ? [renamedNote] : []), ...modelNotes];
|
|
1202
|
+
return {
|
|
1203
|
+
content: [
|
|
1204
|
+
{
|
|
1205
|
+
type: "text" as const,
|
|
1206
|
+
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${result.output}\n\n(${formatStats(result.toolCalls, result.usage)})`,
|
|
1207
|
+
},
|
|
1208
|
+
],
|
|
1209
|
+
details: { agentRuns: [record] },
|
|
1210
|
+
isError: result.failed ?? false,
|
|
1211
|
+
};
|
|
1212
|
+
},
|
|
1213
|
+
}) as ToolDefinition;
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* The spawn tool a run is entitled to. THE one depth gate: it reads the
|
|
1217
|
+
* record's own depth (set once at creation), so every handout site —
|
|
1218
|
+
* fresh spawn, background resident, SendMessage resume — enforces
|
|
1219
|
+
* MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped.
|
|
1220
|
+
*/
|
|
1221
|
+
const spawnToolsFor = (record: AgentRunRecord): ToolDefinition[] => {
|
|
1222
|
+
const depth = record.depth ?? 0;
|
|
1223
|
+
return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth)] : [];
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* The text a child's first turn is prompted with: a fork gets the inherited-
|
|
1228
|
+
* context framing (plus, in a worktree, where its paths moved to); a named
|
|
1229
|
+
* agent gets the task as written. Shared by both spawn paths.
|
|
1230
|
+
*/
|
|
1231
|
+
const frameTask = (request: RunRequest, worktree: Worktree | undefined, parentCwd: string): string =>
|
|
1232
|
+
request.fork ? forkTaskMessage(request.task, worktree ? { worktreePath: worktree.path, parentCwd } : undefined) : request.task;
|
|
1233
|
+
|
|
1234
|
+
/** Create a run's isolation worktree and point its record at it (both spawn paths). */
|
|
1235
|
+
const isolateInWorktree = async (ctx: ExtensionContext, record: AgentRunRecord, name: string): Promise<Worktree> => {
|
|
1236
|
+
const worktree = await createWorktree(ctx.cwd, name);
|
|
1237
|
+
record.cwd = worktree.path;
|
|
1238
|
+
record.worktree = true;
|
|
1239
|
+
return worktree;
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Run one child to completion, blocking until it finishes — the shape a
|
|
1244
|
+
* NESTED spawn needs (a child's own Agent call has no notification surface,
|
|
1245
|
+
* so it waits for the result) and the one-shot modes need (a `-p` process
|
|
1246
|
+
* exits when the turn settles, so a background run would be orphaned).
|
|
1247
|
+
* Main-conversation runs in TUI/RPC sessions are background residents
|
|
1248
|
+
* instead. Creates a worktree first if asked and finalizes the record when
|
|
1249
|
+
* done. `parent` links the run under its parent in the panel tree.
|
|
1250
|
+
*/
|
|
302
1251
|
const executeRun = async (
|
|
303
1252
|
prepared: PreparedRun,
|
|
304
1253
|
ctx: ExtensionContext,
|
|
305
|
-
forkFrom: string | undefined,
|
|
306
1254
|
signal: AbortSignal | undefined,
|
|
307
|
-
|
|
308
|
-
|
|
1255
|
+
forkFrom?: string,
|
|
1256
|
+
parent?: { taskId: string; depth: number },
|
|
1257
|
+
onProgress?: (toolCalls: number, text: string, usage: UsageTotals) => void,
|
|
309
1258
|
): Promise<TaskResult> => {
|
|
310
1259
|
const { request, record, agentDef } = prepared;
|
|
311
1260
|
|
|
312
1261
|
let worktree: Worktree | undefined;
|
|
313
1262
|
if (request.worktree) {
|
|
314
1263
|
try {
|
|
315
|
-
worktree = await
|
|
316
|
-
record.cwd = worktree.path;
|
|
1264
|
+
worktree = await isolateInWorktree(ctx, record, request.name);
|
|
317
1265
|
} catch (error) {
|
|
318
1266
|
return {
|
|
319
1267
|
agent: request.agent,
|
|
@@ -328,25 +1276,36 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
328
1276
|
}
|
|
329
1277
|
}
|
|
330
1278
|
|
|
331
|
-
|
|
332
|
-
const
|
|
1279
|
+
runningIds.add(record.taskId);
|
|
1280
|
+
const live = trackLiveRun(record, request, parent);
|
|
1281
|
+
const runtime = await getRuntime(ctx);
|
|
1282
|
+
const handle = runtime.run({
|
|
1283
|
+
name: request.name,
|
|
333
1284
|
agent: agentDef,
|
|
334
|
-
task: request.
|
|
1285
|
+
task: frameTask(request, worktree, ctx.cwd),
|
|
335
1286
|
cwd: record.cwd,
|
|
336
1287
|
forkFrom: request.fork ? forkFrom : undefined,
|
|
1288
|
+
parentSystemPrompt: request.fork ? ctx.getSystemPrompt() : undefined,
|
|
337
1289
|
sessionDir: record.sessionSearchDir || undefined,
|
|
338
1290
|
model: request.model,
|
|
1291
|
+
fallbackModel: request.fallbackModel,
|
|
339
1292
|
thinking: request.thinking,
|
|
340
1293
|
signal,
|
|
341
|
-
onProgress,
|
|
342
|
-
|
|
1294
|
+
onProgress: (toolCalls, text, usage) => {
|
|
1295
|
+
live.progress(toolCalls, usage);
|
|
1296
|
+
onProgress?.(toolCalls, text, usage);
|
|
1297
|
+
},
|
|
1298
|
+
sink: live.sink,
|
|
1299
|
+
onMessageToMain: (message, summary) =>
|
|
1300
|
+
parent ? relayToParent(parent.taskId, request.name, message, summary) : notifyAgentMessage(request.name, message, summary),
|
|
1301
|
+
extraTools: spawnToolsFor(record),
|
|
343
1302
|
});
|
|
344
|
-
|
|
345
|
-
onStarted?.(handle, worktree);
|
|
1303
|
+
liveHandles.set(record.taskId, handle);
|
|
346
1304
|
|
|
347
1305
|
try {
|
|
348
1306
|
const outcome: ChildOutcome = await handle.result;
|
|
349
1307
|
registry.sessionFileFor(record); // resolve now that the child has written it
|
|
1308
|
+
live.finish(Boolean(outcome.failed));
|
|
350
1309
|
let worktreeKept: boolean | undefined;
|
|
351
1310
|
if (worktree) {
|
|
352
1311
|
worktreeKept = !(await cleanupWorktree(ctx.cwd, worktree));
|
|
@@ -361,6 +1320,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
361
1320
|
worktreeKept,
|
|
362
1321
|
};
|
|
363
1322
|
} catch (error) {
|
|
1323
|
+
live.finish(true);
|
|
364
1324
|
if (worktree) await cleanupWorktree(ctx.cwd, worktree);
|
|
365
1325
|
return {
|
|
366
1326
|
agent: request.agent,
|
|
@@ -373,57 +1333,73 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
373
1333
|
failed: true,
|
|
374
1334
|
};
|
|
375
1335
|
} finally {
|
|
376
|
-
|
|
377
|
-
runningNames.delete(request.name);
|
|
1336
|
+
runningIds.delete(record.taskId);
|
|
378
1337
|
}
|
|
379
1338
|
};
|
|
380
1339
|
|
|
381
1340
|
pi.registerTool({
|
|
382
|
-
name: "
|
|
383
|
-
label: "
|
|
384
|
-
...ccToolRenderers<{
|
|
385
|
-
title: (a) =>
|
|
386
|
-
a?.tasks?.length
|
|
387
|
-
? `${a.agent ?? "agents"} × ${a.tasks.length}`
|
|
388
|
-
: a
|
|
389
|
-
? [a.agent, a.task ?? a.action].filter(Boolean).join(": ")
|
|
390
|
-
: undefined,
|
|
1341
|
+
name: "Agent",
|
|
1342
|
+
label: "Agent",
|
|
1343
|
+
...ccToolRenderers<{ subagent_type?: string; task?: string; prompt?: string; description?: string; action?: string }>("Agent", {
|
|
1344
|
+
title: (a) => (a ? [a.subagent_type, a.description ?? a.task ?? a.prompt ?? a.action].filter(Boolean).join(": ") || undefined : undefined),
|
|
391
1345
|
}),
|
|
392
1346
|
description:
|
|
393
|
-
|
|
1347
|
+
'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' +
|
|
1348
|
+
"\n" +
|
|
1349
|
+
"## When to use\n" +
|
|
1350
|
+
"- Broad codebase searches or exploration where you need the conclusion, not the file dumps.\n" +
|
|
1351
|
+
"- Self-contained research, reviews, or verification whose intermediate output you won't need again.\n" +
|
|
1352
|
+
"- Independent questions that can run at once — issue multiple Agent tool calls in one message to run them in parallel.\n" +
|
|
1353
|
+
"\n" +
|
|
1354
|
+
"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" +
|
|
1355
|
+
"\n" +
|
|
1356
|
+
"## How agents run\n" +
|
|
1357
|
+
"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 system 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" +
|
|
1358
|
+
"\n" +
|
|
1359
|
+
"## Usage notes\n" +
|
|
1360
|
+
"- Give a complete, self-contained task: the agent cannot ask follow-up questions.\n" +
|
|
1361
|
+
"- 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" +
|
|
1362
|
+
'- `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' +
|
|
1363
|
+
'- `isolation: "worktree"` gives the agent its own git worktree when it will edit files.\n' +
|
|
1364
|
+
"- 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" +
|
|
1365
|
+
"- The agent's final report is not shown to the user, so relay what matters.",
|
|
394
1366
|
promptSnippet: "Delegate scoped work to a specialist agent in its own context",
|
|
395
1367
|
parameters: SubagentParams,
|
|
396
1368
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
397
1369
|
const agents = loadAgents(ctx.cwd);
|
|
398
1370
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
1371
|
+
// `task` is our name, `prompt` Claude Code's; a CC-trained model sends
|
|
1372
|
+
// the latter (H1). Resolve to one value here so the rest of execute is
|
|
1373
|
+
// unaffected.
|
|
1374
|
+
const taskText = params.task ?? params.prompt;
|
|
1375
|
+
|
|
1376
|
+
// A call carrying run options (a task, a name, a model/thinking/isolation
|
|
1377
|
+
// override, or action:"run") but no `subagent_type` is a run that forgot
|
|
1378
|
+
// to name its agent. Fail loudly with a diagnostic rather than silently
|
|
1379
|
+
// returning the catalog: a weaker model reads the catalog as a
|
|
1380
|
+
// non-sequitur and invents wrong reasons for it, instead of learning it
|
|
1381
|
+
// omitted `subagent_type`.
|
|
405
1382
|
const wantsRun =
|
|
406
1383
|
params.action === "run" ||
|
|
407
|
-
|
|
1384
|
+
taskText != null ||
|
|
408
1385
|
params.name != null ||
|
|
409
|
-
params.run_in_background != null ||
|
|
410
1386
|
params.model != null ||
|
|
411
1387
|
params.isolation != null ||
|
|
412
1388
|
params.thinking != null;
|
|
413
1389
|
|
|
414
|
-
if (params.action === "list" || (!params.
|
|
1390
|
+
if (params.action === "list" || (!params.subagent_type && !wantsRun)) {
|
|
415
1391
|
return {
|
|
416
1392
|
content: [{ type: "text", text: `Available agents:\n${describeAgents(ctx.cwd)}` }],
|
|
417
1393
|
details: { agents: agents.map((a) => a.name) },
|
|
418
1394
|
};
|
|
419
1395
|
}
|
|
420
1396
|
|
|
421
|
-
if (!params.
|
|
1397
|
+
if (!params.subagent_type) {
|
|
422
1398
|
return {
|
|
423
1399
|
content: [
|
|
424
1400
|
{
|
|
425
1401
|
type: "text",
|
|
426
|
-
text: `No \`
|
|
1402
|
+
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)}`,
|
|
427
1403
|
},
|
|
428
1404
|
],
|
|
429
1405
|
details: {},
|
|
@@ -431,14 +1407,34 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
431
1407
|
};
|
|
432
1408
|
}
|
|
433
1409
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
1410
|
+
// A run needs a task. A CC-shaped call that put its instruction in an
|
|
1411
|
+
// unrecognized key, or none at all, must fail loud rather than start a
|
|
1412
|
+
// child prompted with "" (H1). Fork included — cloning to do nothing is
|
|
1413
|
+
// never intended.
|
|
1414
|
+
if (!taskText || !taskText.trim()) {
|
|
1415
|
+
return {
|
|
1416
|
+
content: [{ type: "text", text: "`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted)." }],
|
|
1417
|
+
details: {},
|
|
1418
|
+
isError: true,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Match `subagent_type` against the catalog case-insensitively and run
|
|
1423
|
+
// the agent under its canonical name: a CC-trained model writes
|
|
1424
|
+
// `Explore`/`Plan` where our bundled agents are `explore`/`plan` (H1).
|
|
1425
|
+
// "Fork" is canonicalized the same way so downstream fork detection
|
|
1426
|
+
// (entry.agent === FORK_AGENT) is case-insensitive too.
|
|
1427
|
+
const requestedType = params.subagent_type!;
|
|
1428
|
+
const canonicalType =
|
|
1429
|
+
requestedType.toLowerCase() === FORK_AGENT
|
|
1430
|
+
? FORK_AGENT
|
|
1431
|
+
: (agents.find((a) => a.name.toLowerCase() === requestedType.toLowerCase())?.name ?? requestedType);
|
|
1432
|
+
|
|
1433
|
+
/** A requested name already used this session → the run gets a fresh one, and the result says so (M1). */
|
|
1434
|
+
const renamed: string[] = [];
|
|
1435
|
+
const requested: RunRequest[] = [{ agent: canonicalType, task: taskText, name: params.name }].map((entry) => {
|
|
1436
|
+
const { name, note } = resolveRunName(registry, entry.agent, entry.name);
|
|
1437
|
+
if (note) renamed.push(note);
|
|
442
1438
|
return {
|
|
443
1439
|
agent: entry.agent,
|
|
444
1440
|
task: entry.task,
|
|
@@ -516,6 +1512,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
516
1512
|
cwd: ctx.cwd,
|
|
517
1513
|
model: request.model,
|
|
518
1514
|
thinking: request.thinking,
|
|
1515
|
+
depth: 0,
|
|
519
1516
|
},
|
|
520
1517
|
};
|
|
521
1518
|
});
|
|
@@ -527,20 +1524,41 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
527
1524
|
* `provider/id`; anything surprising (a fallback, a provider crossing)
|
|
528
1525
|
* is said out loud rather than happening silently.
|
|
529
1526
|
*/
|
|
530
|
-
const available = ctx
|
|
1527
|
+
const available = usableModels(ctx);
|
|
531
1528
|
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
1529
|
+
/**
|
|
1530
|
+
* What the model is told about its own model choice: the same sentences
|
|
1531
|
+
* the user gets, plus the model each child actually runs on. Deduped —
|
|
1532
|
+
* a batch of spawns that all requested one unavailable alias says it
|
|
1533
|
+
* once. See `subagentModelNotes` for why the model needs this.
|
|
1534
|
+
*/
|
|
1535
|
+
const modelNotes = new Set<string>();
|
|
532
1536
|
for (const p of prepared) {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1537
|
+
// A fork inherits the parent transcript, so it must continue on THIS
|
|
1538
|
+
// conversation's exact model — never a configured default or the
|
|
1539
|
+
// automatic same-provider pick, which would move the inherited context
|
|
1540
|
+
// onto a different (often weaker) model. Leaving model undefined makes
|
|
1541
|
+
// the forked session restore its own model. (Per-call `model` on a fork
|
|
1542
|
+
// is already rejected above.)
|
|
1543
|
+
if (p.request.fork) continue;
|
|
1544
|
+
// No per-call or agent-frontmatter override → the automatic default,
|
|
1545
|
+
// which is stable for the session, so reuse the cached resolution
|
|
1546
|
+
// instead of re-ranking the catalog per spawn. An override varies per
|
|
1547
|
+
// call and always resolves fresh.
|
|
1548
|
+
const resolution =
|
|
1549
|
+
p.request.model || p.agentDef?.model
|
|
1550
|
+
? resolveSubagentModel({
|
|
1551
|
+
requested: p.request.model,
|
|
1552
|
+
agentModel: p.agentDef?.model,
|
|
1553
|
+
configuredDefault: configuredDefault,
|
|
1554
|
+
sessionModel: ctx.model,
|
|
1555
|
+
available,
|
|
1556
|
+
})
|
|
1557
|
+
: resolveAutoDefault(ctx.model, available, configuredDefault);
|
|
540
1558
|
if (resolution.unresolved) {
|
|
541
1559
|
// The main model chose this string; the menu lets it retry.
|
|
542
1560
|
const fallback = resolveSubagentModel({
|
|
543
|
-
configuredDefault: configuredDefault
|
|
1561
|
+
configuredDefault: configuredDefault,
|
|
544
1562
|
sessionModel: ctx.model,
|
|
545
1563
|
available,
|
|
546
1564
|
});
|
|
@@ -565,7 +1583,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
565
1583
|
const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
|
|
566
1584
|
if (gate) {
|
|
567
1585
|
const fallback = resolveSubagentModel({
|
|
568
|
-
configuredDefault: configuredDefault
|
|
1586
|
+
configuredDefault: configuredDefault,
|
|
569
1587
|
sessionModel: ctx.model,
|
|
570
1588
|
available,
|
|
571
1589
|
});
|
|
@@ -590,219 +1608,271 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
590
1608
|
};
|
|
591
1609
|
}
|
|
592
1610
|
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
593
|
-
const
|
|
1611
|
+
for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
|
|
1612
|
+
const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
|
|
594
1613
|
p.request.model = resolved;
|
|
1614
|
+
p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
|
|
595
1615
|
p.record.model = resolved;
|
|
596
1616
|
}
|
|
597
1617
|
|
|
598
1618
|
for (const p of prepared) registry.add(p.record);
|
|
599
1619
|
const records = prepared.map((p) => p.record);
|
|
600
1620
|
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1621
|
+
// One-shot modes (`-p` / `--mode json`) exit when the turn settles, so
|
|
1622
|
+
// a background run would be orphaned with its report undelivered — run
|
|
1623
|
+
// blocking there and return the report in the tool result instead.
|
|
1624
|
+
if (!sessionOutlivesTurn(ctx.mode)) {
|
|
1625
|
+
// Stream progress through onUpdate (tool_execution_update) like the
|
|
1626
|
+
// old foreground path — a headless JSON/RPC consumer has no panel,
|
|
1627
|
+
// so this is its only signal between dispatch and result.
|
|
1628
|
+
let progress = { toolCalls: 0, text: "", usage: emptyUsage() };
|
|
1629
|
+
const report = () => {
|
|
1630
|
+
const stats = formatStats(progress.toolCalls, progress.usage);
|
|
1631
|
+
const line = `${progress.text ? "✓" : "⏳"} ${prepared[0].request.name} (${stats})${progress.text ? "" : " running…"}`;
|
|
1632
|
+
onUpdate?.({ content: [{ type: "text", text: line }], details: {} });
|
|
1633
|
+
};
|
|
1634
|
+
report();
|
|
1635
|
+
const result = await executeRun(prepared[0], ctx, signal, sessionFile ?? undefined, undefined, (toolCalls, text, usage) => {
|
|
1636
|
+
progress = { toolCalls, text, usage };
|
|
1637
|
+
report();
|
|
1638
|
+
});
|
|
1639
|
+
// Auto mode reviews what the child actually did once it returns; the
|
|
1640
|
+
// permission gate attaches the review to this tool_result.
|
|
1641
|
+
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
|
|
1642
|
+
toolCallId,
|
|
1643
|
+
actions: result.actions ?? [],
|
|
1644
|
+
} satisfies SubagentActionsPayload);
|
|
1645
|
+
const stats = formatStats(result.toolCalls, result.usage);
|
|
1646
|
+
const worktreeNote = result.worktreePath ? `\n\n(Changes left in worktree ${result.worktreePath} — review or merge them.)` : "";
|
|
1647
|
+
// The report arrives inline with no frame, no task id and no note, so a
|
|
1648
|
+
// model told to "retrieve the result with task_output" chased an id that
|
|
1649
|
+
// was never registered (WEAK-MODEL-REVIEW-2026-09-06 M3). Say it up
|
|
1650
|
+
// front, in bash's and monitor's shared wording.
|
|
1651
|
+
prepared[0].record.inline = true;
|
|
1652
|
+
const inlineNote =
|
|
1653
|
+
`${prepared[0].record.name} (task ${prepared[0].record.taskId}): ${oneShotNote("agent")} ` +
|
|
1654
|
+
"Its report follows here; there is no background task to poll, and task_output does not know this id.";
|
|
1655
|
+
// The background path reports renames and model fallbacks in its own
|
|
1656
|
+
// result; a one-shot run must say the same things or the model reads
|
|
1657
|
+
// the report believing it named the run and the model.
|
|
1658
|
+
const notes = [...renamed, ...modelNotes];
|
|
1659
|
+
return {
|
|
1660
|
+
content: [
|
|
1661
|
+
{
|
|
1662
|
+
type: "text",
|
|
1663
|
+
text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${result.output}${worktreeNote}\n\n(${stats})`,
|
|
1664
|
+
},
|
|
1665
|
+
],
|
|
1666
|
+
details: { results: [result], agentRuns: records },
|
|
1667
|
+
isError: result.failed ?? false,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Every run is a background resident (Claude Code parity): the call
|
|
1672
|
+
// returns as soon as the child is launched, the report arrives as a
|
|
1673
|
+
// steered system notification, and the child stays resident so
|
|
1674
|
+
// SendMessage can reach it live (steer mid-turn, prompt when idle).
|
|
1675
|
+
const lines: string[] = [...renamed, ...modelNotes];
|
|
1676
|
+
const runtime = await getRuntime(ctx);
|
|
1677
|
+
for (const p of prepared) {
|
|
1678
|
+
let worktree: Worktree | undefined;
|
|
1679
|
+
if (p.request.worktree) {
|
|
1680
|
+
try {
|
|
1681
|
+
worktree = await isolateInWorktree(ctx, p.record, p.request.name);
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
|
|
1684
|
+
continue;
|
|
615
1685
|
}
|
|
1686
|
+
}
|
|
616
1687
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1688
|
+
const logPath = p.record.sessionSearchDir ? join(p.record.sessionSearchDir, "output.log") : undefined;
|
|
1689
|
+
let lastLogWrite = 0;
|
|
1690
|
+
let finish!: () => void;
|
|
1691
|
+
const finished = new Promise<void>((resolve) => {
|
|
1692
|
+
finish = resolve;
|
|
1693
|
+
});
|
|
622
1694
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1695
|
+
// The child's final output. task.output() falls back to this
|
|
1696
|
+
// because handle.snapshot().text can be blank at a turn boundary —
|
|
1697
|
+
// the same reason the log write below uses `|| outcome.output`.
|
|
1698
|
+
// Keeps task_output consistent with the log and the completion
|
|
1699
|
+
// notification rather than showing an empty body in that case.
|
|
1700
|
+
let lastOutput = "";
|
|
1701
|
+
// The initial task's own report. task_output for THIS task must return the
|
|
1702
|
+
// first turn's reply — not the resident's growing multi-turn transcript —
|
|
1703
|
+
// the same way a SendMessage task returns one reply (review S12).
|
|
1704
|
+
let firstTurnOutput: string | undefined;
|
|
1705
|
+
const live = trackLiveRun(p.record, p.request);
|
|
1706
|
+
const resident: Resident = { handle: undefined as never, turnHandlers: [] };
|
|
1707
|
+
const worktreeNote = worktree
|
|
1708
|
+
? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
|
|
1709
|
+
: "";
|
|
1710
|
+
resident.turnHandlers.push((outcome, review) => {
|
|
1711
|
+
const stopped = stoppedTaskIds.has(p.record.taskId);
|
|
1712
|
+
task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
|
|
1713
|
+
task.finishedAt = Date.now();
|
|
1714
|
+
firstTurnOutput = outcome.output;
|
|
1715
|
+
finish();
|
|
1716
|
+
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
1717
|
+
const verb = stopped ? "was stopped" : outcome.failed ? "failed" : "completed";
|
|
1718
|
+
notify(
|
|
1719
|
+
"subagent-result",
|
|
1720
|
+
systemNotification(
|
|
1721
|
+
withReview(
|
|
1722
|
+
`Agent ${p.record.name} (task ${p.record.taskId}) ${verb} (${stats}). It stays reachable with SendMessage.\n\n${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
|
|
1723
|
+
review,
|
|
1724
|
+
),
|
|
1725
|
+
),
|
|
1726
|
+
{ taskId: p.record.taskId, name: p.record.name, failed: stopped ? false : (outcome.failed ?? false), reviewed: review !== undefined },
|
|
1727
|
+
);
|
|
1728
|
+
});
|
|
644
1729
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1730
|
+
// Idle reaper: every run is a resident now, so without this a long
|
|
1731
|
+
// session accumulates one live AgentSession (extension instances,
|
|
1732
|
+
// message array, MCP tool refs) per delegation for its whole life.
|
|
1733
|
+
// After RESIDENT_IDLE_MS idle the session is released quietly; the
|
|
1734
|
+
// agent stays reachable — SendMessage resumes it from its session
|
|
1735
|
+
// file. Worktree residents are exempt: release would remove the
|
|
1736
|
+
// worktree a resume still needs. Armed from every turn end.
|
|
1737
|
+
let reaper: ReturnType<typeof setTimeout> | undefined;
|
|
1738
|
+
const armReaper = () => {
|
|
1739
|
+
if (worktree) return;
|
|
1740
|
+
if (reaper) clearTimeout(reaper);
|
|
1741
|
+
reaper = setTimeout(() => {
|
|
1742
|
+
reaper = undefined;
|
|
1743
|
+
if (handle.exited()) return;
|
|
1744
|
+
if (handle.busy()) {
|
|
1745
|
+
armReaper();
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
handle.release();
|
|
1749
|
+
}, RESIDENT_IDLE_MS);
|
|
1750
|
+
reaper.unref?.();
|
|
1751
|
+
};
|
|
1752
|
+
// Captured as a string: onExit runs from a `.finally` long after this
|
|
1753
|
+
// turn's ctx may be stale (review S5).
|
|
1754
|
+
const parentCwd = ctx.cwd;
|
|
1755
|
+
const forkPrompt = p.request.fork ? ctx.getSystemPrompt() : undefined;
|
|
1756
|
+
if (forkPrompt !== undefined) persistForkPrompt(p.record, forkPrompt);
|
|
1757
|
+
// No `signal` here on purpose: a resident outlives the spawning turn and
|
|
1758
|
+
// is stopped through task_stop / the panel, not by the turn ending (S15).
|
|
1759
|
+
const handle = await runtime.runResident({
|
|
1760
|
+
name: p.record.name,
|
|
1761
|
+
agent: p.agentDef,
|
|
1762
|
+
cwd: p.record.cwd,
|
|
1763
|
+
forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
|
|
1764
|
+
parentSystemPrompt: forkPrompt,
|
|
1765
|
+
sessionDir: p.record.sessionSearchDir || undefined,
|
|
1766
|
+
model: p.request.model,
|
|
1767
|
+
fallbackModel: p.request.fallbackModel,
|
|
1768
|
+
thinking: p.request.thinking,
|
|
1769
|
+
onProgress: (toolCalls, text, usage) => {
|
|
1770
|
+
live.progress(toolCalls, usage);
|
|
1771
|
+
// Throttled: onProgress fires per tool call/message with the whole
|
|
1772
|
+
// turn text so far, and this path now carries EVERY run — an
|
|
1773
|
+
// unthrottled sync rewrite would be O(n²) bytes on the hot path.
|
|
1774
|
+
// onTurnEnd below flushes the final state, so the tail is never lost.
|
|
1775
|
+
if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
|
|
1776
|
+
lastLogWrite = Date.now();
|
|
1777
|
+
writeFileSync(logPath, text);
|
|
1778
|
+
}
|
|
1779
|
+
},
|
|
1780
|
+
sink: live.sink,
|
|
1781
|
+
onMessageToMain: (message, summary) => notifyAgentMessage(p.record.name, message, summary),
|
|
1782
|
+
extraTools: spawnToolsFor(p.record),
|
|
1783
|
+
onTurnEnd: (outcome) => {
|
|
1784
|
+
registry.sessionFileFor(p.record);
|
|
1785
|
+
live.settle();
|
|
1786
|
+
lastOutput = resident.handle.snapshot().text || outcome.output;
|
|
1787
|
+
if (logPath) writeFileSync(logPath, lastOutput);
|
|
1788
|
+
// The handler is claimed now (so a message arriving mid-review pairs
|
|
1789
|
+
// with the NEXT turn), but runs only once auto mode's hand-back
|
|
1790
|
+
// review of this turn's action sequence has answered, so the
|
|
1791
|
+
// verdict rides in the same notification as the report instead of
|
|
1792
|
+
// trailing it. The wait is bounded (hand-back-review.ts) and
|
|
1793
|
+
// answered synchronously when auto mode is off.
|
|
1794
|
+
const handler = resident.turnHandlers.shift();
|
|
1795
|
+
armReaper();
|
|
1796
|
+
void awaitHandBackReview(pi.events, p.record, outcome.actions).then((review) => {
|
|
672
1797
|
if (handler) {
|
|
673
|
-
handler(outcome);
|
|
1798
|
+
handler(outcome, review);
|
|
674
1799
|
} else {
|
|
675
1800
|
// A turn nobody is waiting on (e.g. a steer that raced past its
|
|
676
1801
|
// target turn and ran on its own) must still surface.
|
|
677
1802
|
notify(
|
|
678
1803
|
"subagent-result",
|
|
679
|
-
systemNotification(`Update from ${p.record.name}:\n\n${outcome.output.
|
|
680
|
-
{ name: p.record.name, failed: outcome.failed ?? false },
|
|
1804
|
+
systemNotification(withReview(`Update from ${p.record.name}:\n\n${bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP)}`, review)),
|
|
1805
|
+
{ name: p.record.name, failed: outcome.failed ?? false, reviewed: review !== undefined },
|
|
681
1806
|
);
|
|
682
1807
|
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
liveChildren.add(handle);
|
|
693
|
-
|
|
694
|
-
const task: BackgroundTask = {
|
|
695
|
-
id: p.record.taskId,
|
|
696
|
-
kind: "subagent",
|
|
697
|
-
description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
|
|
698
|
-
status: "running",
|
|
699
|
-
startedAt: Date.now(),
|
|
700
|
-
logPath,
|
|
701
|
-
output: () => handle.snapshot().text || lastOutput,
|
|
702
|
-
stop: () => handle.kill(),
|
|
703
|
-
resident: () => !handle.exited(),
|
|
704
|
-
finished,
|
|
705
|
-
};
|
|
706
|
-
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
707
|
-
handle.send(p.request.fork ? forkTaskMessage(p.request.task) : p.request.task);
|
|
708
|
-
|
|
709
|
-
lines.push(
|
|
710
|
-
`⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
return {
|
|
714
|
-
content: [
|
|
715
|
-
{
|
|
716
|
-
type: "text",
|
|
717
|
-
text: `${lines.join("\n")}\n\nCompletion (with the agent's report) will arrive as a system notification on its own — you do not need to wait for it or poll; keep working. Call task_output only if your next step cannot proceed without the result (block=true waits). Stop with task_stop; send_message reaches the agent even while it runs (the message is steered into its current turn).`,
|
|
718
|
-
},
|
|
719
|
-
],
|
|
720
|
-
details: { agentRuns: records, background: true },
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// --- Foreground: bounded pool, live progress, blocking result.
|
|
725
|
-
const progress = requested.map((r) => ({ name: r.name, toolCalls: 0, text: "", usage: emptyUsage() }));
|
|
726
|
-
const report = () => {
|
|
727
|
-
const lines = progress.map((p) => {
|
|
728
|
-
const stats = formatStats(p.toolCalls, p.usage);
|
|
729
|
-
return `${p.text ? "✓" : "⏳"} ${p.name} (${stats})${p.text ? "" : " running…"}`;
|
|
1808
|
+
});
|
|
1809
|
+
},
|
|
1810
|
+
onExit: () => {
|
|
1811
|
+
if (reaper) clearTimeout(reaper);
|
|
1812
|
+
live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
|
|
1813
|
+
if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
|
|
1814
|
+
liveHandles.delete(p.record.taskId);
|
|
1815
|
+
if (worktree) void cleanupWorktree(parentCwd, worktree);
|
|
1816
|
+
},
|
|
730
1817
|
});
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const results = await runPool(
|
|
736
|
-
prepared.map((p, index) => ({ p, index })),
|
|
737
|
-
MAX_PARALLEL,
|
|
738
|
-
({ p, index }) =>
|
|
739
|
-
executeRun(p, ctx, sessionFile ?? undefined, signal, (toolCalls, text, usage) => {
|
|
740
|
-
progress[index] = { name: p.request.name, toolCalls, text, usage };
|
|
741
|
-
report();
|
|
742
|
-
}),
|
|
743
|
-
);
|
|
1818
|
+
resident.handle = handle;
|
|
1819
|
+
residents.set(p.record.taskId, resident);
|
|
1820
|
+
liveHandles.set(p.record.taskId, handle);
|
|
744
1821
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
|
|
761
|
-
toolCallId,
|
|
762
|
-
actions: results.flatMap((r) => r.actions ?? []),
|
|
763
|
-
} satisfies SubagentActionsPayload);
|
|
1822
|
+
const task: BackgroundTask = {
|
|
1823
|
+
id: p.record.taskId,
|
|
1824
|
+
kind: "subagent",
|
|
1825
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
1826
|
+
description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
|
|
1827
|
+
status: "running",
|
|
1828
|
+
startedAt: Date.now(),
|
|
1829
|
+
logPath,
|
|
1830
|
+
output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
|
|
1831
|
+
stop: () => handle.kill(),
|
|
1832
|
+
resident: () => !handle.exited(),
|
|
1833
|
+
finished,
|
|
1834
|
+
};
|
|
1835
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
1836
|
+
void handle.send(frameTask(p.request, worktree, parentCwd));
|
|
764
1837
|
|
|
1838
|
+
spawnedThisLoop.add(p.record.taskId);
|
|
1839
|
+
lines.push(
|
|
1840
|
+
`⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
765
1843
|
return {
|
|
766
|
-
content: [
|
|
767
|
-
|
|
768
|
-
|
|
1844
|
+
content: [
|
|
1845
|
+
{
|
|
1846
|
+
type: "text",
|
|
1847
|
+
text: `${lines.join("\n")}\n\nCompletion (with the agent's report) will arrive as a system notification on its own — you do not need to wait for it or poll; keep working. ${PENDING_RESULT_PROHIBITION} Call task_output only if your next step cannot proceed without the result (block=true waits). Stop with task_stop; SendMessage reaches the agent even while it runs (the message is steered into its current turn).`,
|
|
1848
|
+
},
|
|
1849
|
+
],
|
|
1850
|
+
details: { agentRuns: records, background: true },
|
|
769
1851
|
};
|
|
770
1852
|
},
|
|
771
1853
|
});
|
|
772
1854
|
|
|
773
|
-
const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
774
1855
|
pi.registerTool({
|
|
775
|
-
name: "
|
|
1856
|
+
name: "SendMessage",
|
|
776
1857
|
label: "Send Message",
|
|
777
1858
|
...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
|
|
778
1859
|
title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
|
|
779
1860
|
}),
|
|
780
1861
|
description:
|
|
781
|
-
'Send a message to
|
|
782
|
-
// A background child's model must know it can report back; the parent keeps
|
|
783
|
-
// the tool deferred instead (see the DEFER emit below).
|
|
784
|
-
promptSnippet: isSubagentChild
|
|
785
|
-
? 'Report progress or findings to the main conversation (to: "main")'
|
|
786
|
-
: undefined,
|
|
1862
|
+
'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 system notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
|
|
787
1863
|
parameters: Type.Object({
|
|
788
|
-
to: Type.String({ description:
|
|
1864
|
+
to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
|
|
789
1865
|
message: Type.String({ description: "Plain text message for the agent" }),
|
|
790
1866
|
summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
|
|
791
1867
|
}),
|
|
792
1868
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
793
1869
|
if (params.to === "main") {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
|
|
797
|
-
details: {},
|
|
798
|
-
isError: true,
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
// No IPC needed: the parent reads this tool result off the child's
|
|
802
|
-
// event stream (toMainMessage in rpc-turns.ts) and relays it.
|
|
1870
|
+
// The main conversation's SendMessage only addresses spawned agents; the
|
|
1871
|
+
// "main" recipient exists only on a subagent's own injected SendMessage.
|
|
803
1872
|
return {
|
|
804
|
-
content: [{ type: "text", text:
|
|
805
|
-
details: {
|
|
1873
|
+
content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
|
|
1874
|
+
details: {},
|
|
1875
|
+
isError: true,
|
|
806
1876
|
};
|
|
807
1877
|
}
|
|
808
1878
|
|
|
@@ -813,26 +1883,49 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
813
1883
|
content: [
|
|
814
1884
|
{
|
|
815
1885
|
type: "text",
|
|
816
|
-
text: `No run named "${params.to}".
|
|
1886
|
+
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}.`,
|
|
817
1887
|
},
|
|
818
1888
|
],
|
|
819
1889
|
details: {},
|
|
820
1890
|
isError: true,
|
|
821
1891
|
};
|
|
822
1892
|
}
|
|
823
|
-
// Resident background agent: reach
|
|
824
|
-
const resident = residents.get(record.
|
|
1893
|
+
// Resident background agent: reach its in-process session live.
|
|
1894
|
+
const resident = residents.get(record.taskId);
|
|
825
1895
|
if (resident && !resident.handle.exited()) {
|
|
826
1896
|
if (resident.handle.busy()) {
|
|
827
|
-
|
|
1897
|
+
let delivery: "started" | "steered";
|
|
1898
|
+
try {
|
|
1899
|
+
delivery = await resident.handle.send(params.message);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
return {
|
|
1902
|
+
content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
|
|
1903
|
+
details: {},
|
|
1904
|
+
isError: true,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
if (delivery === "steered") {
|
|
1908
|
+
return {
|
|
1909
|
+
content: [
|
|
1910
|
+
{
|
|
1911
|
+
type: "text",
|
|
1912
|
+
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.`,
|
|
1913
|
+
},
|
|
1914
|
+
],
|
|
1915
|
+
details: { agentRuns: [record], steered: true },
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
// The turn had just ended (settle window): the message rides the next
|
|
1919
|
+
// turn, whose reply nobody has claimed yet — announce it as an update.
|
|
1920
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
828
1921
|
return {
|
|
829
1922
|
content: [
|
|
830
1923
|
{
|
|
831
1924
|
type: "text",
|
|
832
|
-
text:
|
|
1925
|
+
text: `${record.name}'s turn had just finished; the message starts its next turn. The reply will arrive as a system notification ("Update from ${record.name}"). ${PENDING_RESULT_PROHIBITION}`,
|
|
833
1926
|
},
|
|
834
1927
|
],
|
|
835
|
-
details: { agentRuns: [record]
|
|
1928
|
+
details: { agentRuns: [record] },
|
|
836
1929
|
};
|
|
837
1930
|
}
|
|
838
1931
|
|
|
@@ -849,6 +1942,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
849
1942
|
const task: BackgroundTask = {
|
|
850
1943
|
id: taskId,
|
|
851
1944
|
kind: "subagent",
|
|
1945
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
852
1946
|
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
853
1947
|
status: "running",
|
|
854
1948
|
startedAt: Date.now(),
|
|
@@ -857,7 +1951,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
857
1951
|
resident: () => !resident.handle.exited(),
|
|
858
1952
|
finished,
|
|
859
1953
|
};
|
|
860
|
-
resident.turnHandlers.push((outcome) => {
|
|
1954
|
+
resident.turnHandlers.push((outcome, review) => {
|
|
861
1955
|
task.status = outcome.failed ? "failed" : "completed";
|
|
862
1956
|
task.finishedAt = Date.now();
|
|
863
1957
|
replyOutput = outcome.output;
|
|
@@ -865,24 +1959,28 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
865
1959
|
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
866
1960
|
notify(
|
|
867
1961
|
"subagent-result",
|
|
868
|
-
systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output
|
|
869
|
-
{ taskId, name: record.name, failed: outcome.failed ?? false },
|
|
1962
|
+
systemNotification(withReview(`Reply from ${record.name} (${stats}):\n\n${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`, review)),
|
|
1963
|
+
{ taskId, name: record.name, failed: outcome.failed ?? false, reviewed: review !== undefined },
|
|
870
1964
|
);
|
|
871
1965
|
});
|
|
872
1966
|
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
873
|
-
resident.handle.send(params.message);
|
|
1967
|
+
void resident.handle.send(params.message);
|
|
1968
|
+
// Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
|
|
1969
|
+
// the reply arrives as a notification, so forbid predicting it and arm
|
|
1970
|
+
// the agent_end backstop (keyed by the agent's persistent id).
|
|
1971
|
+
spawnedThisLoop.add(record.taskId);
|
|
874
1972
|
return {
|
|
875
1973
|
content: [
|
|
876
1974
|
{
|
|
877
1975
|
type: "text",
|
|
878
|
-
text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a system notification; inspect with task_output
|
|
1976
|
+
text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a system notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
|
|
879
1977
|
},
|
|
880
1978
|
],
|
|
881
1979
|
details: { agentRuns: [record], taskId },
|
|
882
1980
|
};
|
|
883
1981
|
}
|
|
884
1982
|
|
|
885
|
-
if (
|
|
1983
|
+
if (runningIds.has(record.taskId)) {
|
|
886
1984
|
return {
|
|
887
1985
|
content: [
|
|
888
1986
|
{ type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
|
|
@@ -892,6 +1990,22 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
892
1990
|
};
|
|
893
1991
|
}
|
|
894
1992
|
const sessionFile = registry.sessionFileFor(record);
|
|
1993
|
+
// A fork has no agent definition: its identity is the parent's prompt at
|
|
1994
|
+
// spawn time, persisted beside its session. Without it a resume would run
|
|
1995
|
+
// on pi's stock prompt and default tools — refuse instead (review S6).
|
|
1996
|
+
const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
|
|
1997
|
+
if (record.agent === FORK_AGENT && forkPrompt === undefined) {
|
|
1998
|
+
return {
|
|
1999
|
+
content: [
|
|
2000
|
+
{
|
|
2001
|
+
type: "text",
|
|
2002
|
+
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.`,
|
|
2003
|
+
},
|
|
2004
|
+
],
|
|
2005
|
+
details: {},
|
|
2006
|
+
isError: true,
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
895
2009
|
if (!sessionFile) {
|
|
896
2010
|
return {
|
|
897
2011
|
content: [
|
|
@@ -902,27 +2016,60 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
902
2016
|
};
|
|
903
2017
|
}
|
|
904
2018
|
|
|
2019
|
+
// A worktree run whose agent left no changes had its worktree removed at
|
|
2020
|
+
// exit, but the record (persisted in the tool result) still points there.
|
|
2021
|
+
// Resume in the parent cwd instead of a deleted directory, say so in the
|
|
2022
|
+
// reply, and fix the record so later messages don't repeat the note (M2).
|
|
2023
|
+
let relocationNote = "";
|
|
2024
|
+
if (!existsSync(record.cwd)) {
|
|
2025
|
+
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`;
|
|
2026
|
+
record.cwd = ctx.cwd;
|
|
2027
|
+
record.worktree = undefined;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
905
2030
|
const taskId = generateTaskId();
|
|
906
2031
|
let finish!: () => void;
|
|
907
2032
|
const finished = new Promise<void>((resolve) => {
|
|
908
2033
|
finish = resolve;
|
|
909
2034
|
});
|
|
910
2035
|
|
|
911
|
-
|
|
912
|
-
|
|
2036
|
+
runningIds.add(record.taskId);
|
|
2037
|
+
// Re-enter the panel: the finished run's entry flips back to running (or
|
|
2038
|
+
// registers fresh after a session resume) and streams this turn live.
|
|
2039
|
+
const live = trackLiveRun(record, {
|
|
2040
|
+
agent: record.agent,
|
|
2041
|
+
name: record.name,
|
|
2042
|
+
task: params.message,
|
|
2043
|
+
model: record.model,
|
|
2044
|
+
thinking: record.thinking,
|
|
2045
|
+
});
|
|
2046
|
+
const runtime = await getRuntime(ctx);
|
|
2047
|
+
const handle = runtime.run({
|
|
2048
|
+
name: record.name,
|
|
2049
|
+
agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
|
|
913
2050
|
task: params.message,
|
|
914
2051
|
cwd: record.cwd,
|
|
915
2052
|
sessionFile,
|
|
2053
|
+
parentSystemPrompt: forkPrompt,
|
|
916
2054
|
model: record.model,
|
|
2055
|
+
// Resume degrades to the session model if the recorded model has become
|
|
2056
|
+
// unavailable since the original run, rather than failing the resume
|
|
2057
|
+
// outright (the note surfaces the swap). Undefined for a fork (record.model
|
|
2058
|
+
// unset — it inherits) or when the record already is the session model.
|
|
2059
|
+
fallbackModel:
|
|
2060
|
+
record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
|
|
917
2061
|
thinking: record.thinking,
|
|
918
|
-
onProgress: () =>
|
|
2062
|
+
onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
|
|
2063
|
+
sink: live.sink,
|
|
919
2064
|
onMessageToMain: (message, summary) => notifyAgentMessage(record.name, message, summary),
|
|
2065
|
+
extraTools: spawnToolsFor(record),
|
|
920
2066
|
});
|
|
921
|
-
|
|
2067
|
+
liveHandles.set(record.taskId, handle);
|
|
922
2068
|
|
|
923
2069
|
const task: BackgroundTask = {
|
|
924
2070
|
id: taskId,
|
|
925
2071
|
kind: "subagent",
|
|
2072
|
+
ownUI: true, // rendered live by the subagents panel's agent strip
|
|
926
2073
|
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
927
2074
|
status: "running",
|
|
928
2075
|
startedAt: Date.now(),
|
|
@@ -933,37 +2080,86 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
933
2080
|
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
934
2081
|
|
|
935
2082
|
void handle.result.then((outcome) => {
|
|
936
|
-
|
|
937
|
-
|
|
2083
|
+
runningIds.delete(record.taskId);
|
|
2084
|
+
live.finish(Boolean(outcome.failed));
|
|
938
2085
|
task.status = outcome.failed ? "failed" : "completed";
|
|
939
2086
|
task.finishedAt = Date.now();
|
|
940
2087
|
finish();
|
|
941
2088
|
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
942
2089
|
notify(
|
|
943
2090
|
"subagent-result",
|
|
944
|
-
systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output
|
|
2091
|
+
systemNotification(`Reply from ${record.name} (${stats}):\n\n${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`),
|
|
945
2092
|
{ taskId, name: record.name, failed: outcome.failed ?? false },
|
|
946
2093
|
);
|
|
947
2094
|
});
|
|
948
2095
|
|
|
2096
|
+
spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
|
|
949
2097
|
return {
|
|
950
2098
|
content: [
|
|
951
2099
|
{
|
|
952
2100
|
type: "text",
|
|
953
|
-
text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a system notification; inspect with task_output
|
|
2101
|
+
text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a system notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
|
|
954
2102
|
},
|
|
955
2103
|
],
|
|
956
2104
|
details: { agentRuns: [record], taskId },
|
|
957
2105
|
};
|
|
958
2106
|
},
|
|
959
2107
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
2108
|
+
pi.events.emit(DEFER_CHANNEL, { name: "SendMessage", keywords: ["message", "agent", "resume", "continue", "teammate"] });
|
|
2109
|
+
|
|
2110
|
+
// Same precedence as SendMessage's own dispatch (a live resident is reachable
|
|
2111
|
+
// even while a blocking run of the same name is in flight): resident-live
|
|
2112
|
+
// first, then running, then a finished run resumable from its session.
|
|
2113
|
+
const agentStatus = (record: AgentRunRecord) => {
|
|
2114
|
+
const resident = residents.get(record.taskId);
|
|
2115
|
+
if (resident && !resident.handle.exited()) return "resident (reachable live)";
|
|
2116
|
+
if (runningIds.has(record.taskId)) return "running";
|
|
2117
|
+
// A one-shot run returned its report in the tool result and never registered
|
|
2118
|
+
// a background task, so pointing at task_output here sends the model chasing
|
|
2119
|
+
// an unknown id (WEAK-MODEL-REVIEW-2026-09-06 M3).
|
|
2120
|
+
if (record.inline) return "finished (one-shot run, report was returned inline)";
|
|
2121
|
+
return "finished (resume with SendMessage)";
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
pi.registerTool({
|
|
2125
|
+
name: "list_agents",
|
|
2126
|
+
label: "List Agents",
|
|
2127
|
+
...ccToolRenderers("List Agents"),
|
|
2128
|
+
description:
|
|
2129
|
+
"List the agents spawned in this session — their names, types, and current status — so you know which ones SendMessage can reach. `running` is executing now; `resident` is idle but reachable live; `finished` is resumable from its session. This lists running/spawned instances, not the agent catalog (use Agent action:'list' for that).",
|
|
2130
|
+
parameters: Type.Object({}),
|
|
2131
|
+
async execute() {
|
|
2132
|
+
const runs = registry.list();
|
|
2133
|
+
if (runs.length === 0) {
|
|
2134
|
+
return {
|
|
2135
|
+
content: [{ type: "text", text: "No agents have been spawned in this session yet. Start one with the Agent tool." }],
|
|
2136
|
+
details: { agents: [] as unknown[] },
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
const agents = runs.map((r) => ({ name: r.name, agent: r.agent, taskId: r.taskId, status: agentStatus(r) }));
|
|
2140
|
+
const lines = agents.map((a) => `- ${a.name} [${a.agent}] — ${a.status} (task ${a.taskId})`);
|
|
2141
|
+
return {
|
|
2142
|
+
content: [{ type: "text", text: `Agents spawned this session:\n${lines.join("\n")}` }],
|
|
2143
|
+
details: { agents },
|
|
2144
|
+
};
|
|
2145
|
+
},
|
|
2146
|
+
});
|
|
2147
|
+
pi.events.emit(DEFER_CHANNEL, { name: "list_agents", keywords: ["agents", "list", "running", "spawned", "subagents", "who"] });
|
|
963
2148
|
|
|
964
2149
|
pi.registerCommand("agents", {
|
|
965
|
-
description: "
|
|
2150
|
+
description: "Open the live subagent panel, or list available agents",
|
|
966
2151
|
handler: async (_args, ctx) => {
|
|
2152
|
+
// With live children, focus the strip on the newest and open its view
|
|
2153
|
+
// (Claude Code's agent panel); otherwise there is nothing running, so
|
|
2154
|
+
// list the catalog the way the tool's action:"list" does.
|
|
2155
|
+
if (ctx.hasUI && panel.rowCount() > 1) {
|
|
2156
|
+
panel.setFocus(1); // row 0 is `main`; 1 is the newest live child
|
|
2157
|
+
const run = panel.selectedRun();
|
|
2158
|
+
if (run) {
|
|
2159
|
+
openView(ctx, run.taskId);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
967
2163
|
ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
|
|
968
2164
|
},
|
|
969
2165
|
});
|
|
@@ -971,9 +2167,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
971
2167
|
const showSubagentModelStatus = (ctx: ExtensionContext) => {
|
|
972
2168
|
const configured = loadSubagentDefault(os.homedir());
|
|
973
2169
|
const applicable = applicableSubagentDefault(configured, ctx.model);
|
|
974
|
-
const available = ctx
|
|
2170
|
+
const available = usableModels(ctx);
|
|
975
2171
|
const resolution = resolveSubagentModel({
|
|
976
|
-
configuredDefault: applicable
|
|
2172
|
+
configuredDefault: applicable,
|
|
977
2173
|
sessionModel: ctx.model,
|
|
978
2174
|
available,
|
|
979
2175
|
});
|
|
@@ -1007,15 +2203,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1007
2203
|
return;
|
|
1008
2204
|
}
|
|
1009
2205
|
emitModelStatus(ctx);
|
|
1010
|
-
ctx.ui.notify('Subagent default set to the session model ("inherit", saved to ~/.
|
|
2206
|
+
ctx.ui.notify('Subagent default set to the session model ("inherit", saved to ~/.onecode/settings.json).', "info");
|
|
1011
2207
|
return;
|
|
1012
2208
|
}
|
|
1013
2209
|
|
|
1014
|
-
const available = ctx
|
|
2210
|
+
const available = usableModels(ctx);
|
|
1015
2211
|
const resolution = resolveSubagentModel({ requested: spec, sessionModel: ctx.model, available });
|
|
1016
2212
|
if (resolution.unresolved) {
|
|
1017
2213
|
const fallback = resolveSubagentModel({
|
|
1018
|
-
configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model)
|
|
2214
|
+
configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model),
|
|
1019
2215
|
sessionModel: ctx.model,
|
|
1020
2216
|
available,
|
|
1021
2217
|
});
|
|
@@ -1040,13 +2236,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1040
2236
|
}
|
|
1041
2237
|
}
|
|
1042
2238
|
try {
|
|
1043
|
-
|
|
2239
|
+
// Stamp the session's containment identity (provider for direct vendors,
|
|
2240
|
+
// provider:route:vendor on gateways) so a later session on a different
|
|
2241
|
+
// provider/vendor can treat this cross-provider choice as stale (a
|
|
2242
|
+
// deliberate choice made here stays honored; see resolveSubagentModel).
|
|
2243
|
+
persistSubagentModel(spec, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
|
|
1044
2244
|
} catch (error) {
|
|
1045
2245
|
ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
|
|
1046
2246
|
return;
|
|
1047
2247
|
}
|
|
1048
2248
|
emitModelStatus(ctx);
|
|
1049
|
-
ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.
|
|
2249
|
+
ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.onecode/settings.json).`, "info");
|
|
1050
2250
|
};
|
|
1051
2251
|
|
|
1052
2252
|
pi.registerCommand("subagent", {
|
|
@@ -1089,7 +2289,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1089
2289
|
showSubagentModelStatus(ctx);
|
|
1090
2290
|
return;
|
|
1091
2291
|
}
|
|
1092
|
-
const available = ctx
|
|
2292
|
+
const available = usableModels(ctx);
|
|
1093
2293
|
if (available.length === 0) {
|
|
1094
2294
|
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
1095
2295
|
return;
|
|
@@ -1104,8 +2304,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
1104
2304
|
entries,
|
|
1105
2305
|
current,
|
|
1106
2306
|
title: "Select the default subagent model",
|
|
1107
|
-
subtitle:
|
|
1108
|
-
"Default for subagent/workflow runs unless overridden · type to filter · ↑/↓ · enter · esc",
|
|
2307
|
+
subtitle: "Default for subagent/workflow runs unless overridden",
|
|
1109
2308
|
},
|
|
1110
2309
|
tui,
|
|
1111
2310
|
theme,
|