@ferris1225/pi-subagents 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +579 -476
- package/package.json +1 -1
- package/src/background.ts +112 -106
- package/src/fixloop.ts +84 -76
- package/src/index.ts +503 -14
- package/src/monitor.ts +6 -1
- package/src/spawn.ts +171 -14
package/src/index.ts
CHANGED
|
@@ -154,17 +154,40 @@ function formatUsage(usage: UsageStats): string {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
|
|
157
|
-
const
|
|
157
|
+
const failed = isFailedResult(result);
|
|
158
|
+
const failedTools = result.failedTools ?? [];
|
|
159
|
+
const status = failed
|
|
160
|
+
? "failed"
|
|
161
|
+
: failedTools.length > 0
|
|
162
|
+
? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
|
|
163
|
+
: "completed";
|
|
158
164
|
const usage = formatUsage(result.usage);
|
|
159
165
|
const output = getResultOutput(result);
|
|
160
166
|
const { text, truncated } = truncateResultOutput(output, maxResultLines);
|
|
161
167
|
const fallbackNote = result.modelFallbackFrom
|
|
162
168
|
? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
|
|
163
169
|
: "";
|
|
164
|
-
const
|
|
170
|
+
const startupRetryNote = result.startupRetries
|
|
165
171
|
? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
|
|
166
172
|
: "";
|
|
167
|
-
const
|
|
173
|
+
const modelRetryNote = result.modelRetries
|
|
174
|
+
? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
|
|
175
|
+
: "";
|
|
176
|
+
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
|
|
177
|
+
// A run can exit cleanly while its last tools failed (e.g. a build that broke):
|
|
178
|
+
// the final text alone may claim more than the tools achieved, so surface the
|
|
179
|
+
// failures explicitly and tell the main agent to verify before relying on it.
|
|
180
|
+
if (!failed && failedTools.length > 0) {
|
|
181
|
+
const shown = failedTools.slice(0, 3);
|
|
182
|
+
const more = failedTools.length - shown.length;
|
|
183
|
+
lines.push(
|
|
184
|
+
"",
|
|
185
|
+
`⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"} failed during this run — the final text above may not reflect a working state:`,
|
|
186
|
+
...shown.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
|
|
187
|
+
);
|
|
188
|
+
if (more > 0) lines.push(`- … and ${more} more`);
|
|
189
|
+
lines.push("Verify the actual artifacts before relying on this report.");
|
|
190
|
+
}
|
|
168
191
|
if (truncated) {
|
|
169
192
|
// The full text lives on disk so the main agent can read it on demand.
|
|
170
193
|
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
|
|
@@ -176,8 +199,21 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
|
|
|
176
199
|
* produced usable output (or the run stalled), so the task is handed back to the
|
|
177
200
|
* main window instead of being left as a dead failure. */
|
|
178
201
|
function modelLevelTakeoverNote(result: SingleResult): string {
|
|
202
|
+
const sameModel = result.modelRetries
|
|
203
|
+
? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
|
|
204
|
+
: "";
|
|
179
205
|
const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
|
|
180
|
-
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
206
|
+
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
|
210
|
+
* (so "1" never fans out to 10, 11, …); only when no exact match exists does a
|
|
211
|
+
* prefix match run, as a convenience for partial ids. Keeps single-digit lookups
|
|
212
|
+
* from returning — or, for subagent_stop, acting on — a whole prefix family. */
|
|
213
|
+
export function matchRunIds(ids: number[], requested: string): number[] {
|
|
214
|
+
const exact = ids.filter((id) => String(id) === requested);
|
|
215
|
+
if (exact.length > 0) return exact;
|
|
216
|
+
return ids.filter((id) => String(id).startsWith(requested));
|
|
181
217
|
}
|
|
182
218
|
|
|
183
219
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -195,7 +231,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
195
231
|
display: true,
|
|
196
232
|
};
|
|
197
233
|
if (completionGroupTriggersTurn(items)) {
|
|
198
|
-
|
|
234
|
+
// steer: the result is injected after the current tool call even mid-turn, or
|
|
235
|
+
// starts a new turn when idle. followUp would sit in the queue until the whole
|
|
236
|
+
// turn ends — a main agent waiting for the result (sleep/poll) would never see
|
|
237
|
+
// it delivered, which is exactly the "returned but never woken" failure mode.
|
|
238
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
199
239
|
} else {
|
|
200
240
|
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
201
241
|
// never start a continuation by itself. followUp would auto-continue
|
|
@@ -205,6 +245,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
205
245
|
};
|
|
206
246
|
const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
|
|
207
247
|
|
|
248
|
+
// Abort controllers per active run, so subagent_stop can cancel a run in-turn.
|
|
249
|
+
const runControllers = new Map<number, AbortController>();
|
|
250
|
+
|
|
251
|
+
// Final results keyed by run id, so `subagent_wait` can hand the model the
|
|
252
|
+
// actual result in-turn instead of it sleeping/polling for a wake-up message.
|
|
253
|
+
const settledRuns = new Map<number, SingleResult>();
|
|
254
|
+
const settledListeners = new Map<number, Set<(result: SingleResult) => void>>();
|
|
255
|
+
const registerRunResult = (runId: number, result: SingleResult): void => {
|
|
256
|
+
settledRuns.set(runId, result);
|
|
257
|
+
const listeners = settledListeners.get(runId);
|
|
258
|
+
if (listeners) {
|
|
259
|
+
settledListeners.delete(runId);
|
|
260
|
+
for (const listener of listeners) {
|
|
261
|
+
try {
|
|
262
|
+
listener(result);
|
|
263
|
+
} catch {
|
|
264
|
+
/* listener errors must never break settling */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
208
270
|
// Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
|
|
209
271
|
// excluded from their toolset at spawn (--exclude-tools); this check is defense
|
|
210
272
|
// in depth so a child can never expose the tool back to its model, even if
|
|
@@ -231,6 +293,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
231
293
|
sessionActive = false;
|
|
232
294
|
completionBatcher.dispose();
|
|
233
295
|
backgroundQueue.cancelAll();
|
|
296
|
+
settledRuns.clear();
|
|
297
|
+
settledListeners.clear();
|
|
298
|
+
runControllers.clear();
|
|
234
299
|
// Clear the monitor so stale runs from this session never leak into the
|
|
235
300
|
// next one (the module-level singleton survives across sessions).
|
|
236
301
|
monitor.clear();
|
|
@@ -244,7 +309,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
244
309
|
"Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
|
|
245
310
|
"Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
|
|
246
311
|
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
247
|
-
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
|
|
312
|
+
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
313
|
+
"To get a result in-turn without sleeping, use the subagent_wait tool."
|
|
248
314
|
].join(" "),
|
|
249
315
|
promptSnippet:
|
|
250
316
|
"Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
|
|
@@ -255,7 +321,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
255
321
|
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
256
322
|
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
257
323
|
"Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
|
|
258
|
-
"NEVER sleep,
|
|
324
|
+
"NEVER sleep, poll, or call other tools alongside subagent — it ends the turn immediately. The main agent is auto-resumed when results arrive; manual waiting only blocks the turn and delays delivery. The one exception is subagent_wait (below): only when you must stay in the turn.",
|
|
325
|
+
"If you must keep the turn for a result, call subagent_wait (blocks in-tool and returns the result) — never bash sleep/timeout to wait for a sub-agent.",
|
|
259
326
|
],
|
|
260
327
|
parameters: SubagentParams,
|
|
261
328
|
|
|
@@ -424,11 +491,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
424
491
|
sessionRef,
|
|
425
492
|
);
|
|
426
493
|
finishRun(runId, isFailedResult(result) ? "failed" : "done");
|
|
494
|
+
registerRunResult(runId, result);
|
|
427
495
|
return result;
|
|
428
496
|
} catch (error) {
|
|
429
497
|
finishRun(runId, "failed");
|
|
430
498
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
431
|
-
|
|
499
|
+
const crashed = {
|
|
432
500
|
...queuedResult(agent, task, thinkingLevel),
|
|
433
501
|
exitCode: 1,
|
|
434
502
|
stderr: errorMessage,
|
|
@@ -436,6 +504,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
436
504
|
errorMessage,
|
|
437
505
|
dispatchFailed: true,
|
|
438
506
|
};
|
|
507
|
+
registerRunResult(runId, crashed);
|
|
508
|
+
return crashed;
|
|
439
509
|
}
|
|
440
510
|
};
|
|
441
511
|
|
|
@@ -448,7 +518,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
448
518
|
* the chain resolves, so the ↳ rows have an obvious parent.
|
|
449
519
|
*/
|
|
450
520
|
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
|
|
451
|
-
backgroundQueue.enqueue(
|
|
521
|
+
runControllers.set(parentRunId, backgroundQueue.enqueue(
|
|
452
522
|
async (signal) => {
|
|
453
523
|
const chain: SingleResult[] = [initialReviewerResult];
|
|
454
524
|
let lastReviewer = initialReviewerResult;
|
|
@@ -477,7 +547,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
477
547
|
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
478
548
|
// parent row, then deliver the whole chain as one group. The loop's
|
|
479
549
|
// outcome always wakes the main agent (a passing chain reports
|
|
480
|
-
// success, a stuck one needs a human).
|
|
550
|
+
// success, a stuck one needs a human). Register the parent's final
|
|
551
|
+
// state (the last chain result) before removal so subagent_wait can
|
|
552
|
+
// resolve it.
|
|
553
|
+
registerRunResult(parentRunId, chain[chain.length - 1]);
|
|
554
|
+
runControllers.delete(parentRunId);
|
|
481
555
|
monitor.removeRun(parentRunId);
|
|
482
556
|
if (!sessionActive) return;
|
|
483
557
|
const items: CompletionMessageItem[] = chain.map((r) => {
|
|
@@ -500,6 +574,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
500
574
|
() => {
|
|
501
575
|
// Cancelled before delivery: clean up the retained parent row (each
|
|
502
576
|
// in-flight chain run was already finished by its launchInLoop path).
|
|
577
|
+
runControllers.delete(parentRunId);
|
|
503
578
|
monitor.removeRun(parentRunId);
|
|
504
579
|
},
|
|
505
580
|
(error) => {
|
|
@@ -507,6 +582,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
507
582
|
// launchInLoop and delivered as part of the chain) must not vanish:
|
|
508
583
|
// drop the retained parent row, notify, and deliver a failed result
|
|
509
584
|
// so the main agent knows the chain never completed.
|
|
585
|
+
registerRunResult(parentRunId, initialReviewerResult);
|
|
586
|
+
runControllers.delete(parentRunId);
|
|
510
587
|
monitor.removeRun(parentRunId);
|
|
511
588
|
if (!sessionActive) return;
|
|
512
589
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -526,7 +603,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
526
603
|
/* a second delivery failure must not throw through the queue */
|
|
527
604
|
}
|
|
528
605
|
},
|
|
529
|
-
);
|
|
606
|
+
));
|
|
530
607
|
};
|
|
531
608
|
|
|
532
609
|
const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
|
|
@@ -541,7 +618,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
541
618
|
// only its finish is deferred to the queue task (see startFixLoop).
|
|
542
619
|
const onLive = makeLiveHandler(runId);
|
|
543
620
|
|
|
544
|
-
backgroundQueue.enqueue(
|
|
621
|
+
runControllers.set(runId, backgroundQueue.enqueue(
|
|
545
622
|
async (backgroundSignal) => {
|
|
546
623
|
let result: SingleResult;
|
|
547
624
|
try {
|
|
@@ -573,6 +650,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
573
650
|
// The dedicated dispatch-failure notification below replaces the generic
|
|
574
651
|
// failure toast for dispatch crashes, so finish silently here.
|
|
575
652
|
finishRun(runId, "failed", { silent: true });
|
|
653
|
+
registerRunResult(runId, result);
|
|
654
|
+
runControllers.delete(runId);
|
|
576
655
|
}
|
|
577
656
|
|
|
578
657
|
if (!sessionActive) return;
|
|
@@ -600,6 +679,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
600
679
|
const modelLevel = failed && isModelLevelFailure(result);
|
|
601
680
|
const dispatchFailed = result.dispatchFailed === true;
|
|
602
681
|
finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
|
|
682
|
+
// Register before delivery so a concurrent subagent_wait resolves with
|
|
683
|
+
// the result even though the run row is already gone from the monitor.
|
|
684
|
+
registerRunResult(runId, result);
|
|
685
|
+
runControllers.delete(runId);
|
|
603
686
|
if (!sessionActive) return;
|
|
604
687
|
// Model-level failure: the configured model is unavailable or broke
|
|
605
688
|
// and the retry with the main-window model (when distinct) also
|
|
@@ -628,7 +711,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
628
711
|
completionBatcher.push(completion);
|
|
629
712
|
}
|
|
630
713
|
},
|
|
631
|
-
() =>
|
|
714
|
+
() => {
|
|
715
|
+
runControllers.delete(runId);
|
|
716
|
+
finishRun(runId, "failed");
|
|
717
|
+
},
|
|
632
718
|
(error) => {
|
|
633
719
|
// The task body converts sub-agent failures into delivered results; an
|
|
634
720
|
// exception escaping it (spawn infra, delivery API, ...) must not
|
|
@@ -636,6 +722,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
636
722
|
// agent knows the dispatch failed and can re-dispatch.
|
|
637
723
|
const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
|
|
638
724
|
finishRun(runId, "failed", { silent: true });
|
|
725
|
+
registerRunResult(runId, crashed);
|
|
726
|
+
runControllers.delete(runId);
|
|
639
727
|
if (!sessionActive) return;
|
|
640
728
|
try {
|
|
641
729
|
ctx.ui.notify(`✗ ${agent.name} 派发失败: ${crashed.errorMessage}`, "error");
|
|
@@ -651,7 +739,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
651
739
|
/* a second delivery failure must not throw through the queue */
|
|
652
740
|
}
|
|
653
741
|
},
|
|
654
|
-
);
|
|
742
|
+
));
|
|
655
743
|
|
|
656
744
|
return pending;
|
|
657
745
|
};
|
|
@@ -754,6 +842,407 @@ export default function (pi: ExtensionAPI): void {
|
|
|
754
842
|
},
|
|
755
843
|
});
|
|
756
844
|
|
|
845
|
+
// Blocking wait: keeps the turn alive until the targeted run(s) settle, then
|
|
846
|
+
// returns the actual result(s) to the model in-turn. Without it, a model that
|
|
847
|
+
// must stay in the turn falls back to bash sleep/poll — blocking the turn and
|
|
848
|
+
// delaying the very wake-up it is waiting for. Ending the turn and letting the
|
|
849
|
+
// steer-delivered completion wake it is still the preferred path; this tool is
|
|
850
|
+
// for when the result is needed NOW (sequential dependent steps).
|
|
851
|
+
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
852
|
+
|
|
853
|
+
const SubagentWaitParams = Type.Object({
|
|
854
|
+
id: Type.Optional(
|
|
855
|
+
Type.String({
|
|
856
|
+
description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
|
|
857
|
+
}),
|
|
858
|
+
),
|
|
859
|
+
timeoutMs: Type.Optional(
|
|
860
|
+
Type.Number({
|
|
861
|
+
description: `Give up after this many milliseconds and report the still-running runs (default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}).`,
|
|
862
|
+
}),
|
|
863
|
+
),
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
pi.registerTool({
|
|
867
|
+
name: "subagent_wait",
|
|
868
|
+
label: "Subagent Wait",
|
|
869
|
+
description: [
|
|
870
|
+
"Block the current turn until background sub-agent run(s) finish, then return their results.",
|
|
871
|
+
"Use ONLY when you must stay in the turn and act on the result immediately (sequential dependent steps).",
|
|
872
|
+
"Prefer ending your turn after subagent — the result arrives automatically and wakes you.",
|
|
873
|
+
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
874
|
+
"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.",
|
|
875
|
+
].join(" "),
|
|
876
|
+
promptSnippet: "Wait for a background subagent to finish and get its result in-turn (id: run id from the widget; omit for all).",
|
|
877
|
+
promptGuidelines: [
|
|
878
|
+
"Call subagent_wait only when you must keep the turn and need the result now — e.g. the next step depends on it.",
|
|
879
|
+
"After dispatching via subagent, prefer ending the turn: the completion message wakes you automatically (no waiting).",
|
|
880
|
+
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
881
|
+
"If subagent_wait times out, call it again with a longer timeoutMs or end the turn and wait for the wake-up message.",
|
|
882
|
+
],
|
|
883
|
+
parameters: SubagentWaitParams,
|
|
884
|
+
|
|
885
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
886
|
+
const config = await loadConfig(configPath);
|
|
887
|
+
// A non-finite or negative timeout would produce a nonsensical note
|
|
888
|
+
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
889
|
+
// asked for; fall back to the default. Zero is honored as an immediate
|
|
890
|
+
// give-up (clamped to 1ms below).
|
|
891
|
+
const timeoutMs =
|
|
892
|
+
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
893
|
+
? params.timeoutMs
|
|
894
|
+
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
895
|
+
const isActive = (run: { status: string; retained?: boolean }): boolean =>
|
|
896
|
+
run.status === "queued" || run.status === "running" || run.retained === true;
|
|
897
|
+
|
|
898
|
+
const requested = params.id?.trim();
|
|
899
|
+
// A run that already settled resolves immediately with its result.
|
|
900
|
+
if (requested) {
|
|
901
|
+
const settledIds = matchRunIds([...settledRuns.keys()], requested);
|
|
902
|
+
if (settledIds.length > 0) {
|
|
903
|
+
return {
|
|
904
|
+
content: [
|
|
905
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
906
|
+
],
|
|
907
|
+
details: {},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const activeRuns = monitor.getRuns().filter(isActive);
|
|
913
|
+
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
914
|
+
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
915
|
+
if (targets.length === 0) {
|
|
916
|
+
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
917
|
+
return {
|
|
918
|
+
content: [
|
|
919
|
+
{
|
|
920
|
+
type: "text",
|
|
921
|
+
text: requested
|
|
922
|
+
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
923
|
+
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
details: {},
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
931
|
+
const already = settledRuns.get(runId);
|
|
932
|
+
if (already) return Promise.resolve({ result: already });
|
|
933
|
+
return new Promise((resolve) => {
|
|
934
|
+
let done = false;
|
|
935
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
936
|
+
let unsub: (() => void) | undefined;
|
|
937
|
+
const cleanup = (): void => {
|
|
938
|
+
if (timer) clearTimeout(timer);
|
|
939
|
+
if (unsub) unsub();
|
|
940
|
+
signal?.removeEventListener("abort", onAbort);
|
|
941
|
+
const listeners = settledListeners.get(runId);
|
|
942
|
+
if (listeners) {
|
|
943
|
+
listeners.delete(onSettled);
|
|
944
|
+
if (listeners.size === 0) settledListeners.delete(runId);
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
948
|
+
if (done) return;
|
|
949
|
+
done = true;
|
|
950
|
+
cleanup();
|
|
951
|
+
resolve(outcome);
|
|
952
|
+
};
|
|
953
|
+
const onSettled = (result: SingleResult): void => finish({ result });
|
|
954
|
+
const onMonitor = (): void => {
|
|
955
|
+
const current = settledRuns.get(runId);
|
|
956
|
+
if (current) {
|
|
957
|
+
finish({ result: current });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!monitor.findRun(runId)) {
|
|
961
|
+
// Removal is followed synchronously by registerRunResult in the
|
|
962
|
+
// finishing task; re-check on the next tick so the result wins.
|
|
963
|
+
setTimeout(() => {
|
|
964
|
+
const late = settledRuns.get(runId);
|
|
965
|
+
if (late) finish({ result: late });
|
|
966
|
+
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
967
|
+
}, 0);
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
971
|
+
let listeners = settledListeners.get(runId);
|
|
972
|
+
if (!listeners) {
|
|
973
|
+
listeners = new Set();
|
|
974
|
+
settledListeners.set(runId, listeners);
|
|
975
|
+
}
|
|
976
|
+
listeners.add(onSettled);
|
|
977
|
+
unsub = monitor.subscribe(onMonitor);
|
|
978
|
+
timer = setTimeout(
|
|
979
|
+
() =>
|
|
980
|
+
finish({
|
|
981
|
+
note: `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)`,
|
|
982
|
+
}),
|
|
983
|
+
Math.max(1, timeoutMs),
|
|
984
|
+
);
|
|
985
|
+
if (signal?.aborted) onAbort();
|
|
986
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
987
|
+
});
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
991
|
+
const blocks = outcomes.map((outcome) =>
|
|
992
|
+
outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
|
|
993
|
+
);
|
|
994
|
+
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
renderCall(args, theme) {
|
|
998
|
+
const target = args.id ? `#${args.id}` : "all";
|
|
999
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
1000
|
+
},
|
|
1001
|
+
|
|
1002
|
+
renderResult(result, _options, theme) {
|
|
1003
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1004
|
+
const text = parts
|
|
1005
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1006
|
+
.join(" ")
|
|
1007
|
+
.trim();
|
|
1008
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1009
|
+
return new Text(
|
|
1010
|
+
`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1011
|
+
0,
|
|
1012
|
+
0,
|
|
1013
|
+
);
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// Status overview: what is running right now and what finished this session,
|
|
1018
|
+
// with per-run details (id, agent, model, usage, elapsed, activity) so the
|
|
1019
|
+
// main agent can decide whether to wait, stop, or re-dispatch. Learned from
|
|
1020
|
+
// nicobailon/pi-subagents ({action:"status"} + status files): inspect before
|
|
1021
|
+
// you act, and report run ids when handing off.
|
|
1022
|
+
const SubagentStatusParams = Type.Object({
|
|
1023
|
+
id: Type.Optional(
|
|
1024
|
+
Type.String({
|
|
1025
|
+
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
1026
|
+
}),
|
|
1027
|
+
),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
pi.registerTool({
|
|
1031
|
+
name: "subagent_status",
|
|
1032
|
+
label: "Subagent Status",
|
|
1033
|
+
description: [
|
|
1034
|
+
"List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
|
|
1035
|
+
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
1036
|
+
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
1037
|
+
].join(" "),
|
|
1038
|
+
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
1039
|
+
promptGuidelines: [
|
|
1040
|
+
"Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
|
|
1041
|
+
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
1042
|
+
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
1043
|
+
],
|
|
1044
|
+
parameters: SubagentStatusParams,
|
|
1045
|
+
|
|
1046
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1047
|
+
const config = await loadConfig(configPath);
|
|
1048
|
+
const requested = params.id?.trim();
|
|
1049
|
+
|
|
1050
|
+
if (requested) {
|
|
1051
|
+
const settledIds = matchRunIds([...settledRuns.keys()], requested);
|
|
1052
|
+
if (settledIds.length > 0) {
|
|
1053
|
+
return {
|
|
1054
|
+
content: [
|
|
1055
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
1056
|
+
],
|
|
1057
|
+
details: {},
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const runs = monitor.getRuns();
|
|
1061
|
+
const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
|
|
1062
|
+
const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
|
|
1063
|
+
if (active) {
|
|
1064
|
+
return {
|
|
1065
|
+
content: [
|
|
1066
|
+
{
|
|
1067
|
+
type: "text",
|
|
1068
|
+
text: `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}). Use subagent_wait to block for its result, or subagent_stop to cancel it.`,
|
|
1069
|
+
},
|
|
1070
|
+
],
|
|
1071
|
+
details: {},
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const now = Date.now();
|
|
1078
|
+
const activeRuns = monitor.getRuns().filter(
|
|
1079
|
+
(run) => run.status === "queued" || run.status === "running" || run.retained,
|
|
1080
|
+
);
|
|
1081
|
+
const activeLines = activeRuns.map((run) => {
|
|
1082
|
+
const parts = [
|
|
1083
|
+
`#${run.id} ${run.agent}`,
|
|
1084
|
+
run.model ?? "?",
|
|
1085
|
+
formatUsageCompact(run.usage),
|
|
1086
|
+
formatElapsed(run, now),
|
|
1087
|
+
].filter(Boolean);
|
|
1088
|
+
return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
|
|
1089
|
+
});
|
|
1090
|
+
const completed = [...settledRuns.entries()].slice(-5);
|
|
1091
|
+
const completedLines = completed.map(([id, result]) => {
|
|
1092
|
+
const usage = formatUsage(result.usage);
|
|
1093
|
+
return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
const sections: string[] = [];
|
|
1097
|
+
sections.push(`### Active subagent runs (${activeRuns.length})`);
|
|
1098
|
+
sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
|
|
1099
|
+
sections.push(`### Finished this session (${settledRuns.size})`);
|
|
1100
|
+
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
1101
|
+
sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
|
|
1102
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
1103
|
+
},
|
|
1104
|
+
|
|
1105
|
+
renderCall(args, theme) {
|
|
1106
|
+
return new Text(
|
|
1107
|
+
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
|
|
1108
|
+
0,
|
|
1109
|
+
0,
|
|
1110
|
+
);
|
|
1111
|
+
},
|
|
1112
|
+
|
|
1113
|
+
renderResult(result, _options, theme) {
|
|
1114
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1115
|
+
const text = parts
|
|
1116
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1117
|
+
.join(" ")
|
|
1118
|
+
.trim();
|
|
1119
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1120
|
+
return new Text(
|
|
1121
|
+
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1122
|
+
0,
|
|
1123
|
+
0,
|
|
1124
|
+
);
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Cancel one or more active runs: aborts the queue controller, which
|
|
1129
|
+
// terminates the child and delivers an aborted result (with whatever partial
|
|
1130
|
+
// output it produced) so the main agent always knows the run stopped.
|
|
1131
|
+
const SubagentStopParams = Type.Object({
|
|
1132
|
+
id: Type.Optional(
|
|
1133
|
+
Type.String({
|
|
1134
|
+
description: "Run id or prefix to stop (see the widget or subagent_status).",
|
|
1135
|
+
}),
|
|
1136
|
+
),
|
|
1137
|
+
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
pi.registerTool({
|
|
1141
|
+
name: "subagent_stop",
|
|
1142
|
+
label: "Subagent Stop",
|
|
1143
|
+
description: [
|
|
1144
|
+
"Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
|
|
1145
|
+
"Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
|
|
1146
|
+
].join(" "),
|
|
1147
|
+
promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
|
|
1148
|
+
promptGuidelines: [
|
|
1149
|
+
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
|
|
1150
|
+
"A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
|
|
1151
|
+
],
|
|
1152
|
+
parameters: SubagentStopParams,
|
|
1153
|
+
|
|
1154
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
1155
|
+
const targets =
|
|
1156
|
+
params.all === true
|
|
1157
|
+
? [...runControllers.keys()]
|
|
1158
|
+
: params.id !== undefined && params.id.trim() !== ""
|
|
1159
|
+
? matchRunIds([...runControllers.keys()], params.id!.trim())
|
|
1160
|
+
: [];
|
|
1161
|
+
|
|
1162
|
+
if (targets.length === 0) {
|
|
1163
|
+
const activeList = [...runControllers.keys()].map((id) => `#${id}`).join(", ");
|
|
1164
|
+
return {
|
|
1165
|
+
content: [
|
|
1166
|
+
{
|
|
1167
|
+
type: "text",
|
|
1168
|
+
text:
|
|
1169
|
+
params.all === true
|
|
1170
|
+
? "No active subagent runs to stop."
|
|
1171
|
+
: `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
|
|
1172
|
+
},
|
|
1173
|
+
],
|
|
1174
|
+
details: {},
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const stopped: string[] = [];
|
|
1179
|
+
for (const runId of targets) {
|
|
1180
|
+
const run = monitor.findRun(runId);
|
|
1181
|
+
if (!run) {
|
|
1182
|
+
runControllers.delete(runId);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
// Abort before registering the synthetic result: abort() only marks the
|
|
1186
|
+
// queue entry (drain delivers the cancellation callback later), so the
|
|
1187
|
+
// has() re-check right after it distinguishes an entry that never ran
|
|
1188
|
+
// from one whose task already started under a stale "queued" status —
|
|
1189
|
+
// a started task owns its own (real, partial-output) result.
|
|
1190
|
+
const controller = runControllers.get(runId);
|
|
1191
|
+
controller?.abort();
|
|
1192
|
+
// A queued run never reaches the child-spawn code path, so its abort
|
|
1193
|
+
// goes through the queue's cancelled callback with no result object;
|
|
1194
|
+
// register a synthetic aborted result so subagent_wait resolves.
|
|
1195
|
+
if (run.status === "queued" && runControllers.has(runId)) {
|
|
1196
|
+
registerRunResult(runId, {
|
|
1197
|
+
agent: run.agent,
|
|
1198
|
+
agentSource: "builtin",
|
|
1199
|
+
task: run.task,
|
|
1200
|
+
exitCode: 1,
|
|
1201
|
+
messages: [],
|
|
1202
|
+
stderr: "Stopped by subagent_stop before the run started.",
|
|
1203
|
+
usage: emptyUsage(),
|
|
1204
|
+
model: run.model,
|
|
1205
|
+
thinking: run.thinking,
|
|
1206
|
+
stopReason: "aborted",
|
|
1207
|
+
errorMessage: "Stopped by subagent_stop before the run started.",
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
content: [
|
|
1214
|
+
{
|
|
1215
|
+
type: "text",
|
|
1216
|
+
text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
1219
|
+
details: {},
|
|
1220
|
+
};
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
renderCall(args, theme) {
|
|
1224
|
+
return new Text(
|
|
1225
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
1226
|
+
0,
|
|
1227
|
+
0,
|
|
1228
|
+
);
|
|
1229
|
+
},
|
|
1230
|
+
|
|
1231
|
+
renderResult(result, _options, theme) {
|
|
1232
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1233
|
+
const text = parts
|
|
1234
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1235
|
+
.join(" ")
|
|
1236
|
+
.trim();
|
|
1237
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1238
|
+
return new Text(
|
|
1239
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1240
|
+
0,
|
|
1241
|
+
0,
|
|
1242
|
+
);
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
|
|
757
1246
|
pi.registerCommand("subagents-setup", {
|
|
758
1247
|
description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
|
|
759
1248
|
handler: async (_args, ctx) => {
|
package/src/monitor.ts
CHANGED
|
@@ -252,7 +252,12 @@ export function formatDuration(ms: number): string {
|
|
|
252
252
|
/** Elapsed wall time of a run: live while running, final once finished. */
|
|
253
253
|
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
254
254
|
if (run.startedAt === undefined) return "";
|
|
255
|
-
|
|
255
|
+
// A retained row (e.g. an auto-fix chain parent whose chain is still running)
|
|
256
|
+
// must keep ticking: its `endedAt` was stamped when the review itself
|
|
257
|
+
// finished, but the work is ongoing, so show live elapsed until the chain
|
|
258
|
+
// resolves and the row is removed. Without this, subagent_status would show a
|
|
259
|
+
// frozen elapsed for a run the UI otherwise presents as still active.
|
|
260
|
+
const end = run.retained ? now : (run.endedAt ?? now);
|
|
256
261
|
return formatDuration(end - run.startedAt);
|
|
257
262
|
}
|
|
258
263
|
|