@ferris1225/pi-subagents 0.29.0 → 0.31.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 CHANGED
@@ -28,6 +28,10 @@ on its own — no prompt engineering, no babysitting.
28
28
  "go check" step. `subagent_wait` is a **non-blocking** in-turn lookup by default
29
29
  (pass `timeoutMs` to block); `subagent_status` inspects runs; `subagent_stop`
30
30
  cancels one and delivers its partial output.
31
+ - **Results are not re-narrated** — a sub-agent's completion is shown to you
32
+ verbatim, and the main agent is told not to paraphrase it back. It replies with
33
+ only its own conclusion or next step, so the same findings are never paid for
34
+ twice in tokens.
31
35
  - **A quality gate that closes the loop** — when a reviewer returns `REVIEW_FAIL`,
32
36
  the extension dispatches a worker briefed with the concrete findings, then a
33
37
  re-review, up to `maxFixRounds` times — and only then wakes the main agent.
@@ -36,6 +40,10 @@ on its own — no prompt engineering, no babysitting.
36
40
  with backoff, then falls back once to the main window's model; terminal errors
37
41
  (quota/auth) short-circuit straight to the main agent; an idle watchdog kills
38
42
  runs that go silent; startup races are retried with backoff.
43
+ - **Resumes, not restarts, on a model switch** — every run is session-backed, so
44
+ a model quota/auth failure resumes on another model with its earlier searches,
45
+ reads, and edits intact (no re-scanning). When every model is out, the run is
46
+ handed back with its session preserved for a one-call `subagent({ resume })`.
39
47
  - **Honest completions** — a run that ended with failed tool calls (e.g. a broken
40
48
  build) is reported as `completed with N failed tool call(s)` with the errors
41
49
  attached — a cheerful final text can never hide a failure.
@@ -195,6 +203,22 @@ errors, then once with the main window's model — per-run only, never persisted
195
203
  if everything fails, the task is handed back to the main window with
196
204
  instructions to execute it directly.
197
205
 
206
+ ### Resuming after a model quota/auth failure
207
+
208
+ Every sub-agent run is **session-backed**: its pi session is persisted to a
209
+ temp dir for the run. When a model fails at the provider level, the retry and
210
+ the fallback **resume that session** instead of starting over — so a model
211
+ switch inherits the sub-agent's earlier searches, reads, and edits and never
212
+ re-scans. If every available model is exhausted (e.g. the account is out of
213
+ quota), the run is handed back with its session preserved; once you have a
214
+ working model again, resume it in-context:
215
+
216
+ ```ts
217
+ subagent({ resume: 7 }); // continue run #7 from where its model stopped
218
+ ```
219
+
220
+ The session is reclaimed once the resume succeeds (or when the session ends).
221
+
198
222
  ### Configuration migration
199
223
 
200
224
  The config file migrates itself on load — no manual steps after an upgrade:
@@ -241,25 +265,6 @@ model), `tools.ts` (wait/status/stop), `widget.ts` (widget + announcements),
241
265
  `monitor.ts` (run tracking), `setup.ts` (wizard), `prompt.ts` (delegation
242
266
  directive). No runtime dependencies beyond pi peer dependencies.
243
267
 
244
- ## Acknowledgments
245
-
246
- - The official [pi subagent example](https://github.com/earendil-works/pi)
247
- (`examples/extensions/subagent`) — the child-process dispatch and
248
- event-stream handling build on it.
249
- - [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) — the
250
- live widget and parallel fan-out follow its design.
251
- - [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) — the
252
- result-delivery design is learned from it: prompt **steer** delivery, a
253
- non-blocking `subagent_wait`, status inspection, and stop/interrupt
254
- management. Its status-file and workflow-script orchestration are deliberately
255
- out of scope: this extension stays a focused 3-agent delegation tool with a
256
- configuration wizard.
257
- - The sub-agent pattern itself, popularized by
258
- [Claude Code](https://github.com/anthropics/claude-code).
259
-
260
- The agent prompts and extension code are written independently for this
261
- project; the projects above served as design references.
262
-
263
268
  ## License
264
269
 
265
270
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/completion.ts CHANGED
@@ -151,3 +151,31 @@ export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass:
151
151
  reviewVerdict(getResultOutput(result)) === "pass"
152
152
  );
153
153
  }
154
+
155
+ /** Minimal shape of an active run, for the "others still running" footer. Kept
156
+ * decoupled from the monitor's RunView so this stays a pure, easily tested
157
+ * formatter; the caller maps its live runs into this shape. */
158
+ export interface ActiveRunFoot {
159
+ id: number;
160
+ agent: string;
161
+ /** Optional content label (task-derived) shown next to the agent name. */
162
+ label?: string;
163
+ }
164
+
165
+ /**
166
+ * Footer appended to a completion message when OTHER runs are still active, so
167
+ * the main agent does not declare the overall task done prematurely. A result
168
+ * arriving for one run does not mean sibling runs are finished; naming them
169
+ * gives the main agent concrete, in-context awareness to keep waiting.
170
+ *
171
+ * Returns "" when nothing is active (the common, single-run case stays quiet).
172
+ */
173
+ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string {
174
+ if (runs.length === 0) return "";
175
+ const listed = runs.slice(0, maxListed);
176
+ const items = listed
177
+ .map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
178
+ .join(", ");
179
+ const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
180
+ return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
181
+ }
package/src/dispatch.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { rm } from "node:fs/promises";
16
17
  import { Text } from "@earendil-works/pi-tui";
