@d3ara1n/pi-subagent 3.2.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
6
6
 
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`.
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_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`.
8
8
 
9
9
  ## Design Philosophy
10
10
 
@@ -55,7 +55,7 @@ Observations on how main models behave with this plugin, one family per subsecti
55
55
 
56
56
  ## TUI Display
57
57
 
58
- - **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks and tool calls (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model). Delegates using inherited conversation are marked in the tool-call title.
58
+ - **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks, tool calls, and steering messages (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model). Delegates using inherited conversation are marked in the tool-call title.
59
59
  - **Collapsed result**: the task's first line, then `✓` + the AI-generated summary (or the first line of the output), then the usage line — no activity replay
60
60
  - **Expanded result** (Ctrl+O): reference files, context size, the full task, the complete activity stream, the final output as rendered Markdown, and usage details
61
61
  - **Fallback trace**: when a provider error (429, quota, timeout, ...) kills a run and it is retried on the role's `fallbackRole`, a `⚠ fallback: first attempt <model> failed (<reason>)` line appears in both views — also while the retry is running (see [Fallback observability](#fallback-observability))
@@ -73,11 +73,11 @@ Observations on how main models behave with this plugin, one family per subsecti
73
73
 
74
74
  A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run forward and `Shift+Tab` backward, and the rest of the viewport belongs to it alone — showing one of two pages, toggled with `d`. Runs are ordered newest-first, and the row is windowed around the focused cell — the archive can outgrow the terminal width, but focus never scrolls off-screen.
75
75
 
76
- The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. Background runs stay listed for the whole session — the view doubles as the session's run archive, so a run's activity and brief remain browsable even after the model has collected the result. The centered empty notice (with `Esc close` hinted) appears only when nothing has been delegated yet.
76
+ The **activity page** (default) is the run's live feed: a continuous list with pending input pinned below current activity, where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. Background runs stay listed for the whole session — the view doubles as the session's run archive, so a run's activity and brief remain browsable even after the model has collected the result. The centered empty notice (with `Esc close` hinted) appears only when nothing has been delegated yet.
77
77
 
78
78
  The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), inherited-conversation size and truncation status when enabled (never its text), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
79
79
 
80
- Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call the run keeps its progress. `Esc` in browse mode closes the overlay.
80
+ Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately at the bottom of the feed as an `↩ steer (queued):` entry. When the child consumes the user message, the queued marker disappears in place; the entry remains accent-colored in the history. Delivery happens after the current tool batch, before the next LLM call, without resetting the run's progress. `Esc` in browse mode closes the overlay.
81
81
 
82
82
  ## Dependencies
83
83
 
@@ -240,12 +240,12 @@ Three execution properties, kept separate:
240
240
  | Tool | Purpose | Returns to the model |
241
241
  |------|---------|---------------------|
242
242
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
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 |
243
+ | `subagent_wait(ids?)` | Block until **all** listed runs end (omit `ids` for all current background runs) | A per-run roll call — one `id (role): state (turns, elapsed, tokens, cost)` line per run; retrieve results with `subagent_check` |
244
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 |
245
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 |
246
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 |
247
247
 
248
- Typical flow:
248
+ Example tool calls made by the main model:
249
249
 
250
250
  ```json
251
251
  [
@@ -254,22 +254,28 @@ Typical flow:
254
254
  ]
255
255
  ```
256
256
 
257
- …continue other work, then:
257
+ The main model continues its own work while these runs execute. Before each model request, the extension refreshes an inbox reminder with the status of runs whose results have not been collected. When the model needs a result from a run that has ended, it retrieves it through `subagent_check`:
258
258
 
259
259
  ```json
260
- { "ids": ["sub-1", "sub-2"] }
260
+ { "id": "sub-1" }
261
261
  ```
262
262
 
263
- (call `subagent_wait`), and finally `subagent_check` each finished id to fetch its result. `subagent_check` accepts one id per call because results can be large.
263
+ If the needed run is still queued or running, the model can wait for it through `subagent_wait`:
264
+
265
+ ```json
266
+ { "ids": ["sub-1"] }
267
+ ```
268
+
269
+ Once the wait returns, the model retrieves the result through `subagent_check`. The reminder supplies status information, while check provides execution details and results; a separate check is not required before waiting. `subagent_check` accepts one id per call because results can be large; `subagent_wait` can await multiple unfinished runs together.
264
270
 
265
271
  Semantics worth knowing:
