@d3ara1n/pi-subagent 3.1.0 → 3.2.1

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`.
@@ -41,13 +43,13 @@ Observations on how main models behave with this plugin, one family per subsecti
41
43
  | Role | Model Role | Timeout | Tools | Can Delegate To | Description |
42
44
  |------|-----------|---------|-------|-----------------|-------------|
43
45
  | `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
44
- | `reviewer` | heavy | 3600s | read, bash, grep, find | | Deep code review, runs git/tests for evidence (read-only) |
46
+ | `reviewer` | heavy | 3600s | read, bash, grep, find, subagent_delegate | explorer, researcher | Deep code review, runs git/tests for evidence (read-only); delegates exploration & web verification |
45
47
  | `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
46
- | `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
48
+ | `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, subagent_delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
47
49
 
48
50
  **Web tool naming**: `researcher`'s web tools use the community-standard names (`web_search`, `fetch_content`, `source_check`, `get_search_content`) shared by the most popular Pi web extensions — [pi-web-access](https://github.com/nicobailon/pi-web-access), `pi-web-tools`, `pi-browse`, and others. Install any of those and the researcher gets web access out of the box. If your web extension uses different tool names (e.g. `websearch`/`webfetch`) or you renamed the tools via a `toolNames` config, override `researcher.tools` in `agentOverrides` to match.
49
51
 
50
- **Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
52
+ **Nested delegation**: `worker`, `reviewer`, and `researcher` can spawn their own subagents. This keeps the caller's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results, and a reviewer can verify third-party library behavior via a `researcher` subagent without leaving the diff.
51
53
 
52
54
  **Parallel execution**: To run multiple subagents concurrently, emit multiple `subagent_delegate` calls in a single turn. Pi's framework executes them in parallel automatically, with each subagent getting its own TUI progress display.
53
55
 
@@ -134,6 +136,8 @@ Timeouts are defined per role. Built-in defaults are `explorer: 900`, `reviewer:
134
136
 
135
137
  Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
136
138
 
139
+ The built-in roles are defined in [`src/roles.ts`](src/roles.ts) — read them as reference templates when overriding. Each entry shows the exact shape and wording of every field (`role`, `description`, `examples`, `decisionTrigger`, `tools`/`subagentRoles`, `systemPrompt`, `timeout`, `fallbackRole`), so you can copy the built-in closest to what you want, paste it under `agentOverrides`, and adjust from a known-good starting point rather than writing a role from scratch.
140
+
137
141
  ```json
138
142
  {
139
143
  "subagent": {
@@ -172,7 +176,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
172
176
 
173
177
  Configuring both on the same role is an error — the role is skipped with an error notification at session start.
174
178
 
175
- **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
179
+ **Optional fields:** `subagentRoles` (roles this role can spawn via delegate; absent means any available role, mirroring the `tools` default — declare it explicitly when a restricted role grants `subagent_delegate`), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
176
180
 
177
181
  Invalid custom roles (missing required fields) are skipped with an error notification at session start.
178
182
 
@@ -236,10 +240,10 @@ Three execution properties, kept separate:
236
240
  | Tool | Purpose | Returns to the model |
237
241
  |------|---------|---------------------|
238
242
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
239
- | `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 |
240
- | `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 |
241
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 |
242
- | `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 |
243
247
 
244
248
  Typical flow:
245
249
 