17
18
  import { Type } from "typebox";
18
19
  import { discoverAgents, type AgentConfig } from "./agents.ts";
@@ -47,6 +48,8 @@ import {
47
48
  } from "./monitor.ts";
48
49
  import type { SubagentRuntime } from "./runtime.ts";
49
50
  import {
51
+ buildFallbackResumeReason,
52
+ buildResumePrompt,
50
53
  getResultOutput,
51
54
  isFailedResult,
52
55
  isModelLevelFailure,
@@ -81,6 +84,12 @@ const SubagentParams = Type.Object({
81
84
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
82
85
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
83
86
  vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
87
+ resume: Type.Optional(
88
+ Type.Number({
89
+ description:
90
+ "Resume a handed-back run by its id: continue a sub-agent whose model hit a quota/auth limit, picking up its preserved context without re-scanning. Use the run id from a model-level handback message.",
91
+ }),
92
+ ),
84
93
  });
85
94
 
86
95
  /** True when any dispatched task carries the vision flag. */
@@ -142,6 +151,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
142
151
  "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
143
152
  "Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
144
153
  "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
154
+ "Resume: pass { resume: <runId> } to continue a run that was handed back after its model hit a quota/auth limit — it picks up the preserved context without re-scanning.",
145
155
  "It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
146
156
  "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
147
157
  "Results arrive as wake-up messages automatically — you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
@@ -159,6 +169,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
159
169
  "NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
160
170
  "If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) — never bash sleep/timeout to wait for a sub-agent.",
161
171
  "When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured.",
172
+ "When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
162
173
  ],
163
174
  parameters: SubagentParams,
164
175
 
@@ -251,6 +262,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
251
262
 
252
263
  const hasTasks = (params.tasks?.length ?? 0) > 0;
253
264
  const hasSingle = Boolean(params.agent) && params.task !== undefined;
265
+ // `resume` is its own exclusive mode (it re-dispatches a handed-back run
266
+ // from its preserved session), so it bypasses the single/parallel check.
267
+ const hasResume = typeof params.resume === "number";
254
268
 
255
269
  const makeDetails =
256
270
  (mode: "single" | "parallel", background = false) =>
@@ -258,7 +272,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
258
272
 
259
273
  const catalog = agents.map((a) => a.name).join(", ") || "none";
260
274
 