266
272
 
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 collected by a terminal check on the active branch on every request, but it never pushes results.
273
+ - **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 collects results with `subagent_check`, using `subagent_wait` when a run has not ended yet. The inbox reminder (below) lists runs not yet collected by a terminal check on the active branch on every request, but it never pushes results.
268
274
  - **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.
269
275
  - **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.
270
276
  - **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.
271
277
  - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet collected by a terminal check on the active branch (queued, running, and finished/failed-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Only checks that returned a terminal snapshot count — a peek at a live frame never silences the inbox, so the steer flow's check → steer → check-later cycle cannot strand an outcome. 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.
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).
278
+ - **Wait has no timeout parameter.** `subagent_wait` blocks until all specified runs end. Each run's own role timeout and budgets still apply; cancelling the wait leaves the runs running.
273
279
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
274
280
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
275
281
  - 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.
@@ -281,16 +287,16 @@ Steering queues a correction into a running subagent without killing it — the
281
287
  There are two channels into the same mechanism:
282
288
 
283
289
  - **The model** calls `subagent_steer(id, message)` — typically right after a `subagent_check` snapshot revealed the run heading down a wrong path (check → steer → check again later).
284
- - **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed as an `↩ steer:` entry, so whoever watches the view sees what was injected and when.
290
+ - **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed, so whoever watches the view sees what was injected and when.
285
291
 
286
- Queued steers are visible in neither wait nor check results they shape the run's subsequent behavior, not its transcript.
292
+ Steering messages appear in the delegate, wait, check, and live-view activity streams. Pending entries use `↩ steer (queued):`, stay below current activity, and count toward the same five-item collapsed limit. Consumption removes the queued marker without moving the row; `↩ steer:` remains accent-colored in history. Expanded views retain the full activity history. This is TUI presentation only: wait's model-facing response remains a status roll call.
287
293
 
288
294
  ### Background TUI display
289
295
 
290
296
  Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
291
297
 
292
298
  - **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, inherited-conversation size/truncation metadata when enabled, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
293
- - **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`) plus the timeout ceiling (`≤30s`) when one was given. One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead. A timed-out wait freezes the view.
299
+ - **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`). One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead.
294
300
  - **subagent_check row = the result view.** Same block shape as subagent_wait's single-run view (no id — there is only one), but the result line shows `✓ <AI summary>` (or the budget/failure reason when the run stopped early) and the expanded view renders the **full output** — subagent_check is where the conclusion lives.
295
301
  - **subagent_cancel row = confirmation only.** Collapsed: `⏹ sub-1 (worker): cancelled after 1 turn (~29s)` (or `• sub-1 (worker) already finished — nothing to cancel` for a no-op). Expanded adds the reason and the pointer to `subagent_check` — the partial output **never renders here**; it stays in the registry until a check row fetches it (layer contract: delegate = input, wait = process, cancel = intervention, check = result).
