@d3ara1n/pi-subagent 3.2.0 → 3.2.2

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
@@ -1,5 +1,7 @@
1
1
  # @d3ara1n/pi-subagent
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@d3ara1n/pi-subagent)](https://www.npmjs.com/package/@d3ara1n/pi-subagent) [![npm downloads](https://img.shields.io/npm/dm/@d3ara1n/pi-subagent)](https://www.npmjs.com/package/@d3ara1n/pi-subagent) [![license](https://img.shields.io/npm/l/@d3ara1n/pi-subagent)](https://www.npmjs.com/package/@d3ara1n/pi-subagent)
4
+
3
5
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
6
 
5
7
  Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, with a per-run brief page for inputs and stats; mid-run corrections can be queued into a running subagent from the view's steer editor or via `subagent_steer`.
@@ -238,10 +240,10 @@ Three execution properties, kept separate:
238
240
  | Tool | Purpose | Returns to the model |
239
241
  |------|---------|---------------------|
240
242
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
241
- | `subagent_wait(ids?, timeout_ms?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | Statuses only, one `id (role): finished/failed` line per run never results; errors when the timeout hits with runs unfinished |
242
- | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output. Checking a terminal run **collects** it: the output is returned once and the run leaves the registry |
243
+ | `subagent_wait(ids?, timeout?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | A per-run roll call — one `id (role): state (turns, elapsed, tokens, cost)` line per run, never the output; on timeout, the same roll call (live runs carry usage so far) under a timeout header |
244
+ | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity, elapsed/budget, and usage so far / the **full output** with a usage footer once finished / failure reason + partial output + usage on failed or cancelled. Idempotent: re-delivers the same terminal snapshot the run stays in the registry for the whole session |
243
245
  | `subagent_steer(id, message)` | Queue a mid-run correction into one running run (typically right after a check revealed it heading down a wrong path) | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
244
- | `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation with the partial-output size the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message; the partial output stays in the registry for `subagent_check` to collect |
246
+ | `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation the same roll-call line wait uses, plus a pointer to check for the partial output; the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message |
245
247
 
246
248
  Typical flow:
247
249
 
@@ -265,9 +267,9 @@ Semantics worth knowing:
265
267
  - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists runs not yet checked on the active branch on every request, but it never pushes results.
266
268
  - **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
267
269
  - **Idempotent check, session-tree delivery state:** `subagent_check` re-delivers the same terminal snapshot on every call — runs stay in the registry for the whole session, so no result can ever be stranded by branch navigation or compaction. Whether a run still needs collecting is not tracked in the registry: it derives from the session tree itself. The session is append-only, so branching back past a check entry drops it from the active path — the inbox reminder re-arms and the model simply checks again (the id still resolves; the run is still there). Branching forward to the original branch restores the check entry and silences the reminder again.
268
- - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
270
+ - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled` with its usage stats.
269
271
  - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet checked on the active branch (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already checked on this branch — so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
270
- - **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
272
+ - **`timeout` (seconds) is optional omitting it is the normal usage.** `subagent_wait` blocks until every run finishes, with each run bounded by its own role timeout; subagent runs typically take minutes. Set a timeout only when the waiter must resume soon (e.g. to report progress to the user).
271
273
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
272
274
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
273
275
  - The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run and its current state.
@@ -324,7 +326,7 @@ Each path is injected as an independent `<file name="...">` block in the child's
324
326
 
325
327
  ### Budget enforcement
326
328
 
327
- `maxTurns` / `maxCost` cap a run. When exceeded, the child is killed and the last completed output is returned with `stopReason: "budget_exceeded"`. Budget stops are **intentional finishes** — the output is partial but valid: the TUI marks the run with a ⏲ line stating the reason, `subagent_wait` reports `finished (budget exceeded — output is partial)`, and the tool result (and `subagent_check`) append a `--- Budget exceeded (...) ---` note so the model knows to treat the output as partial. Defaults are unlimited (`0`); set global defaults in config or per-role overrides in `agentOverrides`. Negative values are normalized to `0`.
329
+ `maxTurns` / `maxCost` cap a run. When exceeded, the child is killed and the last completed output is returned with `stopReason: "budget_exceeded"`. Budget stops are **intentional finishes** — the output is partial but valid: the TUI marks the run with a ⏲ line stating the reason, and the tool result (and `subagent_check`) append a `--- Budget exceeded (...) ---` note so the model knows to treat the output as partial (`subagent_wait` shows them as plain `finished` with usage stats — the roll call carries no notes, check is the complete view). Defaults are unlimited (`0`); set global defaults in config or per-role overrides in `agentOverrides`. Negative values are normalized to `0`.
328
330
 
329
331
  ### Oversized outputs
330
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  formatCancelText,
31
31
  formatCheckText,
32
32
  formatFallbackNote,
33
+ formatRunLine,
33
34
  formatTimePart,
34
35
  formatUsageFooter,
35
36
  freezeFrame,
@@ -42,7 +43,7 @@ import { startSubagentRun, type RunHandle } from "./run.ts";
42
43
  import { buildInboxReminder, injectReminder } from "./reminder.ts";
43
44
  import { serializeInheritedConversation } from "./inheritance.ts";
44
45
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
45
- import { createViewPanel } from "./view.ts";
46
+ import { createViewPanel, filterDeliveredRuns } from "./view.ts";
46
47
  import {
47
48
  createSteerCallRender,
48
49
  renderBackgroundDelegateCall,
@@ -59,6 +60,11 @@ import {
59
60
 
60
61
  const BACKGROUND_COMPLETION_MESSAGE_TYPE = "subagent-completion";
61
62
 
63
+ /** Refresh ceiling for the /subagent:view delivered-ids cache (ms). The panel
64
+ * re-renders on every animation tick (~150ms); deriving delivery from the
65
+ * session tree is throttled so the overlay stays cheap. */
66
+ const VIEW_DELIVERED_CACHE_MS = 1000;
67
+
62
68
  // ── Extension entry ────────────────────────────────────────────────
63
69
 
64
70
  export default function subagentExtension(pi: ExtensionAPI) {
@@ -178,7 +184,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
178
184
  "BACKGROUND DELEGATION:",
179
185
  "",
180
186
  "- Use it only when you have your own work this turn (including an ongoing discussion with the user) while the run executes; otherwise let the call block and return the result directly.",
181
- "- Results are pull-only for the model — a completion notice is shown to the user, but nothing wakes you or delivers the result. Dispatching means owning the collection point: finish your own work, then subagent_check(id) for each result. Use subagent_wait(ids) to block until the run finish.",
187
+ "- Results are pull-only for the model — a completion notice is shown to the user, but nothing wakes you or delivers the result. Dispatching means owning the collection point: finish your own work, then subagent_check(id) for each result. Use subagent_wait(ids) to block until runs finish.",
182
188
  "- Cancel a run you no longer need with subagent_cancel(id) — the child stops and its partial output stays in the registry for subagent_check to collect.",
183
189
  "- Background delegation works only in the top-level session.",
184
190
  );
@@ -497,8 +503,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
497
503
  // of collapsing to a bare error line.
498
504
  if (isFailedResult(result)) {
499
505
  const failedText =
500
- `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
501
- fallbackNote;
506
+ `${params.role}: failed ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
507
+ fallbackNote +
508
+ formatUsageFooter(result);
502
509
  emit([result], failedText);
503
510
  return {
504
511
  content: [{ type: "text", text: failedText }],
@@ -506,7 +513,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
506
513
  };
507
514
  }
508
515
 
509
- const finalText = result.output + budgetNote + fallbackNote + formatUsageFooter(result);
516
+ const finalText = `${params.role}: finished\n\n${result.output}${budgetNote}${fallbackNote}${formatUsageFooter(result)}`;
510
517
  emit([result], finalText);
511
518
  return {
512
519
  content: [{ type: "text", text: finalText }],
@@ -543,7 +550,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
543
550
  name: "subagent_wait",
544
551
  label: "Wait for background subagents",
545
552
  description:
546
- "Block until one or more background subagents (started via subagent_delegate with background: true) finish. Omit ids to wait for ALL current background runs. Returns ONLY each run's final status — one `id (role): finished/failed` line per run, never the results; fetch them afterwards with subagent_check. If timeout_ms elapses before every run finishes, returns an error listing per-run statuses. Cancelling the wait never cancels the runs — to stop a run, use subagent_cancel(id).",
553
+ "Block until one or more background subagents (started via subagent_delegate with background: true) finish. Omit ids to wait for ALL current background runs. Returns a per-run roll call — one `id (role): state (turns, elapsed, tokens, cost)` line per run, never the output; fetch it afterwards with subagent_check. If timeout elapses before every run finishes, returns the same roll call (live runs carry usage so far) under a timeout header. Cancelling the wait never cancels the runs — to stop a run, use subagent_cancel(id).",
547
554
  promptSnippet: "Wait for background subagents to finish",
548
555
  parameters: Type.Object({
549
556
  ids: Type.Optional(
@@ -553,10 +560,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
553
560
  "Run ids returned by background delegate calls. Omit to wait for all current background runs.",
554
561
  }),
555
562
  ),
556
- timeout_ms: Type.Optional(
563
+ timeout: Type.Optional(
557
564
  Type.Number({
558
565
  description:
559
- "Max time to wait in milliseconds. Omit to wait until all runs finish (each run still has its own role timeout).",
566
+ "Max time to wait in seconds. Subagent runs typically take minutes — omit this and let the wait block until they finish (each run's own role timeout is the ceiling); that is the normal usage. Set it only when you must resume soon, e.g. to report progress to the user.",
560
567
  }),
561
568
  ),
562
569
  }),
@@ -576,8 +583,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
576
583
  );
577
584
  }
578
585
  const runs = ids.map((id) => backgroundRuns.get(id)!);
579
- const timeoutMs =
580
- typeof params.timeout_ms === "number" && params.timeout_ms > 0 ? params.timeout_ms : 0;
586
+ const timeoutMs = typeof params.timeout === "number" && params.timeout > 0 ? params.timeout * 1000 : 0;
581
587
 
582
588
  // ── Live mirror: forward combined snapshots into this tool row ──
583
589
  const entries = () => runs.map((r) => ({ id: r.id, role: r.role, result: r.snapshot }));
@@ -642,20 +648,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
642
648
  for (const u of unsubscribers) u();
643
649
  }
644
650
 
645
- // Status line per run — same `id (role): state` shape check uses, so
646
- // the model can map ids to roles from wait output alone. Budget stops
647
- // report "finished" but their output is partial; cancelled runs keep
648
- // their partial output in the registry for check flag both inline.
649
- const perId = () =>
650
- runs
651
- .map((r) =>
652
- r.result?.stopReason === "budget_exceeded"
653
- ? `${r.id} (${r.role}): ${r.state} (budget exceeded — output is partial)`
654
- : r.result?.stopReason === "cancelled"
655
- ? `${r.id} (${r.role}): cancelled (partial output kept)`
656
- : `${r.id} (${r.role}): ${r.state}`,
657
- )
658
- .join("\n");
651
+ // One roll-call line per run — the same `id (role): state (stats)`
652
+ // shape cancel uses, so the model can map ids to roles and gauge
653
+ // task scale from wait output alone; the output itself stays
654
+ // check's to deliver. Timeout reuses the same lines: live runs carry
655
+ // their so-far usage.
656
+ const perId = () => runs.map((r) => formatRunLine(r.id, r.role, r.snapshot)).join("\n");
659
657
  if (timedOut) {
660
658
  const unfinished = runs.filter((r) => r.state === "queued" || r.state === "running");
661
659
  const text =
@@ -680,7 +678,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
680
678
  name: "subagent_check",
681
679
  label: "Check a background subagent",
682
680
  description:
683
- "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
681
+ "Get an instant snapshot of ONE background subagent run: queued / running (with current activity, elapsed/budget, and usage so far) / finished (with the full output and usage) / failed or cancelled (with reason, partial output, and usage). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
684
682
  promptSnippet: "Inspect a background subagent run",
685
683
  parameters: Type.Object({
686
684
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -818,7 +816,24 @@ export default function subagentExtension(pi: ExtensionAPI) {
818
816
  handler: async (_args, ctx) => {
819
817
  // Union of every known run: the background registry plus live
820
818
  // in-flight runs (foreground delegate calls included). Dedupe by id —
821
- // background runs appear in both.
819
+ // background runs appear in both. Terminal runs stay listed until
820
+ // their result is delivered (subagent_check on the active branch,
821
+ // derived from the session tree — same source of truth as the inbox
822
+ // reminder), then leave the view: it is for live watching and pending
823
+ // collection, not an archive. Delivery re-derivation is throttled
824
+ // because the panel re-renders on every animation tick.
825
+ const deliveredCache = { ids: new Set<string>(), at: 0 };
826
+ const deliveredIds = (): Set<string> => {
827
+ if (Date.now() - deliveredCache.at > VIEW_DELIVERED_CACHE_MS) {
828
+ try {
829
+ deliveredCache.ids = collectDeliveredIds(ctx.sessionManager.buildContextEntries());
830
+ } catch {
831
+ /* keep the previous set */
832
+ }
833
+ deliveredCache.at = Date.now();
834
+ }
835
+ return deliveredCache.ids;
836
+ };
822
837
  const runsProvider = () => {
823
838
  const seen = new Set<string>();
824
839
  const out: RunHandle[] = [];
@@ -828,7 +843,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
828
843
  out.push(r);
829
844
  }
830
845
  }
831
- return out;
846
+ return filterDeliveredRuns(out, deliveredIds());
832
847
  };
833
848
  if (runsProvider().length === 0) {
834
849
  ctx.ui.notify("No subagent runs yet.", "info");
@@ -1035,8 +1050,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
1035
1050
  const result = await run.promise;
1036
1051
  lines.push(
1037
1052
  result.usage.turns > 0 || result.output
1038
- ? `\u2717 ${run.id} (${run.role}): cancelled after ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"} — partial output kept (subagent_check / history)`
1039
- : `\u2717 ${run.id} (${run.role}): cancelled (nothing had run yet)`,
1053
+ ? `✗ ${formatRunLine(run.id, run.role, result)} — partial output kept`
1054
+ : `✗ ${formatRunLine(run.id, run.role, result)}`,
1040
1055
  );
1041
1056
  }