261
- if (Number(hasTasks) + Number(hasSingle) !== 1) {
275
+ if (!hasResume && Number(hasTasks) + Number(hasSingle) !== 1) {
262
276
  return {
263
277
  content: [
264
278
  {
@@ -436,7 +450,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
436
450
  const last = chain[chain.length - 1];
437
451
  let block = formatChainSummary(chain);
438
452
  if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
439
- block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result)}`;
453
+ if (last.result.sessionDir && last.result.sessionId) {
454
+ runtime.preservedSessions.set(parentRunId, {
455
+ sessionId: last.result.sessionId,
456
+ sessionDir: last.result.sessionDir,
457
+ agentName: last.result.agent,
458
+ task: last.result.task,
459
+ vision,
460
+ });
461
+ }
462
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
440
463
  } else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
441
464
  block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
442
465
  }
@@ -487,7 +510,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
487
510
  ));
488
511
  };
489
512
 
490
- const startBackground = (agentName: string, task: string, cwd?: string, vision = false): SingleResult => {
513
+ const startBackground = (
514
+ agentName: string,
515
+ task: string,
516
+ cwd?: string,
517
+ vision = false,
518
+ resumeSession?: { sessionId: string; sessionDir: string; preservedRunId: number },
519
+ ): SingleResult => {
491
520
  const agent = agents.find((candidate) => candidate.name === agentName);
492
521
  if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
493
522
  // A vision-flagged task runs on the configured vision model (or the main
@@ -519,6 +548,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
519
548
  onLive,
520
549
  makeDetails: makeDetails("single", true),
521
550
  idleTimeoutMs: config.idleTimeoutSec * 1000,
551
+ // A resume reuses a preserved session (handed back after a
552
+ // model-level failure) so it continues in-context instead of
553
+ // re-scanning. The wrapper detects the existing session file and
554
+ // resumes it; the continuation prompt steers the model to pick up.
555
+ ...(resumeSession
556
+ ? {
557
+ sessionId: resumeSession.sessionId,
558
+ sessionDir: resumeSession.sessionDir,
559
+ stdinText: buildResumePrompt(task, buildFallbackResumeReason()),
560
+ }
561
+ : {}),
522
562
  },
523
563
  sessionRef,
524
564
  );
@@ -568,15 +608,36 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
568
608
  // the result even though the run row is already gone from the monitor.
569
609
  runtime.registerRunResult(runId, result);
570
610
  runtime.runControllers.delete(runId);
611
+ // A successful resume consumed the preserved session: reclaim its temp
612
+ // dir and drop the id so it cannot be re-resumed. A failed resume keeps
613
+ // it (still filed under the original preserved run id) for another try.
614
+ if (resumeSession && !failed) {
615
+ runtime.preservedSessions.delete(resumeSession.preservedRunId);
616
+ void rm(resumeSession.sessionDir, { recursive: true, force: true }).catch(() => undefined);
617
+ }
571
618
  if (!runtime.sessionActive) return;
619
+ // A model-level failure that preserved a session (the run did real work
620
+ // before the model quota/auth broke) files it under this run id so a
621
+ // later `subagent({ resume: <runId> })` can continue in-context. Skipped
622
+ // for a resume run — its session is already filed under the original id.
623
+ if (modelLevel && !resumeSession && result.sessionDir && result.sessionId) {
624
+ runtime.preservedSessions.set(runId, {
625
+ sessionId: result.sessionId,
626
+ sessionDir: result.sessionDir,
627
+ agentName: agent.name,
628
+ task,
629
+ vision,
630
+ });
631
+ }
572
632
  // Model-level failure: the configured model is unavailable or broke
573
- // and the retry with the main-window model (when distinct) also
574
- // failed. Instead of leaving a dead failure, hand the task to the
575
- // main window the main agent executes it itself with its own tools.
633
+ // and the resume on the main-window model (when distinct) also failed.
634
+ // Hand the task back; when a session was preserved, steer the main agent
635
+ // to resume it in-context instead of executing it fresh.
636
+ const handbackRunId = resumeSession ? resumeSession.preservedRunId : runId;
576
637
  const completion: CompletionMessageItem = {
577
638
  agent: result.agent,
578
639
  block: modelLevel
579
- ? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
640
+ ? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId: handbackRunId })}`
580
641
  : formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
581
642
  triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
582
643
  };
@@ -629,6 +690,61 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
629
690
  return pending;
630
691
  };
631
692
 
693
+ // Resume mode (exclusive): continue a handed-back run in its preserved
694
+ // session on the agent's configured model, picking up the prior context
695
+ // instead of re-scanning. Triggered by a model-level handback that named
696
+ // the run id, after the user has a working model again.
697
+ if (typeof params.resume === "number") {
698
+ const preserved = runtime.preservedSessions.get(params.resume);
699
+ if (!preserved) {
700
+ return {
701
+ content: [
702
+ {
703
+ type: "text",
704
+ text: `No preservable session for run #${params.resume}. It completed normally, was not a model-level handback, or the session has ended.`,
705
+ },
706
+ ],
707
+ details: makeDetails("single")([]),
708
+ isError: true,
709
+ };
710
+ }
711
+ const resumeAgent = agents.find((a) => a.name === preserved.agentName);
712
+ if (!resumeAgent) {
713
+ return {
714
+ content: [
715
+ {
716
+ type: "text",
717
+ text: `Cannot resume run #${params.resume}: agent "${preserved.agentName}" is not enabled. Re-enable it (or run /subagents-setup) and resume again.`,
718
+ },
719
+ ],
720
+ details: makeDetails("single")([]),
721
+ isError: true,
722
+ };
723
+ }
724
+ const pending = startBackground(preserved.agentName, preserved.task, undefined, preserved.vision, {
725
+ sessionId: preserved.sessionId,
726
+ sessionDir: preserved.sessionDir,
727
+ preservedRunId: params.resume,
728
+ });
729
+ if (pending.exitCode !== -1) {
730
+ return {
731
+ content: [{ type: "text", text: getResultOutput(pending) }],
732
+ details: makeDetails("single")([pending]),
733
+ isError: true,
734
+ };
735
+ }
736
+ return {
737
+ content: [
738
+ {
739
+ type: "text",
740
+ text: `Resuming ${preserved.agentName} (run #${params.resume}) in the background on its configured model, picking up its preserved context. Its result will automatically resume the main agent when ready.`,
741
+ },
742
+ ],
743
+ details: makeDetails("single", true)([pending]),
744
+ terminate: true,
745
+ };
746
+ }
747
+
632
748
  // Sub-agents intentionally detach from the foreground turn. This makes the
633
749
  // editor available immediately; completion messages later wake the main agent.
634
750
  if (params.tasks && params.tasks.length > 0) {
package/src/format.ts CHANGED
@@ -122,13 +122,20 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
122
122
 
123
123
  /** Instruction appended to a model-level failure: the sub-agent's provider never
124
124
  * produced usable output (or the run stalled), so the task is handed back to the
125
- * main window instead of being left as a dead failure. */
126
- export function modelLevelTakeoverNote(result: SingleResult): string {
125
+ * main window instead of being left as a dead failure. When the run preserved a
126
+ * session with earlier work (and the run id is known), steer the main agent to
127
+ * RESUME it in-context once a model is available, instead of re-dispatching
128
+ * fresh (which would re-scan everything). */
129
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
127
130
  const sameModel = result.modelRetries
128
131
  ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
129
132
  : "";
130
- const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
131
- 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.`;
133
+ const retry = result.modelFallbackFrom ? ", and the resume on the main-window model also failed" : "";
134
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
135
+ const recovery = sessionPreserved
136
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent with { resume: ${opts!.runId} } to CONTINUE it in-context (it picks up where it stopped — no re-scan), or execute the task in the main window with your own tools.`
137
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
138
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
132
139
  }
133
140
 
134
141
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
package/src/monitor.ts CHANGED
@@ -39,6 +39,10 @@ export interface RunView {
39
39
  id: number;
40
40
  agent: string;
41
41
  task: string;
42
+ /** Short content label derived from the task (paths/symbols), shown next to
43
+ * the agent name so concurrent same-agent runs are told apart by what they
44
+ * are doing, not just their run id. */
45
+ label?: string;
42
46
  model?: string;
43
47
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
44
48
  thinking?: string;
@@ -228,6 +232,28 @@ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_
228
232
  return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
229
233
  }
230
234
 
235
+ /** Max display width of a run's content label. */
236
+ export const RUN_LABEL_MAX = 32;
237
+
238
+ /**
239
+ * Short content label for a run, derived from its task: the single most
240
+ * distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
241
+ * runs are told apart by WHAT they do, not just their run id. A long path keeps
242
+ * its tail (the filename is the recognisable part); a task with no recognizable
243
+ * fragment falls back to a head slice of its prose. Grapheme-safe.
244
+ */
245
+ export function runLabel(task: string): string {
246
+ const fragment = extractKeyFragments(task)[0];
247
+ const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
248
+ if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
249
+ const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
250
+ // A path/symbol fragment keeps its tail (filename/symbol is recognisable);
251
+ // a prose fallback keeps its head.
252
+ return fragment
253
+ ? `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, RUN_LABEL_MAX - 1)}`
254
+ : `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
255
+ }
256
+
231
257
  function formatTokens(count: number): string {
232
258
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
233
259
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -401,6 +427,7 @@ export class MonitorStore {
401
427
  id,
402
428
  agent,
403
429
  task,
430
+ label: runLabel(task),
404
431
  model,
405
432
  thinking,
406
433
  status: "queued",
package/src/prompt.ts CHANGED
@@ -66,6 +66,12 @@ Vision tasks:
66
66
  - Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
67
67
  - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
68
68
 
69
+ Result handoff (do not re-state):
70
+ - A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
71
+ - Reply only with what you ADD: your own conclusion, the next action you are taking, or a one-line acknowledgement. When the result already answers the user, a single sentence is enough — then end your turn or proceed.
72
+ - Read the result and act on it (verify, continue, commit). Keep your own output short.
73
+ - A result arriving does NOT mean all work is finished: sub-agents run in the background and siblings may still be active (a delivery names any still-running runs). Do not report the overall task complete until no runs are active — call subagent_status to confirm before saying Done.
74
+
69
75
  Review & verification:
70
76
  - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
71
77
  ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
package/src/runtime.ts CHANGED
@@ -9,10 +9,12 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { rmSync } from "node:fs";
12
13
  import { BackgroundTaskQueue } from "./background.ts";
13
14
  import {
14
15
  completionGroupTriggersTurn,
15
16
  createCompletionBatcher,
17
+ formatActiveRunsFooter,
16
18
  formatCompletionMessage,
17
19
  type CompletionBatcher,
18
20
  type CompletionMessageItem,
@@ -37,10 +39,23 @@ export interface SubagentRuntime {
37
39
  settledRuns: Map<number, SingleResult>;
38
40
  settledListeners: Map<number, Set<(result: SingleResult) => void>>;
39
41
  registerRunResult: (runId: number, result: SingleResult) => void;
42
+ /** Sessions preserved on disk after a model-level handback (the run did real
43
+ * work but its model quota/auth failed), keyed by the original run id so a
44
+ * later `subagent({ resume: <runId> })` can continue in-context. Cleaned up
45
+ * on shutdown so a crashed/ended session never leaks temp session dirs. */
46
+ preservedSessions: Map<number, PreservedSession>;
40
47
  /** Flip sessionActive off and release all session-scoped resources. */
41
48
  shutdown: () => void;
42
49
  }
43
50
 
51
+ export interface PreservedSession {
52
+ sessionId: string;
53
+ sessionDir: string;
54
+ agentName: string;
55
+ task: string;
56
+ vision: boolean;
57
+ }
58
+
44
59
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
45
60
  // Init-time decisions need the config synchronously; the full (migrating)
46
61
  // async load runs per tool call.
@@ -53,9 +68,17 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
53
68
  sessionActive: true,
54
69
  sendCompletionGroup: (items) => {
55
70
  if (!runtime.sessionActive || items.length === 0) return;
71
+ // A result arriving for one run does not mean sibling runs are done.
72
+ // Computing this at delivery (emit) time — not when the item was
73
+ // pushed — reflects the current monitor state, since finishing runs
74
+ // are removed from the monitor before their completion is pushed.
75
+ const active = monitor
76
+ .getRuns()
77
+ .filter((run) => run.status === "queued" || run.status === "running" || run.retained)
78
+ .map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
56
79
  const message = {
57
80
  customType: "subagent-result",
58
- content: formatCompletionMessage(items),
81
+ content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
59
82
  display: true,
60
83
  };
61
84
  if (completionGroupTriggersTurn(items)) {
@@ -76,6 +99,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
76
99
  runControllers: new Map<number, AbortController>(),
77
100
  settledRuns: new Map<number, SingleResult>(),
78
101
  settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
102
+ preservedSessions: new Map<number, PreservedSession>(),
79
103
  registerRunResult: (runId, result) => {
80
104
  runtime.settledRuns.set(runId, result);
81
105
  const listeners = runtime.settledListeners.get(runId);
@@ -97,6 +121,17 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
97
121
  runtime.settledRuns.clear();
98
122
  runtime.settledListeners.clear();
99
123
  runtime.runControllers.clear();
124
+ // Best-effort cleanup of preserved sub-agent session dirs so an
125
+ // ended/crashed session does not leak temp files; the OS reclaims
126
+ // tmpdir eventually, but this keeps things tidy between sessions.
127
+ for (const { sessionDir } of runtime.preservedSessions.values()) {
128
+ try {
129
+ rmSync(sessionDir, { recursive: true, force: true });
130
+ } catch {
131
+ /* best-effort */
132
+ }
133
+ }
134
+ runtime.preservedSessions.clear();
100
135
  // Clear the monitor so stale runs from this session never leak into the
101
136
  // next one (the module-level singleton survives across sessions).
102
137
  monitor.clear();
package/src/spawn.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { spawn, type ChildProcess } from "node:child_process";
13
- import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
14
15
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
16
  import { tmpdir } from "node:os";
16
17
  import { basename, join } from "node:path";
@@ -99,6 +100,16 @@ export interface SingleResult {
99
100
  * message must surface these so the main agent is never misled by a rosy final
100
101
  * text (e.g. a worker that ended with "keep waiting" while its build failed). */
101
102
  failedTools?: Array<{ toolName: string; error: string }>;
103
+ /** The pi session id this run used (every run is session-backed so a
104
+ * model-level failure can be resumed on another model without re-scanning). */
105
+ sessionId?: string;
106
+ /** Directory holding the run's pi session file. Preserved across the initial
107
+ * attempt and any resume attempts; kept on disk only when a model-level
108
+ * failure is handed back, so a later `resume` can pick up the context. */
109
+ sessionDir?: string;
110
+ /** True when the result was produced by resuming an earlier session (a
111
+ * model-level fallback or an explicit resume) rather than a fresh start. */
112
+ resumed?: boolean;
102
113
  }
103
114
 
104
115
  export interface SubagentDetails {
@@ -348,6 +359,44 @@ export function getResultOutput(result: SingleResult): string {
348
359
  return getFinalOutput(result.messages) || "(no output)";
349
360
  }
350
361
 
362
+ /**
363
+ * True when a pi session file for `sessionId` already exists in `sessionDir`.
364
+ * Every sub-agent run is session-backed; the FIRST attempt creates the session
365
+ * (`--session-id`) and every later attempt on the same session RESUMES it
366
+ * (`--session`), so a model-level retry or fallback picks up the prior context
367
+ * instead of re-scanning. The session file is named `<timestamp>Z_<id>.jsonl`
368
+ * (pi's convention), so a suffix match is exact and cheap.
369
+ */
370
+ export function sessionExists(sessionDir: string, sessionId: string): boolean {
371
+ try {
372
+ return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Build the continuation prompt sent to a RESUMED sub-agent session. The model
380
+ * sees the full prior history (loaded by `--session`) plus this new user turn,
381
+ * so it continues from where it stopped. Steering it not to redo finished work
382
+ * is what saves the re-scan the user wants to avoid.
383
+ *
384
+ * `reason` is a short clause describing why the session is resuming
385
+ * ("a transient provider error" / "your previous model hit a quota or auth
386
+ * limit, so a different model is now continuing").
387
+ */
388
+ export function buildResumePrompt(task: string, reason: string): string {
389
+ return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
390
+ }
391
+
392
+ /** Reason clause for resuming on a DIFFERENT model after the configured model
393
+ * failed at the provider level (quota/auth/overloaded/...). */
394
+ export function buildFallbackResumeReason(fromModel?: string): string {
395
+ return fromModel
396
+ ? `your previous model (${fromModel}) hit a quota, billing, or auth limit, so a different model is now continuing`
397
+ : "your previous model became unavailable, so a different model is now continuing";
398
+ }
399
+
351
400
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
352
401
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
353
402
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
@@ -424,6 +473,21 @@ export interface RunSingleOptions {
424
473
  * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
425
474
  * test wants to assert only the fallback path runs once). */
426
475
  runLevelRetryDelaysMs?: readonly number[];
476
+ /** Directory holding this run's pi session. When set (with sessionId), the
477
+ * child is session-backed: it creates the session on the first attempt and
478
+ * RESUMES it on any later attempt (model-level retry/fallback), so a model
479
+ * switch inherits the prior context. When unset, the child runs ephemerally
480
+ * (--no-session) and cannot be resumed. The caller owns the directory's
481
+ * lifecycle; runSingleAgent neither creates nor removes it. */
482
+ sessionDir?: string;
483
+ /** Pi session id paired with sessionDir. The first attempt creates it
484
+ * (--session-id); later attempts resume (--session) once the session file
485
+ * exists. */
486
+ sessionId?: string;
487
+ /** Text sent to the child via stdin. Defaults to `Task: ${task}`. A resumed
488
+ * attempt passes a continuation prompt (see buildResumePrompt) so the model
489
+ * picks up the prior session instead of starting the task over. */
490
+ stdinText?: string;
427
491
  signal?: AbortSignal;
428
492
  onLive?: (e: SubagentLiveEvent) => void;
429
493
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -458,7 +522,20 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
458
522
 
459
523
  // Defense in depth: even if another extension ignores the depth marker, a
460
524
  // child process can never expose a tool named `subagent` back to its model.
461
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--exclude-tools", "subagent"];
525
+ const args: string[] = ["--mode", "json", "-p", "--exclude-tools", "subagent"];
526
+ // Session-backed: every run persists its pi session so a model-level retry or
527
+ // fallback can RESUME it (--session) instead of re-scanning from scratch. The
528
+ // first attempt creates the session (--session-id); once the session file
529
+ // exists, later attempts resume it. No sessionDir → ephemeral (--no-session).
530
+ if (options.sessionDir && options.sessionId) {
531
+ args.push("--session-dir", options.sessionDir);
532
+ args.push(
533
+ sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id",
534
+ options.sessionId,
535
+ );
536
+ } else {
537
+ args.push("--no-session");
538
+ }
462
539
  if (agent.model) args.push("--model", agent.model);
463
540
  // The configured level is clamped adaptively per model by pi.
464
541
  args.push("--thinking", thinkingLevel);
@@ -467,6 +544,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
467
544
  let tmpPromptDir: string | null = null;
468
545
  let tmpPromptPath: string | null = null;
469
546
 
547
+ const hasSession = Boolean(options.sessionDir && options.sessionId);
470
548
  const currentResult: SingleResult = {
471
549
  agent: agentName,
472
550
  agentSource: agent.source,
@@ -477,6 +555,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
477
555
  usage: emptyUsage(),
478
556
  model: agent.model,
479
557
  thinking: thinkingLevel,
558
+ sessionId: options.sessionId,
559
+ sessionDir: options.sessionDir,
560
+ resumed: hasSession && sessionExists(options.sessionDir as string, options.sessionId as string),
480
561
  };
481
562
 
482
563
  try {
@@ -618,11 +699,11 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
618
699
  currentResult.messages.push(event.message as Message);
619
700
  }
620
701
  };
621
- // Send the task through the child stdin pipe instead of the process
622
- // command line. This avoids OS argument-length limits and does not
623
- // require another temporary file for conversation data.
702
+ // Send the task (or a continuation prompt for a resumed session) through
703
+ // the child stdin pipe instead of the command line. This avoids OS
704
+ // argument-length limits and requires no extra temp file for the data.
624
705
  proc.stdin?.on("error", () => undefined);
625
- proc.stdin?.end(`Task: ${task}`);
706
+ proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
626
707
 
627
708
  // Decode stdout through a StringDecoder so multi-byte UTF-8 characters
628
709
  // (CJK, emoji) split across chunk boundaries never produce U+FFFD
@@ -725,7 +806,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
725
806
  }
726
807
 
727
808
  /**
728
- * Run one agent with three layers of resilience against transient dispatch failures:
809
+ * Run one agent with three layers of resilience, all session-backed so a model
810
+ * switch RESUMES the prior context instead of re-scanning from scratch:
729
811
  *
730
812
  * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
731
813
  * exit before any model/tool activity. Relaunch with backoff so the startup
@@ -733,23 +815,19 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
733
815
  * model — and only a clean, silent, zero-activity exit qualifies (see
734
816
  * isRetryableStartupFailure), so retrying can never duplicate real work.
735
817
  * 2. Run-level retry on the SAME configured model (middle): when the provider
736
- * rejects the model before producing output with a TRANSIENT error
737
- * (503/429/timeout/network/stream/...) — i.e. NOT a terminal one (quota
738
- * exhausted, billing, an invalid API key, auth rejected see
739
- * isTerminalModelError) relaunch the whole run up to
740
- * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff, so a
741
- * one-off provider hiccup recovers without demoting the configured agent
742
- * model. Each relaunch gets its own inner startup-retry loop. This sits
743
- * outside pi-ai's per-request provider retry, which by then has already
744
- * tried (default 3 attempts) and given up.
745
- * 3. Model fallback (outer): when the same model is still failing after all its
746
- * run-level retries, retry once with the main window's current model. The
747
- * fallback gets its own startup-retry loop, since a startup race can hit any
748
- * relaunch regardless of model.
818
+ * rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
819
+ * NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
820
+ * RESUME the session on the same model up to
821
+ * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
822
+ * preserves any work done before the hiccup.
823
+ * 3. Model fallback (outer): when the same model still fails, RESUME the session
824
+ * once on the main window's current model the fallback inherits the prior
825
+ * context, so the user never pays to re-scan after a model switch.
749
826
  *
750
- * Terminal model errors short-circuit straight to the caller (modelRetries is
751
- * set): the account is the bottleneck, so neither same-model retry nor a
752
- * same-account fallback can help, and the run is left for the main agent to fix.
827
+ * Terminal model errors short-circuit to the caller: the account is the
828
+ * bottleneck, so neither same-model retry nor a same-account fallback can help.
829
+ * The session is preserved on disk (the result carries sessionId/sessionDir) so
830
+ * a later manual resume on a working model can continue without re-scanning.
753
831
  *
754
832
  * The fallback is per-run only and never persisted: a transient provider hiccup
755
833
  * must not silently downgrade the configured agent model.
@@ -802,55 +880,98 @@ export async function runSingleAgentWithModelFallback(
802
880
  }
803
881
  };
804
882
 
805
- let result = await runWithStartupRetry(options);
883
+ // One pi session backs the whole logical run, shared by the initial attempt
884
+ // and every resume (model-level retry / fallback), so a model switch inherits
885
+ // the prior context instead of re-scanning. Created here unless the caller
886
+ // passed one in (an explicit resume of a handed-back session).
887
+ const sessionId = options.sessionId ?? randomUUID();
888
+ const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
889
+ const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
806
890
 
807
- // After a model-level failure, classify before reacting. A TERMINAL error
808
- // (quota/billing/invalid key/auth) is account-scoped: neither same-model
809
- // retry nor a same-account fallback can help, so hand the run straight back
810
- // to the main agent instead of burning its time on a doomed retry.
811
- if (agent && launchedRef && isModelLevelFailure(result) && isTerminalModelError(result)) return result;
812
-
813
- // A TRANSIENT provider failure (503/429/timeout/network/stream/...) is usually
814
- // a one-off hiccup. Relaunch the SAME configured model up to runDelays.length
815
- // more times with backoff before degrading to a fallback model — the run's own
816
- // provider retry already tried and failed, so each relaunch here is an
817
- // independent, fresh attempt that can recover without losing the configured
818
- // model's capability to review.
819
891
  let modelRetries = 0;
820
- if (agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
821
- for (let attempt = 0; ; attempt++) {
822
- const delay = runDelays[attempt];
823
- if (delay === undefined) break;
824
- try {
825
- options.onLive?.({ kind: "status", status: "running" });
826
- } catch { /* never throw from event handling */ }
827
- const shouldRetry = await waitForStartupRetry(delay, options.signal);
828
- if (!shouldRetry) return { ...result, modelRetries };
829
- const retried = await runWithStartupRetry(options);
830
- modelRetries++;
831
- if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) {
832
- // Each relaunch redoes the work; failedTools reflect ONLY the final
833
- // attempt (no stale build errors from earlier transient failures),
834
- // so the completion message's claim stays accurate.
835
- return { ...retried, modelRetries };
892
+ let result: SingleResult | undefined;
893
+ try {
894
+ result = await runWithStartupRetry(baseOptions);
895
+
896
+ // A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
897
+ // neither a same-model retry nor a same-account fallback can help. Skip the
898
+ // automatic retry/fallback and hand the run back the session is preserved
899
+ // (see finally) so a later manual resume on a working model can continue
900
+ // without re-scanning.
901
+ const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
902
+
903
+ // A TRANSIENT provider failure (503/429/timeout/network/...) usually
904
+ // recovers on a relaunch. RESUME the same session on the same configured
905
+ // model up to runDelays.length more times with backoff — resuming (not
906
+ // restarting) preserves any work done before the hiccup. This sits outside
907
+ // pi-ai's per-request provider retry, which by then already tried and gave up.
908
+ if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
909
+ const retryOpts: RunSingleOptions = {
910
+ ...baseOptions,
911
+ stdinText: buildResumePrompt(options.task, "a transient provider error"),
912
+ };
913
+ for (let attempt = 0; ; attempt++) {
914
+ const delay = runDelays[attempt];
915
+ if (delay === undefined) break;
916
+ try {
917
+ options.onLive?.({ kind: "status", status: "running" });
918
+ } catch { /* never throw from event handling */ }
919
+ const shouldRetry = await waitForStartupRetry(delay, options.signal);
920
+ if (!shouldRetry) break;
921
+ const retried = await runWithStartupRetry(retryOpts);
922
+ modelRetries++;
923
+ // failedTools reflect ONLY the final attempt (each runSingleAgent call
924
+ // accumulates its own), so the completion message stays accurate.
925
+ result = retried;
926
+ if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
836
927
  }
837
- result = retried;
838
928
  }
839
- }
840
929
 
841
- // Same-model retries exhausted (or none configured) and still failing: fall
842
- // back to the main window's current model exactly once. Skipped when there is
843
- // no fallback ref or it equals the configured model a same-ref rerun would
844
- // just repeat the already-exhausted failure for nothing.
845
- if (agent && launchedRef && fallbackModelRef && launchedRef !== fallbackModelRef && isModelLevelFailure(result)) {
846
- const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
847
- // The fallback replaces the result wholesale: `retried.failedTools` reflect
848
- // ONLY the fallback (final) attempt. The original attempt's failedTools are
849
- // intentionally not merged — a fallback relaunch redoes the work, so attaching
850
- // the first attempt's stale build errors to a clean final attempt would
851
- // misattribute failures the worker already fixed. This makes the README's
852
- // "failed tool calls from the run's final attempt" claim accurate.
853
- return { ...retried, modelFallbackFrom: launchedRef, modelRetries };
930
+ // Transient retries exhausted (or none) and still a model-level failure:
931
+ // RESUME the session on the main window's current model exactly once. The
932
+ // fallback inherits the prior context (no re-scan). Skipped when there is no
933
+ // fallback ref, it equals the configured model, or the failure is terminal
934
+ // (a same-account fallback would fail identically leave it for manual resume).
935
+ if (
936
+ !terminal &&
937
+ agent &&
938
+ launchedRef &&
939
+ fallbackModelRef &&
940
+ launchedRef !== fallbackModelRef &&
941
+ isModelLevelFailure(result)
942
+ ) {
943
+ const retried = await runWithStartupRetry({
944
+ ...baseOptions,
945
+ stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
946
+ agent: { ...agent, model: fallbackModelRef },
947
+ });
948
+ // The fallback replaces the result wholesale: failedTools reflect ONLY the
949
+ // fallback (final) attempt — stale build errors from the first model are
950
+ // not merged, so a clean final attempt is never misattributed a failure.
951
+ result = { ...retried, modelFallbackFrom: launchedRef };
952
+ }
953
+
954
+ // A terminal failure takes the bare result (no retries or fallback ran, so
955
+ // modelRetries stays undefined — matching the contract callers assert).
956
+ return terminal ? result : { ...result, modelRetries };
957
+ } finally {
958
+ // Keep the session on disk only for a model-level failure that did real work
959
+ // and is being handed back, so a later `resume` can continue it. A provider
960
+ // rejection before any work (no messages/tools/output) has nothing to resume,
961
+ // so it is cleaned up along with every success and task-level failure. Never
962
+ // remove a caller-provided sessionDir (an explicit resume owns its dir).
963
+ if (result) {
964
+ result.sessionId ??= sessionId;
965
+ result.sessionDir ??= sessionDir;
966
+ }
967
+ const hasWork =
968
+ !!result &&
969
+ (result.messages.length > 1 ||
970
+ (result.failedTools?.length ?? 0) > 0 ||
971
+ Boolean(getFinalOutput(result.messages)));
972
+ const keep = !!result && isModelLevelFailure(result) && hasWork;
973
+ if (!keep && !options.sessionDir) {
974
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
975
+ }
854
976
  }
855
- return { ...result, modelRetries };
856
977
  }
package/src/tools.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  formatElapsed,
14
14
  formatUsageCompact,
15
15
  monitor,
16
+ runLabel,
16
17
  statusLabel,
17
18
  } from "./monitor.ts";
18
19
  import type { SubagentRuntime } from "./runtime.ts";
@@ -262,6 +263,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
262
263
  const activeLines = activeRuns.map((run) => {
263
264
  const parts = [
264
265
  `#${run.id} ${run.agent}`,
266
+ run.label,
265
267
  run.model ?? "?",
266
268
  formatUsageCompact(run.usage),
267
269
  formatElapsed(run, now),
@@ -271,7 +273,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
271
273
  const completed = [...runtime.settledRuns.entries()].slice(-5);
272
274
  const completedLines = completed.map(([id, result]) => {
273
275
  const usage = formatUsage(result.usage);
274
- return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
276
+ const label = runLabel(result.task);
277
+ return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
275
278
  });
276
279
 
277
280
  const sections: string[] = [];
package/src/widget.ts CHANGED
@@ -126,7 +126,11 @@ export function registerWidget(pi: ExtensionAPI, runtime: SubagentRuntime): void
126
126
  // uses the accent color, everything else is quiet.
127
127
  if (!isChain && lines.length > 0) lines.push("");
128
128
  const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
129
- const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}`;
129
+ // Content label (task-derived) trails the agent name so concurrent
130
+ // same-agent runs read as what they do, not just their run id. Chain
131
+ // nodes already carry a distinguishing relationLabel.
132
+ const labelPart = !isChain && r.label ? ` ${dim(`· ${r.label}`)}` : "";
133
+ const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}${labelPart}`;
130
134
 
131
135
  // Right side: full model ref (provider/model), token usage (in/out +
132
136
  // cache read/write), tool count, elapsed, and the soft activity-state