@d3ara1n/pi-subagent 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`).
5
+ Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`).
6
6
 
7
7
  ## Design Philosophy
8
8
 
@@ -55,7 +55,8 @@ This means:
55
55
  | Command | Description |
56
56
  |---------|-------------|
57
57
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
58
- | `/subagent:status` | List background subagent runs and their current state |
58
+ | `/subagent:status` | List background runs (active + collected) and their current state |
59
+ | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
59
60
 
60
61
  ## Dependencies
61
62
 
@@ -181,13 +182,14 @@ Three execution properties, kept separate:
181
182
 
182
183
  - **Foreground** (default): the call blocks until the run finishes and returns the final output directly. (Under the hood foreground and background share one async run engine — foreground is simply background-but-blocking.)
183
184
  - **Parallel**: multiple `subagent_delegate` calls in one turn run concurrently — foreground and background alike, no special flag.
184
- - **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes; two companion tools collect the outcome:
185
+ - **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes; three companion tools manage the outcome:
185
186
 
186
187
  | Tool | Purpose | Returns to the model |
187
188
  |------|---------|---------------------|
188
189
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
189
190
  | `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 |
190
191
  | `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 |
192
+ | `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation with the partial-output size — the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message; the partial output stays in the registry for `subagent_check` to collect |
191
193
 
192
194
  Typical flow:
193
195
 
@@ -211,7 +213,8 @@ Semantics worth knowing:
211
213
  - **Results are pull-only.** Nothing delivers them to the model — no completion event, no notification, nothing wakes the model up. 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.
212
214
  - **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.
213
215
  - **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.
214
- - **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 collectedso a finished run the model forgot to check keeps surfacing until it does.
216
+ - **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)`.
217
+ - **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.
215
218
  - **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
216
219
  - Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
217
220
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
@@ -222,8 +225,9 @@ Semantics worth knowing:
222
225
  Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
223
226
 
224
227
  - **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
225
- - **subagent_wait row = process + usage.** One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead. A timed-out wait freezes the view.
228
+ - **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`) plus the timeout ceiling (`≤30s`) when one was given. One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead. A timed-out wait freezes the view.
226
229
  - **subagent_check row = the result view.** Same block shape as subagent_wait's single-run view (no id — there is only one), but the result line shows `✓ <AI summary>` (or the budget/failure reason when the run stopped early) and the expanded view renders the **full output** — subagent_check is where the conclusion lives.
230
+ - **subagent_cancel row = confirmation only.** Collapsed: `⏹ sub-1 (worker): cancelled after 1 turn (~29s)` (or `• sub-1 (worker) already finished — nothing to cancel` for a no-op). Expanded adds the reason and the pointer to `subagent_check` — the partial output **never renders here**; it stays in the registry until a check row fetches it (layer contract: delegate = input, wait = process, cancel = intervention, check = result).
227
231
 
228
232
  ### Passing context and reference files
229
233
 
@@ -269,7 +273,7 @@ When a provider error (429, quota, timeout, ...) kills a run and the whole task
269
273
 
270
274
  ### Run history
271
275
 
272
- 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`.
276
+ 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 before starting — at the concurrency gate or during model resolution) are not recorded. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
273
277
 
274
278
  ## License
275
279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  createThrottler,
32
32
  describeCurrentActivity,
33
33
  formatBudgetNote,
34
+ formatCancelText,
34
35
  formatCheckText,
35
36
  formatFallbackNote,
36
37
  formatTimePart,
@@ -47,6 +48,8 @@ import { renderDelegateCall, renderDelegateResult } from "./render.ts";
47
48
  import {
48
49
  renderBackgroundDelegateCall,
49
50
  renderBackgroundDelegateResult,
51
+ renderCancelCall,
52
+ renderCancelResult,
50
53
  renderCheckCall,
51
54
  renderCheckResult,
52
55
  renderWaitCall,
@@ -159,6 +162,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
159
162
  "",
160
163
  "- Use it only when you have your own work this turn (including an ongoing discussion with the user) while the run executes; otherwise let the call block and return the result directly.",
161
164
  "- Results are pull-only — no completion event, no notification, nothing wakes you. Dispatching means owning the collection point: finish your own work, then subagent_check(id) for each result. Use subagent_wait(ids) to block until the run finish.",
165
+ "- Cancel a run you no longer need with subagent_cancel(id) — the child stops and its partial output stays in the registry for subagent_check to collect.",
162
166
  "- Background delegation works only in the top-level session.",
163
167
  );