296
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "3.2.3",
3
+ "version": "3.3.0",
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",
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { appendActivity, consumeSteer } from "./activity.ts";
4
+ import { buildDisplayItems, formatDisplayItem, renderDisplayItems } from "./utils.ts";
5
+ import type { ActivityEntry } from "./types.ts";
6
+
7
+ const plain = (_color: string, text: string) => text;
8
+
9
+ test("pending entries stay below internal activity and consume in place", () => {
10
+ const log: ActivityEntry[] = [];
11
+ const first = appendActivity(log, { kind: "toolCall", id: "a", status: "running" });
12
+ appendActivity(log, { kind: "steer", id: "s1", status: "queued", text: "correct" });
13
+ appendActivity(log, { kind: "steer", id: "s2", status: "queued", text: "correct" });
14
+ const second = appendActivity(log, { kind: "toolCall", id: "b", status: "running" });
15
+ assert.deepEqual(log.map((e) => e.id), ["a", "b", "s1", "s2"]);
16
+ assert.equal(log[first].id, "a");
17
+ assert.equal(log[second].id, "b");
18
+ const before = log.map((e) => e.id);
19
+ assert.equal(consumeSteer(log, { role: "assistant", content: "correct" }), false);
20
+ assert.equal(consumeSteer(log, { role: "user", content: "unrelated" }), false);
21
+ assert.equal(consumeSteer(log, { role: "user", content: [{ type: "text", text: "correct" }] }), true);
22
+ assert.deepEqual(log.map((e) => e.id), before);
23
+ assert.deepEqual(log.slice(-2).map((e) => e.status), ["done", "queued"]);
24
+ appendActivity(log, { kind: "thinking", id: "next", status: "running" });
25
+ assert.deepEqual(log.map((e) => e.id), ["a", "b", "s1", "next", "s2"]);
26
+ assert.equal(consumeSteer(log, { role: "user", content: "correct" }), true);
27
+ assert.equal(consumeSteer(log, { role: "user", content: "correct" }), false);
28
+ });
29
+
30
+ test("queued items share the visible limit and consumption changes only the marker", () => {
31
+ const log: ActivityEntry[] = Array.from({ length: 6 }, (_, i) => ({
32
+ kind: "toolCall", id: `t${i}`, toolName: `tool${i}`, status: "done",
33
+ }));
34
+ appendActivity(log, { kind: "steer", id: "s", status: "queued", text: "first\nsecond" });
35
+ const before = renderDisplayItems(buildDisplayItems(log), 5, plain);
36
+ assert.equal(before.split("\n").length, 6); // Five items plus the existing history marker.
37
+ assert.ok(!before.includes("tool1"));
38
+ assert.ok(before.endsWith("\u21a9 steer (queued): first second"));
39
+ consumeSteer(log, { role: "user", content: "first\nsecond" });
40
+ const after = renderDisplayItems(buildDisplayItems(log), 5, plain);
41
+ assert.equal(after, before.replace(" (queued)", ""));
42
+ assert.equal(buildDisplayItems(log).length, 7);
43
+ });
44
+
45
+ test("queued rendering uses status for every display type and steers retain accent", () => {
46
+ const color = (name: string, text: string) => `<${name}>${text}</${name}>`;
47
+ for (const status of ["queued", "done"] as const) {
48
+ assert.match(formatDisplayItem({ type: "steer", status, text: "input" }, color), /^<accent>/);
49
+ }
50
+ assert.equal(formatDisplayItem({ type: "steer", status: "done", text: "queued" }, plain), "\u21a9 steer: queued");
51
+ assert.match(formatDisplayItem({ type: "thinking", status: "queued" }, plain), /\(queued\)/);
52
+ assert.match(formatDisplayItem({ type: "toolCall", name: "read", args: {}, status: "queued" }, plain), /\(queued\)/);
53
+ });
@@ -0,0 +1,23 @@
1
+ import type { ActivityEntry } from "./types.ts";
2
+
3
+ /** @internal Insert live activity before pending entries, keeping the queue at the tail. */
4
+ export function appendActivity(log: ActivityEntry[], entry: ActivityEntry): number {
5
+ const queued = entry.status === "queued" ? -1 : log.findIndex((item) => item.status === "queued");
6
+ const index = queued < 0 ? log.length : queued;
7
+ log.splice(index, 0, entry);
8
+ return index;
9
+ }
10
+
11
+ /** @internal Match the user message actually consumed by the child, one occurrence at a time. */
12
+ export function consumeSteer(log: ActivityEntry[], message: { role?: string; content?: unknown }): boolean {
13
+ if (message.role !== "user") return false;
14
+ const text = typeof message.content === "string"
15
+ ? message.content
16
+ : Array.isArray(message.content)
17
+ ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")
18
+ : undefined;
19
+ const entry = log.find((item) => item.kind === "steer" && item.status === "queued" && item.text === text);
20
+ if (!entry) return false;
21
+ entry.status = "done";
22
+ return true;
23
+ }
package/src/index.ts CHANGED
@@ -179,7 +179,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
179
179
  "BACKGROUND DELEGATION:",
180
180
  "",
181
181
  "- 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.",
182
- "- 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
+ "- When you need a background run's result, collect it with subagent_check if the run has ended; otherwise use subagent_wait, then check to collect it.",
183
183
  "- 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.",
184
184
  "- Background delegation works only in the top-level session.",
185
185
  );
@@ -304,7 +304,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
304
304
  name: "subagent_delegate",
305
305
  label: "Delegate to subagent",
306
306
  description:
307
- "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
307
+ "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
308
308
  promptSnippet: "Delegate tasks to specialized subagents",
309
309
  promptGuidelines: guidelines,
310
310
 
