@ferris1225/pi-subagents 4.1.13 → 4.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -291
- package/agents/cleaner.md +24 -30
- package/agents/documenter.md +23 -20
- package/agents/explorer.md +7 -2
- package/agents/reviewer.md +82 -77
- package/agents/worker.md +45 -37
- package/package.json +1 -1
- package/src/announcements.ts +59 -54
- package/src/background.ts +26 -10
- package/src/completion.ts +7 -1
- package/src/config.ts +1 -1
- package/src/dispatch.ts +133 -133
- package/src/durable.ts +85 -19
- package/src/format.ts +179 -167
- package/src/monitor.ts +4 -2
- package/src/prompt.ts +14 -27
- package/src/runtime.ts +18 -14
- package/src/setup.ts +3 -3
- package/src/spawn.ts +650 -642
- package/src/temp-hygiene.ts +0 -28
- package/src/thread-lifecycle.ts +85 -99
- package/src/tools.ts +712 -708
- package/src/widget.ts +9 -0
- package/src/workflow.ts +199 -248
- package/src/worktree.ts +64 -37
package/src/tools.ts
CHANGED
|
@@ -1,708 +1,712 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thread controls and lookup tools around the subagent runtime:
|
|
3
|
-
* subagent_control (resume), subagent_wait (in-turn
|
|
4
|
-
* result lookup), subagent_status, and destructive subagent_stop.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
|
-
import { Type } from "typebox";
|
|
12
|
-
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
13
|
-
import { removeThreadRecord } from "./durable.ts";
|
|
14
|
-
import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
15
|
-
import { emptyUsage } from "./rpc-run.ts";
|
|
16
|
-
import {
|
|
17
|
-
formatTaskSummary,
|
|
18
|
-
isRunActiveStatus,
|
|
19
|
-
monitor,
|
|
20
|
-
runLabel,
|
|
21
|
-
statusLabel,
|
|
22
|
-
type RunStatus,
|
|
23
|
-
} from "./monitor.ts";
|
|
24
|
-
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
25
|
-
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
26
|
-
import { CONTROL_QUIESCE_TIMEOUT_MS, quiesced } from "./thread-lifecycle.ts";
|
|
27
|
-
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
28
|
-
import type { WorktreeFinalization } from "./worktree.ts";
|
|
29
|
-
|
|
30
|
-
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
31
|
-
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
32
|
-
* result immediately, a still-active run returns a "still running — end your
|
|
33
|
-
* turn" note and the model finishes (the completion then wakes it). Blocking
|
|
34
|
-
* is opt-in via an explicit timeoutMs — a long default would hold the turn
|
|
35
|
-
* hostage for nothing, since the result arrives on its own either way. */
|
|
36
|
-
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 0;
|
|
37
|
-
|
|
38
|
-
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
39
|
-
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
40
|
-
const text = parts
|
|
41
|
-
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
42
|
-
.join(" ")
|
|
43
|
-
.trim();
|
|
44
|
-
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
45
|
-
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
49
|
-
const SubagentControlParams = Type.Object({
|
|
50
|
-
action: StringEnum(["resume"] as const, {
|
|
51
|
-
description: "Control operation for the logical sub-agent thread.",
|
|
52
|
-
}),
|
|
53
|
-
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch/status output." }),
|
|
54
|
-
objective: Type.Optional(
|
|
55
|
-
Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
|
|
56
|
-
),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
pi.registerTool({
|
|
60
|
-
name: "subagent_control",
|
|
61
|
-
label: "Subagent Control",
|
|
62
|
-
description: [
|
|
63
|
-
"Resume an existing sub-agent thread by stable run id.",
|
|
64
|
-
"resume restarts a parked, completed, or failed retained thread with the same run id and cumulative active time; omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed current goal.",
|
|
65
|
-
"Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
66
|
-
].join(" "),
|
|
67
|
-
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
68
|
-
promptGuidelines: [
|
|
69
|
-
"Use subagent_control resume to continue a parked/settled thread on the same run id. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
|
|
70
|
-
"Use subagent_stop only for destructive cancellation; it retires that thread's retained session.",
|
|
71
|
-
],
|
|
72
|
-
parameters: SubagentControlParams,
|
|
73
|
-
|
|
74
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
75
|
-
const thread = runtime.threads.get(params.id);
|
|
76
|
-
if (!thread) {
|
|
77
|
-
return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
|
|
78
|
-
}
|
|
79
|
-
const nonBlank = (value: string | undefined): string | undefined => {
|
|
80
|
-
const trimmed = value?.trim();
|
|
81
|
-
return trimmed ? trimmed : undefined;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
switch (params.action) {
|
|
86
|
-
case "resume": {
|
|
87
|
-
if (thread.retired) {
|
|
88
|
-
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
89
|
-
}
|
|
90
|
-
if (!(["parked", "completed", "failed"] as const).includes(thread.state as any)) {
|
|
91
|
-
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.` }], details: {} };
|
|
92
|
-
}
|
|
93
|
-
const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
|
|
94
|
-
if (params.objective !== undefined && !objective) {
|
|
95
|
-
return { content: [{ type: "text", text: "resume objective must be non-blank when provided." }], details: {} };
|
|
96
|
-
}
|
|
97
|
-
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
98
|
-
const pending = await thread.resume(objective, ctx);
|
|
99
|
-
if (pending.exitCode !== -1) {
|
|
100
|
-
return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
|
|
101
|
-
}
|
|
102
|
-
const currentObjective = formatTaskSummary(objective ?? thread.task, 80, false);
|
|
103
|
-
const mode = objective
|
|
104
|
-
? `appended objective: ${currentObjective}`
|
|
105
|
-
: `continuing current objective: ${currentObjective}`;
|
|
106
|
-
const context = hadRetainedSession
|
|
107
|
-
? "the same retained session and prior context are preserved"
|
|
108
|
-
: "no prior child session existed, so only the logical run and objective are continued";
|
|
109
|
-
return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved.
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
renderCall(args, theme) {
|
|
118
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_control "))}${theme.fg("accent", `${args.action} #${args.id}`)}`, 0, 0);
|
|
119
|
-
},
|
|
120
|
-
renderResult(result, _options, theme) {
|
|
121
|
-
return renderFirstLine(result, "subagent_control ", theme);
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const SubagentWaitParams = Type.Object({
|
|
126
|
-
id: Type.Optional(
|
|
127
|
-
Type.String({
|
|
128
|
-
description: "Run id or prefix shown by subagent dispatch/status output. Omit to wait for all active runs in this session.",
|
|
129
|
-
}),
|
|
130
|
-
),
|
|
131
|
-
timeoutMs: Type.Optional(
|
|
132
|
-
Type.Number({
|
|
133
|
-
description: `Block for up to this many milliseconds and report the still-running runs. Default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}: no blocking — settled runs return their result immediately, active runs return a note telling the model to end its turn.`,
|
|
134
|
-
}),
|
|
135
|
-
),
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
pi.registerTool({
|
|
139
|
-
name: "subagent_wait",
|
|
140
|
-
label: "Subagent Wait",
|
|
141
|
-
description: [
|
|
142
|
-
"Look up background sub-agent run(s) and return their results.",
|
|
143
|
-
"PREFER NOT CALLING THIS: dispatching already ended your turn and results arrive as a message that wakes you automatically.",
|
|
144
|
-
"By default it does NOT block: a settled run returns its result immediately; a still-active run returns a 'still running — end your turn' note.",
|
|
145
|
-
"Pass an explicit timeoutMs ONLY when you must stay in the turn and need the result right now (sequential dependent steps).",
|
|
146
|
-
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
147
|
-
"The same result is also delivered as a completion message that resumes the main agent, so you may see it twice (once here, once as a wake-up) — that is expected, not a duplicate.",
|
|
148
|
-
].join(" "),
|
|
149
|
-
promptSnippet: "Look up a background subagent result in-turn (id: run id from dispatch/status output; omit for all). Non-blocking by default; pass timeoutMs to block.",
|
|
150
|
-
promptGuidelines: [
|
|
151
|
-
"Do NOT call subagent_wait to hold the turn: results arrive as wake-up messages automatically. The default call is a non-blocking lookup — settled results return immediately, active runs return a note telling you to end your turn.",
|
|
152
|
-
"Pass an explicit timeoutMs only when you must keep the turn AND the next step depends on the result right now — e.g. the user asked you to wait for it.",
|
|
153
|
-
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
154
|
-
"If subagent_wait times out, end the turn and wait for the wake-up message, or call it again with a longer timeoutMs.",
|
|
155
|
-
],
|
|
156
|
-
parameters: SubagentWaitParams,
|
|
157
|
-
|
|
158
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
159
|
-
const config = await loadConfig(runtime.configPath);
|
|
160
|
-
// A non-finite or negative timeout would produce a nonsensical note
|
|
161
|
-
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
162
|
-
// asked for; fall back to the default. Zero is honored as an immediate
|
|
163
|
-
// give-up (clamped to 1ms below).
|
|
164
|
-
const timeoutMs =
|
|
165
|
-
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
166
|
-
? params.timeoutMs
|
|
167
|
-
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
168
|
-
const isActive = (run: { status: RunStatus }): boolean => isRunActiveStatus(run.status);
|
|
169
|
-
|
|
170
|
-
const requested = params.id?.trim();
|
|
171
|
-
// A run that already settled resolves immediately with its result.
|
|
172
|
-
if (requested) {
|
|
173
|
-
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
174
|
-
if (settledIds.length > 0) {
|
|
175
|
-
return {
|
|
176
|
-
content: [
|
|
177
|
-
{ type: "text", text: settledIds.map((id) => {
|
|
178
|
-
const result = runtime.settledRuns.get(id)!;
|
|
179
|
-
return formatCompletionBlock(result, config.maxResultLines, result.projectCwd ?? ctx.cwd);
|
|
180
|
-
}).join("\n\n") },
|
|
181
|
-
],
|
|
182
|
-
details: {},
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const activeRuns = monitor.getRuns().filter(isActive);
|
|
188
|
-
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
189
|
-
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
190
|
-
if (targets.length === 0) {
|
|
191
|
-
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
192
|
-
return {
|
|
193
|
-
content: [
|
|
194
|
-
{
|
|
195
|
-
type: "text",
|
|
196
|
-
text: requested
|
|
197
|
-
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
198
|
-
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
199
|
-
},
|
|
200
|
-
],
|
|
201
|
-
details: {},
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
206
|
-
const already = runtime.settledRuns.get(runId);
|
|
207
|
-
if (already) return Promise.resolve({ result: already });
|
|
208
|
-
return new Promise((resolve) => {
|
|
209
|
-
let done = false;
|
|
210
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
211
|
-
let unsub: (() => void) | undefined;
|
|
212
|
-
const cleanup = (): void => {
|
|
213
|
-
if (timer) clearTimeout(timer);
|
|
214
|
-
if (unsub) unsub();
|
|
215
|
-
signal?.removeEventListener("abort", onAbort);
|
|
216
|
-
const listeners = runtime.settledListeners.get(runId);
|
|
217
|
-
if (listeners) {
|
|
218
|
-
listeners.delete(onSettled);
|
|
219
|
-
if (listeners.size === 0) runtime.settledListeners.delete(runId);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
223
|
-
if (done) return;
|
|
224
|
-
done = true;
|
|
225
|
-
cleanup();
|
|
226
|
-
resolve(outcome);
|
|
227
|
-
};
|
|
228
|
-
const onSettled = (result: SingleResult): void => finish({ result });
|
|
229
|
-
const onMonitor = (): void => {
|
|
230
|
-
const current = runtime.settledRuns.get(runId);
|
|
231
|
-
if (current) {
|
|
232
|
-
finish({ result: current });
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const live = monitor.findRun(runId);
|
|
236
|
-
if (live?.status === "parked") {
|
|
237
|
-
finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
if (!live) {
|
|
241
|
-
// Removal is followed synchronously by registerRunResult in the
|
|
242
|
-
// finishing task; re-check on the next tick so the result wins.
|
|
243
|
-
setTimeout(() => {
|
|
244
|
-
const late = runtime.settledRuns.get(runId);
|
|
245
|
-
if (late) finish({ result: late });
|
|
246
|
-
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
247
|
-
}, 0);
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
251
|
-
let listeners = runtime.settledListeners.get(runId);
|
|
252
|
-
if (!listeners) {
|
|
253
|
-
listeners = new Set();
|
|
254
|
-
runtime.settledListeners.set(runId, listeners);
|
|
255
|
-
}
|
|
256
|
-
listeners.add(onSettled);
|
|
257
|
-
unsub = monitor.subscribe(onMonitor);
|
|
258
|
-
timer = setTimeout(
|
|
259
|
-
() =>
|
|
260
|
-
finish({
|
|
261
|
-
note:
|
|
262
|
-
timeoutMs === 0
|
|
263
|
-
? `run #${runId} is still active — end your turn: the result will wake you (or call subagent_wait again with an explicit timeoutMs to block)`
|
|
264
|
-
: `wait timed out after ${Math.round(timeoutMs / 1000)}s — run #${runId} is still active; call subagent_wait again or end the turn (the result will wake you when ready)`,
|
|
265
|
-
}),
|
|
266
|
-
Math.max(1, timeoutMs),
|
|
267
|
-
);
|
|
268
|
-
if (signal?.aborted) onAbort();
|
|
269
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
270
|
-
});
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
274
|
-
const blocks = outcomes.map((outcome) =>
|
|
275
|
-
outcome.result
|
|
276
|
-
? formatCompletionBlock(outcome.result, config.maxResultLines, outcome.result.projectCwd ?? ctx.cwd)
|
|
277
|
-
: (outcome.note ?? "(no outcome)"),
|
|
278
|
-
);
|
|
279
|
-
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
280
|
-
},
|
|
281
|
-
|
|
282
|
-
renderCall(args, theme) {
|
|
283
|
-
const target = args.id ? `#${args.id}` : "all";
|
|
284
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
285
|
-
},
|
|
286
|
-
|
|
287
|
-
renderResult(result, _options, theme) {
|
|
288
|
-
return renderFirstLine(result, "subagent_wait ", theme);
|
|
289
|
-
},
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// Status overview: what is running right now and what finished this session,
|
|
293
|
-
// with per-run details (id, role, model, usage, elapsed, activity).
|
|
294
|
-
const SubagentStatusParams = Type.Object({
|
|
295
|
-
id: Type.Optional(
|
|
296
|
-
Type.String({
|
|
297
|
-
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
298
|
-
}),
|
|
299
|
-
),
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
pi.registerTool({
|
|
303
|
-
name: "subagent_status",
|
|
304
|
-
label: "Subagent Status",
|
|
305
|
-
description: [
|
|
306
|
-
"List active background sub-agent runs (id, role, model, thinking, usage, elapsed, current activity) and recently finished results.",
|
|
307
|
-
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
308
|
-
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
309
|
-
].join(" "),
|
|
310
|
-
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
311
|
-
promptGuidelines: [
|
|
312
|
-
"Call subagent_status to see what is running and what already finished.",
|
|
313
|
-
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
314
|
-
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
315
|
-
],
|
|
316
|
-
parameters: SubagentStatusParams,
|
|
317
|
-
|
|
318
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
319
|
-
const config = await loadConfig(runtime.configPath);
|
|
320
|
-
const requested = params.id?.trim();
|
|
321
|
-
|
|
322
|
-
if (requested) {
|
|
323
|
-
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
324
|
-
if (settledIds.length > 0) {
|
|
325
|
-
return {
|
|
326
|
-
content: [
|
|
327
|
-
{
|
|
328
|
-
type: "text",
|
|
329
|
-
text: settledIds
|
|
330
|
-
.map((id) => formatCompletionBlock(
|
|
331
|
-
runtime.settledRuns.get(id)!,
|
|
332
|
-
config.maxResultLines,
|
|
333
|
-
runtime.settledRuns.get(id)!.projectCwd ?? ctx.cwd,
|
|
334
|
-
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
run.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
sections.push(`###
|
|
419
|
-
sections.push(
|
|
420
|
-
sections.push(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
return
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
},
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
"
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
})
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
},
|
|
707
|
-
|
|
708
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thread controls and lookup tools around the subagent runtime:
|
|
3
|
+
* subagent_control (resume), subagent_wait (in-turn
|
|
4
|
+
* result lookup), subagent_status, and destructive subagent_stop.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
13
|
+
import { removeThreadRecord } from "./durable.ts";
|
|
14
|
+
import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
15
|
+
import { emptyUsage } from "./rpc-run.ts";
|
|
16
|
+
import {
|
|
17
|
+
formatTaskSummary,
|
|
18
|
+
isRunActiveStatus,
|
|
19
|
+
monitor,
|
|
20
|
+
runLabel,
|
|
21
|
+
statusLabel,
|
|
22
|
+
type RunStatus,
|
|
23
|
+
} from "./monitor.ts";
|
|
24
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
25
|
+
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
26
|
+
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
|
|
27
|
+
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
28
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
29
|
+
|
|
30
|
+
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
31
|
+
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
32
|
+
* result immediately, a still-active run returns a "still running — end your
|
|
33
|
+
* turn" note and the model finishes (the completion then wakes it). Blocking
|
|
34
|
+
* is opt-in via an explicit timeoutMs — a long default would hold the turn
|
|
35
|
+
* hostage for nothing, since the result arrives on its own either way. */
|
|
36
|
+
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 0;
|
|
37
|
+
|
|
38
|
+
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
39
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
40
|
+
const text = parts
|
|
41
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
42
|
+
.join(" ")
|
|
43
|
+
.trim();
|
|
44
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
45
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
49
|
+
const SubagentControlParams = Type.Object({
|
|
50
|
+
action: StringEnum(["resume"] as const, {
|
|
51
|
+
description: "Control operation for the logical sub-agent thread.",
|
|
52
|
+
}),
|
|
53
|
+
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch/status output." }),
|
|
54
|
+
objective: Type.Optional(
|
|
55
|
+
Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.registerTool({
|
|
60
|
+
name: "subagent_control",
|
|
61
|
+
label: "Subagent Control",
|
|
62
|
+
description: [
|
|
63
|
+
"Resume an existing sub-agent thread by stable run id.",
|
|
64
|
+
"resume restarts a parked, completed, or failed retained thread with the same run id and cumulative active time; omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed current goal.",
|
|
65
|
+
"Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
66
|
+
].join(" "),
|
|
67
|
+
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
68
|
+
promptGuidelines: [
|
|
69
|
+
"Use subagent_control resume to continue a parked/settled thread on the same run id. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
|
|
70
|
+
"Use subagent_stop only for destructive cancellation; it retires that thread's retained session.",
|
|
71
|
+
],
|
|
72
|
+
parameters: SubagentControlParams,
|
|
73
|
+
|
|
74
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
75
|
+
const thread = runtime.threads.get(params.id);
|
|
76
|
+
if (!thread) {
|
|
77
|
+
return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
|
|
78
|
+
}
|
|
79
|
+
const nonBlank = (value: string | undefined): string | undefined => {
|
|
80
|
+
const trimmed = value?.trim();
|
|
81
|
+
return trimmed ? trimmed : undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
switch (params.action) {
|
|
86
|
+
case "resume": {
|
|
87
|
+
if (thread.retired) {
|
|
88
|
+
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
89
|
+
}
|
|
90
|
+
if (!(["parked", "completed", "failed"] as const).includes(thread.state as any)) {
|
|
91
|
+
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.` }], details: {} };
|
|
92
|
+
}
|
|
93
|
+
const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
|
|
94
|
+
if (params.objective !== undefined && !objective) {
|
|
95
|
+
return { content: [{ type: "text", text: "resume objective must be non-blank when provided." }], details: {} };
|
|
96
|
+
}
|
|
97
|
+
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
98
|
+
const pending = await thread.resume(objective, ctx);
|
|
99
|
+
if (pending.exitCode !== -1) {
|
|
100
|
+
return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
|
|
101
|
+
}
|
|
102
|
+
const currentObjective = formatTaskSummary(objective ?? thread.task, 80, false);
|
|
103
|
+
const mode = objective
|
|
104
|
+
? `appended objective: ${currentObjective}`
|
|
105
|
+
: `continuing current objective: ${currentObjective}`;
|
|
106
|
+
const context = hadRetainedSession
|
|
107
|
+
? "the same retained session and prior context are preserved"
|
|
108
|
+
: "no prior child session existed, so only the logical run and objective are continued";
|
|
109
|
+
return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. It runs in the background — keep working; the result resumes you automatically.` }], details: {} };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
renderCall(args, theme) {
|
|
118
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_control "))}${theme.fg("accent", `${args.action} #${args.id}`)}`, 0, 0);
|
|
119
|
+
},
|
|
120
|
+
renderResult(result, _options, theme) {
|
|
121
|
+
return renderFirstLine(result, "subagent_control ", theme);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const SubagentWaitParams = Type.Object({
|
|
126
|
+
id: Type.Optional(
|
|
127
|
+
Type.String({
|
|
128
|
+
description: "Run id or prefix shown by subagent dispatch/status output. Omit to wait for all active runs in this session.",
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
timeoutMs: Type.Optional(
|
|
132
|
+
Type.Number({
|
|
133
|
+
description: `Block for up to this many milliseconds and report the still-running runs. Default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}: no blocking — settled runs return their result immediately, active runs return a note telling the model to end its turn.`,
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
pi.registerTool({
|
|
139
|
+
name: "subagent_wait",
|
|
140
|
+
label: "Subagent Wait",
|
|
141
|
+
description: [
|
|
142
|
+
"Look up background sub-agent run(s) and return their results.",
|
|
143
|
+
"PREFER NOT CALLING THIS: dispatching already ended your turn and results arrive as a message that wakes you automatically.",
|
|
144
|
+
"By default it does NOT block: a settled run returns its result immediately; a still-active run returns a 'still running — end your turn' note.",
|
|
145
|
+
"Pass an explicit timeoutMs ONLY when you must stay in the turn and need the result right now (sequential dependent steps).",
|
|
146
|
+
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
147
|
+
"The same result is also delivered as a completion message that resumes the main agent, so you may see it twice (once here, once as a wake-up) — that is expected, not a duplicate.",
|
|
148
|
+
].join(" "),
|
|
149
|
+
promptSnippet: "Look up a background subagent result in-turn (id: run id from dispatch/status output; omit for all). Non-blocking by default; pass timeoutMs to block.",
|
|
150
|
+
promptGuidelines: [
|
|
151
|
+
"Do NOT call subagent_wait to hold the turn: results arrive as wake-up messages automatically. The default call is a non-blocking lookup — settled results return immediately, active runs return a note telling you to end your turn.",
|
|
152
|
+
"Pass an explicit timeoutMs only when you must keep the turn AND the next step depends on the result right now — e.g. the user asked you to wait for it.",
|
|
153
|
+
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
154
|
+
"If subagent_wait times out, end the turn and wait for the wake-up message, or call it again with a longer timeoutMs.",
|
|
155
|
+
],
|
|
156
|
+
parameters: SubagentWaitParams,
|
|
157
|
+
|
|
158
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
159
|
+
const config = await loadConfig(runtime.configPath);
|
|
160
|
+
// A non-finite or negative timeout would produce a nonsensical note
|
|
161
|
+
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
162
|
+
// asked for; fall back to the default. Zero is honored as an immediate
|
|
163
|
+
// give-up (clamped to 1ms below).
|
|
164
|
+
const timeoutMs =
|
|
165
|
+
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
166
|
+
? params.timeoutMs
|
|
167
|
+
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
168
|
+
const isActive = (run: { status: RunStatus }): boolean => isRunActiveStatus(run.status);
|
|
169
|
+
|
|
170
|
+
const requested = params.id?.trim();
|
|
171
|
+
// A run that already settled resolves immediately with its result.
|
|
172
|
+
if (requested) {
|
|
173
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
174
|
+
if (settledIds.length > 0) {
|
|
175
|
+
return {
|
|
176
|
+
content: [
|
|
177
|
+
{ type: "text", text: settledIds.map((id) => {
|
|
178
|
+
const result = runtime.settledRuns.get(id)!;
|
|
179
|
+
return formatCompletionBlock(result, config.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? ctx.cwd) });
|
|
180
|
+
}).join("\n\n") },
|
|
181
|
+
],
|
|
182
|
+
details: {},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const activeRuns = monitor.getRuns().filter(isActive);
|
|
188
|
+
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
189
|
+
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
190
|
+
if (targets.length === 0) {
|
|
191
|
+
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: requested
|
|
197
|
+
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
198
|
+
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
details: {},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
206
|
+
const already = runtime.settledRuns.get(runId);
|
|
207
|
+
if (already) return Promise.resolve({ result: already });
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
let done = false;
|
|
210
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
211
|
+
let unsub: (() => void) | undefined;
|
|
212
|
+
const cleanup = (): void => {
|
|
213
|
+
if (timer) clearTimeout(timer);
|
|
214
|
+
if (unsub) unsub();
|
|
215
|
+
signal?.removeEventListener("abort", onAbort);
|
|
216
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
217
|
+
if (listeners) {
|
|
218
|
+
listeners.delete(onSettled);
|
|
219
|
+
if (listeners.size === 0) runtime.settledListeners.delete(runId);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
223
|
+
if (done) return;
|
|
224
|
+
done = true;
|
|
225
|
+
cleanup();
|
|
226
|
+
resolve(outcome);
|
|
227
|
+
};
|
|
228
|
+
const onSettled = (result: SingleResult): void => finish({ result });
|
|
229
|
+
const onMonitor = (): void => {
|
|
230
|
+
const current = runtime.settledRuns.get(runId);
|
|
231
|
+
if (current) {
|
|
232
|
+
finish({ result: current });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const live = monitor.findRun(runId);
|
|
236
|
+
if (live?.status === "parked") {
|
|
237
|
+
finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!live) {
|
|
241
|
+
// Removal is followed synchronously by registerRunResult in the
|
|
242
|
+
// finishing task; re-check on the next tick so the result wins.
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
const late = runtime.settledRuns.get(runId);
|
|
245
|
+
if (late) finish({ result: late });
|
|
246
|
+
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
247
|
+
}, 0);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
251
|
+
let listeners = runtime.settledListeners.get(runId);
|
|
252
|
+
if (!listeners) {
|
|
253
|
+
listeners = new Set();
|
|
254
|
+
runtime.settledListeners.set(runId, listeners);
|
|
255
|
+
}
|
|
256
|
+
listeners.add(onSettled);
|
|
257
|
+
unsub = monitor.subscribe(onMonitor);
|
|
258
|
+
timer = setTimeout(
|
|
259
|
+
() =>
|
|
260
|
+
finish({
|
|
261
|
+
note:
|
|
262
|
+
timeoutMs === 0
|
|
263
|
+
? `run #${runId} is still active — end your turn: the result will wake you (or call subagent_wait again with an explicit timeoutMs to block)`
|
|
264
|
+
: `wait timed out after ${Math.round(timeoutMs / 1000)}s — run #${runId} is still active; call subagent_wait again or end the turn (the result will wake you when ready)`,
|
|
265
|
+
}),
|
|
266
|
+
Math.max(1, timeoutMs),
|
|
267
|
+
);
|
|
268
|
+
if (signal?.aborted) onAbort();
|
|
269
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
274
|
+
const blocks = outcomes.map((outcome) =>
|
|
275
|
+
outcome.result
|
|
276
|
+
? formatCompletionBlock(outcome.result, config.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? ctx.cwd) })
|
|
277
|
+
: (outcome.note ?? "(no outcome)"),
|
|
278
|
+
);
|
|
279
|
+
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
renderCall(args, theme) {
|
|
283
|
+
const target = args.id ? `#${args.id}` : "all";
|
|
284
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
renderResult(result, _options, theme) {
|
|
288
|
+
return renderFirstLine(result, "subagent_wait ", theme);
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Status overview: what is running right now and what finished this session,
|
|
293
|
+
// with per-run details (id, role, model, usage, elapsed, activity).
|
|
294
|
+
const SubagentStatusParams = Type.Object({
|
|
295
|
+
id: Type.Optional(
|
|
296
|
+
Type.String({
|
|
297
|
+
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
pi.registerTool({
|
|
303
|
+
name: "subagent_status",
|
|
304
|
+
label: "Subagent Status",
|
|
305
|
+
description: [
|
|
306
|
+
"List active background sub-agent runs (id, role, model, thinking, usage, elapsed, current activity) and recently finished results.",
|
|
307
|
+
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
308
|
+
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
309
|
+
].join(" "),
|
|
310
|
+
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
311
|
+
promptGuidelines: [
|
|
312
|
+
"Call subagent_status to see what is running and what already finished.",
|
|
313
|
+
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
314
|
+
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
315
|
+
],
|
|
316
|
+
parameters: SubagentStatusParams,
|
|
317
|
+
|
|
318
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
319
|
+
const config = await loadConfig(runtime.configPath);
|
|
320
|
+
const requested = params.id?.trim();
|
|
321
|
+
|
|
322
|
+
if (requested) {
|
|
323
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
324
|
+
if (settledIds.length > 0) {
|
|
325
|
+
return {
|
|
326
|
+
content: [
|
|
327
|
+
{
|
|
328
|
+
type: "text",
|
|
329
|
+
text: settledIds
|
|
330
|
+
.map((id) => formatCompletionBlock(
|
|
331
|
+
runtime.settledRuns.get(id)!,
|
|
332
|
+
config.maxResultLines,
|
|
333
|
+
{ failedToolDetails: true, resultRoot: projectResultsRoot(runtime.configPath, runtime.settledRuns.get(id)!.projectCwd ?? ctx.cwd) },
|
|
334
|
+
))
|
|
335
|
+
.join("\n\n"),
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
details: {},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const runs = monitor.getRuns();
|
|
342
|
+
const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
|
|
343
|
+
const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
|
|
344
|
+
if (active) {
|
|
345
|
+
const parked = active.status === "parked";
|
|
346
|
+
const activeThread = runtime.threads.get(active.id);
|
|
347
|
+
const managedDownstream =
|
|
348
|
+
activeThread?.state === "running" && activeThread.control.getPhase() === "settled";
|
|
349
|
+
const activeChild = runs.find((run) =>
|
|
350
|
+
run.parentRunId === active.id && isRunActiveStatus(run.status)
|
|
351
|
+
);
|
|
352
|
+
const owner = active.managedWorkflow ? `${active.agent} workflow` : active.agent;
|
|
353
|
+
const retainedStage = active.managedWorkflow && activeThread?.agentName !== active.agent
|
|
354
|
+
? activeThread?.agentName
|
|
355
|
+
: undefined;
|
|
356
|
+
const metadata = [
|
|
357
|
+
activeThread?.isolation === "worktree" ? `worktree ${active.integrationStatus ?? activeThread.worktree?.state ?? "active"}` : undefined,
|
|
358
|
+
].filter(Boolean).join(" · ");
|
|
359
|
+
const stageStatus = activeChild
|
|
360
|
+
? monitor.summarize(activeChild)
|
|
361
|
+
: active.activity ?? statusLabel(active.status);
|
|
362
|
+
return {
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: parked
|
|
367
|
+
? `Run #${active.id} ${owner} is parked with retained${retainedStage ? ` ${retainedStage} stage` : ""} context${metadata ? ` (${metadata})` : ""}. Use subagent_control resume to restart it, or subagent_stop to retire it.`
|
|
368
|
+
: managedDownstream
|
|
369
|
+
? `Run #${active.id} ${owner} is in a managed downstream stage (${stageStatus}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result or subagent_stop to cancel it.`
|
|
370
|
+
: `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result or subagent_stop to cancel it.`,
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
details: {},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const activeRuns = monitor.getRuns().filter(
|
|
380
|
+
(run) => isRunActiveStatus(run.status),
|
|
381
|
+
);
|
|
382
|
+
const activeLines = activeRuns.map((run) => {
|
|
383
|
+
const thread = runtime.threads.get(run.id);
|
|
384
|
+
const parts = [
|
|
385
|
+
`#${run.id} ${monitor.summarize(run)}`,
|
|
386
|
+
run.label,
|
|
387
|
+
run.activity ?? statusLabel(run.status),
|
|
388
|
+
].filter(Boolean);
|
|
389
|
+
return `- ${parts.join(" · ")}`;
|
|
390
|
+
});
|
|
391
|
+
const parkedThreads = [...runtime.threads.values()].filter((thread) => thread.state === "parked");
|
|
392
|
+
const parkedLines = parkedThreads.map((thread) => {
|
|
393
|
+
const run = monitor.findRun(thread.id);
|
|
394
|
+
const owner = run?.managedWorkflow ? `${run.agent} workflow` : run?.agent ?? thread.agentName;
|
|
395
|
+
const retainedStage = run?.managedWorkflow && thread.agentName !== run.agent
|
|
396
|
+
? ` · retained stage ${thread.agentName}`
|
|
397
|
+
: "";
|
|
398
|
+
const isolation = thread.isolation === "worktree" ? ` · worktree ${thread.worktree?.state ?? "active"}` : "";
|
|
399
|
+
return `- #${thread.id} ${owner} · ${run?.label ?? runLabel(thread.task)} · parked${thread.sessionDir ? " · context retained" : " · not started"}${retainedStage}${isolation}`;
|
|
400
|
+
});
|
|
401
|
+
const completed = [...runtime.settledRuns.entries()].slice(-5);
|
|
402
|
+
const completedLines = completed.map(([id, result]) => {
|
|
403
|
+
const usage = formatUsage(result.usage);
|
|
404
|
+
const label = runLabel(result.task);
|
|
405
|
+
const model = result.modelFallbackFrom
|
|
406
|
+
? `${result.model ?? "?"} (main after ${result.modelFallbackFrom} failed)`
|
|
407
|
+
: (result.model ?? "?");
|
|
408
|
+
const isolation = result.isolation === "worktree" ? ` · worktree ${result.integrationStatus ?? "unknown"}` : "";
|
|
409
|
+
return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"} · ${model}${isolation}${usage ? ` · ${usage}` : ""}`;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const sections: string[] = [];
|
|
413
|
+
const queuedCount = activeRuns.filter((run) => run.status === "queued").length;
|
|
414
|
+
const runningCount = activeRuns.length - queuedCount;
|
|
415
|
+
const pacing = queuedCount > 0
|
|
416
|
+
? `${runningCount} running · ${queuedCount} queued for a free process slot`
|
|
417
|
+
: `${runningCount} running`;
|
|
418
|
+
sections.push(`### Active subagent runs (${pacing}; process capacity ${runtime.backgroundQueue.capacity} — queued runs start automatically, dispatch is never capped)`);
|
|
419
|
+
sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
|
|
420
|
+
sections.push(`### Parked subagent threads (${parkedThreads.length})`);
|
|
421
|
+
sections.push(parkedLines.length > 0 ? parkedLines.join("\n") : "(none)");
|
|
422
|
+
sections.push(`### Finished this session (${runtime.settledRuns.size})`);
|
|
423
|
+
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
424
|
+
sections.push("Pass a run id to subagent_status for the full result, use subagent_control to resume a settled thread, or subagent_wait for active work.");
|
|
425
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
renderCall(args, theme) {
|
|
429
|
+
return new Text(
|
|
430
|
+
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
|
|
431
|
+
0,
|
|
432
|
+
0,
|
|
433
|
+
);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
renderResult(result, _options, theme) {
|
|
437
|
+
return renderFirstLine(result, "subagent_status ", theme);
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Cancel one or more active runs: aborts the queue controller, which
|
|
442
|
+
// terminates the child and delivers an aborted result (with whatever partial
|
|
443
|
+
// output it produced) so the main agent always knows the run stopped.
|
|
444
|
+
const SubagentStopParams = Type.Object({
|
|
445
|
+
id: Type.Optional(
|
|
446
|
+
Type.String({
|
|
447
|
+
description: "Run id or prefix to stop (see subagent dispatch output or subagent_status).",
|
|
448
|
+
}),
|
|
449
|
+
),
|
|
450
|
+
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
pi.registerTool({
|
|
454
|
+
name: "subagent_stop",
|
|
455
|
+
label: "Subagent Stop",
|
|
456
|
+
description: [
|
|
457
|
+
"Destructively stop a sub-agent thread: terminate active work, deliver its aborted partial result, and retire any retained session so it cannot be resumed.",
|
|
458
|
+
"Pass id (run id or prefix) to stop one active, parked, or completed thread; all: true stops every active run.",
|
|
459
|
+
].join(" "),
|
|
460
|
+
promptSnippet: "Stop a running background subagent (id from dispatch output/subagent_status; or all: true).",
|
|
461
|
+
promptGuidelines: [
|
|
462
|
+
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
|
|
463
|
+
"A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
|
|
464
|
+
],
|
|
465
|
+
parameters: SubagentStopParams,
|
|
466
|
+
|
|
467
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
468
|
+
// Start config I/O without yielding: every target below must be claimed
|
|
469
|
+
// synchronously before a resume preflight can cross its next await.
|
|
470
|
+
const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
|
|
471
|
+
const completionResults: SingleResult[] = [];
|
|
472
|
+
const candidateIds = params.all === true
|
|
473
|
+
? [...new Set([
|
|
474
|
+
...runtime.runControllers.keys(),
|
|
475
|
+
...[...runtime.threads.values()]
|
|
476
|
+
.filter((thread) =>
|
|
477
|
+
thread.lifecycleOperation !== undefined ||
|
|
478
|
+
["queued", "resuming", "running", "interrupting"].includes(thread.state),
|
|
479
|
+
)
|
|
480
|
+
.map((thread) => thread.id),
|
|
481
|
+
])]
|
|
482
|
+
: [...runtime.threads.keys()];
|
|
483
|
+
const targets =
|
|
484
|
+
params.all === true
|
|
485
|
+
? candidateIds
|
|
486
|
+
: params.id !== undefined && params.id.trim() !== ""
|
|
487
|
+
? matchRunIds(candidateIds, params.id.trim())
|
|
488
|
+
: [];
|
|
489
|
+
|
|
490
|
+
if (targets.length === 0) {
|
|
491
|
+
const available = [...runtime.threads.keys()].map((id) => `#${id}`).join(", ");
|
|
492
|
+
return {
|
|
493
|
+
content: [{
|
|
494
|
+
type: "text",
|
|
495
|
+
text: params.all === true
|
|
496
|
+
? "No active subagent runs to stop."
|
|
497
|
+
: `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}`,
|
|
498
|
+
}],
|
|
499
|
+
details: {},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const claimed: Array<{
|
|
504
|
+
runId: number;
|
|
505
|
+
thread: SubagentThread;
|
|
506
|
+
run: ReturnType<typeof monitor.findRun>;
|
|
507
|
+
previousState: SubagentThread["state"];
|
|
508
|
+
wasQueued: boolean;
|
|
509
|
+
wasResuming: boolean;
|
|
510
|
+
wasActive: boolean;
|
|
511
|
+
generation: number;
|
|
512
|
+
controller: AbortController | undefined;
|
|
513
|
+
completion: Promise<void>;
|
|
514
|
+
stopVersion: number;
|
|
515
|
+
stopMessage: string;
|
|
516
|
+
}> = [];
|
|
517
|
+
for (const runId of targets) {
|
|
518
|
+
const thread = runtime.threads.get(runId);
|
|
519
|
+
if (!thread) continue;
|
|
520
|
+
const previousState = thread.state;
|
|
521
|
+
const wasQueued = previousState === "queued";
|
|
522
|
+
const wasResuming = previousState === "resuming";
|
|
523
|
+
const wasActive =
|
|
524
|
+
thread.lifecycleOperation !== undefined ||
|
|
525
|
+
["queued", "resuming", "running", "interrupting"].includes(previousState);
|
|
526
|
+
const stopVersion = ++thread.lifecycleVersion;
|
|
527
|
+
// Stop-all claims every target before the first await. This invalidates
|
|
528
|
+
// all concurrent resume preflights as one synchronous operation.
|
|
529
|
+
thread.lifecycleOperation = "stop";
|
|
530
|
+
thread.retired = true;
|
|
531
|
+
thread.retireOnSettle = true;
|
|
532
|
+
thread.state = "stopped";
|
|
533
|
+
const stopMessage = wasQueued
|
|
534
|
+
? "Stopped by subagent_stop before the run started."
|
|
535
|
+
: wasResuming
|
|
536
|
+
? "Stopped by subagent_stop while resume was preparing."
|
|
537
|
+
: wasActive
|
|
538
|
+
? "Stopped by subagent_stop."
|
|
539
|
+
: previousState === "parked"
|
|
540
|
+
? "Stopped by subagent_stop from a parked checkpoint."
|
|
541
|
+
: "Retired by subagent_stop.";
|
|
542
|
+
claimed.push({
|
|
543
|
+
runId,
|
|
544
|
+
thread,
|
|
545
|
+
run: monitor.findRun(runId),
|
|
546
|
+
previousState,
|
|
547
|
+
wasQueued,
|
|
548
|
+
wasResuming,
|
|
549
|
+
wasActive,
|
|
550
|
+
generation: thread.generation,
|
|
551
|
+
controller: thread.queueController,
|
|
552
|
+
completion: thread.generationCompletion,
|
|
553
|
+
stopVersion,
|
|
554
|
+
stopMessage,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Interrupt every claimed generation before awaiting any one of them.
|
|
559
|
+
// An isolated stop may need the repository lane for final integration;
|
|
560
|
+
// cancelling all holders first prevents stop-all from waiting behind a
|
|
561
|
+
// later shared workflow that this same operation has not interrupted yet.
|
|
562
|
+
const interruptionPromises = claimed.map(({ thread, stopMessage, controller }) => {
|
|
563
|
+
const stopping = thread.control.stop(stopMessage).catch(() => undefined);
|
|
564
|
+
runtime.backgroundQueue.cancel(controller);
|
|
565
|
+
return stopping;
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const stopped: string[] = [];
|
|
569
|
+
const retainedIntegration: string[] = [];
|
|
570
|
+
const pendingIntegration: string[] = [];
|
|
571
|
+
for (const [claimIndex, claim] of claimed.entries()) {
|
|
572
|
+
const {
|
|
573
|
+
runId,
|
|
574
|
+
thread,
|
|
575
|
+
run,
|
|
576
|
+
previousState,
|
|
577
|
+
wasQueued,
|
|
578
|
+
wasResuming,
|
|
579
|
+
wasActive,
|
|
580
|
+
generation,
|
|
581
|
+
controller,
|
|
582
|
+
completion,
|
|
583
|
+
stopVersion,
|
|
584
|
+
stopMessage,
|
|
585
|
+
} = claim;
|
|
586
|
+
// Every wait here is bounded: the queue task can sit for minutes in
|
|
587
|
+
// worktree finalization or behind the managed repository lane, and an
|
|
588
|
+
// unkillable child can stall even the RPC-level stop. Stop owns the
|
|
589
|
+
// lifecycle synchronously, so a stuck tail settles silently after we
|
|
590
|
+
// proceed; none of its late paths can publish a second result.
|
|
591
|
+
await quiesced(interruptionPromises[claimIndex]);
|
|
592
|
+
if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
|
|
593
|
+
if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
|
|
594
|
+
if (thread.queueController === controller) thread.queueController = undefined;
|
|
595
|
+
|
|
596
|
+
// Dispatch yields publication ownership as soon as stop claims the
|
|
597
|
+
// lifecycle. Synthesize and publish the one aborted result here only when
|
|
598
|
+
// this stop actually interrupted unfinished work.
|
|
599
|
+
let stoppedResult: SingleResult | undefined;
|
|
600
|
+
if (
|
|
601
|
+
wasQueued ||
|
|
602
|
+
wasResuming ||
|
|
603
|
+
previousState === "parked" ||
|
|
604
|
+
!runtime.settledRuns.has(runId)
|
|
605
|
+
) {
|
|
606
|
+
const prior = thread.lastResult;
|
|
607
|
+
stoppedResult = prior
|
|
608
|
+
? {
|
|
609
|
+
...prior,
|
|
610
|
+
exitCode: 1,
|
|
611
|
+
stopReason: "aborted",
|
|
612
|
+
errorMessage: stopMessage,
|
|
613
|
+
runId,
|
|
614
|
+
}
|
|
615
|
+
: {
|
|
616
|
+
agent: thread.agentName,
|
|
617
|
+
task: thread.task,
|
|
618
|
+
exitCode: 1,
|
|
619
|
+
messages: [],
|
|
620
|
+
stderr: stopMessage,
|
|
621
|
+
usage: emptyUsage(),
|
|
622
|
+
model: run?.model,
|
|
623
|
+
thinking: run?.thinking,
|
|
624
|
+
projectCwd: thread.cwd,
|
|
625
|
+
stopReason: "aborted",
|
|
626
|
+
errorMessage: stopMessage,
|
|
627
|
+
runId,
|
|
628
|
+
isolation: thread.isolation,
|
|
629
|
+
};
|
|
630
|
+
const worktree = thread.worktree;
|
|
631
|
+
let finalization: WorktreeFinalization | undefined;
|
|
632
|
+
try {
|
|
633
|
+
finalization = await Promise.race([
|
|
634
|
+
thread.finalizeIsolation(generation, stoppedResult),
|
|
635
|
+
new Promise<undefined>((resolve) => {
|
|
636
|
+
const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
|
|
637
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
638
|
+
}),
|
|
639
|
+
]);
|
|
640
|
+
} catch {
|
|
641
|
+
/* an unexpected finalize rejection must not block the stop */
|
|
642
|
+
}
|
|
643
|
+
if (finalization === undefined) {
|
|
644
|
+
// Integration is still settling in the background. Point a
|
|
645
|
+
// durable recovery record at the artifacts so the isolated work
|
|
646
|
+
// stays findable even if the background tail later fails; a
|
|
647
|
+
// successful tail removes them and the record self-prunes.
|
|
648
|
+
if (thread.isolation === "worktree" && worktree) {
|
|
649
|
+
stoppedResult.integrationStatus = "pending";
|
|
650
|
+
stoppedResult.integrationWorktreePath = worktree.worktreePath;
|
|
651
|
+
await persistRecoveryRecords(runtime.configPath, [
|
|
652
|
+
recoveryRecordFromFinalization(runId, {
|
|
653
|
+
status: "retained",
|
|
654
|
+
integrated: false,
|
|
655
|
+
hadChanges: false,
|
|
656
|
+
...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
|
|
657
|
+
...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
|
|
658
|
+
error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
|
|
659
|
+
}),
|
|
660
|
+
]).catch(() => undefined);
|
|
661
|
+
}
|
|
662
|
+
pendingIntegration.push(`#${runId}`);
|
|
663
|
+
} else if (finalization.status === "retained") {
|
|
664
|
+
retainedIntegration.push(`#${runId}`);
|
|
665
|
+
}
|
|
666
|
+
runtime.registerRunResult(runId, stoppedResult);
|
|
667
|
+
thread.lastResult = stoppedResult;
|
|
668
|
+
}
|
|
669
|
+
monitor.setStatus(runId, "failed");
|
|
670
|
+
if (stoppedResult) completionResults.push(stoppedResult);
|
|
671
|
+
monitor.removeRun(runId);
|
|
672
|
+
runtime.retireThreadSession(thread);
|
|
673
|
+
// The destructive retire removes the durable record with the session;
|
|
674
|
+
// an id never resurrects after subagent_stop.
|
|
675
|
+
await removeThreadRecord(runtime.configPath, runId).catch(() => undefined);
|
|
676
|
+
if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
|
|
677
|
+
thread.lifecycleOperation = undefined;
|
|
678
|
+
}
|
|
679
|
+
stopped.push(`#${runId} ${thread.agentName}${wasQueued ? " (queued)" : wasActive ? "" : ` (${previousState})`}`);
|
|
680
|
+
}
|
|
681
|
+
if (completionResults.length > 0) {
|
|
682
|
+
const maxResultLines = (await configPromise)?.maxResultLines ?? DEFAULT_MAX_RESULT_LINES;
|
|
683
|
+
runtime.sendCompletionGroup(completionResults.map((result) => ({
|
|
684
|
+
agent: result.agent,
|
|
685
|
+
block: formatCompletionBlock(result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? ctx.cwd) }),
|
|
686
|
+
triggerTurn: true,
|
|
687
|
+
usage: result.usage,
|
|
688
|
+
})));
|
|
689
|
+
runtime.completionBatcher.flush();
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
content: [{
|
|
693
|
+
type: "text",
|
|
694
|
+
text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
|
|
695
|
+
}],
|
|
696
|
+
details: {},
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
renderCall(args, theme) {
|
|
701
|
+
return new Text(
|
|
702
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
703
|
+
0,
|
|
704
|
+
0,
|
|
705
|
+
);
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
renderResult(result, _options, theme) {
|
|
709
|
+
return renderFirstLine(result, "subagent_stop ", theme);
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|