@d3ara1n/pi-subagent 2.1.0 → 3.0.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
@@ -32,7 +32,7 @@ This means:
32
32
 
33
33
  | Role | Model Role | Timeout | Tools | Can Delegate To | Description |
34
34
  |------|-----------|---------|-------|-----------------|-------------|
35
- | `explorer` | fast | 900s | read, find, grep | — | Fast code exploration (read-only) |
35
+ | `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
36
36
  | `reviewer` | heavy | 3600s | read, bash, grep, find | — | Deep code review, runs git/tests for evidence (read-only) |
37
37
  | `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
38
38
  | `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
@@ -56,7 +56,7 @@ This means:
56
56
  |---------|-------------|
57
57
  | `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run |
58
58
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
59
- | `/subagent:status` | List background runs (active + collected) and their current state |
59
+ | `/subagent:status` | List background runs and their current state |
60
60
  | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
61
61
 
62
62
  ### Live view (`/subagent:view`)
@@ -229,15 +229,15 @@ Typical flow:
229
229
 
230
230
  Semantics worth knowing:
231
231
 
232
- - **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 unclaimed runs on every request, but it never pushes results.
232
+ - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists runs not yet checked on the active branch on every request, but it never pushes results.
233
233
  - **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.
234
- - **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.
235
- - **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 collect: `subagent_check` still returns the partial output once, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
236
- - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the unclaimed runs (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already collected — so a finished run the model forgot to check keeps surfacing until it does.
234
+ - **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.
235
+ - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
236
+ - **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet checked on the active branch (queued, running, and finished-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Runs missing from the list were already checked on this branch — so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
237
237
  - **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
238
238
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
239
239
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
240
- - 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.
240
+ - 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.
241
241
 
242
242
  ### Steering a running subagent
243
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "2.1.0",
3
+ "version": "3.0.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/index.ts CHANGED
@@ -17,7 +17,6 @@ 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
19
  import type {
20
- CollectedRun,
21
20
  SubagentConfig,
22
21
  SubagentResult,
23
22
  SubagentRole,
@@ -28,6 +27,7 @@ import { BUILTIN_ROLES } from "./roles.ts";
28
27
  import { getPiInvocation } from "./spawn.ts";
29
28
  import {
30
29
  AsyncSemaphore,
30
+ collectDeliveredIds,
31
31
  createThrottler,
32
32
  describeCurrentActivity,
33
33
  formatBudgetNote,
@@ -47,6 +47,7 @@ import { buildInboxReminder, injectReminder } from "./reminder.ts";
47
47
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
48
48
  import { createViewPanel } from "./view.ts";
49
49
  import {
50
+ createSteerCallRender,
50
51
  renderBackgroundDelegateCall,
51
52
  renderBackgroundDelegateResult,
52
53
  renderCancelCall,
@@ -54,6 +55,7 @@ import {
54
55
  renderCheckCall,
55
56
  renderCheckResult,
56
57
  renderCompletionNotice,
58
+ renderSteerResult,
57
59
  renderWaitCall,
58
60
  renderWaitResult,
59
61
  } from "./render-async.ts";
@@ -101,14 +103,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
101
103
  refreshAvailableRoles();
102
104
 
103
105
  // ── Background run registry ────────────────────────────────────
104
- // Process-lifetime map of unclaimed background runs (queued, running, and
106
+ // Process-lifetime map of background runs (queued, running, and
105
107
  // finished/failed alike). Foreground delegate runs are NOT registered —
106
- // their lifecycle is the tool call itself. subagent_check on a terminal run
107
- // collects it: the handle is freed and a lightweight CollectedRun tombstone
108
- // takes its place, so ids stay resolvable while memory does not grow with
109
- // full results.
108
+ // their lifecycle is the tool call itself. Runs stay registered for the
109
+ // whole session: subagent_check is idempotent and re-delivers the terminal
110
+ // snapshot on every call, so branch navigation or compaction can never
111
+ // strand a result outside the model's reach. Whether a run still needs
112
+ // reminding is NOT tracked here — it derives from the session tree (see
113
+ // collectDeliveredIds + the context handler), the single source of truth.
110
114
  const backgroundRuns = new Map<string, RunHandle>();
111
- const collectedRuns = new Map<string, CollectedRun>();
112
115
  let runCounter = 0;
113
116
  let sessionGeneration = 0;
114
117
 
@@ -250,7 +253,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
250
253
  rebuildGuidelines(availableRoles);
251
254
  });
252
255
 
253
- pi.on("context", async (event) => {
256
+ pi.on("context", async (event, ctx) => {
254
257
  // Completion notices are persisted custom messages so the user can see
255
258
  // them in the transcript, but they are deliberately UI-only. Keep the
256
259
  // model on the reminder/check path instead of duplicating the notice in
@@ -260,10 +263,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
260
263
  message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
261
264
  );
262
265
 
263
- // The model's inbox: every unclaimed background run, injected at a
264
- // cache-stable head position before every provider call. Empty inbox and
265
- // no filtered notices context stays untouched, cache fully stable.
266
- const reminder = buildInboxReminder(backgroundRuns.values());
266
+ // The model's inbox: every background run not yet checked on the active
267
+ // branch, injected at a cache-stable head position before every provider
268
+ // call. Delivery state is derived from the session tree (append-only:
269
+ // branching away drops the check entry, branching back restores it), so
270
+ // the inbox re-arms itself after tree navigation. Empty inbox and no
271
+ // filtered notices → context stays untouched, cache fully stable.
272
+ const reminder = buildInboxReminder(
273
+ backgroundRuns.values(),
274
+ collectDeliveredIds(ctx.sessionManager.buildContextEntries()),
275
+ );
267
276
  if (!reminder && messages.length === event.messages.length) return;
268
277
  return { messages: reminder ? injectReminder(messages, reminder) : messages };
269
278
  });
@@ -386,9 +395,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
386
395
  backgroundRuns.set(run.id, run);
387
396
  const runGeneration = sessionGeneration;
388
397
  void run.promise.then((result) => {
389
- // A collected run already has a visible check result. Do not emit a
390
- // second notice, and never publish completions from an old session.
391
- if (!backgroundRuns.has(run.id) || runGeneration !== sessionGeneration) return;
398
+ // Never publish completions from an old session. Within a session
399
+ // every completion emits its UI-only notice card once check
400
+ // results never suppress it (they are not LLM-visible either way).
401
+ if (runGeneration !== sessionGeneration) return;
392
402
 
393
403
  const outcome = isFailedResult(result)
394
404
  ? "failed"
@@ -543,16 +553,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
543
553
  const unknown = ids.filter((id) => !backgroundRuns.has(id));
544
554
  if (unknown.length > 0) {
545
555
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
546
- const collectedNotes = unknown
547
- .filter((id) => collectedRuns.has(id))
548
- .map((id) => `${id} was already collected (result is in your history)`);
549
- const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
550
- const parts = [
551
- `Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
552
- collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
553
- `Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
554
- ].filter(Boolean);
555
- throw new Error(parts.join(" "));
556
+ throw new Error(
557
+ `Unknown subagent id(s): ${unknown.join(", ")}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
558
+ );
556
559
  }
557
560
  const runs = ids.map((id) => backgroundRuns.get(id)!);
558
561
  const timeoutMs =
@@ -659,7 +662,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
659
662
  name: "subagent_check",
660
663
  label: "Check a background subagent",
661
664
  description:
662
- "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.",
665
+ "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
663
666
  promptSnippet: "Inspect a background subagent run",
664
667
  parameters: Type.Object({
665
668
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -668,12 +671,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
668
671
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
669
672
  const run = backgroundRuns.get(params.id);
670
673
  if (!run) {
671
- const collected = collectedRuns.get(params.id);
672
- if (collected) {
673
- throw new Error(
674
- `${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
675
- );
676
- }
677
674
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
678
675
  throw new Error(
679
676
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
@@ -683,19 +680,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
683
680
  // Freeze live frames so the snapshot's elapsed time stays static.
684
681
  const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
685
682
 
686
- // Read-once collection: a terminal check returns the result AND frees
687
- // the run — the output now lives in the conversation history, so the
688
- // registry keeps only a lightweight tombstone for id resolution.
689
- if (run.state === "finished" || run.state === "failed") {
690
- backgroundRuns.delete(run.id);
691
- collectedRuns.set(run.id, {
692
- id: run.id,
693
- role: run.role,
694
- task: taskPreview(run.task),
695
- state: run.state,
696
- });
697
- }
698
-
699
683
  return {
700
684
  content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
701
685
  details: { id: run.id, role: run.role, result: snap },
@@ -710,7 +694,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
710
694
  name: "subagent_steer",
711
695
  label: "Steer a running background subagent",
712
696
  description:
713
- "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and terminal runs are collected by check instead. Typical flow: check → steer → check again later.",
697
+ "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and check is the tool for terminal runs. Typical flow: check → steer → check again later.",
714
698
  promptSnippet: "Send a mid-run correction to a background subagent",
715
699
  parameters: Type.Object({
716
700
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -723,12 +707,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
723
707
  async execute(_toolCallId, params) {
724
708
  const run = backgroundRuns.get(params.id);
725
709
  if (!run) {
726
- const collected = collectedRuns.get(params.id);
727
- if (collected) {
728
- throw new Error(
729
- `${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
730
- );
731
- }
732
710
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
733
711
  throw new Error(
734
712
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
@@ -752,16 +730,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
752
730
  text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
753
731
  },
754
732
  ],
755
- details: { id: params.id, role: run.role },
733
+ details: { id: params.id, role: run.role, message: params.message },
756
734
  };
757
735
  },
736
+
737
+ renderCall: createSteerCallRender((id) => backgroundRuns.get(id)?.role),
738
+ renderResult: renderSteerResult,
758
739
  });
759
740
 
760
741
  pi.registerTool({
761
742
  name: "subagent_cancel",
762
743
  label: "Cancel a background subagent",
763
744
  description:
764
- "Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not collect — check still returns the partial output once. A finished/failed run cannot be cancelled; check it instead.",
745
+ "Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not remove the run — check still returns the partial output. A finished/failed run cannot be cancelled; check it instead.",
765
746
  promptSnippet: "Cancel a background subagent run",
766
747
  parameters: Type.Object({
767
748
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -776,19 +757,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
776
757
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
777
758
  const run = backgroundRuns.get(params.id);
778
759
  if (!run) {
779
- const collected = collectedRuns.get(params.id);
780
- if (collected) {
781
- throw new Error(
782
- `${params.id} (${collected.role}) was already collected — its result is in your conversation history. There is nothing left to cancel.`,
783
- );
784
- }
785
760
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
786
761
  throw new Error(
787
762
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
788
763
  );
789
764
  }
790
765
 
791
- // Terminal runs cannot be cancelled — point at the collector instead.
766
+ // Terminal runs cannot be cancelled — point at check instead.
792
767
  if (run.state === "finished" || run.state === "failed") {
793
768
  const what = run.state === "finished" ? "its result" : "the failure reason and partial output";
794
769
  const text =
@@ -822,8 +797,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
822
797
  pi.registerCommand("subagent:view", {
823
798
  description: "Open the live subagent activity view (watch progress, steer runs)",
824
799
  handler: async (_args, ctx) => {
825
- // Union of every known run: background registry (until collected) plus
826
- // live in-flight runs (foreground delegate calls included). Dedupe by id —
800
+ // Union of every known run: the background registry plus live
801
+ // in-flight runs (foreground delegate calls included). Dedupe by id —
827
802
  // background runs appear in both.
828
803
  const runsProvider = () => {
829
804
  const seen = new Set<string>();
@@ -942,40 +917,31 @@ export default function subagentExtension(pi: ExtensionAPI) {
942
917
  pi.registerCommand("subagent:status", {
943
918
  description: "List background subagent runs and their current state",
944
919
  handler: async (_args, ctx) => {
945
- if (backgroundRuns.size === 0 && collectedRuns.size === 0) {
920
+ if (backgroundRuns.size === 0) {
946
921
  ctx.ui.notify("No background runs.", "info");
947
922
  return;
948
923
  }
949
924
  const lines: string[] = [];
950
- if (backgroundRuns.size > 0) {
951
- lines.push("Active:");
952
- for (const run of backgroundRuns.values()) {
953
- // Freeze live frames so elapsed-dependent details don't drift in the listing.
954
- const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
955
- let icon: string;
956
- let detail: string;
957
- if (run.state === "failed") {
958
- icon = "\u2717";
959
- detail = snap.errorMessage || "unknown error";
960
- } else if (run.state === "finished") {
961
- icon = "\u2713";
962
- detail = snap.summary || taskPreview(snap.output) || "(no output)";
963
- } else if (run.state === "queued") {
964
- icon = "\u23F8";
965
- detail = "queued — waiting for a concurrency slot";
966
- } else {
967
- icon = "\u23F3";
968
- detail = `running — ${describeCurrentActivity(snap)}`;
969
- }
970
- lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
971
- }
972
- }
973
- if (collectedRuns.size > 0) {
974
- if (lines.length > 0) lines.push("");
975
- lines.push("Collected (result already returned via subagent_check):");
976
- for (const c of collectedRuns.values()) {
977
- lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
925
+ lines.push("Runs:");
926
+ for (const run of backgroundRuns.values()) {
927
+ // Freeze live frames so elapsed-dependent details don't drift in the listing.
928
+ const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
929
+ let icon: string;
930
+ let detail: string;
931
+ if (run.state === "failed") {
932
+ icon = "\u2717";
933
+ detail = snap.errorMessage || "unknown error";
934
+ } else if (run.state === "finished") {
935
+ icon = "\u2713";
936
+ detail = snap.summary || taskPreview(snap.output) || "(no output)";
937
+ } else if (run.state === "queued") {
938
+ icon = "\u23F8";
939
+ detail = "queued — waiting for a concurrency slot";
940
+ } else {
941
+ icon = "\u23F3";
942
+ detail = `running — ${describeCurrentActivity(snap)}`;
978
943
  }
944
+ lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
979
945
  }
980
946
  ctx.ui.notify(lines.join("\n"), "info");
981
947
  },
@@ -1018,13 +984,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
1018
984
  }
1019
985
 
1020
986
  if (target !== "all" && !backgroundRuns.has(target)) {
1021
- const collected = collectedRuns.get(target);
1022
987
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
1023
988
  ctx.ui.notify(
1024
- (collected
1025
- ? `${target} (${collected.role}) was already collected — nothing to cancel.`
1026
- : `Unknown subagent id: ${target}.`) +
1027
- ` Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
989
+ `Unknown subagent id: ${target}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
1028
990
  "error",
1029
991
  );
1030
992
  return;
@@ -5,9 +5,10 @@
5
5
  * node --test packages/pi-subagent/src/reminder.test.ts
6
6
  *
7
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).
8
+ * delivery-derived row removal (terminal rows checked on the active branch
9
+ * drop out; live rows never do), byte-stability between calls (the
10
+ * cache-prefix contract), empty-inbox no-op, and cache-stable head injection
11
+ * (string content, block content, non-user first message, empty transcript).
11
12
  */
12
13
 
13
14
  import { test, describe } from "node:test";
@@ -40,19 +41,22 @@ function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">):
40
41
 
41
42
  describe("buildInboxReminder", () => {
42
43
  test("empty inbox returns undefined (zero injection)", () => {
43
- assert.equal(buildInboxReminder([]), undefined);
44
+ assert.equal(buildInboxReminder([], new Set()), undefined);
44
45
  });
45
46
 
46
47
  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
- ])!;
48
+ const text = buildInboxReminder(
49
+ [
50
+ entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
51
+ entry({
52
+ id: "sub-2",
53
+ state: "running",
54
+ // Live frame: startTime present a naive formatter would derive elapsed from it.
55
+ snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
56
+ }),
57
+ ],
58
+ new Set(),
59
+ )!;
56
60
  assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
57
61
  assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
58
62
  // No seconds anywhere on live rows — byte-stability contract.
@@ -60,56 +64,68 @@ describe("buildInboxReminder", () => {
60
64
  });
61
65
 
62
66
  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
- ])!;
67
+ const text = buildInboxReminder(
68
+ [
69
+ entry({
70
+ id: "sub-1",
71
+ state: "finished",
72
+ snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
73
+ }),
74
+ ],
75
+ new Set(),
76
+ )!;
70
77
  assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
71
78
  });
72
79
 
73
80
  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
+ const text = buildInboxReminder(
82
+ [
83
+ entry({
84
+ id: "sub-1",
85
+ state: "finished",
86
+ snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
87
+ }),
88
+ ],
89
+ new Set(),
90
+ )!;
81
91
  assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
82
92
  });
83
93
 
84
94
  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",
95
+ const text = buildInboxReminder(
96
+ [
97
+ entry({
98
+ id: "sub-4",
99
+ state: "failed",
100
+ snapshot: frame({
101
+ exitCode: 1,
102
+ elapsedMs: 5_000,
103
+ errorMessage: "provider timeout\nretry hint: check quota",
104
+ }),
93
105
  }),
94
- }),
95
- ])!;
106
+ ],
107
+ new Set(),
108
+ )!;
96
109
  assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
97
110
  assert.doesNotMatch(text, /retry hint/);
98
111
  });
99
112
 
100
113
  test("cancelled row carries the cancel label, not failed", () => {
101
- const text = buildInboxReminder([
102
- entry({
103
- id: "sub-5",
104
- state: "failed",
105
- snapshot: frame({
106
- exitCode: 1,
107
- stopReason: "cancelled",
108
- elapsedMs: 7_000,
109
- errorMessage: "user: wrong direction after review",
114
+ const text = buildInboxReminder(
115
+ [
116
+ entry({
117
+ id: "sub-5",
118
+ state: "failed",
119
+ snapshot: frame({
120
+ exitCode: 1,
121
+ stopReason: "cancelled",
122
+ elapsedMs: 7_000,
123
+ errorMessage: "user: wrong direction after review",
124
+ }),
110
125
  }),
111
- }),
112
- ])!;
126
+ ],
127
+ new Set(),
128
+ )!;
113
129
  assert.match(text, /— cancelled — user: wrong direction after review \(ran 7s\) —/);
114
130
  });
115
131
 
@@ -118,19 +134,52 @@ describe("buildInboxReminder", () => {
118
134
  entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
119
135
  entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
120
136
  ];
121
- assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
137
+ assert.equal(buildInboxReminder(entries, new Set()), buildInboxReminder(entries, new Set()));
122
138
  });
123
139
 
124
140
  test("long task text is truncated to the shared 70-char preview cap", () => {
125
141
  const long = "x".repeat(120);
126
- const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
142
+ const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })], new Set())!;
127
143
  assert.ok(text.includes(`"${"x".repeat(70)}..."`));
128
144
  });
129
145
 
130
146
  test("header explains pull-only collection semantics", () => {
131
- const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
147
+ const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
132
148
  assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
133
- assert.match(text, /already collected\]/);
149
+ assert.match(text, /already checked on this branch\]/);
150
+ });
151
+
152
+ test("terminal rows in the delivered set drop out of the inbox", () => {
153
+ const text = buildInboxReminder(
154
+ [
155
+ entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
156
+ entry({ id: "sub-2", state: "failed", snapshot: frame({ exitCode: 1, elapsedMs: 5_000 }) }),
157
+ ],
158
+ new Set(["sub-1"]),
159
+ )!;
160
+ assert.doesNotMatch(text, /sub-1/);
161
+ assert.match(text, /sub-2 \(worker\) — failed/);
162
+ });
163
+
164
+ test("live rows stay listed even when their id is in the delivered set", () => {
165
+ // A live frame checked mid-run does not count as delivery — the result
166
+ // was not final yet, so the run keeps nagging until a terminal check.
167
+ const text = buildInboxReminder(
168
+ [
169
+ entry({ id: "sub-1", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
170
+ entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 99 }) }),
171
+ ],
172
+ new Set(["sub-1", "sub-2"]),
173
+ )!;
174
+ assert.match(text, /sub-1 \(worker\) — queued/);
175
+ assert.match(text, /sub-2 \(worker\) — running/);
176
+ });
177
+
178
+ test("all terminal rows delivered returns undefined (zero injection)", () => {
179
+ const entries = [
180
+ entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
181
+ ];
182
+ assert.equal(buildInboxReminder(entries, new Set(["sub-1"])), undefined);
134
183
  });
135
184
  });
136
185
 
package/src/reminder.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * The model's inbox of unclaimed background subagent runs.
2
+ * The model's inbox of background subagent runs.
3
3
  *
4
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).
5
+ * event. The reminder lists every delegated run not yet delivered by a
6
+ * subagent_check on the active branch — queued, running, and
7
+ * finished/failed alike so the model cannot forget about them. Delivery
8
+ * state is derived from the session tree (see collectDeliveredIds), not
9
+ * tracked in the registry: branching past a check re-arms the inbox,
10
+ * branching back silences it, and compaction un-delivers naturally.
9
11
  *
10
12
  * Cache discipline: the reminder is prepended to the FIRST user message, so
11
13
  * it sits at a stable position in the message prefix. Its content must stay
@@ -28,7 +30,7 @@ export interface InboxEntry {
28
30
  }
29
31
 
30
32
  const INBOX_HEADER =
31
- "[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 collected]";
33
+ "[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]";
32
34
 
33
35
  /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
34
36
  function formatDuration(totalSec: number): string {
@@ -61,12 +63,16 @@ function inboxStatus(entry: InboxEntry): string {
61
63
 
62
64
  /**
63
65
  * Build the inbox reminder text, or undefined when every delegated run has
64
- * been collected (nothing to remind about — inject nothing, keep the context
65
- * untouched and the provider cache fully stable).
66
+ * already been checked on the active branch (nothing to remind about —
67
+ * inject nothing, keep the context untouched and the provider cache fully
68
+ * stable). Queued/running runs are always listed regardless of delivery
69
+ * state — their result is not final yet, so a past check (of a live frame)
70
+ * never counts as delivered.
66
71
  */
67
- export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
72
+ export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
68
73
  const rows: string[] = [];
69
74
  for (const entry of entries) {
75
+ if (entry.state !== "queued" && entry.state !== "running" && delivered.has(entry.id)) continue;
70
76
  rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
71
77
  }
72
78
  if (rows.length === 0) return undefined;
@@ -33,6 +33,7 @@ import type {
33
33
  CheckDetails,
34
34
  CompletionNoticeDetails,
35
35
  RunViewEntry,
36
+ SteerDetails,
36
37
  SubagentResult,
37
38
  WaitDetails,
38
39
  } from "./types.ts";
@@ -387,6 +388,54 @@ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _
387
388
  return collapsedText(checkEntryCollapsedText(details.result, fg));
388
389
  };
389
390
 
391
+ // ── steer: correction echo (check verifies the effect) ──
392
+
393
+ /**
394
+ * Factory: the call line shows the run's role, but args carry only id +
395
+ * message — the registry lookup is injected. Unknown id (e.g. re-rendering a
396
+ * persisted session where the registry is empty) degrades to the bare id.
397
+ */
398
+ export function createSteerCallRender(roleOf: (id: string) => string | undefined): RenderCallFn {
399
+ return (args, theme) => {
400
+ const id = (args as any).id || "...";
401
+ const role = roleOf(id);
402
+ const label = role ? `${id} (${role})` : id;
403
+ const text = theme.fg("toolTitle", theme.bold("subagent_steer ")) + theme.fg("accent", label);
404
+ return new Text(text, 0, 0);
405
+ };
406
+ }
407
+
408
+ export const renderSteerResult: RenderResultFn = (result, { expanded }, theme) => {
409
+ const details = result.details as SteerDetails | undefined;
410
+ if (!details) return collapsedText(contentText(result));
411
+
412
+ const fg = theme.fg.bind(theme) as Fg;
413
+ const icon = fg("accent", "\u21a9"); // ↩ — same marker as steer entries in the activity stream
414
+ const message = details.message.trim() || "(empty message)";
415
+
416
+ // Body carries the correction only — no id/role prefix; the target lives in
417
+ // the call line and the delivery hint below.
418
+ if (!expanded) {
419
+ const firstLine = message.split("\n")[0];
420
+ return collapsedText(`${icon} ${fg("text", firstLine)}`);
421
+ }
422
+
423
+ const container = new Container();
424
+ container.addChild(new Text(`${icon} ${fg("text", message)}`, 0, 0));
425
+ container.addChild(new Spacer(1));
426
+ container.addChild(
427
+ new Text(
428
+ fg(
429
+ "dim",
430
+ `${details.id} (${details.role}) — delivered after the current tool batch, verify with subagent_check later`,
431
+ ),
432
+ 0,
433
+ 0,
434
+ ),
435
+ );
436
+ return container;
437
+ };
438
+
390
439
  // ── cancel: confirmation-only view (check is the result-fetcher) ──
391
440
 
392
441
  export const renderCancelCall: RenderCallFn = (args, theme) => {
package/src/roles.ts CHANGED
@@ -15,13 +15,18 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
15
15
  fallbackRole: "default",
16
16
  timeout: 900,
17
17
  description:
18
- "READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures.",
19
- examples: ["Find where auth middleware is implemented", "Map the routing structure"],
20
- decisionTrigger: "Task finds or maps code without touch?",
21
- tools: ["read", "find", "grep"],
18
+ "READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures, inspect git history (log/diff/blame).",
19
+ examples: [
20
+ "Find where auth middleware is implemented",
21
+ "Map the routing structure",
22
+ "Summarize what the uncommitted diff changes, file by file",
23
+ ],
24
+ decisionTrigger: "Task finds or maps code (including git history) without touch?",
25
+ tools: ["read", "find", "grep", "bash"],
22
26
  systemPrompt: [
23
27
  "Code explorer. READ-ONLY — locate code, understand it, and report findings; never modify anything.",
24
28
  "Search to locate → read the files relevant to the task → trace imports, identify types, interfaces, functions.",
29
+ "Bash for read-only inspection only: git log/show/blame/diff, ls, wc. Never run a command that modifies files or state (sed, tee, echo >, git checkout/commit, installs).",
25
30
  "Skip noise: lockfiles, vendored, minified, and generated files.",
26
31
  "",
27
32
  "Output format:",
package/src/types.ts CHANGED
@@ -230,15 +230,6 @@ export interface WaitDetails {
230
230
  timedOut?: boolean;
231
231
  }
232
232
 
233
- /** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
234
- export interface CollectedRun {
235
- id: string;
236
- role: string;
237
- /** First-line task preview (same 70-char cap as the inbox reminder). */
238
- task: string;
239
- state: "finished" | "failed";
240
- }
241
-
242
233
  /** Details for a check tool result — a frozen one-shot snapshot of a single run. */
243
234
  export interface CheckDetails {
244
235
  id: string;
@@ -253,6 +244,17 @@ export interface CheckDetails {
253
244
  */
254
245
  export type CancelDetails = CheckDetails;
255
246
 
247
+ /**
248
+ * Details for a steer tool result — the echoed correction. Collapsed shows
249
+ * icon + first line; expanded shows the full message plus the delivery hint
250
+ * (check is the result-fetcher for the effect, never this row).
251
+ */
252
+ export interface SteerDetails {
253
+ id: string;
254
+ role: string;
255
+ message: string;
256
+ }
257
+
256
258
  /**
257
259
  * Details for the background-run completion notice (custom message
258
260
  * `subagent-completion`). The renderer lays these out as a structured notice
package/src/utils.test.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  completionNoticeLines,
45
45
  formatToolCall,
46
46
  briefFilesUsed,
47
+ collectDeliveredIds,
47
48
  } from "./utils.ts";
48
49
  import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
49
50
 
@@ -853,3 +854,40 @@ describe("briefFilesUsed", () => {
853
854
  assert.equal(used.get(F1), false);
854
855
  });
855
856
  });
857
+
858
+ describe("collectDeliveredIds", () => {
859
+ const checkEntry = (id: string) => ({
860
+ type: "message",
861
+ message: { role: "toolResult", toolName: "subagent_check", details: { id, role: "worker" } },
862
+ });
863
+
864
+ test("collects ids from subagent_check tool results only", () => {
865
+ const entries = [
866
+ { type: "message", message: { role: "user", content: "hi" } },
867
+ { type: "message", message: { role: "assistant", content: [] } },
868
+ { type: "message", message: { role: "toolResult", toolName: "read", details: { id: "sub-9" } } },
869
+ { type: "message", message: { role: "toolResult", toolName: "subagent_wait", details: { entries: [] } } },
870
+ checkEntry("sub-1"),
871
+ { type: "message", message: { role: "custom", customType: "subagent-completion" } },
872
+ { type: "compaction" },
873
+ ];
874
+ assert.deepEqual(collectDeliveredIds(entries), new Set(["sub-1"]));
875
+ });
876
+
877
+ test("dedupes repeated checks of the same id", () => {
878
+ assert.deepEqual(collectDeliveredIds([checkEntry("sub-1"), checkEntry("sub-1")]), new Set(["sub-1"]));
879
+ });
880
+
881
+ test("empty path means nothing delivered (branch rewound past the check)", () => {
882
+ assert.equal(collectDeliveredIds([]).size, 0);
883
+ });
884
+
885
+ test("ignores malformed details", () => {
886
+ const entries = [
887
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check" } },
888
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check", details: {} } },
889
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check", details: { id: 42 } } },
890
+ ];
891
+ assert.equal(collectDeliveredIds(entries).size, 0);
892
+ });
893
+ });
package/src/utils.ts CHANGED
@@ -638,9 +638,9 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
638
638
  }
639
639
 
640
640
  /**
641
- * Cancel confirmation text: short, no output dump — the partial output is
642
- * check's job to return (read-once collection). Always points at check so
643
- * the now-failed registry entry (and its inbox-reminder line) gets cleared.
641
+ * Cancel confirmation text for the /subagent:cancel command: short, no output
642
+ * dump — the partial output is check's job to return. Always points at check
643
+ * so the user knows where the partial output lives.
644
644
  */
645
645
  /**
646
646
  * Compact stop summary shared by the cancel tool text and its TUI row:
@@ -657,8 +657,8 @@ export function cancelStopSummary(r: SubagentResult): string {
657
657
 
658
658
  /**
659
659
  * Cancel confirmation text: short, no output dump — the partial output is
660
- * check's job to return (read-once collection). Always points at check so
661
- * the now-cancelled registry entry (and its inbox-reminder line) gets cleared.
660
+ * check's job to return. Always points at check so the model fetches the
661
+ * partial output it is entitled to.
662
662
  */
663
663
  export function formatCancelText(id: string, role: string, r: SubagentResult): string {
664
664
  const head = `${id} (${role})`;
@@ -819,3 +819,40 @@ export function truncateOutput(t: string): string {
819
819
  const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
820
820
  return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
821
821
  }
822
+
823
+ // ── Session-tree delivery derivation ────────────────────────
824
+
825
+ /**
826
+ * Minimal structural slice of a session entry — the only fields
827
+ * collectDeliveredIds reads. The real SessionEntry from
828
+ * ctx.sessionManager.buildContextEntries() satisfies this shape structurally;
829
+ * keeping it local preserves this module's zero pi-API-dependency rule and
830
+ * lets tests build plain fakes.
831
+ */
832
+ interface SessionEntryLike {
833
+ type: string;
834
+ message?: {
835
+ role?: string;
836
+ toolName?: string;
837
+ details?: unknown;
838
+ };
839
+ }
840
+
841
+ /**
842
+ * Derive the set of background-run ids already delivered by subagent_check
843
+ * on the given session entries. The session tree is the single source of
844
+ * truth for delivery state: it is append-only and branch navigation rebuilds
845
+ * the active path, so branching past a check entry un-delivers (the inbox
846
+ * re-arms) while branching back re-delivers — no mirrored state to sync.
847
+ */
848
+ export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
849
+ const ids = new Set<string>();
850
+ for (const entry of entries) {
851
+ if (entry.type !== "message") continue;
852
+ const message = entry.message;
853
+ if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
854
+ const id = (message.details as { id?: unknown } | undefined)?.id;
855
+ if (typeof id === "string" && id) ids.add(id);
856
+ }
857
+ return ids;
858
+ }