@@ -335,7 +335,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
335
335
  background: Type.Optional(
336
336
  Type.Boolean({
337
337
  description:
338
- "Non-blocking: returns an id immediately so you can do your own work (or keep discussing with the user) while the run executes — not for parallelism (several foreground calls in one turn already run concurrently). Results are pull-only for the model: a completion notice is shown to the user, but nothing delivers the result or wakes you; fetch with subagent_wait/subagent_check when your own work is done. If the next thing you'd do is wait for the result, omit this and let the call block.",
338
+ "Return immediately with a run id instead of waiting for the final output. Results are pull-only: a completion notice is shown to the user, but does not deliver the result to the model or wake it.",
339
339
  }),
340
340
  ),
341
341
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
@@ -545,7 +545,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
545
545
  name: "subagent_wait",
546
546
  label: "Wait for background subagents",
547
547
  description:
548
- "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).",
548
+ "Wait until all specified background subagent runs end. Omit ids to wait for all current background runs. Returns status and usage for each run; use subagent_check to retrieve results. Cancelling the wait leaves the runs running.",
549
549
  promptSnippet: "Wait for background subagents to finish",
550
550
  parameters: Type.Object({
551
551
  ids: Type.Optional(
@@ -555,12 +555,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
555
555
  "Run ids returned by background delegate calls. Omit to wait for all current background runs.",
556
556
  }),
557
557
  ),
558
- timeout: Type.Optional(
559
- Type.Number({
560
- description:
561
- "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.",
562
- }),
563
- ),
558
+ // Timeout is hidden from the tool schema; restore with the timeoutMs expression below.
559
+ // timeout: Type.Optional(
560
+ // Type.Number({
561
+ // description: "Max time to wait in seconds. Omit to wait until all specified runs end.",
562
+ // }),
563
+ // ),
564
564
  }),
565
565
 
566
566
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
@@ -578,7 +578,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
578
578
  );
579
579
  }
580
580
  const runs = ids.map((id) => backgroundRuns.get(id)!);
581
- const timeoutMs = typeof params.timeout === "number" && params.timeout > 0 ? params.timeout * 1000 : 0;
581
+ // Keep the timeout machinery available while the tool exposes completion-only waiting.
582
+ // const timeoutMs = typeof params.timeout === "number" && params.timeout > 0 ? params.timeout * 1000 : 0;
583
+ const timeoutMs = 0;
582
584
 
583
585
  // ── Live mirror: forward combined snapshots into this tool row ──
584
586
  const entries = () => runs.map((r) => ({ id: r.id, role: r.role, result: r.snapshot }));
@@ -143,9 +143,9 @@ describe("buildInboxReminder", () => {
143
143
  assert.ok(text.includes(`"${"x".repeat(70)}..."`));
144
144
  });
145
145
 
146
- test("header explains pull-only collection semantics", () => {
146
+ test("header identifies results awaiting collection with check", () => {
147
147
  const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
148
- assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
148
+ assert.match(text, /^\[background subagent runs — results not yet collected with subagent_check/);
149
149
  assert.match(text, /already checked on this branch\]/);
150
150
  });
151
151
 
package/src/reminder.ts CHANGED
@@ -31,7 +31,7 @@ export interface InboxEntry {
31
31
  }
32
32
 
33
33
  const INBOX_HEADER =
34
- "[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already checked on this branch]";
34
+ "[background subagent runs — results not yet collected with subagent_check; a terminal check removes a run from this list; runs missing here were already checked on this branch]";
35
35
 
36
36
  /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
37
37
  function formatDuration(totalSec: number): string {
@@ -48,13 +48,11 @@ import {
48
48
  ensureElapsedTimer,
49
49
  formatFallback,
50
50
  formatInheritedConversationInput,
51
- formatThinking,
51
+ formatDisplayItem,
52
52
  formatTimePart,
53
- formatToolCall,
54
53
  formatUsageStats,
55
54
  renderDisplayItems,
56
55
  runIcon,
57
- statusStyle,
58
56
  taskPreview,
59
57
  terminalResultLine,
60
58
  } from "./utils.ts";
@@ -85,12 +83,8 @@ function addActivityRows(container: Container, r: SubagentResult, fg: Fg): void
85
83
  return;
86
84
  }
87
85
  for (const item of activity) {
88
- if (item.type === "thinking") {
89
- container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
90
- } else {
91
- const { prefix, color } = statusStyle(item.status, fg);
92
- container.addChild(new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0));
93
- }
86
+ const row = formatDisplayItem(item, fg);
87
+ container.addChild(item.type === "steer" ? collapsedText(row) : new Text(row, 0, 0));
94
88
  }
95
89
  }
