@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 +14 -10
- package/package.json +1 -1
- package/src/index.ts +44 -29
- package/src/render-async.ts +2 -2
- package/src/roles.ts +9 -2
- package/src/utils.test.ts +60 -19
- package/src/utils.ts +81 -28
- package/src/view.test.ts +45 -0
- package/src/view.ts +26 -8
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @d3ara1n/pi-subagent
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@d3ara1n/pi-subagent) [](https://www.npmjs.com/package/@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 |
|
|
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,
|
|
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
|
|
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?,
|
|
240
|
-
| `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output
|
|
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
|
|
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
|
|
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
|
-
- **`
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
563
|
+
timeout: Type.Optional(
|
|
557
564
|
Type.Number({
|
|
558
565
|
description:
|
|
559
|
-
"Max time to wait in
|
|
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
|
-
//
|
|
646
|
-
// the model can map ids to roles
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
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
|
-
?
|
|
1039
|
-
:
|
|
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");
|
package/src/render-async.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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({
|
|
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
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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:
|
|
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.
|
|
776
|
+
assert.equal(
|
|
737
777
|
formatCancelText("sub-2", "worker", midRun),
|
|
738
|
-
|
|
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:
|
|
780
|
+
// Aborted before the first completed turn: elapsed-only stats.
|
|
741
781
|
const underATurn = baseResult({
|
|
742
|
-
exitCode:
|
|
782
|
+
exitCode: 1,
|
|
783
|
+
stopReason: "cancelled",
|
|
743
784
|
elapsedMs: 2000,
|
|
744
785
|
output: "",
|
|
745
|
-
usage: { input:
|
|
786
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
746
787
|
});
|
|
747
|
-
assert.
|
|
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
|
|
49
|
-
*
|
|
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
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
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")
|
|
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 —
|
|
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
|
|
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
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
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
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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
|
|
671
|
-
|
|
672
|
-
|
|
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. */
|
package/src/view.test.ts
ADDED
|
@@ -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
|
-
*
|
|
27
|
-
*
|
|
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).
|
|
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.
|
|
208
|
+
this.resetRunView();
|
|
196
209
|
}
|
|
197
210
|
return run;
|
|
198
211
|
}
|
|
199
212
|
|
|
200
|
-
|
|
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
|
-
|
|
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.
|
|
230
|
+
this.resetRunView();
|
|
213
231
|
this.tui.requestRender();
|
|
214
232
|
}
|
|
215
233
|
|