1042
1057
  ctx.ui.notify(lines.join("\n"), "info");
@@ -341,11 +341,11 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
341
341
 
342
342
  export const renderWaitCall: RenderCallFn = (args, theme) => {
343
343
  const ids = ((args as any).ids as string[] | undefined) ?? [];
344
- const timeoutMs = ((args as any).timeout_ms as number | undefined) ?? 0;
344
+ const timeoutSec = ((args as any).timeout as number | undefined) ?? 0;
345
345
  const label = ids.length > 0 ? ids.join(", ") : "(all)";
346
346
  // Show the wait ceiling up front — the user should know how long this row
347
347
  // can block before it gives up. Omitted timeout = wait until runs finish.
348
- const cap = timeoutMs > 0 ? ` \u2264${Math.max(1, Math.round(timeoutMs / 1000))}s` : "";
348
+ const cap = timeoutSec > 0 ? ` \u2264${Math.max(1, Math.round(timeoutSec))}s` : "";
349
349
  const text =
350
350
  theme.fg("toolTitle", theme.bold("subagent_wait ")) +
351
351
  theme.fg("accent", label) +
package/src/utils.test.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  formatBudgetNote,
38
38
  formatCheckText,
39
39
  formatCancelText,
40
+ formatRunLine,
40
41
  formatTimePart,
41
42
  freezeFrame,
42
43
  createThrottler,
@@ -654,13 +655,14 @@ describe("background run helpers", () => {
654
655
  assert.equal(describeCurrentActivity(withTool), "$ ls");
655
656
  });
656
657
 
657
- test("formatUsageFooter renders turns, tokens, cost, and model", () => {
658
+ test("formatUsageFooter renders turns, elapsed, tokens, cost, and model", () => {
658
659
  assert.equal(formatUsageFooter(baseResult()), "");
659
660
  const r = baseResult({
661
+ elapsedMs: 135000,
660
662
  usage: { input: 1200, output: 300, cacheRead: 0, cacheWrite: 0, cost: 0.5, contextTokens: 0, turns: 2 },
661
663
  model: "test/model-x",
662
664
  });
663
- assert.equal(formatUsageFooter(r), "\n\n--- 2 turns \u21911.2k \u2193300 $0.5000 test/model-x ---");
665
+ assert.equal(formatUsageFooter(r), "\n\n--- 2 turns ~135s \u21911.2k \u2193300 $0.5000 test/model-x ---");
664
666
  });
665
667
 
666
668
  test("formatUsageStats adds cache and peak-context figures the footer omits", () => {
@@ -671,7 +673,7 @@ describe("background run helpers", () => {
671
673
  );
672
674
  // Footer stays lean — no cache/context parts.
673
675
  assert.equal(
674
- formatUsageFooter({ usage, model: "test/model-x" }),
676
+ formatUsageFooter(baseResult({ usage, model: "test/model-x" })),
675
677
  "\n\n--- 2 turns \u21911.2k \u2193300 $0.5000 test/model-x ---",
676
678
  );
677
679
  });
@@ -706,45 +708,84 @@ describe("background run helpers", () => {
706
708
  assert.match(formatCheckText("sub-1", "explorer", baseResult()), /^sub-1 \(explorer\): finished\n\nok$/);
707
709
  });
708
710
 
709
- test("formatCheckText renders cancelled with the bare reason and partial output", () => {
711
+ test("formatCheckText running carries elapsed/budget in the head and a usage footer", () => {
712
+ const r = runningFrame();
713
+ r.startTime = Date.now() - 135000;
714
+ r.budgetMs = 300000;
715
+ r.usage = { input: 8000, output: 900, cacheRead: 0, cacheWrite: 0, cost: 0.02, contextTokens: 0, turns: 2 };
716
+ const text = formatCheckText("sub-1", "explorer", r);
717
+ assert.match(text, /^sub-1 \(explorer\): running — waiting for first event \(135s\/300s\)/);
718
+ assert.match(text, /\n\n--- 2 turns \u21918.0k \u2193900 \$0\.0200 ---$/);
719
+ });
720
+
721
+ test("formatRunLine is the shared roll-call line for wait and cancel", () => {
722
+ assert.equal(formatRunLine("sub-1", "explorer", queuedFrame()), "sub-1 (explorer): queued");
723
+ assert.equal(formatRunLine("sub-1", "explorer", baseResult()), "sub-1 (explorer): finished");
724
+ const finished = baseResult({
725
+ elapsedMs: 120000,
726
+ usage: { input: 12000, output: 1000, cacheRead: 0, cacheWrite: 0, cost: 0.01, contextTokens: 0, turns: 3 },
727
+ });
728
+ assert.equal(
729
+ formatRunLine("sub-1", "explorer", finished),
730
+ "sub-1 (explorer): finished (3 turns ~120s \u219112k \u21931.0k $0.0100)",
731
+ );
732
+ });
733
+
734
+ test("formatCheckText renders cancelled with reason, partial output, and usage", () => {
710
735
  assert.match(
711
736
  formatCheckText(
712
737
  "sub-1",
713
738
  "explorer",
714
- baseResult({ exitCode: 1, stopReason: "cancelled", errorMessage: "user: wrong direction" }),
739
+ baseResult({
740
+ exitCode: 1,
741
+ stopReason: "cancelled",
742
+ errorMessage: "user: wrong direction",
743
+ elapsedMs: 45000,
744
+ usage: { input: 1000, output: 200, cacheRead: 0, cacheWrite: 0, cost: 0.1, contextTokens: 0, turns: 3 },
745
+ }),
715
746
  ),
716
- /^sub-1 \(explorer\): cancelled — user: wrong direction\n\nPartial output:\nok$/,
747
+ /^sub-1 \(explorer\): cancelled — user: wrong direction\n\nPartial output:\nok\n\n--- 3 turns ~45s \u21911.0k \u2193200 \$0\.1000 ---$/,
748
+ );
749
+ // Queue-time cancel: never spawned — nothing to show past the head.
750
+ assert.equal(
751
+ formatCheckText(
752
+ "sub-2",
753
+ "worker",
754
+ baseResult({ exitCode: 1, stopReason: "cancelled", errorMessage: "still queued (user: x)" }),
755
+ ),
756
+ "sub-2 (worker): cancelled — never started",
717
757
  );
718
758
  });
719
759
 
720
760
  test("formatCancelText distinguishes never-started cancels from mid-run cancels", () => {
721
761
  // Never-started cancel (gate/model-resolution phase): terminal frame has
722
- // NO elapsedMs — nothing ran. Mirrors the real terminal-frame shape:
723
- // startTime never survives (live-frame-only field), duration freezes
724
- // into elapsedMs.
725
- assert.match(
726
- formatCancelText("sub-1", "explorer", baseResult({ exitCode: -1, output: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } })),
727
- /^sub-1 \(explorer\): cancelled — never started, no partial output\. subagent_check\(sub-1\) clears the registry entry\.$/,
762
+ // no elapsedMs and zero usage — nothing ran. Mirrors the real terminal
763
+ // shape (run.ts settles aborts via inputFrame(1, false)).
764
+ assert.equal(
765
+ formatCancelText("sub-1", "explorer", baseResult({ exitCode: 1, stopReason: "cancelled", output: "" })),
766
+ "sub-1 (explorer): cancelled never started",
728
767
  );
729
768
  // Mid-run cancel: real terminal shape — elapsedMs frozen, no startTime.
730
769
  const midRun = baseResult({
731
- exitCode: -1,
770
+ exitCode: 1,
771
+ stopReason: "cancelled",
732
772
  elapsedMs: 45000,
733
773
  output: "partial findings",
734
774
  usage: { input: 1000, output: 200, cacheRead: 0, cacheWrite: 0, cost: 0.1, contextTokens: 0, turns: 3 },
735
775
  });
736
- assert.match(
776
+ assert.equal(
737
777
  formatCancelText("sub-2", "worker", midRun),
738
- /^sub-2 \(worker\): cancelled after 3 turns \(~45s\) — partial output \(16 chars\) kept in the registry; subagent_check\(sub-2\) returns it once\.$/,
778
+ "sub-2 (worker): cancelled (3 turns ~45s \u21911.0k \u2193200 $0.1000) — partial output kept; subagent_check(sub-2) returns it.",
739
779
  );
740
- // Aborted before the first completed turn: "under a turn", not "0 turns".
780
+ // Aborted before the first completed turn: elapsed-only stats.
741
781
  const underATurn = baseResult({
742
- exitCode: -1,
782
+ exitCode: 1,
783
+ stopReason: "cancelled",
743
784
  elapsedMs: 2000,
744
785
  output: "",
745
- usage: { input: 1000, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0.05, contextTokens: 0, turns: 0 },
786
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
746
787
  });
747
- assert.match(formatCancelText("sub-3", "worker", underATurn), /cancelled after under a turn \(~2s\)/);
788
+ assert.equal(formatCancelText("sub-3", "worker", underATurn), "sub-3 (worker): cancelled (~2s)");
748
789
  });
749
790
 
750
791
  test("formatCheckText flags budget-stopped runs as partial on the finished line", () => {
package/src/utils.ts CHANGED
@@ -45,9 +45,8 @@ export function formatInheritedConversationInput(chars: number, truncated: boole
45
45
  }
46
46
 
47
47
  /**
48
- * Usage parts shared by the TUI stats line and the LLM usage footer.
49
- * `withCache` adds the cache-read/write and peak-context figures (TUI only
50
- * the LLM footer stays lean).
48
+ * Usage parts for the TUI stats line. `withCache` adds the
49
+ * cache-read/write and peak-context figures (TUI only).
51
50
  */
52
51
  function usageParts(usage: SubagentUsage, model: string | undefined, withCache: boolean): string[] {
53
52
  const parts: string[] = [];
@@ -63,6 +62,31 @@ function usageParts(usage: SubagentUsage, model: string | undefined, withCache:
63
62
  if (model) parts.push(model);
64
63
  return parts;
65
64
  }
65
+ /**
66
+ * Usage-stat segments for every LLM-facing run line: turns, elapsed,
67
+ * tokens, cost — model last when requested. Empty segments are omitted; an
68
+ * empty array means nothing measurable ran. `withElapsed` is off where
69
+ * elapsed already lives elsewhere in the same view (check's running head
70
+ * shows it as `42s/900s` next to the activity).
71
+ */
72
+ function statsParts(
73
+ r: { usage: SubagentUsage; exitCode: number; startTime?: number; elapsedMs?: number },
74
+ opts: { withElapsed?: boolean; model?: string } = {},
75
+ ): string[] {
76
+ const parts: string[] = [];
77
+ if (r.usage.turns) parts.push(`${r.usage.turns} turn${r.usage.turns > 1 ? "s" : ""}`);
78
+ if (opts.withElapsed) {
79
+ const secs = elapsedSeconds(r);
80
+ if (secs != null && secs > 0) parts.push(`~${secs}s`);
81
+ }
82
+ if (r.usage.input) parts.push(`↑${formatTokens(r.usage.input)}`);
83
+ if (r.usage.output) parts.push(`↓${formatTokens(r.usage.output)}`);
84
+ if (r.usage.cost) parts.push(`$${r.usage.cost.toFixed(4)}`);
85
+ // The model is an annotation on the stats, never a stat itself — skip it
86
+ // when nothing measurable ran.
87
+ if (opts.model && parts.length > 0) parts.push(opts.model);
88
+ return parts;
89
+ }
66
90
 
67
91
  export function formatUsageStats(usage: SubagentUsage, model?: string): string {
68
92
  return usageParts(usage, model, true).join(" ");
@@ -602,12 +626,32 @@ export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): st
602
626
  return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
603
627
  }
604
628
 
605
- /** Footer appended to terminal results for the main model: `\n\n--- 3 turns ↑12k ↓1k $0.01 model ---` (empty when nothing to show). */
606
- export function formatUsageFooter(r: { usage: SubagentUsage; model?: string }): string {
607
- const parts = usageParts(r.usage, r.model, false);
629
+ /**
630
+ * Terminal-result footer for full views (check, foreground delegate):
631
+ * `\n\n--- 3 turns ~45s ↑12k ↓1k $0.01 model ---` (empty when nothing to show).
632
+ */
633
+ export function formatUsageFooter(r: SubagentResult): string {
634
+ const parts = statsParts(r, { withElapsed: true, model: r.model });
608
635
  return parts.length > 0 ? `\n\n--- ${parts.join(" ")} ---` : "";
609
636
  }
610
637
 
638
+ /**
639
+ * One-line roll-call status shared by wait's per-run lines and cancel's
640
+ * confirmation: `id (role): state (stats)` — state plus usage stats (turns,
641
+ * elapsed, tokens, cost), nothing else; no output, no model, no notes.
642
+ * check is the complete view.
643
+ */
644
+ export function formatRunLine(id: string, role: string, r: SubagentResult): string {
645
+ const state = r.stopReason === "cancelled" ? "cancelled" : deriveRunState(r);
646
+ const head = `${id} (${role}): ${state}`;
647
+ // Queue-time cancels never spawned: nothing measurable ran.
648
+ if (state === "cancelled" && r.elapsedMs == null && !r.usage.turns) {
649
+ return `${head} — never started`;
650
+ }
651
+ const parts = statsParts(r, { withElapsed: true });
652
+ return parts.length > 0 ? `${head} (${parts.join(" ")})` : head;
653
+ }
654
+
611
655
  /** Fallback provenance note appended to terminal results (empty when no retry happened). */
612
656
  export function formatFallbackNote(r: { fallbackFrom?: FallbackFrom; model?: string }): string {
613
657
  return r.fallbackFrom
@@ -631,27 +675,40 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
631
675
  const state = deriveRunState(r);
632
676
  const head = `${id} (${role})`;
633
677
  if (state === "queued") return `${head}: queued — waiting for a concurrency slot.`;
634
- if (state === "running") return `${head}: running — ${describeCurrentActivity(r)}`;
678
+ if (state === "running") {
679
+ // Elapsed/budget live in the head next to the activity (`42s/900s`); the
680
+ // footer carries turns/tokens/cost so elapsed is never shown twice.
681
+ const time = formatTimePart(r);
682
+ const parts = statsParts(r, { model: r.model });
683
+ return (
684
+ `${head}: running — ${describeCurrentActivity(r)}${time ? ` (${time})` : ""}` +
685
+ (parts.length > 0 ? `\n\n--- ${parts.join(" ")} ---` : "")
686
+ );
687
+ }
635
688
  if (r.stopReason === "cancelled") {
636
689
  // errorMessage is the bare abort reason ("user: ..." / "session shutdown")
637
690
  // — the "cancelled" prefix here is the only wrapper it gets.
638
- return `${head}: cancelled — ${r.errorMessage || "no reason recorded"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
691
+ if (r.elapsedMs == null && !r.usage.turns) return `${head}: cancelled — never started`;
692
+ return (
693
+ `${head}: cancelled — ${r.errorMessage || "no reason recorded"}\n\nPartial output:\n${r.output}` +
694
+ formatFallbackNote(r) +
695
+ formatUsageFooter(r)
696
+ );
639
697
  }
640
698
  if (state === "failed") {
641
- return `${head}: failed — ${r.errorMessage || r.stderr || "unknown error"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
699
+ return (
700
+ `${head}: failed — ${r.errorMessage || r.stderr || "unknown error"}\n\nPartial output:\n${r.output}` +
701
+ formatFallbackNote(r) +
702
+ formatUsageFooter(r)
703
+ );
642
704
  }
643
705
  return `${head}: finished\n\n${r.output}${formatBudgetNote(r)}${formatFallbackNote(r)}${formatUsageFooter(r)}`;
644
706
  }
645
707
 
646
708
  /**
647
- * Cancel confirmation text for the /subagent:cancel command: short, no output
648
- * dump the partial output is check's job to return. Always points at check
649
- * so the user knows where the partial output lives.
650
- */
651
- /**
652
- * Compact stop summary shared by the cancel tool text and its TUI row:
653
- * `cancelled after 3 turns (~45s)` / `cancelled after under a turn (~2s)` /
654
- * `cancelled — never started` (never spawned: no elapsedMs on the frame).
709
+ * Compact stop summary for the cancel TUI row: `cancelled after 3 turns
710
+ * (~45s)` / `cancelled after under a turn (~2s)` / `cancelled never
711
+ * started` (never spawned: no elapsedMs on the frame).
655
712
  */
656
713
  export function cancelStopSummary(r: SubagentResult): string {
657
714
  if (r.elapsedMs == null) return "cancelled — never started";
@@ -662,19 +719,15 @@ export function cancelStopSummary(r: SubagentResult): string {
662
719
  }
663
720
 
664
721
  /**
665
- * Cancel confirmation text: short, no output dump the partial output is
666
- * check's job to return. Always points at check so the model fetches the
667
- * partial output it is entitled to.
722
+ * Cancel confirmation: the same roll-call line wait uses (state + usage
723
+ * stats) plus a pointer to check cancel never dumps the partial output
724
+ * itself (layer contract: cancel intervenes, check fetches).
668
725
  */
669
726
  export function formatCancelText(id: string, role: string, r: SubagentResult): string {
670
- const head = `${id} (${role})`;
671
- if (r.elapsedMs == null) {
672
- return `${head}: cancelled never started, no partial output. subagent_check(${id}) clears the registry entry.`;
673
- }
674
- return (
675
- `${head}: ${cancelStopSummary(r)} — partial output (${r.output.length} chars) ` +
676
- `kept in the registry; subagent_check(${id}) returns it once.`
677
- );
727
+ const line = formatRunLine(id, role, r);
728
+ return r.usage.turns > 0 || r.output.length > 0
729
+ ? `${line} — partial output kept; subagent_check(${id}) returns it.`
730
+ : line;
678
731
  }
679
732
 
680
733
  /** Freeze a live frame into a static snapshot: stop the elapsed clock and fold the open pause into grace. */
@@ -0,0 +1,64 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert";
3
+ import {
4
+ filterDeliveredRuns,
5
+ inheritedConversationFields,
6
+ sortViewRuns,
7
+ } from "./view.ts";
8
+ import type { RunHandle } from "./run.ts";
9
+
10
+ // ── Fakes ──────────────────────────────────────────────────────────
11
+
12
+ function fakeHandle(id: string, state: RunHandle["state"]): RunHandle {
13
+ return { id, state } as unknown as RunHandle;
14
+ }
15
+
16
+ // ── filterDeliveredRuns ────────────────────────────────────────────
17
+
18
+ test("filterDeliveredRuns drops delivered terminal runs, keeps live and undelivered", () => {
19
+ const running = fakeHandle("sub-1", "running");
20
+ const queued = fakeHandle("sub-2", "queued");
21
+ const finishedUndelivered = fakeHandle("sub-3", "finished");
22
+ const finishedDelivered = fakeHandle("sub-4", "finished");
23
+ const failedDelivered = fakeHandle("sub-5", "failed");
24
+
25
+ const out = filterDeliveredRuns(
26
+ [running, queued, finishedUndelivered, finishedDelivered, failedDelivered],
27
+ new Set(["sub-4", "sub-5"]),
28
+ );
29
+
30
+ assert.deepEqual(
31
+ out.map((r) => r.id),
32
+ ["sub-1", "sub-2", "sub-3"],
33
+ );
34
+ });
35
+
36
+ // ── inheritedConversationFields ────────────────────────────────────
37
+
38
+ test("inheritedConversationFields returns aligned-view metadata without body text", () => {
39
+ assert.deepEqual(inheritedConversationFields(50_000, true), [
40
+ ["inherited", "yes"],
41
+ ["size", "50k chars"],
42
+ ["truncated", "yes"],
43
+ ]);
44
+ assert.deepEqual(inheritedConversationFields(0, false), [
45
+ ["inherited", "yes"],
46
+ ["size", "0 chars"],
47
+ ["truncated", "no"],
48
+ ]);
49
+ });
50
+
51
+ // ── sortViewRuns ───────────────────────────────────────────────────
52
+
53
+ test("sortViewRuns ranks running before finished, each group by id", () => {
54
+ const out = sortViewRuns([
55
+ fakeHandle("sub-3", "finished"),
56
+ fakeHandle("sub-2", "running"),
57
+ fakeHandle("sub-4", "running"),
58
+ fakeHandle("sub-1", "failed"),
59
+ ]);
60
+ assert.deepEqual(
61
+ out.map((r) => r.id),
62
+ ["sub-2", "sub-4", "sub-1", "sub-3"],
63
+ );
64
+ });
package/src/view.ts CHANGED
@@ -23,8 +23,8 @@
23
23
  * Steer input is modal so keys never conflict with the editor: browse mode
24
24
  * owns navigation; `s` opens the editor (Enter sends and returns to browse,
25
25
  * Esc cancels and clears). Esc in browse closes the overlay; Tab cycles
26
- * the focused run and resets scrolls (activity re-pins, brief returns to
27
- * the top).
26
+ * the focused run and resets its view page back to activity, scrolls
27
+ * re-pinned.
28
28
  *
29
29
  * Layout: a centered screen overlay (overlay:true) occupying most of the
30
30
  * terminal, framed with a thin border. An embedded Editor accepts steering
@@ -52,7 +52,6 @@ import type { ActivityEntry } from "./types.ts";
52
52
  import {
53
53
  briefFilesUsed,
54
54
  formatFallback,
55
- formatInheritedConversationInput,
56
55
  formatThinking,
57
56
  formatTimePart,
58
57
  formatToolCall,
@@ -119,6 +118,33 @@ export function sortViewRuns(runs: RunHandle[]): RunHandle[] {
119
118
  return [...runs].sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
120
119
  }
121
120
 
121
+ /**
122
+ * Drop terminal runs whose result is already in the conversation — delivered
123
+ * by subagent_check on the active branch (the same session-tree source of
124
+ * truth as the inbox reminder). Live runs and undelivered terminal runs
125
+ * stay: the view is for live watching and pending collection, not an
126
+ * archive.
127
+ */
128
+ export function filterDeliveredRuns(runs: RunHandle[], delivered: Set<string>): RunHandle[] {
129
+ return runs.filter(
130
+ (r) => (r.state !== "finished" && r.state !== "failed") || !delivered.has(r.id),
131
+ );
132
+ }
133
+
134
+ /**
135
+ * @internal — exported for testing; formats the metadata shown in the brief view.
136
+ */
137
+ export function inheritedConversationFields(
138
+ chars: number,
139
+ truncated: boolean,
140
+ ): Array<[label: string, value: string]> {
141
+ return [
142
+ ["inherited", "yes"],
143
+ ["size", `${formatTokens(chars)} chars`],
144
+ ["truncated", truncated ? "yes" : "no"],
145
+ ];
146
+ }
147
+
122
148
  export class SubagentViewPanel implements Component, Focusable {
123
149
  focused = true;
124
150
 
@@ -180,8 +206,8 @@ export class SubagentViewPanel implements Component, Focusable {
180
206
  }
181
207
 
182
208
  /** Resolve the focused run by id; stable across sort-order reshuffles
183
- * (e.g. a run finishing re-ranks the list). Scroll state resets only when
184
- * the focused run actually changes. */
209
+ * (e.g. a run finishing re-ranks the list). The focused run's view state
210
+ * (page + scrolls) resets only when the focused run actually changes. */
185
211
  private focusedRun(): RunHandle | undefined {
186
212
  const runs = sortViewRuns(this.runsProvider());
187
213
  if (runs.length === 0) {
@@ -192,12 +218,15 @@ export class SubagentViewPanel implements Component, Focusable {
192
218
  if (!run) {
193
219
  run = runs[0];
194
220
  this.focusId = run.id;
195
- this.resetScrolls();
221
+ this.resetRunView();
196
222
  }
197
223
  return run;
198
224
  }
199
225
 
200
- private resetScrolls(): void {
226
+ /** Reset the focused run's view state: page back to activity, activity
227
+ * pinned to the end (auto-follow), brief at the top. */
228
+ private resetRunView(): void {
229
+ this.page = "activity";
201
230
  this.activityTop = null;
202
231
  this.briefTop = 0;
203
232
  }
@@ -206,10 +235,12 @@ export class SubagentViewPanel implements Component, Focusable {
206
235
  const runs = sortViewRuns(this.runsProvider());
207
236
  if (runs.length < 2) return;
208
237
  const idx = runs.findIndex((r) => r.id === this.focusId);
209
- const next = runs[((idx >= 0 ? idx : 0) + 1) % runs.length];
238
+ // Focus id gone from the list: fall back to the first run, same as
239
+ // focusedRun() — not to the second.
240
+ const next = idx < 0 ? runs[0] : runs[(idx + 1) % runs.length];
210
241
  if (next.id === this.focusId) return;
211
242
  this.focusId = next.id;
212
- this.resetScrolls();
243
+ this.resetRunView();
213
244
  this.tui.requestRender();
214
245
  }
215
246
 
@@ -365,12 +396,15 @@ export class SubagentViewPanel implements Component, Focusable {
365
396
  }
366
397
 
367
398
  if (run.inheritConversation) {
368
- section(
369
- formatInheritedConversationInput(
370
- run.inheritedConversationChars ?? 0,
371
- run.inheritedConversationTruncated === true,
372
- ),
399
+ section("conversation");
400
+ const fields = inheritedConversationFields(
401
+ run.inheritedConversationChars ?? 0,
402
+ run.inheritedConversationTruncated === true,
373
403
  );
404
+ const labelWidth = Math.max(...fields.map(([label]) => label.length));
405
+ for (const [label, value] of fields) {
406
+ lines.push(` ${fg("dim", label.padEnd(labelWidth))} ${fg("accent", value)}`);
407
+ }
374
408
  }
375
409
 
376
410
  if (run.files && run.files.length > 0) {