164
168
  }
@@ -439,7 +443,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
439
443
  name: "subagent_wait",
440
444
  label: "Wait for background subagents",
441
445
  description:
442
- "Block until one or more background subagents (started via subagent_delegate with background: true) finish. Omit ids to wait for ALL current background runs. Returns ONLY each run's final status — one `id (role): finished/failed` line per run, never the results; fetch them afterwards with subagent_check. If timeout_ms elapses before every run finishes, returns an error listing per-run statuses. Cancelling the wait never cancels the runs.",
446
+ "Block until one or more background subagents (started via subagent_delegate with background: true) finish. Omit ids to wait for ALL current background runs. Returns ONLY each run's final status — one `id (role): finished/failed` line per run, never the results; fetch them afterwards with subagent_check. If timeout_ms elapses before every run finishes, returns an error listing per-run statuses. Cancelling the wait never cancels the runs — to stop a run, use subagent_cancel(id).",
443
447
  promptSnippet: "Wait for background subagents to finish",
444
448
  parameters: Type.Object({
445
449
  ids: Type.Optional(
@@ -546,14 +550,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
546
550
  }
547
551
 
548
552
  // Status line per run — same `id (role): state` shape check uses, so
549
- // the model can map ids to roles from wait output alone. Budget-stopped
550
- // runs report "finished" but their output is partial flag it inline.
553
+ // the model can map ids to roles from wait output alone. Budget stops
554
+ // report "finished" but their output is partial; cancelled runs keep
555
+ // their partial output in the registry for check — flag both inline.
551
556
  const perId = () =>
552
557
  runs
553
558
  .map((r) =>
554
559
  r.result?.stopReason === "budget_exceeded"
555
560
  ? `${r.id} (${r.role}): ${r.state} (budget exceeded — output is partial)`
556
- : `${r.id} (${r.role}): ${r.state}`,
561
+ : r.result?.stopReason === "cancelled"
562
+ ? `${r.id} (${r.role}): cancelled (partial output kept)`
563
+ : `${r.id} (${r.role}): ${r.state}`,
557
564
  )
558
565
  .join("\n");
559
566
  if (timedOut) {
@@ -627,6 +634,68 @@ export default function subagentExtension(pi: ExtensionAPI) {
627
634
  renderResult: renderCheckResult,
628
635
  });
629
636
 
637
+ pi.registerTool({
638
+ name: "subagent_cancel",
639
+ label: "Cancel a background subagent",
640
+ description:
641
+ "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.",
642
+ promptSnippet: "Cancel a background subagent run",
643
+ parameters: Type.Object({
644
+ id: Type.String({ description: "Run id returned by a background delegate call" }),
645
+ reason: Type.Optional(
646
+ Type.String({
647
+ description:
648
+ "Why the run is no longer needed (a few words suffice). Recorded with the run — whoever reads the partial output later (via subagent_check or history) sees why it was stopped.",
649
+ }),
650
+ ),
651
+ }),
652
+
653
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
654
+ const run = backgroundRuns.get(params.id);
655
+ if (!run) {
656
+ const collected = collectedRuns.get(params.id);
657
+ if (collected) {
658
+ throw new Error(
659
+ `${params.id} (${collected.role}) was already collected — its result is in your conversation history. There is nothing left to cancel.`,
660
+ );
661
+ }
662
+ const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
663
+ throw new Error(
664
+ `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
665
+ );
666
+ }
667
+
668
+ // Terminal runs cannot be cancelled — point at the collector instead.
669
+ if (run.state === "finished" || run.state === "failed") {
670
+ const what = run.state === "finished" ? "its result" : "the failure reason and partial output";
671
+ const text =
672
+ `${params.id} (${run.role}) already ${run.state} — nothing to cancel. ` +
673
+ `subagent_check(${params.id}) returns ${what}.`;
674
+ return {
675
+ content: [{ type: "text", text }],
676
+ details: { id: run.id, role: run.role, result: run.snapshot },
677
+ };
678
+ }
679
+
680
+ // Abort, then wait for the terminal frame: SIGTERM → child cleanup →
681
+ // cancelled frame carrying the partial output. Bounded by the kill
682
+ // escalation grace (SIGKILL after 5s), so this await cannot hang.
683
+ // The reason becomes the terminal errorMessage verbatim — check and
684
+ // history readers see it prefixed "cancelled — ...".
685
+ run.abort(params.reason?.trim() || "no longer needed");
686
+ const result = await run.promise;
687
+ return {
688
+ content: [{ type: "text", text: formatCancelText(run.id, run.role, result) }],
689
+ details: { id: run.id, role: run.role, result },
690
+ };
691
+ },
692
+
693
+ // Confirmation-only view — the partial output renders only in a check
694
+ // row (layer contract: cancel intervenes, check fetches).
695
+ renderCall: renderCancelCall,
696
+ renderResult: renderCancelResult,
697
+ });
698
+
630
699
  pi.registerCommand("subagent:doctor", {
631
700
  description: "Diagnose pi-subagent configuration and dependencies",
632
701
  handler: async (_args, ctx) => {
@@ -756,4 +825,83 @@ export default function subagentExtension(pi: ExtensionAPI) {
756
825
  ctx.ui.notify(lines.join("\n"), "info");
757
826
  },
758
827
  });
828
+
829
+ pi.registerCommand("subagent:cancel", {
830
+ description: "Cancel a background subagent run: /subagent:cancel <id|all> [reason]",
831
+ getArgumentCompletions: (prefix) => {
832
+ const items: Array<{ value: string; label: string; description?: string }> = [];
833
+ for (const run of backgroundRuns.values()) {
834
+ if (run.state === "queued" || run.state === "running") {
835
+ items.push({ value: run.id, label: run.id, description: `${run.role} — ${run.state}` });
836
+ }
837
+ }
838
+ if (items.length > 1) {
839
+ items.push({ value: "all", label: "all", description: "cancel every live run" });
840
+ }
841
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
842
+ return filtered.length > 0 ? filtered : null;
843
+ },
844
+ handler: async (args, ctx) => {
845
+ // First token = target (id | all), the rest = free-text reason. Without
846
+ // a reason the abort reads "cancelled by user"; with one, "cancelled by
847
+ // user: <reason>" — the agent reading the partial output via check sees
848
+ // that the user (not the model) stopped the run, and why.
849
+ const trimmed = args.trim();
850
+ const spaceIdx = trimmed.indexOf(" ");
851
+ const target = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
852
+ const reason = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
853
+
854
+ if (!target) {
855
+ const live = [...backgroundRuns.values()]
856
+ .filter((r) => r.state === "queued" || r.state === "running")
857
+ .map((r) => `${r.id} (${r.role})`);
858
+ ctx.ui.notify(
859
+ `Usage: /subagent:cancel <id|all> [reason]\nLive runs: ${live.length > 0 ? live.join(", ") : "(none)"}`,
860
+ "info",
861
+ );
862
+ return;
863
+ }
864
+
865
+ if (target !== "all" && !backgroundRuns.has(target)) {
866
+ const collected = collectedRuns.get(target);
867
+ const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
868
+ ctx.ui.notify(
869
+ (collected
870
+ ? `${target} (${collected.role}) was already collected — nothing to cancel.`
871
+ : `Unknown subagent id: ${target}.`) +
872
+ ` Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
873
+ "error",
874
+ );
875
+ return;
876
+ }
877
+
878
+ // "all" targets only live runs; a named id that finished meanwhile is
879
+ // reported as already-terminal instead of cancelled.
880
+ const targets =
881
+ target === "all"
882
+ ? [...backgroundRuns.values()].filter((r) => r.state === "queued" || r.state === "running")
883
+ : [backgroundRuns.get(target)!];
884
+ if (targets.length === 0) {
885
+ ctx.ui.notify("No live background runs to cancel.", "info");
886
+ return;
887
+ }
888
+
889
+ const abortReason = reason.trim() ? `user: ${reason.trim()}` : "user";
890
+ const lines: string[] = [];
891
+ for (const run of targets) {
892
+ if (run.state === "finished" || run.state === "failed") {
893
+ lines.push(`\u2022 ${run.id} (${run.role}) already ${run.state} — nothing to cancel`);
894
+ continue;
895
+ }
896
+ run.abort(abortReason);
897
+ const result = await run.promise;
898
+ lines.push(
899
+ result.usage.turns > 0 || result.output
900
+ ? `\u2717 ${run.id} (${run.role}): cancelled after ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"} — partial output kept (subagent_check / history)`
901
+ : `\u2717 ${run.id} (${run.role}): cancelled (nothing had run yet)`,
902
+ );
903
+ }
904
+ ctx.ui.notify(lines.join("\n"), "info");
905
+ },
906
+ });
759
907
  }