96
90
 
@@ -207,6 +201,8 @@ function checkEntryExpandedContainer(r: SubagentResult, fg: Fg): Container {
207
201
 
208
202
  if (state === "finished" || state === "failed") {
209
203
  container.addChild(new Text(terminalResultLine(r, fg), 0, 0));
204
+ container.addChild(new Spacer(1));
205
+ addActivityRows(container, r, fg);
210
206
  // check is the result-fetcher: the full output lives here.
211
207
  container.addChild(new Spacer(1));
212
208
  if (r.output.trim()) {
@@ -1,9 +1,50 @@
1
- /** Tests for inherited-conversation TUI observability. */
1
+ /** Tests for delegate and background-run TUI observability. */
2
2
 
3
3
  import assert from "node:assert/strict";
4
4
  import test from "node:test";
5
- import { renderBackgroundDelegateCall, renderBackgroundDelegateResult } from "./render-async.ts";
6
- import { renderDelegateCall } from "./render.ts";
5
+ import { renderBackgroundDelegateCall, renderBackgroundDelegateResult, renderWaitResult, renderCheckResult } from "./render-async.ts";
6
+ import { renderDelegateCall, renderDelegateResult } from "./render.ts";
7
+ import type { SubagentResult } from "./types.ts";
8
+
9
+ test("delegate, wait, and check preserve steer rows through consumption and completion", () => {
10
+ const run: SubagentResult = {
11
+ role: "worker", task: "task", exitCode: -1, output: "", stderr: "",
12
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, contextTokens: 0 },
13
+ activityLog: [
14
+ ...Array.from({ length: 6 }, (_, i) => ({ kind: "toolCall" as const, id: `t${i}`, toolName: `tool${i}`, status: "done" as const })),
15
+ { kind: "steer", id: "s", status: "queued", text: "correction ".repeat(30) },
16
+ ],
17
+ };
18
+ const renderers = [
19
+ () => ({ render: renderDelegateResult, details: { results: [run] } }),
20
+ () => ({ render: renderWaitResult, details: { entries: [{ id: "sub-1", role: "worker", result: run }] } }),
21
+ () => ({ render: renderCheckResult, details: { id: "sub-1", role: "worker", result: run } }),
22
+ ];
23
+ for (const make of renderers) {
24
+ for (const expanded of [false, true]) {
25
+ for (const width of [40, 100]) {
26
+ const { render, details } = make();
27
+ const draw = () => render({ content: [], details } as any, { expanded, isPartial: true }, theme, { state: {} } as any)
28
+ .render(width).map((line) => line.trimEnd());
29
+ run.exitCode = -1;
30
+ run.activityLog.at(-1)!.status = "queued";
31
+ const pending = draw();
32
+ const index = pending.findIndex((line) => line.includes("steer (queued):"));
33
+ assert.ok(index >= 0);
34
+ assert.ok(expanded || !pending.join("\n").includes("tool1"));
35
+ run.activityLog.at(-1)!.status = "done";
36
+ const consumed = draw();
37
+ assert.equal(consumed.length, pending.length);
38
+ assert.ok(consumed[index].includes("steer:"));
39
+ assert.ok(!consumed[index].includes("(queued)"));
40
+ if (expanded) {
41
+ run.exitCode = 0;
42
+ assert.ok(draw().some((line) => line.includes("steer:")));
43
+ }
44
+ }
45
+ }
46
+ }
47
+ });
7
48
 
8
49
  const theme = {
9
50
  fg: (_color: string, text: string) => text,
package/src/render.ts CHANGED
@@ -16,13 +16,11 @@ import {
16
16
  ensureElapsedTimer,
17
17
  formatFallback,
18
18
  formatInheritedConversationInput,
19
- formatThinking,
19
+ formatDisplayItem,
20
20
  formatTimePart,
21
- formatToolCall,
22
21
  formatUsageStats,
23
22
  renderDisplayItems,
24
23
  runIcon,
25
- statusStyle,
26
24
  taskPreview,
27
25
  terminalResultLine,
28
26
  } from "./utils.ts";
@@ -150,14 +148,8 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
150
148
  container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
151
149
  } else {
152
150
  for (const item of displayItems) {
153
- if (item.type === "thinking") {
154
- container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
155
- } else {
156
- const { prefix, color } = statusStyle(item.status, fg);
157
- container.addChild(
158
- new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0),
159
- );
160
- }
151
+ const row = formatDisplayItem(item, fg);
152
+ container.addChild(item.type === "steer" ? collapsedText(row) : new Text(row, 0, 0));
161
153
  }
162
154
  }
163
155
 
package/src/spawn.ts CHANGED
@@ -13,6 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import * as path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { appendActivity, consumeSteer } from "./activity.ts";
16
17
  import type { SubagentControl, SubagentMessage, SubagentResult } from "./types.ts";
17
18
 
18
19
  const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
@@ -463,6 +464,10 @@ export async function spawnSubagent(
463
464
  }
464
465
  }
