@ferris1225/pi-subagents 0.23.0 → 0.25.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/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import { Text } from "@earendil-works/pi-tui";
16
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
17
17
  import { Type } from "typebox";
18
18
  import { discoverAgents, type AgentConfig } from "./agents.ts";
19
19
  import { BackgroundTaskQueue } from "./background.ts";
@@ -52,7 +52,6 @@ import {
52
52
  formatToolActivity,
53
53
  formatUsageCompact,
54
54
  monitor,
55
- rightAlign,
56
55
  statusIcon,
57
56
  statusLabel,
58
57
  type RunChainMeta,
@@ -155,7 +154,13 @@ function formatUsage(usage: UsageStats): string {
155
154
  }
156
155
 
157
156
  function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
158
- const status = isFailedResult(result) ? "failed" : "completed";
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";
159
164
  const usage = formatUsage(result.usage);
160
165
  const output = getResultOutput(result);
161
166
  const { text, truncated } = truncateResultOutput(output, maxResultLines);
@@ -166,6 +171,20 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
166
171
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
167
172
  : "";
168
173
  const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${retryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
174
+ // A run can exit cleanly while its last tools failed (e.g. a build that broke):
175
+ // the final text alone may claim more than the tools achieved, so surface the
176
+ // failures explicitly and tell the main agent to verify before relying on it.
177
+ if (!failed && failedTools.length > 0) {
178
+ const shown = failedTools.slice(0, 3);
179
+ const more = failedTools.length - shown.length;
180
+ lines.push(
181
+ "",
182
+ `⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"} failed during this run — the final text above may not reflect a working state:`,
183
+ ...shown.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
184
+ );
185
+ if (more > 0) lines.push(`- … and ${more} more`);
186
+ lines.push("Verify the actual artifacts before relying on this report.");
187
+ }
169
188
  if (truncated) {
170
189
  // The full text lives on disk so the main agent can read it on demand.
171
190
  lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
@@ -181,6 +200,16 @@ function modelLevelTakeoverNote(result: SingleResult): string {
181
200
  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.`;
182
201
  }
183
202
 
203
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
204
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
205
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
206
+ * from returning — or, for subagent_stop, acting on — a whole prefix family. */
207
+ export function matchRunIds(ids: number[], requested: string): number[] {
208
+ const exact = ids.filter((id) => String(id) === requested);
209
+ if (exact.length > 0) return exact;
210
+ return ids.filter((id) => String(id).startsWith(requested));
211
+ }
212
+
184
213
  export default function (pi: ExtensionAPI): void {
185
214
  const configPath = getConfigPath(getAgentDir());
186
215
  // Init-time decisions need the config synchronously; the full (migrating)
@@ -196,7 +225,11 @@ export default function (pi: ExtensionAPI): void {
196
225
  display: true,
197
226
  };
198
227
  if (completionGroupTriggersTurn(items)) {
199
- pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
228
+ // steer: the result is injected after the current tool call even mid-turn, or
229
+ // starts a new turn when idle. followUp would sit in the queue until the whole
230
+ // turn ends — a main agent waiting for the result (sleep/poll) would never see
231
+ // it delivered, which is exactly the "returned but never woken" failure mode.
232
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
200
233
  } else {
201
234
  // No-wake delivery: nextTurn rides along with the next user turn and can
202
235
  // never start a continuation by itself. followUp would auto-continue
@@ -206,6 +239,28 @@ export default function (pi: ExtensionAPI): void {
206
239
  };
207
240
  const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
208
241
 
242
+ // Abort controllers per active run, so subagent_stop can cancel a run in-turn.
243
+ const runControllers = new Map<number, AbortController>();
244
+
245
+ // Final results keyed by run id, so `subagent_wait` can hand the model the
246
+ // actual result in-turn instead of it sleeping/polling for a wake-up message.
247
+ const settledRuns = new Map<number, SingleResult>();
248
+ const settledListeners = new Map<number, Set<(result: SingleResult) => void>>();
249
+ const registerRunResult = (runId: number, result: SingleResult): void => {
250
+ settledRuns.set(runId, result);
251
+ const listeners = settledListeners.get(runId);
252
+ if (listeners) {
253
+ settledListeners.delete(runId);
254
+ for (const listener of listeners) {
255
+ try {
256
+ listener(result);
257
+ } catch {
258
+ /* listener errors must never break settling */
259
+ }
260
+ }
261
+ }
262
+ };
263
+
209
264
  // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
210
265
  // excluded from their toolset at spawn (--exclude-tools); this check is defense
211
266
  // in depth so a child can never expose the tool back to its model, even if
@@ -232,6 +287,9 @@ export default function (pi: ExtensionAPI): void {
232
287
  sessionActive = false;
233
288
  completionBatcher.dispose();
234
289
  backgroundQueue.cancelAll();
290
+ settledRuns.clear();
291
+ settledListeners.clear();
292
+ runControllers.clear();
235
293
  // Clear the monitor so stale runs from this session never leak into the
236
294
  // next one (the module-level singleton survives across sessions).
237
295
  monitor.clear();
@@ -245,7 +303,8 @@ export default function (pi: ExtensionAPI): void {
245
303
  "Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
246
304
  "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
247
305
  "It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
248
- "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
306
+ "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
307
+ "To get a result in-turn without sleeping, use the subagent_wait tool."
249
308
  ].join(" "),
250
309
  promptSnippet:
251
310
  "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.",
@@ -256,7 +315,8 @@ export default function (pi: ExtensionAPI): void {
256
315
  "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
257
316
  "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
258
317
  "Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
259
- "NEVER sleep, wait, 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.",
318
+ "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.",
319
+ "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.",
260
320
  ],
261
321
  parameters: SubagentParams,
262
322
 
@@ -425,11 +485,12 @@ export default function (pi: ExtensionAPI): void {
425
485
  sessionRef,
426
486
  );
427
487
  finishRun(runId, isFailedResult(result) ? "failed" : "done");
488
+ registerRunResult(runId, result);
428
489
  return result;
429
490
  } catch (error) {
430
491
  finishRun(runId, "failed");
431
492
  const errorMessage = error instanceof Error ? error.message : String(error);
432
- return {
493
+ const crashed = {
433
494
  ...queuedResult(agent, task, thinkingLevel),
434
495
  exitCode: 1,
435
496
  stderr: errorMessage,
@@ -437,6 +498,8 @@ export default function (pi: ExtensionAPI): void {
437
498
  errorMessage,
438
499
  dispatchFailed: true,
439
500
  };
501
+ registerRunResult(runId, crashed);
502
+ return crashed;
440
503
  }
441
504
  };
442
505
 
@@ -449,7 +512,7 @@ export default function (pi: ExtensionAPI): void {
449
512
  * the chain resolves, so the ↳ rows have an obvious parent.
450
513
  */
451
514
  const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
452
- backgroundQueue.enqueue(
515
+ runControllers.set(parentRunId, backgroundQueue.enqueue(
453
516
  async (signal) => {
454
517
  const chain: SingleResult[] = [initialReviewerResult];
455
518
  let lastReviewer = initialReviewerResult;
@@ -478,7 +541,11 @@ export default function (pi: ExtensionAPI): void {
478
541
  // The chain is done (success, exhaustion, or abort): drop the retained
479
542
  // parent row, then deliver the whole chain as one group. The loop's
480
543
  // outcome always wakes the main agent (a passing chain reports
481
- // success, a stuck one needs a human).
544
+ // success, a stuck one needs a human). Register the parent's final
545
+ // state (the last chain result) before removal so subagent_wait can
546
+ // resolve it.
547
+ registerRunResult(parentRunId, chain[chain.length - 1]);
548
+ runControllers.delete(parentRunId);
482
549
  monitor.removeRun(parentRunId);
483
550
  if (!sessionActive) return;
484
551
  const items: CompletionMessageItem[] = chain.map((r) => {
@@ -501,6 +568,7 @@ export default function (pi: ExtensionAPI): void {
501
568
  () => {
502
569
  // Cancelled before delivery: clean up the retained parent row (each
503
570
  // in-flight chain run was already finished by its launchInLoop path).
571
+ runControllers.delete(parentRunId);
504
572
  monitor.removeRun(parentRunId);
505
573
  },
506
574
  (error) => {
@@ -508,6 +576,8 @@ export default function (pi: ExtensionAPI): void {
508
576
  // launchInLoop and delivered as part of the chain) must not vanish:
509
577
  // drop the retained parent row, notify, and deliver a failed result
510
578
  // so the main agent knows the chain never completed.
579
+ registerRunResult(parentRunId, initialReviewerResult);
580
+ runControllers.delete(parentRunId);
511
581
  monitor.removeRun(parentRunId);
512
582
  if (!sessionActive) return;
513
583
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -527,7 +597,7 @@ export default function (pi: ExtensionAPI): void {
527
597
  /* a second delivery failure must not throw through the queue */
528
598
  }
529
599
  },
530
- );
600
+ ));
531
601
  };
532
602
 
533
603
  const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
@@ -542,7 +612,7 @@ export default function (pi: ExtensionAPI): void {
542
612
  // only its finish is deferred to the queue task (see startFixLoop).
543
613
  const onLive = makeLiveHandler(runId);
544
614
 
545
- backgroundQueue.enqueue(
615
+ runControllers.set(runId, backgroundQueue.enqueue(
546
616
  async (backgroundSignal) => {
547
617
  let result: SingleResult;
548
618
  try {
@@ -574,6 +644,8 @@ export default function (pi: ExtensionAPI): void {
574
644
  // The dedicated dispatch-failure notification below replaces the generic
575
645
  // failure toast for dispatch crashes, so finish silently here.
576
646
  finishRun(runId, "failed", { silent: true });
647
+ registerRunResult(runId, result);
648
+ runControllers.delete(runId);
577
649
  }
578
650
 
579
651
  if (!sessionActive) return;
@@ -601,6 +673,10 @@ export default function (pi: ExtensionAPI): void {
601
673
  const modelLevel = failed && isModelLevelFailure(result);
602
674
  const dispatchFailed = result.dispatchFailed === true;
603
675
  finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
676
+ // Register before delivery so a concurrent subagent_wait resolves with
677
+ // the result even though the run row is already gone from the monitor.
678
+ registerRunResult(runId, result);
679
+ runControllers.delete(runId);
604
680
  if (!sessionActive) return;
605
681
  // Model-level failure: the configured model is unavailable or broke
606
682
  // and the retry with the main-window model (when distinct) also
@@ -629,7 +705,10 @@ export default function (pi: ExtensionAPI): void {
629
705
  completionBatcher.push(completion);
630
706
  }
631
707
  },
632
- () => finishRun(runId, "failed"),
708
+ () => {
709
+ runControllers.delete(runId);
710
+ finishRun(runId, "failed");
711
+ },
633
712
  (error) => {
634
713
  // The task body converts sub-agent failures into delivered results; an
635
714
  // exception escaping it (spawn infra, delivery API, ...) must not
@@ -637,6 +716,8 @@ export default function (pi: ExtensionAPI): void {
637
716
  // agent knows the dispatch failed and can re-dispatch.
638
717
  const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
639
718
  finishRun(runId, "failed", { silent: true });
719
+ registerRunResult(runId, crashed);
720
+ runControllers.delete(runId);
640
721
  if (!sessionActive) return;
641
722
  try {
642
723
  ctx.ui.notify(`✗ ${agent.name} 派发失败: ${crashed.errorMessage}`, "error");
@@ -652,7 +733,7 @@ export default function (pi: ExtensionAPI): void {
652
733
  /* a second delivery failure must not throw through the queue */
653
734
  }
654
735
  },
655
- );
736
+ ));
656
737
 
657
738
  return pending;
658
739
  };
@@ -755,6 +836,407 @@ export default function (pi: ExtensionAPI): void {
755
836
  },
756
837
  });
757
838
 
839
+ // Blocking wait: keeps the turn alive until the targeted run(s) settle, then
840
+ // returns the actual result(s) to the model in-turn. Without it, a model that
841
+ // must stay in the turn falls back to bash sleep/poll — blocking the turn and
842
+ // delaying the very wake-up it is waiting for. Ending the turn and letting the
843
+ // steer-delivered completion wake it is still the preferred path; this tool is
844
+ // for when the result is needed NOW (sequential dependent steps).
845
+ const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
846
+
847
+ const SubagentWaitParams = Type.Object({
848
+ id: Type.Optional(
849
+ Type.String({
850
+ description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
851
+ }),
852
+ ),
853
+ timeoutMs: Type.Optional(
854
+ Type.Number({
855
+ description: `Give up after this many milliseconds and report the still-running runs (default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}).`,
856
+ }),
857
+ ),
858
+ });
859
+
860
+ pi.registerTool({
861
+ name: "subagent_wait",
862
+ label: "Subagent Wait",
863
+ description: [
864
+ "Block the current turn until background sub-agent run(s) finish, then return their results.",
865
+ "Use ONLY when you must stay in the turn and act on the result immediately (sequential dependent steps).",
866
+ "Prefer ending your turn after subagent — the result arrives automatically and wakes you.",
867
+ "NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
868
+ "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.",
869
+ ].join(" "),
870
+ promptSnippet: "Wait for a background subagent to finish and get its result in-turn (id: run id from the widget; omit for all).",
871
+ promptGuidelines: [
872
+ "Call subagent_wait only when you must keep the turn and need the result now — e.g. the next step depends on it.",
873
+ "After dispatching via subagent, prefer ending the turn: the completion message wakes you automatically (no waiting).",
874
+ "Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
875
+ "If subagent_wait times out, call it again with a longer timeoutMs or end the turn and wait for the wake-up message.",
876
+ ],
877
+ parameters: SubagentWaitParams,
878
+
879
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
880
+ const config = await loadConfig(configPath);
881
+ // A non-finite or negative timeout would produce a nonsensical note
882
+ // ("timed out after Infinitys") or an instant "timeout" that was never
883
+ // asked for; fall back to the default. Zero is honored as an immediate
884
+ // give-up (clamped to 1ms below).
885
+ const timeoutMs =
886
+ typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
887
+ ? params.timeoutMs
888
+ : SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
889
+ const isActive = (run: { status: string; retained?: boolean }): boolean =>
890
+ run.status === "queued" || run.status === "running" || run.retained === true;
891
+
892
+ const requested = params.id?.trim();
893
+ // A run that already settled resolves immediately with its result.
894
+ if (requested) {
895
+ const settledIds = matchRunIds([...settledRuns.keys()], requested);
896
+ if (settledIds.length > 0) {
897
+ return {
898
+ content: [
899
+ { type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
900
+ ],
901
+ details: {},
902
+ };
903
+ }
904
+ }
905
+
906
+ const activeRuns = monitor.getRuns().filter(isActive);
907
+ const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
908
+ const targets = activeRuns.filter((run) => targetIds.includes(run.id));
909
+ if (targets.length === 0) {
910
+ const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
911
+ return {
912
+ content: [
913
+ {
914
+ type: "text",
915
+ text: requested
916
+ ? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
917
+ : `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
918
+ },
919
+ ],
920
+ details: {},
921
+ };
922
+ }
923
+
924
+ const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
925
+ const already = settledRuns.get(runId);
926
+ if (already) return Promise.resolve({ result: already });
927
+ return new Promise((resolve) => {
928
+ let done = false;
929
+ let timer: ReturnType<typeof setTimeout> | undefined;
930
+ let unsub: (() => void) | undefined;
931
+ const cleanup = (): void => {
932
+ if (timer) clearTimeout(timer);
933
+ if (unsub) unsub();
934
+ signal?.removeEventListener("abort", onAbort);
935
+ const listeners = settledListeners.get(runId);
936
+ if (listeners) {
937
+ listeners.delete(onSettled);
938
+ if (listeners.size === 0) settledListeners.delete(runId);
939
+ }
940
+ };
941
+ const finish = (outcome: { result?: SingleResult; note?: string }): void => {
942
+ if (done) return;
943
+ done = true;
944
+ cleanup();
945
+ resolve(outcome);
946
+ };
947
+ const onSettled = (result: SingleResult): void => finish({ result });
948
+ const onMonitor = (): void => {
949
+ const current = settledRuns.get(runId);
950
+ if (current) {
951
+ finish({ result: current });
952
+ return;
953
+ }
954
+ if (!monitor.findRun(runId)) {
955
+ // Removal is followed synchronously by registerRunResult in the
956
+ // finishing task; re-check on the next tick so the result wins.
957
+ setTimeout(() => {
958
+ const late = settledRuns.get(runId);
959
+ if (late) finish({ result: late });
960
+ else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
961
+ }, 0);
962
+ }
963
+ };
964
+ const onAbort = (): void => finish({ note: "wait aborted" });
965
+ let listeners = settledListeners.get(runId);
966
+ if (!listeners) {
967
+ listeners = new Set();
968
+ settledListeners.set(runId, listeners);
969
+ }
970
+ listeners.add(onSettled);
971
+ unsub = monitor.subscribe(onMonitor);
972
+ timer = setTimeout(
973
+ () =>
974
+ finish({
975
+ 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)`,
976
+ }),
977
+ Math.max(1, timeoutMs),
978
+ );
979
+ if (signal?.aborted) onAbort();
980
+ else signal?.addEventListener("abort", onAbort, { once: true });
981
+ });
982
+ };
983
+
984
+ const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
985
+ const blocks = outcomes.map((outcome) =>
986
+ outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
987
+ );
988
+ return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
989
+ },
990
+
991
+ renderCall(args, theme) {
992
+ const target = args.id ? `#${args.id}` : "all";
993
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
994
+ },
995
+
996
+ renderResult(result, _options, theme) {
997
+ const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
998
+ const text = parts
999
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
1000
+ .join(" ")
1001
+ .trim();
1002
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
1003
+ return new Text(
1004
+ `${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
1005
+ 0,
1006
+ 0,
1007
+ );
1008
+ },
1009
+ });
1010
+
1011
+ // Status overview: what is running right now and what finished this session,
1012
+ // with per-run details (id, agent, model, usage, elapsed, activity) so the
1013
+ // main agent can decide whether to wait, stop, or re-dispatch. Learned from
1014
+ // nicobailon/pi-subagents ({action:"status"} + status files): inspect before
1015
+ // you act, and report run ids when handing off.
1016
+ const SubagentStatusParams = Type.Object({
1017
+ id: Type.Optional(
1018
+ Type.String({
1019
+ description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
1020
+ }),
1021
+ ),
1022
+ });
1023
+
1024
+ pi.registerTool({
1025
+ name: "subagent_status",
1026
+ label: "Subagent Status",
1027
+ description: [
1028
+ "List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
1029
+ "Pass id to read the full result of a finished run; pass no id for the overview.",
1030
+ "Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
1031
+ ].join(" "),
1032
+ promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
1033
+ promptGuidelines: [
1034
+ "Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
1035
+ "Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
1036
+ "A finished run's id stays available for the session; its full result is one subagent_status call away.",
1037
+ ],
1038
+ parameters: SubagentStatusParams,
1039
+
1040
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1041
+ const config = await loadConfig(configPath);
1042
+ const requested = params.id?.trim();
1043
+
1044
+ if (requested) {
1045
+ const settledIds = matchRunIds([...settledRuns.keys()], requested);
1046
+ if (settledIds.length > 0) {
1047
+ return {
1048
+ content: [
1049
+ { type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
1050
+ ],
1051
+ details: {},
1052
+ };
1053
+ }
1054
+ const runs = monitor.getRuns();
1055
+ const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
1056
+ const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
1057
+ if (active) {
1058
+ return {
1059
+ content: [
1060
+ {
1061
+ type: "text",
1062
+ 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.`,
1063
+ },
1064
+ ],
1065
+ details: {},
1066
+ };
1067
+ }
1068
+ return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
1069
+ }
1070
+
1071
+ const now = Date.now();
1072
+ const activeRuns = monitor.getRuns().filter(
1073
+ (run) => run.status === "queued" || run.status === "running" || run.retained,
1074
+ );
1075
+ const activeLines = activeRuns.map((run) => {
1076
+ const parts = [
1077
+ `#${run.id} ${run.agent}`,
1078
+ run.model ?? "?",
1079
+ formatUsageCompact(run.usage),
1080
+ formatElapsed(run, now),
1081
+ ].filter(Boolean);
1082
+ return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
1083
+ });
1084
+ const completed = [...settledRuns.entries()].slice(-5);
1085
+ const completedLines = completed.map(([id, result]) => {
1086
+ const usage = formatUsage(result.usage);
1087
+ return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
1088
+ });
1089
+
1090
+ const sections: string[] = [];
1091
+ sections.push(`### Active subagent runs (${activeRuns.length})`);
1092
+ sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
1093
+ sections.push(`### Finished this session (${settledRuns.size})`);
1094
+ sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
1095
+ sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
1096
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
1097
+ },
1098
+
1099
+ renderCall(args, theme) {
1100
+ return new Text(
1101
+ `${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
1102
+ 0,
1103
+ 0,
1104
+ );
1105
+ },
1106
+
1107
+ renderResult(result, _options, theme) {
1108
+ const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
1109
+ const text = parts
1110
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
1111
+ .join(" ")
1112
+ .trim();
1113
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
1114
+ return new Text(
1115
+ `${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
1116
+ 0,
1117
+ 0,
1118
+ );
1119
+ },
1120
+ });
1121
+
1122
+ // Cancel one or more active runs: aborts the queue controller, which
1123
+ // terminates the child and delivers an aborted result (with whatever partial
1124
+ // output it produced) so the main agent always knows the run stopped.
1125
+ const SubagentStopParams = Type.Object({
1126
+ id: Type.Optional(
1127
+ Type.String({
1128
+ description: "Run id or prefix to stop (see the widget or subagent_status).",
1129
+ }),
1130
+ ),
1131
+ all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
1132
+ });
1133
+
1134
+ pi.registerTool({
1135
+ name: "subagent_stop",
1136
+ label: "Subagent Stop",
1137
+ description: [
1138
+ "Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
1139
+ "Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
1140
+ ].join(" "),
1141
+ promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
1142
+ promptGuidelines: [
1143
+ "Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
1144
+ "A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
1145
+ ],
1146
+ parameters: SubagentStopParams,
1147
+
1148
+ async execute(_toolCallId, params, _signal, _onUpdate) {
1149
+ const targets =
1150
+ params.all === true
1151
+ ? [...runControllers.keys()]
1152
+ : params.id !== undefined && params.id.trim() !== ""
1153
+ ? matchRunIds([...runControllers.keys()], params.id!.trim())
1154
+ : [];
1155
+
1156
+ if (targets.length === 0) {
1157
+ const activeList = [...runControllers.keys()].map((id) => `#${id}`).join(", ");
1158
+ return {
1159
+ content: [
1160
+ {
1161
+ type: "text",
1162
+ text:
1163
+ params.all === true
1164
+ ? "No active subagent runs to stop."
1165
+ : `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
1166
+ },
1167
+ ],
1168
+ details: {},
1169
+ };
1170
+ }
1171
+
1172
+ const stopped: string[] = [];
1173
+ for (const runId of targets) {
1174
+ const run = monitor.findRun(runId);
1175
+ if (!run) {
1176
+ runControllers.delete(runId);
1177
+ continue;
1178
+ }
1179
+ // Abort before registering the synthetic result: abort() only marks the
1180
+ // queue entry (drain delivers the cancellation callback later), so the
1181
+ // has() re-check right after it distinguishes an entry that never ran
1182
+ // from one whose task already started under a stale "queued" status —
1183
+ // a started task owns its own (real, partial-output) result.
1184
+ const controller = runControllers.get(runId);
1185
+ controller?.abort();
1186
+ // A queued run never reaches the child-spawn code path, so its abort
1187
+ // goes through the queue's cancelled callback with no result object;
1188
+ // register a synthetic aborted result so subagent_wait resolves.
1189
+ if (run.status === "queued" && runControllers.has(runId)) {
1190
+ registerRunResult(runId, {
1191
+ agent: run.agent,
1192
+ agentSource: "builtin",
1193
+ task: run.task,
1194
+ exitCode: 1,
1195
+ messages: [],
1196
+ stderr: "Stopped by subagent_stop before the run started.",
1197
+ usage: emptyUsage(),
1198
+ model: run.model,
1199
+ thinking: run.thinking,
1200
+ stopReason: "aborted",
1201
+ errorMessage: "Stopped by subagent_stop before the run started.",
1202
+ });
1203
+ }
1204
+ stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
1205
+ }
1206
+ return {
1207
+ content: [
1208
+ {
1209
+ type: "text",
1210
+ text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
1211
+ },
1212
+ ],
1213
+ details: {},
1214
+ };
1215
+ },
1216
+
1217
+ renderCall(args, theme) {
1218
+ return new Text(
1219
+ `${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
1220
+ 0,
1221
+ 0,
1222
+ );
1223
+ },
1224
+
1225
+ renderResult(result, _options, theme) {
1226
+ const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
1227
+ const text = parts
1228
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
1229
+ .join(" ")
1230
+ .trim();
1231
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
1232
+ return new Text(
1233
+ `${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
1234
+ 0,
1235
+ 0,
1236
+ );
1237
+ },
1238
+ });
1239
+
758
1240
  pi.registerCommand("subagents-setup", {
759
1241
  description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
760
1242
  handler: async (_args, ctx) => {
@@ -799,14 +1281,13 @@ export default function (pi: ExtensionAPI): void {
799
1281
  // their parent reviewer. Their relationLabel ("fix round 1") is more
800
1282
  // distinguishing than the repeated worker/reviewer name.
801
1283
  const name = isChain ? (r.relationLabel ?? r.agent) : r.agent;
802
- // Header row stays short: icon, run id, agent name the task summary
803
- // (keys-only fragments: paths, symbols, quoted phrases) gets its own
804
- // line below with a full width budget, so parallel runs of the SAME
805
- // agent are still distinguishable at a glance.
806
- const title = formatTaskSummary(r.task, Math.max(16, Math.floor(width * 0.65)), true);
807
- // Hierarchy: top-level agent names are the visual anchor (accent +
808
- // bold); chain nodes and all metadata stay quiet, so the widget reads
809
- // top-down: roots → their branches → the fine print.
1284
+ // Two lines per run: the header row (icon, run id, agent name) and the
1285
+ // live activity branch below. The task summary is deliberately not
1286
+ // shown the task lives in the tool result, and the agent name plus
1287
+ // what it is doing right now is enough to tell runs apart. The header
1288
+ // stays exactly as it was (accent name, dim stats), matching the
1289
+ // referenced sub-agent widgets (tintinweb): the running indicator
1290
+ // uses the accent color, everything else is quiet.
810
1291
  if (!isChain && lines.length > 0) lines.push("");
811
1292
  const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
812
1293
  const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}`;
@@ -827,20 +1308,20 @@ export default function (pi: ExtensionAPI): void {
827
1308
  const state = deriveActivityState(r, now);
828
1309
  if (state) metaParts.push(activityStateLabel(state));
829
1310
  if (r.annotation) metaParts.push(r.annotation);
1311
+ // Metadata trails the header in dim — quiet, never competing with the
1312
+ // accent agent name (the same restraint the referenced widgets use).
1313
+ // Trailing with a single " · " chain keeps the row compact (no center
1314
+ // gap); compactLine clips on overflow, never the right side on its own.
830
1315
  const right = metaParts.length ? dim(` · ${metaParts.join(" · ")}`) : "";
831
1316
  lines.push(compactLine(left, right, width));
832
1317
 
833
- // Branches hang off the node's content column; chain nodes that still
834
- // have siblings carry a "│" continuation down to the last one.
835
- const continuation = isChain ? (chainContinues ? "│ " : " ") : "";
836
- // Task summary is the first branch; the summary itself is plain text —
837
- // no accent, so it never competes with the agent name or pi's own UI.
838
- lines.push(
839
- rightAlign(`${continuation}${dim(hasActivity ? "├─ " : "└─ ")}${dim("title: ")}${theme.fg("text", title)}`, "", width),
840
- );
841
- // Current activity is the last branch, only while the run is active.
1318
+ // Current activity ("read src/index.ts", "bash npm test") is the only
1319
+ // branch: gray, so it never competes with the agent name or pi's own
1320
+ // UI. Chain nodes that still have siblings carry a "" continuation
1321
+ // down to the last one.
842
1322
  if (hasActivity) {
843
- lines.push(rightAlign(`${continuation}${dim("└─ ")}${dim(activity)}`, "", width));
1323
+ const continuation = isChain ? (chainContinues ? "│ " : " ") : "";
1324
+ lines.push(truncateToWidth(`${continuation}${dim("└─ ")}${dim(activity)}`, width));
844
1325
  }
845
1326
  }
846
1327
  return lines;