@d3ara1n/pi-subagent 1.0.0 → 1.1.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
@@ -181,7 +181,7 @@ Foreground and background delegation share one async run engine — foreground i
181
181
  |------|---------|---------------------|
182
182
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
183
183
  | `subagent_wait(ids?, timeout_ms?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | Statuses only, one `id (role): finished/failed` line per run — never results; errors when the timeout hits with runs unfinished |
184
- | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output |
184
+ | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output. Checking a terminal run **collects** it: the output is returned once and the run leaves the registry |
185
185
 
186
186
  Typical flow:
187
187
 
@@ -203,10 +203,12 @@ Typical flow:
203
203
  Semantics worth knowing:
204
204
 
205
205
  - **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.
206
+ - **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it — the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
207
+ - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the unclaimed runs (queued, running, and finished-but-unchecked alike), injected at a cache-stable head position. Runs missing from the list were already collected — so a finished run the model forgot to check keeps surfacing until it does.
206
208
  - **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
207
209
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
208
210
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
209
- - 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.
211
+ - 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 (active + collected) and its current state.
210
212
 
211
213
  ### Background TUI display
212
214
 
@@ -260,7 +262,7 @@ When a provider error (429, quota, timeout, ...) kills a run and the whole task
260
262
 
261
263
  ### Run history
262
264
 
263
- Every completed delegate run is written (best-effort) to `~/.pi/subagent/history/{sessionId}/{toolCallId}.json`, recording role, task, usage, activity log, the **full raw output** (even when the main model saw a compressed/truncated version), and the `fallbackFrom` snapshot when the run was retried on the fallback role. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
265
+ Every **spawned** delegate run is written (best-effort) to `~/.pi/subagent/history/{sessionId}/{toolCallId}.json` finished, failed, and aborted alike (an aborted run already consumed tokens, so its partial activity and cost stay auditable). Records cover role, task, usage, activity log, the **full raw output** (even when the main model saw a compressed/truncated version), and the `fallbackFrom` snapshot when the run was retried on the fallback role. Runs that never spawned (cancelled while queued, role/model resolution failures) are not recorded. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
264
266
 
265
267
  ## License
266
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
package/src/history.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * History persistence for pi-subagent delegate runs.
3
3
  *
4
- * Best-effort audit log: writes one JSON record per delegate run under
5
- * ~/.pi/subagent/history/{sessionId}/{toolCallId}.json. Never throws persistence
6
- * must not fail the delegation. Privacy parity with pi's own session files.
4
+ * Best-effort audit log: writes one JSON record per *spawned* delegate run
5
+ * finished, failed, and aborted alike (an aborted run already consumed
6
+ * tokens, so its partial activity and cost stay auditable). Pre-run failures
7
+ * that never spawned (queued-cancel, role/model resolution) are not recorded.
8
+ * Records land under ~/.pi/subagent/history/{sessionId}/{toolCallId}.json.
9
+ * Never throws — persistence must not fail the delegation. Privacy parity
10
+ * with pi's own session files.
7
11
  */
8
12
 
9
13
  import * as os from "node:os";
package/src/index.ts CHANGED
@@ -16,7 +16,12 @@
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
18
  import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
19
- import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
19
+ import type {
20
+ CollectedRun,
21
+ SubagentConfig,
22
+ SubagentResult,
23
+ SubagentRole,
24
+ } from "./types.ts";
20
25
  import { DEFAULT_CONFIG } from "./types.ts";
21
26
  import { loadSubagentConfig } from "./config.ts";
22
27
  import { BUILTIN_ROLES } from "./roles.ts";
@@ -37,6 +42,7 @@ import {
37
42
  taskPreview,
38
43
  } from "./utils.ts";
39
44
  import { startSubagentRun, type RunHandle } from "./run.ts";
45
+ import { buildInboxReminder, injectReminder } from "./reminder.ts";
40
46
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
41
47
  import {
42
48
  renderBackgroundDelegateCall,
@@ -88,10 +94,14 @@ export default function subagentExtension(pi: ExtensionAPI) {
88
94
  refreshAvailableRoles();
89
95
 
90
96
  // ── Background run registry ────────────────────────────────────
91
- // Process-lifetime map of background runs. Foreground delegate runs are NOT
92
- // registered their lifecycle is the tool call itself. Cleared never: ids
93
- // must stay resolvable so check can fetch results long after wait returned.
97
+ // Process-lifetime map of unclaimed background runs (queued, running, and
98
+ // finished/failed alike). Foreground delegate runs are NOT registered
99
+ // their lifecycle is the tool call itself. subagent_check on a terminal run
100
+ // collects it: the handle is freed and a lightweight CollectedRun tombstone
101
+ // takes its place, so ids stay resolvable while memory does not grow with
102
+ // full results.
94
103
  const backgroundRuns = new Map<string, RunHandle>();
104
+ const collectedRuns = new Map<string, CollectedRun>();
95
105
  let runCounter = 0;
96
106
 
97
107
  // Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
@@ -115,10 +125,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
115
125
  guidelines.push(
116
126
  "WHEN TO DELEGATE — offload substantial work when you only need the result:",
117
127
  "",
118
- "- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps.",
119
- "- DO NOT delegate simple tasks: a single read, a one-line edit, a basic grep. Just do them yourself.",
120
- "- DO NOT delegate straightforward file modifications touching 1-2 files. Use edit/write directly.",
121
- "- Delegation has overhead (spawning a child process). Reserve it for tasks that would genuinely clutter your context with 3+ turns of raw tool output.",
128
+ "- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
129
+ "- DO NOT delegate simple tasks a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
122
130
  "",
123
131
  "AVAILABLE ROLES:",
124
132
  ...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
@@ -132,17 +140,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
132
140
  ...exampleLines,
133
141
  "",
134
142
  "For multiple independent substantial tasks, emit multiple subagent_delegate calls in one turn — they run in parallel.",
135
- "Subagents have no access to this conversation — everything they need must come through `task`, `context`, and `files`.",
136
- 'Pass reference files via the `files` parameter (e.g. files: ["src/auth.ts"]) instead of pasting their contents into `context` — the subagent reads them directly without consuming your context window.',
137
- "Override the model per-call with the `model` parameter for one-off vision or model-specific jobs.",
138
143
  "",
139
144
  "BACKGROUND DELEGATION — start runs now, collect results later:",
140
145
  "",
141
- "- subagent_delegate(background: true) starts a run and returns immediately with just an id (sub-N). The run is unaffected by turn cancellation.",
142
- "- After starting background runs, keep working (or start more); then subagent_wait(ids) blocks until every listed run reaches a final state (omit ids to wait for all of them).",
143
- "- subagent_wait returns ONLY each run's status (finished/failed, one `id (role): status` line per run) it never returns results. With timeout_ms it errors if anything is still unfinished.",
144
- "- subagent_check(id) is the result-fetcher: for a finished run it returns the full output; mid-run it returns a snapshot (queued/running + current activity). One id per call — results can be large.",
145
- "- Typical flow: subagent_delegate(background: true) ×N → work on something else → subagent_wait([id1, id2, ...]) → subagent_check(id) for each finished run.",
146
+ "- Typical flow: subagent_delegate(background: true) ×N keep working subagent_wait(ids) to block until every listed run finishes (omit ids to wait for all) subagent_check(id) for each finished run.",
147
+ "- A terminal check collects the run the output is returned once and the run leaves the registry. Mid-run checks are free: peek at progress as often as you like; only terminal checks collect.",
148
+ "- Every LLM call carries a [background subagent runs] reminder listing your unclaimed runs (queued, running, and finished-but-unchecked alike). Runs missing from that list were already collected.",
146
149
  "- Background delegation works only in the top-level session.",
147
150
  );
148
151
  }
@@ -198,6 +201,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
198
201
  rebuildGuidelines(availableRoles);
199
202
  });
200
203
 
204
+ pi.on("context", async (event) => {
205
+ // The model's inbox: every unclaimed background run, injected at a
206
+ // cache-stable head position before every provider call. Empty inbox →
207
+ // zero injection (context untouched, cache fully stable).
208
+ const reminder = buildInboxReminder(backgroundRuns.values());
209
+ if (!reminder) return;
210
+ return { messages: injectReminder(event.messages, reminder) };
211
+ });
212
+
201
213
  pi.on("tool_result", (event) => {
202
214
  if (event.toolName === "subagent_delegate" && hasFailedSubagentResult(event.details)) {
203
215
  return { isError: true };
@@ -236,7 +248,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
236
248
  background: Type.Optional(
237
249
  Type.Boolean({
238
250
  description:
239
- "Run asynchronously: returns an id (sub-N) immediately instead of blocking. Then subagent_wait(ids) to await completion and subagent_check(id) to fetch each result. The run survives turn cancellation.",
251
+ "Run asynchronously: returns an id (sub-N) immediately instead of blocking. The run survives turn cancellation.",
240
252
  }),
241
253
  ),
242
254
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
@@ -345,14 +357,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
345
357
  try {
346
358
  const result = await run.promise;
347
359
 
348
- // Pipeline throws (abort, spawn crash) surface as tool errors. The
349
- // empty-results frame keeps the TUI on the plain-content fallback.
350
- if (run.thrown) {
351
- const errorText = `Subagent (${params.role}) error: ${run.thrown.message || run.thrown}`;
352
- emit([], errorText);
353
- throw new Error(errorText);
354
- }
355
-
356
360
  // Fallback note: the main model must know the answer came from the
357
361
  // fallback model, not the role's primary — on success AND failure.
358
362
  // Budget note: budget stops are intentional successes, but the model
@@ -360,6 +364,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
360
364
  const fallbackNote = formatFallbackNote(result);
361
365
  const budgetNote = formatBudgetNote(result);
362
366
 
367
+ // Aborts and spawn crashes arrive here too: the engine resolves them
368
+ // into failed results that keep the partial frame (task, activity,
369
+ // output, usage), so the TUI renders them like any failure instead
370
+ // of collapsing to a bare error line.
363
371
  if (isFailedResult(result)) {
364
372
  const failedText =
365
373
  `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