@@ -97,6 +97,22 @@ describe("buildInboxReminder", () => {
97
97
  assert.doesNotMatch(text, /retry hint/);
98
98
  });
99
99
 
100
+ 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",
110
+ }),
111
+ }),
112
+ ])!;
113
+ assert.match(text, /— cancelled — user: wrong direction after review \(ran 7s\) —/);
114
+ });
115
+
100
116
  test("byte-stable: identical state produces identical text", () => {
101
117
  const entries = [
102
118
  entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
package/src/reminder.ts CHANGED
@@ -44,6 +44,13 @@ function inboxStatus(entry: InboxEntry): string {
44
44
  if (entry.state === "running") return "running";
45
45
  const secs = elapsedSeconds(entry.snapshot);
46
46
  const ran = secs != null ? ` (ran ${formatDuration(secs)})` : "";
47
+ // Cancels are recorded with their own label — the model reading the inbox
48
+ // must not mistake a deliberate stop ("we no longer need this") for a
49
+ // crashed run. Same shape as the failed row, reason and frozen duration.
50
+ if (entry.snapshot.stopReason === "cancelled") {
51
+ const reason = taskPreview(entry.snapshot.errorMessage || "no reason recorded");
52
+ return `cancelled — ${reason}${ran}`;
53
+ }
47
54
  if (entry.state === "failed") {
48
55
  const reason = taskPreview(entry.snapshot.errorMessage || entry.snapshot.stderr || "unknown error");
49
56
  return `failed — ${reason}${ran}`;
@@ -24,6 +24,7 @@ import { getMarkdownTheme, type ToolDefinition } from "@earendil-works/pi-coding
24
24
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
25
25
  import type {
26
26
  BackgroundDelegateDetails,
27
+ CancelDetails,
27
28
  CheckDetails,
28
29
  RunViewEntry,
29
30
  SubagentResult,
@@ -31,6 +32,7 @@ import type {
31
32
  } from "./types.ts";
32
33
  import {
33
34
  buildDisplayItems,
35
+ cancelStopSummary,
34
36
  clearElapsedTimer,
35
37
  collapsedText,
36
38
  contentText,
@@ -265,8 +267,15 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
265
267
 
266
268
  export const renderWaitCall: RenderCallFn = (args, theme) => {
267
269
  const ids = ((args as any).ids as string[] | undefined) ?? [];
270
+ const timeoutMs = ((args as any).timeout_ms as number | undefined) ?? 0;
268
271
  const label = ids.length > 0 ? ids.join(", ") : "(all)";
269
- const text = theme.fg("toolTitle", theme.bold("subagent_wait ")) + theme.fg("accent", label);
272
+ // Show the wait ceiling up front the user should know how long this row
273
+ // can block before it gives up. Omitted timeout = wait until runs finish.
274
+ const cap = timeoutMs > 0 ? ` \u2264${Math.max(1, Math.round(timeoutMs / 1000))}s` : "";
275
+ const text =
276
+ theme.fg("toolTitle", theme.bold("subagent_wait ")) +
277
+ theme.fg("accent", label) +
278
+ (cap ? theme.fg("dim", cap) : "");
270
279
  return new Text(text, 0, 0);
271
280
  };
272
281
 
@@ -323,3 +332,50 @@ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _
323
332
  if (expanded) return checkEntryExpandedContainer(details.result, fg);
324
333
  return collapsedText(checkEntryCollapsedText(details.result, fg));
325
334
  };
335
+
336
+ // ── cancel: confirmation-only view (check is the result-fetcher) ──
337
+
338
+ export const renderCancelCall: RenderCallFn = (args, theme) => {
339
+ const id = (args as any).id || "...";
340
+ const text = theme.fg("toolTitle", theme.bold("subagent_cancel ")) + theme.fg("accent", id);
341
+ return new Text(text, 0, 0);
342
+ };
343
+
344
+ export const renderCancelResult: RenderResultFn = (result, { expanded }, theme, _context) => {
345
+ const details = result.details as CancelDetails | undefined;
346
+ if (!details) return collapsedText(contentText(result));
347
+
348
+ const fg = theme.fg.bind(theme) as Fg;
349
+ const r = details.result;
350
+ const head = `${details.id} (${details.role})`;
351
+ // Confirmation only — the partial output stays in the registry and renders
352
+ // only in a subagent_check row. Cancel never shows it (layer contract:
353
+ // delegate = input, wait = process, cancel = intervention, check = result).
354
+ const line =
355
+ r.stopReason === "cancelled"
356
+ ? `${fg("warning", "\u23F9")} ${fg("text", `${head}: ${cancelStopSummary(r)}`)}`
357
+ : `${fg("muted", "\u2022")} ${fg("dim", `${head} already ${deriveRunState(r)} — nothing to cancel`)}`;
358
+ if (!expanded) return collapsedText(line);
359
+
360
+ const container = new Container();
361
+ container.addChild(new Text(line, 0, 0));
362
+ container.addChild(new Spacer(1));
363
+ if (r.stopReason === "cancelled") {
364
+ container.addChild(new Text(fg("dim", `reason: ${r.errorMessage || "—"}`), 0, 0));
365
+ container.addChild(
366
+ new Text(
367
+ fg(
368
+ "dim",
369
+ `partial output (${r.output.length} chars) kept in the registry — subagent_check(${details.id}) returns it once`,
370
+ ),
371
+ 0,
372
+ 0,
373
+ ),
374
+ );
375
+ } else {
376
+ container.addChild(
377
+ new Text(fg("dim", `subagent_check(${details.id}) returns its result`), 0, 0),
378
+ );
379
+ }
380
+ return container;
381
+ };
package/src/run.test.ts CHANGED
@@ -137,6 +137,9 @@ test("a throwing spawn resolves the promise with a failed result carrying the er
137
137
  assert.strictEqual(run.state, "failed");
138
138
  assert.ok(run.thrown instanceof Error);
139
139
  assert.strictEqual(run.thrown.message, "Subagent was aborted");
140
+ // Non-abort crashes (spawn failure) keep the plain thrown message — the
141
+ // cancelled stop reason is reserved for real aborts.
142
+ assert.strictEqual(result.stopReason, undefined);
140
143
  assert.strictEqual(result.errorMessage, "Subagent was aborted");
141
144
  // The partial frame survives — the foreground path renders aborts like any
142
145
  // failure (task line + activity + result line) instead of a bare error.
@@ -252,7 +255,8 @@ test("abort while queued fails the run and exposes thrown for the foreground pat
252
255
 
253
256
  assert.strictEqual(run.state, "failed");
254
257
  assert.ok(run.thrown instanceof Error);
255
- assert.match(result.errorMessage!, /cancelled while queued/);
258
+ assert.strictEqual(result.stopReason, "cancelled");
259
+ assert.match(result.errorMessage!, /still queued for a concurrency slot/);
256
260
  gate.release();
257
261
  });
258
262
 
@@ -267,8 +271,8 @@ test("handle.abort() reaps a queued background run (no caller signal)", async ()
267
271
 
268
272
  assert.strictEqual(run.state, "failed");
269
273
  assert.ok(run.thrown instanceof Error);
270
- assert.match(result.errorMessage!, /cancelled while queued/);
271
- assert.match(result.errorMessage!, /session shutdown/);
274
+ assert.strictEqual(result.stopReason, "cancelled");
275
+ assert.strictEqual(result.errorMessage, "still queued for a concurrency slot (session shutdown)");
272
276
  gate.release();
273
277
  });
274
278
 
@@ -289,7 +293,10 @@ test("handle.abort(reason) fails a running run with the reason in the error mess
289
293
  const result = await run.promise;
290
294
 
291
295
  assert.strictEqual(run.state, "failed");
292
- assert.match(result.errorMessage!, /Subagent was aborted \(session shutdown\)/);
296
+ assert.strictEqual(result.stopReason, "cancelled");
297
+ // The abort reason becomes the errorMessage verbatim — renderers add the
298
+ // "cancelled" framing, so the message itself must not repeat it.
299
+ assert.strictEqual(result.errorMessage, "session shutdown");
293
300
  assert.ok(run.thrown instanceof Error);
294
301
  // The internal controller the spawn honored is the same channel abort() used.
295
302
  assert.ok(signals[0].aborted);
@@ -307,7 +314,10 @@ test("a pre-aborted caller signal chains into the run before spawn", async () =>
307
314
  const result = await run.promise;
308
315
 
309
316
  assert.strictEqual(run.state, "failed");
310
- assert.strictEqual(result.errorMessage, "Subagent was aborted");
317
+ // Caller-signal abort (foreground Esc): no explicit reason was given, so
318
+ // the cancelled frame falls back to the bare "cancelled" message.
319
+ assert.strictEqual(result.stopReason, "cancelled");
320
+ assert.strictEqual(result.errorMessage, "cancelled");
311
321
  // abort() after settle is a no-op — the terminal state never flips.
312
322
  run.abort("session shutdown");
313
323
  assert.strictEqual(run.state, "failed");
package/src/run.ts CHANGED
@@ -190,9 +190,11 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
190
190
  await opts.gate.acquire(controller.signal);
191
191
  } catch {
192
192
  const msg =
193
- "cancelled while queued for a concurrency slot" +
194
- (abortReason ? ` (${abortReason})` : "");
195
- finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
193
+ "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
194
+ finish(
195
+ { ...inputFrame(1, false), stopReason: "cancelled", errorMessage: msg },
196
+ new Error(msg),
197
+ );
196
198
  return;
197
199
  }
198
200
 
@@ -364,17 +366,23 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
364
366
  // Keep whatever the last live frame gathered so aborted/crashed runs
365
367
  // still show their partial activity and usage.
366
368
  const partial = snapshot;
369
+ // Aborts settle as their own stop reason ("cancelled", same family as
370
+ // timeout/budget: intentional stop with partial output) and the abort
371
+ // reason becomes the error message verbatim — no wrapper needed, every
372
+ // renderer already prefixes "cancelled". Non-abort crashes (spawn
373
+ // failure) keep the plain thrown message.
374
+ const wasCancelled = controller.signal.aborted;
367
375
  const terminal: SubagentResult = {
368
376
  ...inputFrame(1, false),
369
377
  output: partial.output,
370
378
  usage: partial.usage,
371
379
  model: partial.model,
372
- stopReason: partial.stopReason,
380
+ stopReason: wasCancelled ? "cancelled" : undefined,
373
381
  activityLog: partial.activityLog,
374
382
  budgetMs: partial.budgetMs,
375
383
  elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
376
- errorMessage: abortReason
377
- ? `Subagent was aborted (${abortReason})`
384
+ errorMessage: wasCancelled
385
+ ? abortReason || "cancelled"
378
386
  : err?.message || String(err),
379
387
  };
380
388
  // The run spawned before throwing — audit it like any terminal state.
package/src/spawn.ts CHANGED
@@ -231,6 +231,33 @@ export async function spawnSubagent(
231
231
  "--append-system-prompt",
232
232
  `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
233
233
  );
234
+ // Shared behavioral policy for EVERY subagent run — built-in roles and
235
+ // agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
236
+ // does; this shapes HOW any subagent behaves when the task exceeds its
237
+ // actual capabilities: report the gap and stop instead of improvising
238
+ // workarounds until timeout.
239
+ args.push(
240
+ "--append-system-prompt",
241
+ [
242
+ "<subagent_policy>",
243
+ "Before attempting the task, check it against your actual capabilities in this",
244
+ "session — the tool list here is definitive.",
245
+ "- If the task needs a capability you do not have (web access, bash, file",
246
+ " writes, ...) or material that is not present locally or in the provided",
247
+ " context/files, it is out of scope for you. Do NOT improvise workarounds.",
248
+ '- "Cannot complete" means a capability or material gap — not "difficult" or',
249
+ ' "uncertain". If it is merely hard, keep working within your tools.',
250
+ "- When you hit a genuine gap, stop early and return:",
251
+ " ## Cannot complete",
252
+ " - Missing: the capability or material that is absent",
253
+ " - Needed: what would complete the task",
254
+ " - Found: partial findings so far (optional)",
255
+ "",
256
+ 'An early "cannot complete" report is a successful outcome; grinding on',
257
+ "impossible workarounds until timeout is the failure.",
258
+ "</subagent_policy>",
259
+ ].join("\n"),
260
+ );
234
261
 
235
262
  // ── Context channel: independent size gate ──
236
263
  // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
package/src/types.ts CHANGED
@@ -230,3 +230,10 @@ export interface CheckDetails {
230
230
  role: string;
231
231
  result: SubagentResult;
232
232
  }
233
+
234
+ /**
235
+ * Details for a cancel tool result — same shape as check: the run's terminal
236
+ * frame. Renders with the confirmation-only view: the stop summary and the
237
+ * reason, never the partial output (check is the result-fetcher).
238
+ */
239
+ export type CancelDetails = CheckDetails;
package/src/utils.test.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  formatFallbackNote,
36
36
  formatBudgetNote,
37
37
  formatCheckText,
38
+ formatCancelText,
38
39
  formatTimePart,
39
40
  freezeFrame,
40
41
  createThrottler,
@@ -411,6 +412,15 @@ describe("terminalResultLine", () => {
411
412
  "\u23F2 Budget exceeded (50 turns; partial output returned)",
412
413
  );
413
414
  });
415
+ test("cancelled maps to the ⏹ warning styling like timeout/budget", () => {
416
+ assert.equal(
417
+ terminalResultLine(
418
+ baseResult({ exitCode: 1, stopReason: "cancelled", errorMessage: "user: wrong direction" }),
419
+ id,
420
+ ),
421
+ "\u23F9 user: wrong direction",
422
+ );
423
+ });
414
424
  test("success chain: AI summary wins, then output first line, then placeholder", () => {
415
425
  assert.equal(terminalResultLine(baseResult({ summary: "did the thing" }), id), "\u2713 did the thing");
416
426
  assert.equal(terminalResultLine(baseResult(), id), "\u2713 ok");
@@ -528,6 +538,8 @@ describe("background run helpers", () => {
528
538
  assert.equal(deriveRunState(baseResult()), "finished");
529
539
  assert.equal(deriveRunState(baseResult({ exitCode: 1 })), "failed");
530
540
  assert.equal(deriveRunState(baseResult({ stopReason: "timeout", exitCode: 124 })), "failed");
541
+ // cancelled stops are failures with partial output (same family as timeout)
542
+ assert.equal(deriveRunState(baseResult({ stopReason: "cancelled" })), "failed");
531
543
  // budget stops are intentional finishes
532
544
  assert.equal(deriveRunState(baseResult({ stopReason: "budget_exceeded" })), "finished");
533
545
  });
@@ -603,6 +615,47 @@ describe("background run helpers", () => {
603
615
  assert.match(formatCheckText("sub-1", "explorer", baseResult()), /^sub-1 \(explorer\): finished\n\nok$/);
604
616
  });
605
617
 
618
+ test("formatCheckText renders cancelled with the bare reason and partial output", () => {
619
+ assert.match(
620
+ formatCheckText(
621
+ "sub-1",
622
+ "explorer",
623
+ baseResult({ exitCode: 1, stopReason: "cancelled", errorMessage: "user: wrong direction" }),
624
+ ),
625
+ /^sub-1 \(explorer\): cancelled — user: wrong direction\n\nPartial output:\nok$/,
626
+ );
627
+ });
628
+
629
+ test("formatCancelText distinguishes never-started cancels from mid-run cancels", () => {
630
+ // Never-started cancel (gate/model-resolution phase): terminal frame has
631
+ // NO elapsedMs — nothing ran. Mirrors the real terminal-frame shape:
632
+ // startTime never survives (live-frame-only field), duration freezes
633
+ // into elapsedMs.
634
+ assert.match(
635
+ formatCancelText("sub-1", "explorer", baseResult({ exitCode: -1, output: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } })),
636
+ /^sub-1 \(explorer\): cancelled — never started, no partial output\. subagent_check\(sub-1\) clears the registry entry\.$/,
637
+ );
638
+ // Mid-run cancel: real terminal shape — elapsedMs frozen, no startTime.
639
+ const midRun = baseResult({
640
+ exitCode: -1,
641
+ elapsedMs: 45000,
642
+ output: "partial findings",
643
+ usage: { input: 1000, output: 200, cacheRead: 0, cacheWrite: 0, cost: 0.1, contextTokens: 0, turns: 3 },
644
+ });
645
+ assert.match(
646
+ formatCancelText("sub-2", "worker", midRun),
647
+ /^sub-2 \(worker\): cancelled after 3 turns \(~45s\) — partial output \(16 chars\) kept in the registry; subagent_check\(sub-2\) returns it once\.$/,
648
+ );
649
+ // Aborted before the first completed turn: "under a turn", not "0 turns".
650
+ const underATurn = baseResult({
651
+ exitCode: -1,
652
+ elapsedMs: 2000,
653
+ output: "",
654
+ usage: { input: 1000, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0.05, contextTokens: 0, turns: 0 },
655
+ });
656
+ assert.match(formatCancelText("sub-3", "worker", underATurn), /cancelled after under a turn \(~2s\)/);
657
+ });
658
+
606
659
  test("formatCheckText flags budget-stopped runs as partial on the finished line", () => {
607
660
  const r = baseResult({ stopReason: "budget_exceeded", errorMessage: "Budget exceeded (50 turns)" });
608
661
  assert.match(
package/src/utils.ts CHANGED
@@ -288,6 +288,7 @@ export function runIcon(
288
288
  if (state === "running") return fg("warning", "\u23F3");
289
289
  if (r.stopReason === "timeout") return fg("warning", "\u23F1");
290
290
  if (r.stopReason === "budget_exceeded") return fg("warning", "\u23F2");
291
+ if (r.stopReason === "cancelled") return fg("warning", "\u23F9");
291
292
  if (state === "failed") return fg("error", "\u2717");
292
293
  return fg("success", "\u2713");
293
294
  }
@@ -299,9 +300,14 @@ function failureResultText(r: {
299
300
  }): { content: string; col: "warning" | "error" } {
300
301
  const isTimeout = r.stopReason === "timeout";
301
302
  const isBudget = r.stopReason === "budget_exceeded";
303
+ const isCancelled = r.stopReason === "cancelled";
302
304
  return {
303
- content: r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed"),
304
- col: isTimeout || isBudget ? "warning" : "error",
305
+ content:
306
+ r.errorMessage ||
307
+ (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : isCancelled ? "Cancelled" : "failed"),
308
+ // Timeout/budget/cancel are intentional stops with partial output —
309
+ // warning, not the error red reserved for real failures.
310
+ col: isTimeout || isBudget || isCancelled ? "warning" : "error",
305
311
  };
306
312
  }
307
313
 
@@ -386,6 +392,7 @@ export function isFailedResult(r: { exitCode: number; stopReason?: string }): bo
386
392
  r.exitCode !== 0 ||
387
393
  r.stopReason === "error" ||
388
394
  r.stopReason === "aborted" ||
395
+ r.stopReason === "cancelled" ||
389
396
  r.stopReason === "timeout"
390
397
  );
391
398
  }
@@ -509,12 +516,51 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
509
516
  const head = `${id} (${role})`;
510
517
  if (state === "queued") return `${head}: queued — waiting for a concurrency slot.`;
511
518
  if (state === "running") return `${head}: running — ${describeCurrentActivity(r)}`;
519
+ if (r.stopReason === "cancelled") {
520
+ // errorMessage is the bare abort reason ("user: ..." / "session shutdown")
521
+ // — the "cancelled" prefix here is the only wrapper it gets.
522
+ return `${head}: cancelled — ${r.errorMessage || "no reason recorded"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
523
+ }
512
524
  if (state === "failed") {
513
525
  return `${head}: failed — ${r.errorMessage || r.stderr || "unknown error"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
514
526
  }
515
527
  return `${head}: finished\n\n${r.output}${formatBudgetNote(r)}${formatFallbackNote(r)}${formatUsageFooter(r)}`;
516
528
  }
517
529
 
530
+ /**
531
+ * Cancel confirmation text: short, no output dump — the partial output is
532
+ * check's job to return (read-once collection). Always points at check so
533
+ * the now-failed registry entry (and its inbox-reminder line) gets cleared.
534
+ */
535
+ /**
536
+ * Compact stop summary shared by the cancel tool text and its TUI row:
537
+ * `cancelled after 3 turns (~45s)` / `cancelled after under a turn (~2s)` /
538
+ * `cancelled — never started` (never spawned: no elapsedMs on the frame).
539
+ */
540
+ export function cancelStopSummary(r: SubagentResult): string {
541
+ if (r.elapsedMs == null) return "cancelled — never started";
542
+ const turns = r.usage.turns;
543
+ const secs = Math.max(1, Math.round(r.elapsedMs / 1000));
544
+ const turnNote = turns > 0 ? `${turns} turn${turns === 1 ? "" : "s"}` : "under a turn";
545
+ return `cancelled after ${turnNote} (~${secs}s)`;
546
+ }
547
+
548
+ /**
549
+ * Cancel confirmation text: short, no output dump — the partial output is
550
+ * check's job to return (read-once collection). Always points at check so
551
+ * the now-cancelled registry entry (and its inbox-reminder line) gets cleared.
552
+ */
553
+ export function formatCancelText(id: string, role: string, r: SubagentResult): string {
554
+ const head = `${id} (${role})`;
555
+ if (r.elapsedMs == null) {
556
+ return `${head}: cancelled — never started, no partial output. subagent_check(${id}) clears the registry entry.`;
557
+ }
558
+ return (
559
+ `${head}: ${cancelStopSummary(r)} — partial output (${r.output.length} chars) ` +
560
+ `kept in the registry; subagent_check(${id}) returns it once.`
561
+ );
562
+ }
563
+
518
564
  /** Freeze a live frame into a static snapshot: stop the elapsed clock and fold the open pause into grace. */
519
565
  export function freezeFrame(r: SubagentResult): SubagentResult {
520
566
  return {