one-code-extension 0.1.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 +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagents extension — Claude Code's Agent/Task tool, plus send_message.
|
|
3
|
+
*
|
|
4
|
+
* Delegates a task to a specialist agent running in a separate pi process with
|
|
5
|
+
* its own context window. Agent definitions come from `.claude/agents/*.md`
|
|
6
|
+
* (project), `~/.claude/agents/*.md` (user), and the catalog bundled with this
|
|
7
|
+
* package — the same markdown + frontmatter format Claude Code uses.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code features covered: parallel tasks, per-call `model`/`thinking`
|
|
10
|
+
* overrides, `fork` (a child inheriting this conversation), `isolation:
|
|
11
|
+
* "worktree"`, `run_in_background` (detached runs addressable via
|
|
12
|
+
* task_output/task_stop, completion delivered as a system notification), and
|
|
13
|
+
* send_message (resume a finished agent with its context intact — children
|
|
14
|
+
* persist their sessions per run to make that possible).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
|
|
25
|
+
import { type AgentDefinition, type AgentSource, agentDirs, discoverAgents } from "./agents.ts";
|
|
26
|
+
import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel } from "./default-model.ts";
|
|
27
|
+
import {
|
|
28
|
+
expensiveModelGate,
|
|
29
|
+
resolveSubagentModel,
|
|
30
|
+
SUBAGENT_STATUS_CHANNEL,
|
|
31
|
+
subagentModelsReminder,
|
|
32
|
+
subagentStatusModel,
|
|
33
|
+
} from "./model-select.ts";
|
|
34
|
+
import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../auto-mode/model-picker.ts";
|
|
35
|
+
import { discoverPlugins } from "../lib/plugins.ts";
|
|
36
|
+
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
37
|
+
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
38
|
+
import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
|
|
39
|
+
import { type ChildAction } from "../auto-mode/actions.ts";
|
|
40
|
+
import {
|
|
41
|
+
type ChildHandle,
|
|
42
|
+
type ChildOutcome,
|
|
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";
|
|
50
|
+
import { emptyUsage, formatStats, type UsageTotals } from "./usage.ts";
|
|
51
|
+
import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "./worktree.ts";
|
|
52
|
+
import { systemNotification } from "../lib/notifications.ts";
|
|
53
|
+
import { ccToolRenderers, customMessageText, notificationComponent } from "../lib/tui-render.ts";
|
|
54
|
+
|
|
55
|
+
const MAX_PARALLEL = 4;
|
|
56
|
+
|
|
57
|
+
/** The catalog shipped in this package: <package>/agents. */
|
|
58
|
+
const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
|
|
59
|
+
|
|
60
|
+
interface RunRequest {
|
|
61
|
+
agent: string;
|
|
62
|
+
task: string;
|
|
63
|
+
name: string;
|
|
64
|
+
/** Inherit the caller's conversation instead of starting from an agent prompt. */
|
|
65
|
+
fork?: boolean;
|
|
66
|
+
model?: string;
|
|
67
|
+
thinking?: string;
|
|
68
|
+
worktree?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TaskResult {
|
|
72
|
+
agent: string;
|
|
73
|
+
name: string;
|
|
74
|
+
taskId: string;
|
|
75
|
+
task: string;
|
|
76
|
+
output: string;
|
|
77
|
+
toolCalls: number;
|
|
78
|
+
usage: UsageTotals;
|
|
79
|
+
failed?: boolean;
|
|
80
|
+
worktreePath?: string;
|
|
81
|
+
worktreeKept?: boolean;
|
|
82
|
+
/** What the child did, for auto mode's return review. */
|
|
83
|
+
actions?: ChildAction[];
|
|
84
|
+
}
|
|
85
|
+
|
|
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
|
+
const SubagentParams = Type.Object({
|
|
95
|
+
agent: Type.Optional(
|
|
96
|
+
Type.String({
|
|
97
|
+
description:
|
|
98
|
+
'Agent for a single run — a name from the "Available agents" system reminder, or "fork" to clone this conversation. Required unless passing `tasks` or action:"list"',
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
task: Type.Optional(
|
|
102
|
+
Type.String({ description: 'Task for a single run — required with `agent` (only action:"list" needs neither)' }),
|
|
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)` }),
|
|
107
|
+
),
|
|
108
|
+
model: Type.Optional(
|
|
109
|
+
Type.String({
|
|
110
|
+
description:
|
|
111
|
+
'Override the agent\'s model for this call: "sonnet"/"opus"/"haiku"/"fable" (resolved within this session\'s provider), "inherit", or an exact provider/model-id — see the subagent-models reminder for what is available. Rejected for fork runs (a fork keeps this conversation\'s model)',
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
allow_expensive: Type.Optional(
|
|
115
|
+
Type.Boolean({
|
|
116
|
+
description:
|
|
117
|
+
"Confirm a per-call `model` that costs more per token than this session's model. Set it only when the user explicitly asked for that model",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
thinking: Type.Optional(
|
|
121
|
+
StringEnum(["off", "minimal", "low", "medium", "high"] as const, {
|
|
122
|
+
description:
|
|
123
|
+
"Override the reasoning effort for this call. Rejected for fork runs (a fork keeps this conversation's settings)",
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
isolation: Type.Optional(
|
|
127
|
+
StringEnum(["worktree"] as const, {
|
|
128
|
+
description: "Run each agent in its own git worktree so parallel file edits cannot collide",
|
|
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",
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
action: Type.Optional(
|
|
138
|
+
StringEnum(["run", "list"] as const, {
|
|
139
|
+
description: '"run" (the default) executes; "list" only browses the agent catalog and ignores run options',
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
});
|
|
143
|
+
|
|
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
|
+
export const FORK_AGENT = "fork";
|
|
158
|
+
|
|
159
|
+
/** A background agent's process, kept alive after its run so it can be messaged. */
|
|
160
|
+
interface Resident {
|
|
161
|
+
handle: RpcChildHandle;
|
|
162
|
+
/** FIFO — the head entry handles the next turn_end (initial task, then one per idle-time message). */
|
|
163
|
+
turnHandlers: Array<(outcome: ChildOutcome) => void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
167
|
+
const registry = new RunRegistry();
|
|
168
|
+
/** Names with a foreground/respawned child currently running (send_message must wait for these). */
|
|
169
|
+
const runningNames = new Set<string>();
|
|
170
|
+
const liveChildren = new Set<{ kill(): void }>();
|
|
171
|
+
const residents = new Map<string, Resident>();
|
|
172
|
+
|
|
173
|
+
const loadAgents = (cwd: string) => {
|
|
174
|
+
// Plugin agents sit between bundled and user definitions, and are exposed
|
|
175
|
+
// namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
|
|
176
|
+
const sources: Array<string | AgentSource> = [
|
|
177
|
+
BUNDLED_AGENTS_DIR,
|
|
178
|
+
...discoverPlugins(join(os.homedir(), ".claude")).agentDirs,
|
|
179
|
+
...agentDirs(cwd, os.homedir()),
|
|
180
|
+
];
|
|
181
|
+
return discoverAgents(sources);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const describeAgents = (cwd: string) => {
|
|
185
|
+
const agents = loadAgents(cwd);
|
|
186
|
+
const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}`);
|
|
187
|
+
lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const reconstructRuns = (ctx: ExtensionContext) => {
|
|
192
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
193
|
+
if (entry.type !== "message") continue;
|
|
194
|
+
const msg = entry.message;
|
|
195
|
+
if (msg.role !== "toolResult" || !["subagent", "send_message"].includes(msg.toolName ?? "")) continue;
|
|
196
|
+
const records = (msg.details as { agentRuns?: AgentRunRecord[] } | undefined)?.agentRuns;
|
|
197
|
+
for (const record of records ?? []) registry.add(record);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
/** Notices about model fallbacks/crossings, shown once per distinct message. */
|
|
201
|
+
const noticedModels = new Set<string>();
|
|
202
|
+
const notifyModelOnce = (ctx: ExtensionContext, message: string) => {
|
|
203
|
+
if (noticedModels.has(message)) return;
|
|
204
|
+
noticedModels.add(message);
|
|
205
|
+
ctx.ui.notify(message, "warning");
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The every-turn menu reminder and the banner's subagent-default status.
|
|
210
|
+
* Every-turn because reminders are transient per-request injections — that
|
|
211
|
+
* scope survives compaction by construction — and keyed so a model change
|
|
212
|
+
* replaces it: the very next LLM call, even mid-turn, carries the update.
|
|
213
|
+
*/
|
|
214
|
+
const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model) => {
|
|
215
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
216
|
+
const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
|
|
217
|
+
const resolution = resolveSubagentModel({ configuredDefault: configured?.spec, sessionModel, available });
|
|
218
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
219
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
220
|
+
text: subagentModelsReminder({
|
|
221
|
+
available,
|
|
222
|
+
sessionModel,
|
|
223
|
+
defaultModel: resolution.model,
|
|
224
|
+
defaultSource: resolution.source,
|
|
225
|
+
}),
|
|
226
|
+
scope: "every-turn",
|
|
227
|
+
key: "subagent-models",
|
|
228
|
+
placement: "first-prepend",
|
|
229
|
+
order: CONTEXT_ORDER.subagentModels,
|
|
230
|
+
});
|
|
231
|
+
pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The agent catalog as an every-turn reminder, as Claude Code does ("Available
|
|
236
|
+
* agent types are listed in <system-reminder> messages") — without it the
|
|
237
|
+
* model has to guess names or make a discovery call first. Keyed and
|
|
238
|
+
* byte-stable within a session, so it costs nothing in prompt-cache terms.
|
|
239
|
+
*/
|
|
240
|
+
const emitAgentCatalog = (ctx: ExtensionContext) => {
|
|
241
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
242
|
+
scope: "every-turn",
|
|
243
|
+
key: "subagent-agents",
|
|
244
|
+
text: `Available agents for the subagent tool (\`agent\` field):\n${describeAgents(ctx.cwd)}`,
|
|
245
|
+
placement: "first-prepend",
|
|
246
|
+
order: CONTEXT_ORDER.agents,
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
pi.on("session_start", (_event, ctx) => {
|
|
251
|
+
reconstructRuns(ctx);
|
|
252
|
+
emitModelStatus(ctx);
|
|
253
|
+
emitAgentCatalog(ctx);
|
|
254
|
+
});
|
|
255
|
+
pi.on("model_select", (event, ctx) => emitModelStatus(ctx, event.model));
|
|
256
|
+
pi.on("session_tree", (_event, ctx) => reconstructRuns(ctx));
|
|
257
|
+
pi.on("session_shutdown", () => {
|
|
258
|
+
for (const child of liveChildren) child.kill();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Compact transcript rendering for the notifications this extension injects
|
|
262
|
+
// (full body on ctrl+o); the verbose framing stays model-only.
|
|
263
|
+
for (const customType of ["subagent-message", "subagent-result"]) {
|
|
264
|
+
pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
|
|
265
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const notify = (customType: string, text: string, details: Record<string, unknown>) => {
|
|
270
|
+
pi.sendMessage(
|
|
271
|
+
{ customType, content: [{ type: "text", text }], display: true, details },
|
|
272
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Relay a child's send_message {to: "main"} into this conversation. */
|
|
277
|
+
const notifyAgentMessage = (name: string, message: string, summary?: string) =>
|
|
278
|
+
notify(
|
|
279
|
+
"subagent-message",
|
|
280
|
+
systemNotification(`Message from agent ${name}${summary ? ` (${summary})` : ""}:\n\n${message}`),
|
|
281
|
+
{ name, summary },
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/** Session dir for a run's persisted child session; undefined → child runs --no-session. */
|
|
285
|
+
const runSessionDir = (ctx: ExtensionContext, taskId: string): string | undefined => {
|
|
286
|
+
try {
|
|
287
|
+
const dir = join(ctx.sessionManager.getSessionDir(), "subagents", taskId);
|
|
288
|
+
mkdirSync(dir, { recursive: true });
|
|
289
|
+
return dir;
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
interface PreparedRun {
|
|
296
|
+
request: RunRequest;
|
|
297
|
+
record: AgentRunRecord;
|
|
298
|
+
agentDef?: AgentDefinition;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Start one child (creating a worktree first if asked) and finalize its record when done. */
|
|
302
|
+
const executeRun = async (
|
|
303
|
+
prepared: PreparedRun,
|
|
304
|
+
ctx: ExtensionContext,
|
|
305
|
+
forkFrom: string | undefined,
|
|
306
|
+
signal: AbortSignal | undefined,
|
|
307
|
+
onProgress: (toolCalls: number, text: string, usage: UsageTotals) => void,
|
|
308
|
+
onStarted?: (handle: ChildHandle, worktree?: Worktree) => void,
|
|
309
|
+
): Promise<TaskResult> => {
|
|
310
|
+
const { request, record, agentDef } = prepared;
|
|
311
|
+
|
|
312
|
+
let worktree: Worktree | undefined;
|
|
313
|
+
if (request.worktree) {
|
|
314
|
+
try {
|
|
315
|
+
worktree = await createWorktree(ctx.cwd, request.name);
|
|
316
|
+
record.cwd = worktree.path;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return {
|
|
319
|
+
agent: request.agent,
|
|
320
|
+
name: request.name,
|
|
321
|
+
taskId: record.taskId,
|
|
322
|
+
task: request.task,
|
|
323
|
+
output: `Could not create a worktree: ${(error as Error).message}`,
|
|
324
|
+
toolCalls: 0,
|
|
325
|
+
usage: emptyUsage(),
|
|
326
|
+
failed: true,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
runningNames.add(request.name);
|
|
332
|
+
const handle = startChild({
|
|
333
|
+
agent: agentDef,
|
|
334
|
+
task: request.task,
|
|
335
|
+
cwd: record.cwd,
|
|
336
|
+
forkFrom: request.fork ? forkFrom : undefined,
|
|
337
|
+
sessionDir: record.sessionSearchDir || undefined,
|
|
338
|
+
model: request.model,
|
|
339
|
+
thinking: request.thinking,
|
|
340
|
+
signal,
|
|
341
|
+
onProgress,
|
|
342
|
+
onMessageToMain: (message, summary) => notifyAgentMessage(request.name, message, summary),
|
|
343
|
+
});
|
|
344
|
+
liveChildren.add(handle);
|
|
345
|
+
onStarted?.(handle, worktree);
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const outcome: ChildOutcome = await handle.result;
|
|
349
|
+
registry.sessionFileFor(record); // resolve now that the child has written it
|
|
350
|
+
let worktreeKept: boolean | undefined;
|
|
351
|
+
if (worktree) {
|
|
352
|
+
worktreeKept = !(await cleanupWorktree(ctx.cwd, worktree));
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
agent: request.agent,
|
|
356
|
+
name: request.name,
|
|
357
|
+
taskId: record.taskId,
|
|
358
|
+
task: request.task,
|
|
359
|
+
...outcome,
|
|
360
|
+
worktreePath: worktreeKept ? worktree?.path : undefined,
|
|
361
|
+
worktreeKept,
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (worktree) await cleanupWorktree(ctx.cwd, worktree);
|
|
365
|
+
return {
|
|
366
|
+
agent: request.agent,
|
|
367
|
+
name: request.name,
|
|
368
|
+
taskId: record.taskId,
|
|
369
|
+
task: request.task,
|
|
370
|
+
output: `Subagent failed: ${(error as Error).message}`,
|
|
371
|
+
toolCalls: 0,
|
|
372
|
+
usage: emptyUsage(),
|
|
373
|
+
failed: true,
|
|
374
|
+
};
|
|
375
|
+
} finally {
|
|
376
|
+
liveChildren.delete(handle);
|
|
377
|
+
runningNames.delete(request.name);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
pi.registerTool({
|
|
382
|
+
name: "subagent",
|
|
383
|
+
label: "Subagent",
|
|
384
|
+
...ccToolRenderers<{ agent?: string; task?: string; tasks?: unknown[]; action?: string }>("Subagent", {
|
|
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,
|
|
391
|
+
}),
|
|
392
|
+
description:
|
|
393
|
+
"Delegate a task to a specialist agent that runs in its own context window and reports back. Use it for well-scoped work whose intermediate output you don't need — broad codebase searches, focused reviews, independent research. Give a complete, self-contained task: the agent cannot ask follow-up questions. The available agents are listed in the \"Available agents\" system reminder. Pass `tasks` to run several in parallel, `agent: \"fork\"` for a child that inherits this conversation (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), `isolation: \"worktree\"` when parallel agents will edit files, or `run_in_background: true` to keep working while it runs (completion arrives as a notification; manage with task_output/task_stop). Each run gets a name — continue a finished agent later with send_message. action:'list' re-prints the agent catalog.",
|
|
394
|
+
promptSnippet: "Delegate scoped work to a specialist agent in its own context",
|
|
395
|
+
parameters: SubagentParams,
|
|
396
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
397
|
+
const agents = loadAgents(ctx.cwd);
|
|
398
|
+
|
|
399
|
+
// A call carrying run options (a task, a name, run_in_background, a
|
|
400
|
+
// model/thinking/isolation override, or action:"run") but no `agent`
|
|
401
|
+
// or `tasks` is a run that forgot to name its agent. Fail loudly with a
|
|
402
|
+
// diagnostic rather than silently returning the catalog: a weaker model
|
|
403
|
+
// reads the catalog as a non-sequitur and invents wrong reasons for it,
|
|
404
|
+
// instead of learning it omitted `agent`.
|
|
405
|
+
const wantsRun =
|
|
406
|
+
params.action === "run" ||
|
|
407
|
+
params.task != null ||
|
|
408
|
+
params.name != null ||
|
|
409
|
+
params.run_in_background != null ||
|
|
410
|
+
params.model != null ||
|
|
411
|
+
params.isolation != null ||
|
|
412
|
+
params.thinking != null;
|
|
413
|
+
|
|
414
|
+
if (params.action === "list" || (!params.agent && !params.tasks && !wantsRun)) {
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: "text", text: `Available agents:\n${describeAgents(ctx.cwd)}` }],
|
|
417
|
+
details: { agents: agents.map((a) => a.name) },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!params.agent && !params.tasks) {
|
|
422
|
+
return {
|
|
423
|
+
content: [
|
|
424
|
+
{
|
|
425
|
+
type: "text",
|
|
426
|
+
text: `No \`agent\` given, but you passed run options — this looks like a run that forgot to name its agent. Set \`agent\` to one of the names below (or "fork" to clone this conversation), or pass \`tasks\` to run several. To only browse the catalog, call with action:"list".\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
details: {},
|
|
430
|
+
isError: true,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const taken = new Set(registry.names());
|
|
435
|
+
const requested: RunRequest[] = (
|
|
436
|
+
params.tasks?.length
|
|
437
|
+
? params.tasks
|
|
438
|
+
: [{ agent: params.agent ?? "", task: params.task ?? "", name: params.name }]
|
|
439
|
+
).map((entry) => {
|
|
440
|
+
const name = entry.name || nextRunName(taken, entry.agent);
|
|
441
|
+
taken.add(name);
|
|
442
|
+
return {
|
|
443
|
+
agent: entry.agent,
|
|
444
|
+
task: entry.task,
|
|
445
|
+
name,
|
|
446
|
+
fork: entry.agent === FORK_AGENT,
|
|
447
|
+
model: params.model,
|
|
448
|
+
thinking: params.thinking,
|
|
449
|
+
worktree: params.isolation === "worktree",
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// A fork continues this conversation; running it on a different model or
|
|
454
|
+
// reasoning effort is how the fork-confabulation incident happened (a
|
|
455
|
+
// minimal-thinking fork resumed the inherited topic instead of its task).
|
|
456
|
+
// Claude Code silently ignores `model` for forks; we fail loud instead.
|
|
457
|
+
if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
|
|
458
|
+
return {
|
|
459
|
+
content: [
|
|
460
|
+
{
|
|
461
|
+
type: "text",
|
|
462
|
+
text: '`model`/`thinking` overrides are not applied to fork runs — a fork continues this conversation and keeps its exact model and reasoning settings. Drop the override, or use a named agent (e.g. "general-purpose") to run the task with different settings.',
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
details: {},
|
|
466
|
+
isError: true,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
471
|
+
if (requested.some((r) => r.fork) && !sessionFile) {
|
|
472
|
+
return {
|
|
473
|
+
content: [
|
|
474
|
+
{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: "Cannot fork: this session is not persisted (started with --no-session), so there is no transcript to clone. Use a named agent instead.",
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
details: {},
|
|
480
|
+
isError: true,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
|
|
485
|
+
if (unknown.length > 0) {
|
|
486
|
+
return {
|
|
487
|
+
content: [
|
|
488
|
+
{
|
|
489
|
+
type: "text",
|
|
490
|
+
text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
details: {},
|
|
494
|
+
isError: true,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (requested.some((r) => r.worktree) && !(await isGitRepo(ctx.cwd))) {
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
|
|
501
|
+
details: {},
|
|
502
|
+
isError: true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const prepared: PreparedRun[] = requested.map((request) => {
|
|
507
|
+
const taskId = generateTaskId();
|
|
508
|
+
return {
|
|
509
|
+
request,
|
|
510
|
+
agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
|
|
511
|
+
record: {
|
|
512
|
+
name: request.name,
|
|
513
|
+
agent: request.agent,
|
|
514
|
+
taskId,
|
|
515
|
+
sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
|
|
516
|
+
cwd: ctx.cwd,
|
|
517
|
+
model: request.model,
|
|
518
|
+
thinking: request.thinking,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Model resolution happens here, in the parent, against the real
|
|
525
|
+
* registry — never in the child, whose `--model` fuzzy-matches across
|
|
526
|
+
* every configured provider. The child is spawned with a concrete
|
|
527
|
+
* `provider/id`; anything surprising (a fallback, a provider crossing)
|
|
528
|
+
* is said out loud rather than happening silently.
|
|
529
|
+
*/
|
|
530
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
531
|
+
const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
|
|
532
|
+
for (const p of prepared) {
|
|
533
|
+
const resolution = resolveSubagentModel({
|
|
534
|
+
requested: p.request.model,
|
|
535
|
+
agentModel: p.agentDef?.model,
|
|
536
|
+
configuredDefault: configuredDefault?.spec,
|
|
537
|
+
sessionModel: ctx.model,
|
|
538
|
+
available,
|
|
539
|
+
});
|
|
540
|
+
if (resolution.unresolved) {
|
|
541
|
+
// The main model chose this string; the menu lets it retry.
|
|
542
|
+
const fallback = resolveSubagentModel({
|
|
543
|
+
configuredDefault: configuredDefault?.spec,
|
|
544
|
+
sessionModel: ctx.model,
|
|
545
|
+
available,
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
content: [
|
|
549
|
+
{
|
|
550
|
+
type: "text",
|
|
551
|
+
text:
|
|
552
|
+
`Unknown model "${resolution.unresolved}" — no available model matches it.\n\n` +
|
|
553
|
+
subagentModelsReminder({
|
|
554
|
+
available,
|
|
555
|
+
sessionModel: ctx.model,
|
|
556
|
+
defaultModel: fallback.model,
|
|
557
|
+
defaultSource: fallback.source,
|
|
558
|
+
}),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
details: {},
|
|
562
|
+
isError: true,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
|
|
566
|
+
if (gate) {
|
|
567
|
+
const fallback = resolveSubagentModel({
|
|
568
|
+
configuredDefault: configuredDefault?.spec,
|
|
569
|
+
sessionModel: ctx.model,
|
|
570
|
+
available,
|
|
571
|
+
});
|
|
572
|
+
return {
|
|
573
|
+
content: [
|
|
574
|
+
{
|
|
575
|
+
type: "text",
|
|
576
|
+
text:
|
|
577
|
+
`${gate}\n` +
|
|
578
|
+
"If the user explicitly asked for this model, retry with allow_expensive: true; " +
|
|
579
|
+
"otherwise pick a cheaper model or omit the field.\n\n" +
|
|
580
|
+
subagentModelsReminder({
|
|
581
|
+
available,
|
|
582
|
+
sessionModel: ctx.model,
|
|
583
|
+
defaultModel: fallback.model,
|
|
584
|
+
defaultSource: fallback.source,
|
|
585
|
+
}),
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
details: {},
|
|
589
|
+
isError: true,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
|
|
593
|
+
const resolved = resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : undefined;
|
|
594
|
+
p.request.model = resolved;
|
|
595
|
+
p.record.model = resolved;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
for (const p of prepared) registry.add(p.record);
|
|
599
|
+
const records = prepared.map((p) => p.record);
|
|
600
|
+
|
|
601
|
+
// --- Background: RPC children that stay resident after their run, so
|
|
602
|
+
// send_message can reach them live (steer mid-turn, prompt when idle).
|
|
603
|
+
if (params.run_in_background) {
|
|
604
|
+
const lines: string[] = [];
|
|
605
|
+
for (const p of prepared) {
|
|
606
|
+
let worktree: Worktree | undefined;
|
|
607
|
+
if (p.request.worktree) {
|
|
608
|
+
try {
|
|
609
|
+
worktree = await createWorktree(ctx.cwd, p.request.name);
|
|
610
|
+
p.record.cwd = worktree.path;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const logPath = p.record.sessionSearchDir ? join(p.record.sessionSearchDir, "output.log") : undefined;
|
|
618
|
+
let finish!: () => void;
|
|
619
|
+
const finished = new Promise<void>((resolve) => {
|
|
620
|
+
finish = resolve;
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// The child's final output. task.output() falls back to this
|
|
624
|
+
// because handle.snapshot().text can be blank at a turn boundary —
|
|
625
|
+
// the same reason the log write below uses `|| outcome.output`.
|
|
626
|
+
// Keeps task_output consistent with the log and the completion
|
|
627
|
+
// notification rather than showing an empty body in that case.
|
|
628
|
+
let lastOutput = "";
|
|
629
|
+
const resident: Resident = { handle: undefined as never, turnHandlers: [] };
|
|
630
|
+
const worktreeNote = worktree
|
|
631
|
+
? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
|
|
632
|
+
: "";
|
|
633
|
+
resident.turnHandlers.push((outcome) => {
|
|
634
|
+
task.status = outcome.failed ? "failed" : "completed";
|
|
635
|
+
task.finishedAt = Date.now();
|
|
636
|
+
finish();
|
|
637
|
+
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
638
|
+
notify(
|
|
639
|
+
"subagent-result",
|
|
640
|
+
systemNotification(`Background agent ${p.record.name} (${p.record.taskId}) ${outcome.failed ? "failed" : "completed"} (${stats}). It stays resident — message it with send_message.\n\n${outcome.output.slice(0, OUTPUT_CAP)}${worktreeNote}`),
|
|
641
|
+
{ taskId: p.record.taskId, name: p.record.name, failed: outcome.failed ?? false },
|
|
642
|
+
);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const handle = startRpcChild({
|
|
646
|
+
agent: p.agentDef,
|
|
647
|
+
cwd: p.record.cwd,
|
|
648
|
+
forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
|
|
649
|
+
sessionDir: p.record.sessionSearchDir || undefined,
|
|
650
|
+
model: p.request.model,
|
|
651
|
+
thinking: p.request.thinking,
|
|
652
|
+
onProgress: (_toolCalls, text) => {
|
|
653
|
+
if (logPath && text) writeFileSync(logPath, text);
|
|
654
|
+
},
|
|
655
|
+
onMessageToMain: (message, summary) => notifyAgentMessage(p.record.name, message, summary),
|
|
656
|
+
onTurnEnd: (outcome) => {
|
|
657
|
+
registry.sessionFileFor(p.record);
|
|
658
|
+
lastOutput = resident.handle.snapshot().text || outcome.output;
|
|
659
|
+
if (logPath) writeFileSync(logPath, lastOutput);
|
|
660
|
+
// Auto mode reviews a background/resident turn's action sequence as a
|
|
661
|
+
// whole, like the foreground path (line ~758). There is no tool_result
|
|
662
|
+
// to attach to here, so the gate reviews on receipt and notifies.
|
|
663
|
+
if (outcome.actions?.length) {
|
|
664
|
+
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
|
|
665
|
+
toolCallId: p.record.taskId,
|
|
666
|
+
actions: outcome.actions,
|
|
667
|
+
background: true,
|
|
668
|
+
agentName: p.record.name,
|
|
669
|
+
} satisfies SubagentActionsPayload);
|
|
670
|
+
}
|
|
671
|
+
const handler = resident.turnHandlers.shift();
|
|
672
|
+
if (handler) {
|
|
673
|
+
handler(outcome);
|
|
674
|
+
} else {
|
|
675
|
+
// A turn nobody is waiting on (e.g. a steer that raced past its
|
|
676
|
+
// target turn and ran on its own) must still surface.
|
|
677
|
+
notify(
|
|
678
|
+
"subagent-result",
|
|
679
|
+
systemNotification(`Update from ${p.record.name}:\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
|
|
680
|
+
{ name: p.record.name, failed: outcome.failed ?? false },
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
onExit: () => {
|
|
685
|
+
liveChildren.delete(handle);
|
|
686
|
+
if (residents.get(p.record.name) === resident) residents.delete(p.record.name);
|
|
687
|
+
if (worktree) void cleanupWorktree(ctx.cwd, worktree);
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
resident.handle = handle;
|
|
691
|
+
residents.set(p.record.name, resident);
|
|
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…"}`;
|
|
730
|
+
});
|
|
731
|
+
onUpdate?.({ content: [{ type: "text", text: lines.join("\n") }], details: {} });
|
|
732
|
+
};
|
|
733
|
+
report();
|
|
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
|
+
);
|
|
744
|
+
|
|
745
|
+
const text = results
|
|
746
|
+
.map((r) => {
|
|
747
|
+
const stats = formatStats(r.toolCalls, r.usage);
|
|
748
|
+
const worktreeNote = r.worktreePath
|
|
749
|
+
? `\n\n(Changes left in worktree ${r.worktreePath} — review or merge them.)`
|
|
750
|
+
: "";
|
|
751
|
+
return results.length > 1
|
|
752
|
+
? `## ${r.name}${r.failed ? " (failed)" : ""} (${stats})\nTask: ${r.task}\n\n${r.output}${worktreeNote}`
|
|
753
|
+
: `${r.output}${worktreeNote}\n\n(${stats})`;
|
|
754
|
+
})
|
|
755
|
+
.join("\n\n---\n\n");
|
|
756
|
+
|
|
757
|
+
// Auto mode reviews what a child actually did once it returns, catching a
|
|
758
|
+
// sequence whose individual steps each passed. Emitted rather than
|
|
759
|
+
// checked here: the permission gate owns the classifier.
|
|
760
|
+
pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
|
|
761
|
+
toolCallId,
|
|
762
|
+
actions: results.flatMap((r) => r.actions ?? []),
|
|
763
|
+
} satisfies SubagentActionsPayload);
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
content: [{ type: "text", text }],
|
|
767
|
+
details: { results, agentRuns: records },
|
|
768
|
+
isError: results.every((r) => r.failed),
|
|
769
|
+
};
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
774
|
+
pi.registerTool({
|
|
775
|
+
name: "send_message",
|
|
776
|
+
label: "Send Message",
|
|
777
|
+
...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
|
|
778
|
+
title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
|
|
779
|
+
}),
|
|
780
|
+
description:
|
|
781
|
+
'Send a message to another agent. From the main conversation: address a previously spawned subagent by the name from its spawn result (or task id) — 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 file with full context; replies arrive as system notifications. From inside a subagent: use to: "main" to report progress, findings, or questions to the main conversation mid-run — your plain text output is NOT visible to it until you finish.',
|
|
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,
|
|
787
|
+
parameters: Type.Object({
|
|
788
|
+
to: Type.String({ description: 'Agent name (or task id) from a previous subagent run — or "main" from inside a subagent' }),
|
|
789
|
+
message: Type.String({ description: "Plain text message for the agent" }),
|
|
790
|
+
summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
|
|
791
|
+
}),
|
|
792
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
793
|
+
if (params.to === "main") {
|
|
794
|
+
if (!isSubagentChild) {
|
|
795
|
+
return {
|
|
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.
|
|
803
|
+
return {
|
|
804
|
+
content: [{ type: "text", text: "Message delivered to the main conversation." }],
|
|
805
|
+
details: { toMain: true, message: params.message, summary: params.summary },
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const record = registry.resolve(params.to);
|
|
810
|
+
if (!record) {
|
|
811
|
+
const known = registry.names().join(", ") || "(none)";
|
|
812
|
+
return {
|
|
813
|
+
content: [
|
|
814
|
+
{
|
|
815
|
+
type: "text",
|
|
816
|
+
text: `No run named "${params.to}". send_message only reaches subagent runs already started this session — address them by their run name or task id, not by a catalog agent name. Start one with the subagent tool first if you haven't. Known runs: ${known}.`,
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
details: {},
|
|
820
|
+
isError: true,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
// Resident background agent: reach it live over its RPC channel.
|
|
824
|
+
const resident = residents.get(record.name);
|
|
825
|
+
if (resident && !resident.handle.exited()) {
|
|
826
|
+
if (resident.handle.busy()) {
|
|
827
|
+
resident.handle.send(params.message);
|
|
828
|
+
return {
|
|
829
|
+
content: [
|
|
830
|
+
{
|
|
831
|
+
type: "text",
|
|
832
|
+
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.`,
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
details: { agentRuns: [record], steered: true },
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const taskId = generateTaskId();
|
|
840
|
+
let finish!: () => void;
|
|
841
|
+
const finished = new Promise<void>((resolve) => {
|
|
842
|
+
finish = resolve;
|
|
843
|
+
});
|
|
844
|
+
// This task is ONE turn of the resident agent. Its output must be that
|
|
845
|
+
// turn's reply — the same text the reply notification carries — not the
|
|
846
|
+
// resident's whole multi-turn transcript, or task_output and the
|
|
847
|
+
// notification disagree (superset), which weak models conflate.
|
|
848
|
+
let replyOutput = "";
|
|
849
|
+
const task: BackgroundTask = {
|
|
850
|
+
id: taskId,
|
|
851
|
+
kind: "subagent",
|
|
852
|
+
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
853
|
+
status: "running",
|
|
854
|
+
startedAt: Date.now(),
|
|
855
|
+
output: () => replyOutput || resident.handle.snapshot().text,
|
|
856
|
+
stop: () => resident.handle.kill(),
|
|
857
|
+
resident: () => !resident.handle.exited(),
|
|
858
|
+
finished,
|
|
859
|
+
};
|
|
860
|
+
resident.turnHandlers.push((outcome) => {
|
|
861
|
+
task.status = outcome.failed ? "failed" : "completed";
|
|
862
|
+
task.finishedAt = Date.now();
|
|
863
|
+
replyOutput = outcome.output;
|
|
864
|
+
finish();
|
|
865
|
+
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
866
|
+
notify(
|
|
867
|
+
"subagent-result",
|
|
868
|
+
systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
|
|
869
|
+
{ taskId, name: record.name, failed: outcome.failed ?? false },
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
873
|
+
resident.handle.send(params.message);
|
|
874
|
+
return {
|
|
875
|
+
content: [
|
|
876
|
+
{
|
|
877
|
+
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.`,
|
|
879
|
+
},
|
|
880
|
+
],
|
|
881
|
+
details: { agentRuns: [record], taskId },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (runningNames.has(record.name)) {
|
|
886
|
+
return {
|
|
887
|
+
content: [
|
|
888
|
+
{ type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
|
|
889
|
+
],
|
|
890
|
+
details: {},
|
|
891
|
+
isError: true,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const sessionFile = registry.sessionFileFor(record);
|
|
895
|
+
if (!sessionFile) {
|
|
896
|
+
return {
|
|
897
|
+
content: [
|
|
898
|
+
{ type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
|
|
899
|
+
],
|
|
900
|
+
details: {},
|
|
901
|
+
isError: true,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const taskId = generateTaskId();
|
|
906
|
+
let finish!: () => void;
|
|
907
|
+
const finished = new Promise<void>((resolve) => {
|
|
908
|
+
finish = resolve;
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
runningNames.add(record.name);
|
|
912
|
+
const handle = startChild({
|
|
913
|
+
task: params.message,
|
|
914
|
+
cwd: record.cwd,
|
|
915
|
+
sessionFile,
|
|
916
|
+
model: record.model,
|
|
917
|
+
thinking: record.thinking,
|
|
918
|
+
onProgress: () => {},
|
|
919
|
+
onMessageToMain: (message, summary) => notifyAgentMessage(record.name, message, summary),
|
|
920
|
+
});
|
|
921
|
+
liveChildren.add(handle);
|
|
922
|
+
|
|
923
|
+
const task: BackgroundTask = {
|
|
924
|
+
id: taskId,
|
|
925
|
+
kind: "subagent",
|
|
926
|
+
description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
|
|
927
|
+
status: "running",
|
|
928
|
+
startedAt: Date.now(),
|
|
929
|
+
output: () => handle.snapshot().text,
|
|
930
|
+
stop: () => handle.kill(),
|
|
931
|
+
finished,
|
|
932
|
+
};
|
|
933
|
+
pi.events.emit(TASK_REGISTER_CHANNEL, task);
|
|
934
|
+
|
|
935
|
+
void handle.result.then((outcome) => {
|
|
936
|
+
liveChildren.delete(handle);
|
|
937
|
+
runningNames.delete(record.name);
|
|
938
|
+
task.status = outcome.failed ? "failed" : "completed";
|
|
939
|
+
task.finishedAt = Date.now();
|
|
940
|
+
finish();
|
|
941
|
+
const stats = formatStats(outcome.toolCalls, outcome.usage);
|
|
942
|
+
notify(
|
|
943
|
+
"subagent-result",
|
|
944
|
+
systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
|
|
945
|
+
{ taskId, name: record.name, failed: outcome.failed ?? false },
|
|
946
|
+
);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
content: [
|
|
951
|
+
{
|
|
952
|
+
type: "text",
|
|
953
|
+
text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a system notification; inspect with task_output.`,
|
|
954
|
+
},
|
|
955
|
+
],
|
|
956
|
+
details: { agentRuns: [record], taskId },
|
|
957
|
+
};
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
if (!isSubagentChild) {
|
|
961
|
+
pi.events.emit(DEFER_CHANNEL, { name: "send_message", keywords: ["message", "agent", "resume", "continue", "teammate"] });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
pi.registerCommand("agents", {
|
|
965
|
+
description: "List available subagents",
|
|
966
|
+
handler: async (_args, ctx) => {
|
|
967
|
+
ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const showSubagentModelStatus = (ctx: ExtensionContext) => {
|
|
972
|
+
const configured = loadSubagentDefault(os.homedir());
|
|
973
|
+
const applicable = applicableSubagentDefault(configured, ctx.model);
|
|
974
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
975
|
+
const resolution = resolveSubagentModel({
|
|
976
|
+
configuredDefault: applicable?.spec,
|
|
977
|
+
sessionModel: ctx.model,
|
|
978
|
+
available,
|
|
979
|
+
});
|
|
980
|
+
ctx.ui.notify(
|
|
981
|
+
[
|
|
982
|
+
configured
|
|
983
|
+
? `subagentModel: ${configured.spec} (from the ${configured.source})` +
|
|
984
|
+
(applicable ? "" : " — not applied: CLAUDE_CODE_SUBAGENT_MODEL is Claude Code's knob, and this session is not on a Claude model")
|
|
985
|
+
: "subagentModel: (not set)",
|
|
986
|
+
`effective: ${resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : "none"} (${resolution.source})`,
|
|
987
|
+
...(resolution.notices.length ? [resolution.notices.join("\n")] : []),
|
|
988
|
+
"Set it with /subagent <provider/model-id|sonnet|opus|haiku|fable|inherit>, or clear with /subagent clear.",
|
|
989
|
+
].join("\n"),
|
|
990
|
+
"info",
|
|
991
|
+
);
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Persist a chosen subagent default. `inherit` is literal (session model,
|
|
996
|
+
* no auth needed); any other spec is validated against the registry and its
|
|
997
|
+
* auth checked before saving — a default with no credentials would fail
|
|
998
|
+
* every subagent spawn. The value is saved as the literal spec, so an alias
|
|
999
|
+
* like `sonnet` keeps per-session alias semantics (see model-select.ts).
|
|
1000
|
+
*/
|
|
1001
|
+
const applySubagentModelChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
|
|
1002
|
+
if (spec === "inherit") {
|
|
1003
|
+
try {
|
|
1004
|
+
persistSubagentModel("inherit", os.homedir());
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
emitModelStatus(ctx);
|
|
1010
|
+
ctx.ui.notify('Subagent default set to the session model ("inherit", saved to ~/.claude/settings.json).', "info");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
1015
|
+
const resolution = resolveSubagentModel({ requested: spec, sessionModel: ctx.model, available });
|
|
1016
|
+
if (resolution.unresolved) {
|
|
1017
|
+
const fallback = resolveSubagentModel({
|
|
1018
|
+
configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model)?.spec,
|
|
1019
|
+
sessionModel: ctx.model,
|
|
1020
|
+
available,
|
|
1021
|
+
});
|
|
1022
|
+
ctx.ui.notify(
|
|
1023
|
+
`No available model matches "${spec}".\n\n` +
|
|
1024
|
+
subagentModelsReminder({
|
|
1025
|
+
available,
|
|
1026
|
+
sessionModel: ctx.model,
|
|
1027
|
+
defaultModel: fallback.model,
|
|
1028
|
+
defaultSource: fallback.source,
|
|
1029
|
+
}),
|
|
1030
|
+
"error",
|
|
1031
|
+
);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const resolved = resolution.model;
|
|
1035
|
+
if (resolved) {
|
|
1036
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(resolved);
|
|
1037
|
+
if (!auth.ok) {
|
|
1038
|
+
ctx.ui.notify(`Cannot use ${resolved.provider}/${resolved.id}: ${auth.error}. Not saved.`, "error");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
try {
|
|
1043
|
+
persistSubagentModel(spec, os.homedir());
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
emitModelStatus(ctx);
|
|
1049
|
+
ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.claude/settings.json).`, "info");
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
pi.registerCommand("subagent", {
|
|
1053
|
+
description: "Set the default model for subagent/workflow runs: /subagent [provider/model-id|inherit|status|clear]",
|
|
1054
|
+
getArgumentCompletions: (prefix) =>
|
|
1055
|
+
["inherit", "status", "clear"]
|
|
1056
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
1057
|
+
.map((value) => ({ value, label: value })),
|
|
1058
|
+
handler: async (args, ctx) => {
|
|
1059
|
+
const typed = args.trim();
|
|
1060
|
+
if (typed === "clear") {
|
|
1061
|
+
try {
|
|
1062
|
+
persistSubagentModel(undefined, os.homedir());
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
ctx.ui.notify("Could not update settings: " + (error as Error).message, "error");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
emitModelStatus(ctx);
|
|
1068
|
+
ctx.ui.notify(
|
|
1069
|
+
"subagentModel cleared — the default is CLAUDE_CODE_SUBAGENT_MODEL or managed settings when applicable, else One Code's automatic same-provider profile.",
|
|
1070
|
+
"info",
|
|
1071
|
+
);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (typed === "inherit") {
|
|
1075
|
+
await applySubagentModelChoice("inherit", ctx);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (typed === "status") {
|
|
1079
|
+
showSubagentModelStatus(ctx);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (typed) {
|
|
1083
|
+
await applySubagentModelChoice(typed, ctx);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// The picker needs focus and a terminal; elsewhere show status.
|
|
1088
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
1089
|
+
showSubagentModelStatus(ctx);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
1093
|
+
if (available.length === 0) {
|
|
1094
|
+
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const configured = loadSubagentDefault(os.homedir());
|
|
1098
|
+
const current = configured && configured.spec.includes("/") ? configured.spec : undefined;
|
|
1099
|
+
const entries = toPickerEntries(available);
|
|
1100
|
+
|
|
1101
|
+
const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
1102
|
+
modelPickerComponent(
|
|
1103
|
+
{
|
|
1104
|
+
entries,
|
|
1105
|
+
current,
|
|
1106
|
+
title: "Select the default subagent model",
|
|
1107
|
+
subtitle:
|
|
1108
|
+
"Default for subagent/workflow runs unless overridden · type to filter · ↑/↓ · enter · esc",
|
|
1109
|
+
},
|
|
1110
|
+
tui,
|
|
1111
|
+
theme,
|
|
1112
|
+
done,
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
if (chosen) await applySubagentModelChoice(pickerSpec(chosen), ctx);
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
}
|