@@ -435,10 +443,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
435
443
  }
436
444
  const unknown = ids.filter((id) => !backgroundRuns.has(id));
437
445
  if (unknown.length > 0) {
438
- const known = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
439
- throw new Error(
440
- `Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.length > 0 ? known.join(", ") : "(none)"}.`,
441
- );
446
+ const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
447
+ const collectedNotes = unknown
448
+ .filter((id) => collectedRuns.has(id))
449
+ .map((id) => `${id} was already collected (result is in your history)`);
450
+ const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
451
+ const parts = [
452
+ `Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
453
+ collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
454
+ `Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
455
+ ].filter(Boolean);
456
+ throw new Error(parts.join(" "));
442
457
  }
443
458
  const runs = ids.map((id) => backgroundRuns.get(id)!);
444
459
  const timeoutMs =
@@ -542,7 +557,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
542
557
  name: "subagent_check",
543
558
  label: "Check a background subagent",
544
559
  description:
545
- "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. One id per call because results can be large.",
560
+ "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Checking a terminal run collects it: the output is returned once and the run leaves the background registry. One id per call because results can be large.",
546
561
  promptSnippet: "Inspect a background subagent run",
547
562
  parameters: Type.Object({
548
563
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -551,14 +566,34 @@ export default function subagentExtension(pi: ExtensionAPI) {
551
566
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
552
567
  const run = backgroundRuns.get(params.id);
553
568
  if (!run) {
554
- const known = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
569
+ const collected = collectedRuns.get(params.id);
570
+ if (collected) {
571
+ throw new Error(
572
+ `${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
573
+ );
574
+ }
575
+ const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
555
576
  throw new Error(
556
- `Unknown subagent id: ${params.id}. Known: ${known.length > 0 ? known.join(", ") : "(none)"}.`,
577
+ `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
557
578
  );
558
579
  }
559
580
 
560
581
  // Freeze live frames so the snapshot's elapsed time stays static.
561
582
  const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
583
+
584
+ // Read-once collection: a terminal check returns the result AND frees
585
+ // the run — the output now lives in the conversation history, so the
586
+ // registry keeps only a lightweight tombstone for id resolution.
587
+ if (run.state === "finished" || run.state === "failed") {
588
+ backgroundRuns.delete(run.id);
589
+ collectedRuns.set(run.id, {
590
+ id: run.id,
591
+ role: run.role,
592
+ task: taskPreview(run.task),
593
+ state: run.state,
594
+ });
595
+ }
596
+
562
597
  return {
563
598
  content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
564
599
  details: { id: run.id, role: run.role, result: snap },
@@ -660,30 +695,40 @@ export default function subagentExtension(pi: ExtensionAPI) {
660
695
  pi.registerCommand("subagent:status", {
661
696
  description: "List background subagent runs and their current state",
662
697
  handler: async (_args, ctx) => {
663
- if (backgroundRuns.size === 0) {
698
+ if (backgroundRuns.size === 0 && collectedRuns.size === 0) {
664
699
  ctx.ui.notify("No background runs.", "info");
665
700
  return;
666
701
  }
667
702
  const lines: string[] = [];
668
- for (const run of backgroundRuns.values()) {
669
- // Freeze live frames so elapsed-dependent details don't drift in the listing.
670
- const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
671
- let icon: string;
672
- let detail: string;
673
- if (run.state === "failed") {
674
- icon = "\u2717";
675
- detail = snap.errorMessage || "unknown error";
676
- } else if (run.state === "finished") {
677
- icon = "\u2713";
678
- detail = snap.summary || taskPreview(snap.output) || "(no output)";
679
- } else if (run.state === "queued") {
680
- icon = "\u23F8";
681
- detail = "queued waiting for a concurrency slot";
682
- } else {
683
- icon = "\u23F3";
684
- detail = `running — ${describeCurrentActivity(snap)}`;
703
+ if (backgroundRuns.size > 0) {
704
+ lines.push("Active:");
705
+ for (const run of backgroundRuns.values()) {
706
+ // Freeze live frames so elapsed-dependent details don't drift in the listing.
707
+ const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
708
+ let icon: string;
709
+ let detail: string;
710
+ if (run.state === "failed") {
711
+ icon = "\u2717";
712
+ detail = snap.errorMessage || "unknown error";
713
+ } else if (run.state === "finished") {
714
+ icon = "\u2713";
715
+ detail = snap.summary || taskPreview(snap.output) || "(no output)";
716
+ } else if (run.state === "queued") {
717
+ icon = "\u23F8";
718
+ detail = "queued — waiting for a concurrency slot";
719
+ } else {
720
+ icon = "\u23F3";
721
+ detail = `running — ${describeCurrentActivity(snap)}`;
722
+ }
723
+ lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
724
+ }
725
+ }
726
+ if (collectedRuns.size > 0) {
727
+ if (lines.length > 0) lines.push("");
728
+ lines.push("Collected (result already returned via subagent_check):");
729
+ for (const c of collectedRuns.values()) {
730
+ lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
685
731
  }
686
- lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
687
732
  }
688
733
  ctx.ui.notify(lines.join("\n"), "info");
689
734
  },
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Unit tests for the background-run inbox reminder.
3
+ *
4
+ * Zero-dependency: runs on node's built-in test runner.
5
+ * node --test packages/pi-subagent/src/reminder.test.ts
6
+ *
7
+ * Coverage: row formatting per state (queued/running/finished/failed/budget),
8
+ * byte-stability between calls (the cache-prefix contract), empty-inbox
9
+ * no-op, and cache-stable head injection (string content, block content,
10
+ * non-user first message, empty transcript).
11
+ */
12
+
13
+ import { test, describe } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { buildInboxReminder, injectReminder, type InboxEntry } from "./reminder.ts";
16
+ import type { SubagentResult } from "./types.ts";
17
+ import { emptyUsage } from "./utils.ts";
18
+
19
+ function frame(partial: Partial<SubagentResult>): SubagentResult {
20
+ return {
21
+ role: "worker",
22
+ task: "task",
23
+ exitCode: 0,
24
+ output: "",
25
+ stderr: "",
26
+ usage: emptyUsage(),
27
+ activityLog: [],
28
+ ...partial,
29
+ };
30
+ }
31
+
32
+ function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">): InboxEntry {
33
+ return {
34
+ role: "worker",
35
+ task: "Investigate flaky tests in packages/pi-subagent",
36
+ snapshot: frame({}),
37
+ ...partial,
38
+ };
39
+ }
40
+
41
+ describe("buildInboxReminder", () => {
42
+ test("empty inbox returns undefined (zero injection)", () => {
43
+ assert.equal(buildInboxReminder([]), undefined);
44
+ });
45
+
46
+ test("queued and running rows carry no time-derived detail", () => {
47
+ const text = buildInboxReminder([
48
+ entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
49
+ entry({
50
+ id: "sub-2",
51
+ state: "running",
52
+ // Live frame: startTime present — a naive formatter would derive elapsed from it.
53
+ snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
54
+ }),
55
+ ])!;
56
+ assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
57
+ assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
58
+ // No seconds anywhere on live rows — byte-stability contract.
59
+ assert.doesNotMatch(text, /\d+s/);
60
+ });
61
+
62
+ test("finished row freezes duration from elapsedMs", () => {
63
+ const text = buildInboxReminder([
64
+ entry({
65
+ id: "sub-1",
66
+ state: "finished",
67
+ snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
68
+ }),
69
+ ])!;
70
+ assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
71
+ });
72
+
73
+ test("budget-stopped finished row is flagged partial", () => {
74
+ const text = buildInboxReminder([
75
+ entry({
76
+ id: "sub-1",
77
+ state: "finished",
78
+ snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
79
+ }),
80
+ ])!;
81
+ assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
82
+ });
83
+
84
+ test("failed row carries the error preview, first line only", () => {
85
+ const text = buildInboxReminder([
86
+ entry({
87
+ id: "sub-4",
88
+ state: "failed",
89
+ snapshot: frame({
90
+ exitCode: 1,
91
+ elapsedMs: 5_000,
92
+ errorMessage: "provider timeout\nretry hint: check quota",
93
+ }),
94
+ }),
95
+ ])!;
96
+ assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
97
+ assert.doesNotMatch(text, /retry hint/);
98
+ });
99
+
100
+ test("byte-stable: identical state produces identical text", () => {
101
+ const entries = [
102
+ entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
103
+ entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
104
+ ];
105
+ assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
106
+ });
107
+
108
+ test("long task text is truncated to the shared 70-char preview cap", () => {
109
+ const long = "x".repeat(120);
110
+ const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
111
+ assert.ok(text.includes(`"${"x".repeat(70)}..."`));
112
+ });
113
+
114
+ test("header explains collection semantics", () => {
115
+ const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
116
+ assert.match(text, /^\[background subagent runs — subagent_check claims/);
117
+ assert.match(text, /already collected\]/);
118
+ });
119
+ });
120
+
121
+ describe("injectReminder", () => {
122
+ const reminder = "[background subagent runs]\n- sub-1 (worker) — running";
123
+
124
+ test("string content: reminder prepended, rest of transcript identical", () => {
125
+ const messages = [
126
+ { role: "user" as const, content: "original prompt", timestamp: 1 },
127
+ { role: "assistant" as const, content: [{ type: "text" as const, text: "answer" }] },
128
+ ];
129
+ const out = injectReminder(messages as any, reminder);
130
+ assert.equal(out.length, 2);
131
+ assert.equal(out[0].role, "user");
132
+ assert.equal((out[0] as any).content, `${reminder}\n\noriginal prompt`);
133
+ assert.deepEqual(out[1], messages[1]);
134
+ });
135
+
136
+ test("block content: reminder becomes the first text block", () => {
137
+ const messages = [
138
+ {
139
+ role: "user" as const,
140
+ content: [
141
+ { type: "text" as const, text: "original" },
142
+ { type: "text" as const, text: "blocks" },
143
+ ],
144
+ timestamp: 1,
145
+ },
146
+ ];
147
+ const out = injectReminder(messages as any, reminder);
148
+ const content = (out[0] as any).content;
149
+ assert.equal(content[0].type, "text");
150
+ assert.equal(content[0].text, reminder);
151
+ assert.equal(content.length, 3);
152
+ });
153
+
154
+ test("non-user first message: synthetic leading user message", () => {
155
+ const messages = [{ role: "assistant" as const, content: [{ type: "text" as const, text: "hi" }] }];
156
+ const out = injectReminder(messages as any, reminder);
157
+ assert.equal(out.length, 2);
158
+ assert.equal(out[0].role, "user");
159
+ assert.equal((out[0] as any).content, reminder);
160
+ assert.deepEqual(out[1], messages[0]);
161
+ });
162
+
163
+ test("empty transcript: single injected user message", () => {
164
+ const out = injectReminder([] as any, reminder);
165
+ assert.equal(out.length, 1);
166
+ assert.equal(out[0].role, "user");
167
+ assert.equal((out[0] as any).content, reminder);
168
+ });
169
+
170
+ test("input messages are not mutated", () => {
171
+ const messages = [{ role: "user" as const, content: "original", timestamp: 1 }];
172
+ injectReminder(messages as any, reminder);
173
+ assert.equal(messages[0].content, "original");
174
+ });
175
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The model's inbox of unclaimed background subagent runs.
3
+ *
4
+ * Injected into the LLM context before every provider call via the `context`
5
+ * event. The reminder lists every delegated run whose result has not been
6
+ * claimed yet — queued, running, and finished/failed alike — so the model
7
+ * cannot forget about them. `subagent_check` on a terminal run returns the
8
+ * output AND removes the run from this list (read-once collection).
9
+ *
10
+ * Cache discipline: the reminder is prepended to the FIRST user message, so
11
+ * it sits at a stable position in the message prefix. Its content must stay
12
+ * byte-stable between state transitions — that rules out elapsed time on
13
+ * running rows and any other live-derived detail. Only terminal rows may
14
+ * carry a duration (frozen in elapsedMs at completion).
15
+ */
16
+
17
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
18
+ import type { RunState, SubagentResult } from "./types.ts";
19
+ import { elapsedSeconds, taskPreview } from "./utils.ts";
20
+
21
+ /** One row of the inbox. RunHandle satisfies this shape structurally. */
22
+ export interface InboxEntry {
23
+ id: string;
24
+ role: string;
25
+ task: string;
26
+ state: RunState;
27
+ snapshot: SubagentResult;
28
+ }
29
+
30
+ const INBOX_HEADER =
31
+ "[background subagent runs — subagent_check claims a terminal run's output and removes it from this list; runs missing here were already collected]";
32
+
33
+ /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
34
+ function formatDuration(totalSec: number): string {
35
+ if (totalSec < 60) return `${totalSec}s`;
36
+ const m = Math.floor(totalSec / 60);
37
+ const s = totalSec % 60;
38
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
39
+ }
40
+
41
+ /** Status segment of one row: state plus its stable extras (duration, error). */
42
+ function inboxStatus(entry: InboxEntry): string {
43
+ if (entry.state === "queued") return "queued";
44
+ if (entry.state === "running") return "running";
45
+ const secs = elapsedSeconds(entry.snapshot);
46
+ const ran = secs != null ? ` (ran ${formatDuration(secs)})` : "";
47
+ if (entry.state === "failed") {
48
+ const reason = taskPreview(entry.snapshot.errorMessage || entry.snapshot.stderr || "unknown error");
49
+ return `failed — ${reason}${ran}`;
50
+ }
51
+ const partial = entry.snapshot.stopReason === "budget_exceeded" ? ", partial — budget exceeded" : "";
52
+ return `finished${partial}${ran}`;
53
+ }
54
+
55
+ /**
56
+ * Build the inbox reminder text, or undefined when every delegated run has
57
+ * been collected (nothing to remind about — inject nothing, keep the context
58
+ * untouched and the provider cache fully stable).
59
+ */
60
+ export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
61
+ const rows: string[] = [];
62
+ for (const entry of entries) {
63
+ rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
64
+ }
65
+ if (rows.length === 0) return undefined;
66
+ return `${INBOX_HEADER}\n${rows.join("\n")}`;
67
+ }
68
+
69
+ /** Message array type of the `context` event (AgentMessage[]). */
70
+ type ContextMessages = ContextEvent["messages"];
71
+
72
+ /**
73
+ * Prepend the reminder at a cache-stable position: the first text block of
74
+ * the first user message (or a synthetic leading user message when the
75
+ * transcript does not start with one).
76
+ */
77
+ export function injectReminder(messages: ContextMessages, reminder: string): ContextMessages {
78
+ if (messages.length === 0) {
79
+ return [{ role: "user", content: reminder, timestamp: 0 } as ContextMessages[number]];
80
+ }
81
+ const [first, ...rest] = messages;
82
+ if (first.role === "user") {
83
+ const content =
84
+ typeof first.content === "string"
85
+ ? `${reminder}\n\n${first.content}`
86
+ : [{ type: "text", text: reminder }, ...first.content];
87
+ return [{ ...first, content } as ContextMessages[number], ...rest];
88
+ }
89
+ return [
90
+ { role: "user", content: reminder, timestamp: 0 } as ContextMessages[number],
91
+ ...messages,
92
+ ];
93
+ }
@@ -32,6 +32,7 @@ import type {
32
32
  import {
33
33
  buildDisplayItems,
34
34
  clearElapsedTimer,
35
+ collapsedText,
35
36
  contentText,
36
37
  deriveRunState,
37
38
  ensureElapsedTimer,
@@ -234,7 +235,7 @@ export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
234
235
 
235
236
  export const renderBackgroundDelegateResult: RenderResultFn = (result, { expanded }, theme) => {
236
237
  const details = result.details as BackgroundDelegateDetails | undefined;
237
- if (!details) return new Text(contentText(result), 0, 0);
238
+ if (!details) return collapsedText(contentText(result));
238
239
 
239
240
  const fg = theme.fg.bind(theme) as Fg;
240
241
  // One-line anchor: marker + id + task preview. The run's live state is NOT
@@ -242,7 +243,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
242
243
  // progresses invisibly until a wait/check row picks it up.
243
244
  const summaryLine = `${fg("accent", "\u25B6")} ${fg("dim", details.id)} ${fg("text", taskPreview(details.task))}`;
244
245
 
245
- if (!expanded) return new Text(summaryLine, 0, 0);
246
+ if (!expanded) return collapsedText(summaryLine);
246
247
 
247
248
  // Expanded: full input — reference files, context size, task text.
248
249
  const container = new Container();
@@ -272,7 +273,7 @@ export const renderWaitCall: RenderCallFn = (args, theme) => {
272
273
  export const renderWaitResult: RenderResultFn = (result, { expanded }, theme, context) => {
273
274
  const details = result.details as WaitDetails | undefined;
274
275
  if (!details || details.entries.length === 0) {
275
- return new Text(contentText(result), 0, 0);
276
+ return collapsedText(contentText(result));
276
277
  }
277
278
 
278
279
  // Tick while any watched run is still live. A timed-out wait freezes the
@@ -301,7 +302,7 @@ export const renderWaitResult: RenderResultFn = (result, { expanded }, theme, co
301
302
  }
302
303
 
303
304
  const text = details.entries.map((e) => waitEntryCollapsedText(e, fg)).join("\n\n");
304
- return new Text(text, 0, 0);
305
+ return collapsedText(text);
305
306
  };
306
307
 
307
308
  // ── check: frozen single-run snapshot ──────────────────────────
@@ -314,11 +315,11 @@ export const renderCheckCall: RenderCallFn = (args, theme) => {
314
315
 
315
316
  export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _context) => {
316
317
  const details = result.details as CheckDetails | undefined;
317
- if (!details) return new Text(contentText(result), 0, 0);
318
+ if (!details) return collapsedText(contentText(result));
318
319
 
319
320
  const fg = theme.fg.bind(theme) as Fg;
320
321
  // Static snapshot — never starts the animation timer (the execute layer
321
322
  // freezes the frame before handing it over).
322
323
  if (expanded) return checkEntryExpandedContainer(details.result, fg);
323
- return new Text(checkEntryCollapsedText(details.result, fg), 0, 0);
324
+ return collapsedText(checkEntryCollapsedText(details.result, fg));
324
325
  };
package/src/render.ts CHANGED
@@ -11,6 +11,7 @@ import type { SubagentDetails } from "./types.ts";
11
11
  import {
12
12
  buildDisplayItems,
13
13
  clearElapsedTimer,
14
+ collapsedText,
14
15
  contentText,
15
16
  ensureElapsedTimer,
16
17
  formatFallback,
@@ -45,11 +46,11 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
45
46
  const isRunning = !!details?.results[0] && details.results[0].exitCode === -1;
46
47
 
47
48
  // Tick elapsed time every second while running; stop once terminal.
48
- // Placed BEFORE the empty-results early return so every terminal path
49
- // (abort, model-resolution failure, catch) still clears the timer
50
- // otherwise the interval leaks a permanent 1 Hz re-render per aborted run.
51
- // The timer calls context.invalidate() so the render recomputes elapsed
52
- // time fresh from Date.now() without dirtying the data layer.
49
+ // Placed BEFORE the missing-details early return so every terminal path
50
+ // still clears the timer otherwise the interval leaks a permanent
51
+ // 1 Hz re-render per row. The timer calls context.invalidate() so the
52
+ // render recomputes elapsed time fresh from Date.now() without dirtying
53
+ // the data layer.
53
54
  if (isRunning) {
54
55
  ensureElapsedTimer(context);
55
56
  } else {
@@ -57,7 +58,7 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
57
58
  }
58
59
 
59
60
  if (!details || details.results.length === 0) {
60
- return new Text(contentText(result), 0, 0);
61
+ return collapsedText(contentText(result));
61
62
  }
62
63
 
63
64
  const r = details.results[0];
@@ -192,5 +193,5 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
192
193
  }
193
194
  if (fallbackLine) text += `\n${fallbackLine}`;
194
195
  if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
195
- return new Text(text, 0, 0);
196
+ return collapsedText(text);
196
197
  };
package/src/run.test.ts CHANGED
@@ -123,7 +123,11 @@ test("non-zero exit yields state failed without thrown", async () => {
123
123
  });
124
124
 
125
125
  test("a throwing spawn resolves the promise with a failed result carrying the error", async () => {
126
- const spawnImpl: SpawnImpl = async () => {
126
+ const spawnImpl: SpawnImpl = async (_m, _t, options) => {
127
+ options.onProgress?.({
128
+ output: "partial",
129
+ activityLog: [{ kind: "toolCall", id: "t1", status: "done", toolName: "read", args: {} }],
130
+ });
127
131
  throw new Error("Subagent was aborted");
128
132
  };
129
133
 
@@ -134,6 +138,67 @@ test("a throwing spawn resolves the promise with a failed result carrying the er
134
138
  assert.ok(run.thrown instanceof Error);
135
139
  assert.strictEqual(run.thrown.message, "Subagent was aborted");
136
140
  assert.strictEqual(result.errorMessage, "Subagent was aborted");
141
+ // The partial frame survives — the foreground path renders aborts like any
142
+ // failure (task line + activity + result line) instead of a bare error.
143
+ assert.strictEqual(result.output, "partial");
144
+ assert.strictEqual(result.activityLog.length, 1);
145
+ });
146
+
147
+ test("spawned runs persist to history on every terminal path; pre-run failures do not", async () => {
148
+ const persisted: SubagentResult[] = [];
149
+ const persistImpl = (
150
+ _sessionId: string | undefined,
151
+ _toolCallId: string,
152
+ _role: string,
153
+ _task: string,
154
+ r: SubagentResult,
155
+ ) => {
156
+ persisted.push(r);
157
+ };
158
+ const historyConfig = { ...testConfig, history: { enabled: true } };
159
+
160
+ // Abort mid-run: the run spawned, so it must be audited.
161
+ const aborted = startSubagentRun(
162
+ makeDeps({
163
+ config: historyConfig,
164
+ spawnImpl: async (_m, _t, options) => {
165
+ options.onProgress?.({
166
+ output: "partial",
167
+ activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
168
+ });
169
+ throw new Error("Subagent was aborted");
170
+ },
171
+ persistImpl,
172
+ }),
173
+ );
174
+ await aborted.promise;
175
+ assert.equal(persisted.length, 1);
176
+ assert.match(persisted[0].errorMessage!, /aborted/);
177
+ assert.equal(persisted[0].activityLog.length, 1);
178
+
179
+ // Pre-run failure (roles api unavailable): never spawned, not audited.
180
+ const prerun = startSubagentRun(
181
+ makeDeps({
182
+ config: historyConfig,
183
+ getRolesApi: () => {
184
+ throw new Error("not initialized");
185
+ },
186
+ persistImpl,
187
+ }),
188
+ );
189
+ await prerun.promise;
190
+ assert.equal(persisted.length, 1);
191
+
192
+ // Normal success is audited too.
193
+ const ok = startSubagentRun(
194
+ makeDeps({
195
+ config: historyConfig,
196
+ spawnImpl: async () => makeResult({ output: "done" }),
197
+ persistImpl,
198
+ }),
199
+ );
200
+ await ok.promise;
201
+ assert.equal(persisted.length, 2);
137
202
  });
138
203
 
139
204
  test("provider error on first attempt retries on the fallback role", async () => {
package/src/run.ts CHANGED
@@ -48,7 +48,7 @@ export interface RunHandle {
48
48
  readonly snapshot: SubagentResult;
49
49
  /** Terminal result; undefined while queued/running. */
50
50
  readonly result: SubagentResult | undefined;
51
- /** Set when the pipeline threw (abort, spawn crash). Foreground callers rethrow; wait/check only see state "failed". */
51
+ /** Set when the pipeline threw (abort, spawn crash). The terminal result still carries the partial frame — callers report it as an ordinary failed result; wait/check only see state "failed". */
52
52
  readonly thrown: Error | undefined;
53
53
  /** Resolves with the terminal result once the run finishes (always succeeds). */
54
54
  readonly promise: Promise<SubagentResult>;
@@ -81,6 +81,8 @@ export interface StartRunOptions {
81
81
  getSessionId?: () => string | undefined;
82
82
  /** @internal — injectable spawn for tests. */
83
83
  spawnImpl?: typeof spawnSubagent;
84
+ /** @internal — injectable history persistence for tests. */
85
+ persistImpl?: typeof persistSubagentHistory;
84
86
  }
85
87
 
86
88
  export function startSubagentRun(opts: StartRunOptions): RunHandle {
@@ -164,11 +166,27 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
164
166
  try {
165
167
  await opts.gate.acquire(opts.signal);
166
168
  } catch {
167
- const msg = `Subagent (${opts.role}) was cancelled while queued.`;
168
- finish({ ...inputFrame(1, false), errorMessage: msg }, new Error("cancelled while queued"));
169
+ const msg = "cancelled while queued for a concurrency slot";
170
+ finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
169
171
  return;
170
172
  }
171
173
 
174
+ // Audit every spawned run — finished, failed, and aborted alike: an
175
+ // aborted run already consumed tokens, so its cost must stay in the
176
+ // audit log. Pre-run failures (queued-cancel, role/model resolution)
177
+ // never spawned and are not recorded.
178
+ const persist = opts.persistImpl ?? persistSubagentHistory;
179
+ const persistHistory = (terminal: SubagentResult, rawOutput?: string): void => {
180
+ if (!opts.config.history.enabled) return;
181
+ let sessionId: string | undefined;
182
+ try {
183
+ sessionId = opts.getSessionId?.();
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ persist(sessionId, opts.toolCallId, opts.role, opts.task, terminal, rawOutput);
188
+ };
189
+
172
190
  try {
173
191
  // Resolve the model AFTER acquiring so the queued period stays zero-cost.
174
192
  let rolesApi: ModelRolesAPI;
@@ -312,37 +330,30 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
312
330
  runResult.summary = await generateSummary(rolesApi, runResult.output, opts.config.summary);
313
331
  }
314
332
 
315
- // Persist audit record (best-effort; covers both success and failure).
316
- // History keeps the raw original output even when LLM/TUI saw a compressed/truncated version.
317
- if (opts.config.history.enabled) {
318
- let sessionId: string | undefined;
319
- try {
320
- sessionId = opts.getSessionId?.();
321
- } catch {
322
- /* ignore */
323
- }
324
- persistSubagentHistory(sessionId, opts.toolCallId, opts.role, opts.task, runResult, rawOutput);
325
- }
333
+ // Best-effort audit record. The raw original output is kept even when
334
+ // the LLM/TUI saw a compressed/truncated version.
335
+ persistHistory(runResult, rawOutput);
326
336
 
327
337
  finish(runResult);
328
338
  } catch (err: any) {
329
339
  // Keep whatever the last live frame gathered so aborted/crashed runs
330
340
  // still show their partial activity and usage.
331
341
  const partial = snapshot;
332
- finish(
333
- {
334
- ...inputFrame(1, false),
335
- output: partial.output,
336
- usage: partial.usage,
337
- model: partial.model,
338
- stopReason: partial.stopReason,
339
- activityLog: partial.activityLog,
340
- budgetMs: partial.budgetMs,
341
- elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
342
- errorMessage: err?.message || String(err),
343
- },
344
- err instanceof Error ? err : new Error(String(err)),
345
- );
342
+ const terminal: SubagentResult = {
343
+ ...inputFrame(1, false),
344
+ output: partial.output,
345
+ usage: partial.usage,
346
+ model: partial.model,
347
+ stopReason: partial.stopReason,
348
+ activityLog: partial.activityLog,
349
+ budgetMs: partial.budgetMs,
350
+ elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
351
+ errorMessage: err?.message || String(err),
352
+ };
353
+ // The run spawned before throwing — audit it like any terminal state.
354
+ // The partial output is raw (compression never ran on it).
355
+ persistHistory(terminal);
356
+ finish(terminal, err instanceof Error ? err : new Error(String(err)));
346
357
  } finally {
347
358
  opts.gate.release();
348
359
  }
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ export interface SubagentConfig {
12
12
  maxTurns: number;
13
13
  /** Default cumulative cost budget in USD. `0` means unlimited; negative values are normalized to `0`. Per-role maxCost overrides this. */
14
14
  maxCost: number;
15
- /** Persist each delegate run to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. */
15
+ /** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
16
16
  history: SubagentHistoryConfig;
17
17
  summary: SubagentSummaryConfig;
18
18
  /**
@@ -212,6 +212,15 @@ export interface WaitDetails {
212
212
  timedOut?: boolean;
213
213
  }
214
214
 
215
+ /** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
216
+ export interface CollectedRun {
217
+ id: string;
218
+ role: string;
219
+ /** First-line task preview (same 70-char cap as the inbox reminder). */
220
+ task: string;
221
+ state: "finished" | "failed";
222
+ }
223
+
215
224
  /** Details for a check tool result — a frozen one-shot snapshot of a single run. */
216
225
  export interface CheckDetails {
217
226
  id: string;
package/src/utils.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import * as os from "node:os";
8
+ import type { Component } from "@earendil-works/pi-tui";
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
8
10
  import type {
9
11
  ActivityEntry,
10
12
  FallbackFrom,
@@ -259,6 +261,23 @@ export function taskPreview(task: string): string {
259
261
  return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
260
262
  }
261
263
 
264
+ /**
265
+ * Width-aware collapsed-view component: renders each line truncated with "…"
266
+ * to the actual viewport width (never wraps), padded full-width like Text(0,0).
267
+ *
268
+ * The char caps in taskPreview/formatToolCall/etc. stay as they are — they are
269
+ * content limits shared with the LLM-facing text (check output, error
270
+ * messages), which has no viewport semantics. This component is the TUI-side
271
+ * final guard, applied where the folding affordance exists.
272
+ */
273
+ export function collapsedText(text: string): Component {
274
+ const lines = text.split("\n");
275
+ return {
276
+ render: (width: number) => lines.map((ln) => truncateToWidth(ln, width, "…", true)),
277
+ invalidate: () => {},
278
+ };
279
+ }
280
+
262
281
  /** Status icon for a run frame: ⏸ queued / ⏳ running / ⏱ timeout / ⏲ budget / ✗ failed / ✓ ok */
263
282
  export function runIcon(
264
283
  r: { exitCode: number; queued?: boolean; stopReason?: string },