465
466
 
467
+ if (event.type === "message_start" && event.message && consumeSteer(result.activityLog, event.message)) {
468
+ emitProgress();
469
+ }
470
+
466
471
  if (event.type === "message_end" && event.message) {
467
472
  const msg = event.message as SubagentMessage;
468
473
 
@@ -510,14 +515,14 @@ export async function spawnSubagent(
510
515
  // Activity log: track thinking blocks and tool calls in arrival order.
511
516
  // Both update in place so the TUI reflects real-time state.
512
517
  if (event.type === "tool_execution_start" && event.toolCallId) {
513
- toolCallIndex.set(event.toolCallId, result.activityLog.length);
514
- result.activityLog.push({
518
+ const index = appendActivity(result.activityLog, {
515
519
  kind: "toolCall",
516
520
  id: event.toolCallId,
517
521
  status: "running",
518
522
  toolName: event.toolName,
519
523
  args: event.args ?? {},
520
524
  });
525
+ toolCallIndex.set(event.toolCallId, index);
521
526
  // Pause the parent timeout clock while the child delegates — nested
522
527
  // subagents get their own full budget instead of racing this clock.
523
528
  // Ref-counted: concurrent subagent_delegate calls pause once and resume
@@ -545,7 +550,7 @@ export async function spawnSubagent(
545
550
  const aev = event.assistantMessageEvent;
546
551
  if (event.type === "message_update" && aev) {
547
552
  if (aev.type === "thinking_start") {
548
- result.activityLog.push({
553
+ appendActivity(result.activityLog, {
549
554
  kind: "thinking",
550
555
  id: `thinking-${thinkingCounter++}`,
551
556
  status: "running",
@@ -564,7 +569,7 @@ export async function spawnSubagent(
564
569
  }
565
570
  emitProgress();
566
571
  } else if (aev.type === "text_start") {
567
- result.activityLog.push({
572
+ appendActivity(result.activityLog, {
568
573
  kind: "text",
569
574
  id: `text-${textCounter++}`,
570
575
  status: "running",
@@ -719,12 +724,11 @@ export async function spawnSubagent(
719
724
  steer(message: string) {
720
725
  if (processExited || terminationRequested) return;
721
726
  sendCommand({ type: "steer", message });
722
- // Mirror the steer into the activity feed so the :view overlay shows
723
- // what was injected and when.
724
- result.activityLog.push({
727
+ // Keep pending input visible until the child emits its user message.
728
+ appendActivity(result.activityLog, {
725
729
  kind: "steer",
726
730
  id: `steer-${steerCounter++}`,
727
- status: "done",
731
+ status: "queued",
728
732
  text: message,
729
733
  });
730
734
  emitProgress();
package/src/types.ts CHANGED
@@ -87,7 +87,7 @@ export interface SubagentRole {
87
87
  }
88
88
 
89
89
  /** Status of an individual tool call within a subagent run. */
90
- export type ToolStatus = "running" | "done" | "failed";
90
+ export type ToolStatus = "queued" | "running" | "done" | "failed";
91
91
 
92
92
  /** A single entry in the real-time activity log (thinking block, tool call, streamed assistant text, or a user steer). */
93
93
  export interface ActivityEntry {
package/src/utils.ts CHANGED
@@ -142,7 +142,8 @@ export function formatTimePart(r: {
142
142
 
143
143
  export type DisplayItem =
144
144
  | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
145
- | { type: "thinking"; status?: ToolStatus };
145
+ | { type: "thinking"; status?: ToolStatus }
146
+ | { type: "steer"; text: string; status?: ToolStatus };
146
147
 
147
148
  /**
148
149
  * Map the real-time activity log into renderable display items (in order).
@@ -151,12 +152,12 @@ export type DisplayItem =
151
152
  */
152
153
  export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
153
154
  return activityLog
154
- .filter((a) => a.kind === "thinking" || a.kind === "toolCall")
155
- .map((a) =>
156
- a.kind === "thinking"
157
- ? { type: "thinking", status: a.status }
158
- : { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
159
- );
155
+ .filter((a) => a.kind !== "text")
156
+ .map((a): DisplayItem => {
157
+ if (a.kind === "thinking") return { type: "thinking", status: a.status };
158
+ if (a.kind === "steer") return { type: "steer", text: a.text ?? "", status: a.status };
159
+ return { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status };
160
+ });
160
161
  }
161
162
 
162
163
  export function shortenPath(p: string): string {
@@ -280,6 +281,24 @@ export function formatThinking(
280
281
  return fg("dim", "\u25C6 thought");
281
282
  }
282
283
 
284
+ export function formatDisplayItem(
285
+ item: DisplayItem,
286
+ fg: (color: string, text: string) => string,
287
+ ): string {
288
+ const queued = item.status === "queued";
289
+ const marker = queued ? " (queued)" : "";
290
+ if (item.type === "steer") {
291
+ return fg("accent", `\u21a9 steer${marker}: ${oneLine(item.text)}`);
292
+ }
293
+ if (queued) {
294
+ const body = item.type === "thinking" ? "thinking" : formatToolCall(item.name, item.args, (_c, text) => text);
295
+ return fg("accent", `\u23f8 ${body}${marker}`);
296
+ }
297
+ if (item.type === "thinking") return formatThinking(item.status, fg);
298
+ const { prefix, color } = statusStyle(item.status, fg);
299
+ return prefix + formatToolCall(item.name, item.args, color);
300
+ }
301
+
283
302
  export function renderDisplayItems(
284
303
  items: DisplayItem[],
285
304
  limit: number | undefined,
@@ -290,12 +309,7 @@ export function renderDisplayItems(
290
309
  let text = "";
291
310
  if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
292
311
  for (const item of toShow) {
293
- if (item.type === "thinking") {
294
- text += `${formatThinking(item.status, fg)}\n`;
295
- } else {
296
- const { prefix, color } = statusStyle(item.status, fg);
297
- text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
298
- }
312
+ text += `${formatDisplayItem(item, fg)}\n`;
299
313
  }
300
314
  return text.trimEnd();
301
315
  }
@@ -622,7 +636,7 @@ export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): st
622
636
  if (!last) return "waiting for first event";
623
637
  if (last.kind === "thinking") return last.status === "running" ? "thinking" : "thought";
624
638
  if (last.kind === "text") return last.status === "running" ? "responding" : "responded";
625
- if (last.kind === "steer") return "steered — awaiting next turn";
639
+ if (last.kind === "steer") return last.status === "queued" ? "steer queued — awaiting delivery" : "steer received";
626
640
  return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
627
641
  }
628
642
 
package/src/view.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  briefFilesUsed,
59
59
  formatFallback,
60
60
  formatThinking,
61
+ formatDisplayItem,
61
62
  formatTimePart,
62
63
  formatToolCall,
63
64
  formatTokens,
@@ -396,8 +397,7 @@ export class SubagentViewPanel implements Component, Focusable {
396
397
  return truncateToWidth(indent + formatThinking(e.status, fg), width);
397
398
  }
398
399
  if (e.kind === "steer") {
399
- const firstLine = (e.text ?? "").trimEnd().split("\n")[0] || "";
400
- return truncateToWidth(indent + fg("accent", `↩ steer: ${firstLine}`), width);
400
+ return truncateToWidth(indent + formatDisplayItem({ type: "steer", text: e.text ?? "", status: e.status }, fg), width);
401
401
  }
402
402
  if (e.kind === "text") {
403
403
  const buffer = e.text ?? "";
@@ -539,7 +539,7 @@ export class SubagentViewPanel implements Component, Focusable {
539
539
  });
540
540
  const header =
541
541
  `${fg("accent", th.bold("subagents"))} ` +
542
- th.fg("dim", `${runningCount} running · ${runs.length} total · Tab/⇧Tab switch`) +
542
+ th.fg("dim", `${runningCount} running · ${runs.length} total`) +
543
543
  " ";
544
544
  const focusIdx = Math.max(0, runs.findIndex((r) => r === focused));
545
545
  const win = windowTabCells(cells, focusIdx, innerW - visibleWidth(header));