@@ -263,9 +267,9 @@ Semantics worth knowing:
263
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.
264
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.
265
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.
266
- - **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.
267
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.
268
- - **`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).
269
273
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
270
274
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
271
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.
@@ -322,7 +326,7 @@ Each path is injected as an independent `<file name="...">` block in the child's
322
326
 
323
327
  ### Budget enforcement
324
328
 
325
- `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`.
326
330
 
327
331
  ### Oversized outputs
328
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
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/roles.ts CHANGED
@@ -40,17 +40,24 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
40
40
  fallbackRole: "default",
41
41
  timeout: 3600,
42
42
  description:
43
- "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them.",
43
+ "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them. Can delegate to explorer/researcher.",
44
44
  examples: [
45
45
  "Review the error handling in src/api/ for security issues",
46
46
  "Audit this PR diff for performance regressions",
47
47
  ],
48
48
  decisionTrigger: "Task audits or reviews code quality?",
49
- tools: ["read", "bash", "grep", "find"],
49
+ tools: ["read", "bash", "grep", "find", "subagent_delegate"],
50
+ subagentRoles: ["explorer", "researcher"],
50
51
  systemPrompt: [
51
52
  "Senior code reviewer. READ-ONLY — you must NOT modify any file.",
52
53
  "If the task asks you to fix or implement, do NOT do it: report findings and suggested fixes, and state that implementation is out of scope for this role.",
53
54
  "Run only read-only commands (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
55
+ "",
56
+ "## Delegation",
57
+ "You have a `subagent_delegate` tool — spend it to keep your review context focused:",
58
+ "- subagent_delegate(role=explorer) to map unfamiliar code touched by the change under review",
59
+ "- subagent_delegate(role=researcher) to verify third-party library APIs/versions against official docs",
60
+ "Don't delegate the review itself — reading and judging the code is your job.",
54
61
  "Provide evidence-backed findings with file:line references.",
55
62
  "",
56
63
  "Output format (prioritize critical issues first):",
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,45 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert";
3
+ import { filterDeliveredRuns, sortViewRuns } from "./view.ts";
4
+ import type { RunHandle } from "./run.ts";
5
+
6
+ // ── Fakes ──────────────────────────────────────────────────────────
7
+
8
+ function fakeHandle(id: string, state: RunHandle["state"]): RunHandle {
9
+ return { id, state } as unknown as RunHandle;
10
+ }
11
+
12
+ // ── filterDeliveredRuns ────────────────────────────────────────────
13
+
14
+ test("filterDeliveredRuns drops delivered terminal runs, keeps live and undelivered", () => {
15
+ const running = fakeHandle("sub-1", "running");
16
+ const queued = fakeHandle("sub-2", "queued");
17
+ const finishedUndelivered = fakeHandle("sub-3", "finished");
18
+ const finishedDelivered = fakeHandle("sub-4", "finished");
19
+ const failedDelivered = fakeHandle("sub-5", "failed");
20
+
21
+ const out = filterDeliveredRuns(
22
+ [running, queued, finishedUndelivered, finishedDelivered, failedDelivered],
23
+ new Set(["sub-4", "sub-5"]),
24
+ );
25
+
26
+ assert.deepEqual(
27
+ out.map((r) => r.id),
28
+ ["sub-1", "sub-2", "sub-3"],
29
+ );
30
+ });
31
+
32
+ // ── sortViewRuns ───────────────────────────────────────────────────
33
+
34
+ test("sortViewRuns ranks running before finished, each group by id", () => {
35
+ const out = sortViewRuns([
36
+ fakeHandle("sub-3", "finished"),
37
+ fakeHandle("sub-2", "running"),
38
+ fakeHandle("sub-4", "running"),
39
+ fakeHandle("sub-1", "failed"),
40
+ ]);
41
+ assert.deepEqual(
42
+ out.map((r) => r.id),
43
+ ["sub-2", "sub-4", "sub-1", "sub-3"],
44
+ );
45
+ });
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
@@ -119,6 +119,19 @@ export function sortViewRuns(runs: RunHandle[]): RunHandle[] {
119
119
  return [...runs].sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
120
120
  }
121
121
 
122
+ /**
123
+ * Drop terminal runs whose result is already in the conversation — delivered
124
+ * by subagent_check on the active branch (the same session-tree source of
125
+ * truth as the inbox reminder). Live runs and undelivered terminal runs
126
+ * stay: the view is for live watching and pending collection, not an
127
+ * archive.
128
+ */
129
+ export function filterDeliveredRuns(runs: RunHandle[], delivered: Set<string>): RunHandle[] {
130
+ return runs.filter(
131
+ (r) => (r.state !== "finished" && r.state !== "failed") || !delivered.has(r.id),
132
+ );
133
+ }
134
+
122
135
  export class SubagentViewPanel implements Component, Focusable {
123
136
  focused = true;
124
137
 
@@ -180,8 +193,8 @@ export class SubagentViewPanel implements Component, Focusable {
180
193
  }
181
194
 
182
195
  /** 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. */
196
+ * (e.g. a run finishing re-ranks the list). The focused run's view state
197
+ * (page + scrolls) resets only when the focused run actually changes. */
185
198
  private focusedRun(): RunHandle | undefined {
186
199
  const runs = sortViewRuns(this.runsProvider());
187
200
  if (runs.length === 0) {
@@ -192,12 +205,15 @@ export class SubagentViewPanel implements Component, Focusable {
192
205
  if (!run) {
193
206
  run = runs[0];
194
207
  this.focusId = run.id;
195
- this.resetScrolls();
208
+ this.resetRunView();
196
209
  }
197
210
  return run;
198
211
  }
199
212
 
200
- private resetScrolls(): void {
213
+ /** Reset the focused run's view state: page back to activity, activity
214
+ * pinned to the end (auto-follow), brief at the top. */
215
+ private resetRunView(): void {
216
+ this.page = "activity";
201
217
  this.activityTop = null;
202
218
  this.briefTop = 0;
203
219
  }
@@ -206,10 +222,12 @@ export class SubagentViewPanel implements Component, Focusable {
206
222
  const runs = sortViewRuns(this.runsProvider());
207
223
  if (runs.length < 2) return;
208
224
  const idx = runs.findIndex((r) => r.id === this.focusId);
209
- const next = runs[((idx >= 0 ? idx : 0) + 1) % runs.length];
225
+ // Focus id gone from the list: fall back to the first run, same as
226
+ // focusedRun() — not to the second.
227
+ const next = idx < 0 ? runs[0] : runs[(idx + 1) % runs.length];
210
228
  if (next.id === this.focusId) return;
211
229
  this.focusId = next.id;
212
- this.resetScrolls();
230
+ this.resetRunView();
213
231
  this.tui.requestRender();
214
232
  }
215
233