@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 +10 -6
- package/package.json +1 -1
- package/src/index.ts +152 -4
- package/src/reminder.test.ts +16 -0
- package/src/reminder.ts +7 -0
- package/src/render-async.ts +57 -1
- package/src/run.test.ts +15 -5
- package/src/run.ts +14 -6
- package/src/spawn.ts +27 -0
- package/src/types.ts +7 -0
- package/src/utils.test.ts +53 -0
- package/src/utils.ts +48 -2
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
|
|
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;
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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
|
|
550
|
-
//
|
|
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
|
-
:
|
|
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
|
}
|
package/src/reminder.test.ts
CHANGED
|
@@ -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}`;
|
package/src/render-async.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
271
|
-
assert.
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
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:
|
|
377
|
-
?
|
|
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:
|
|
304
|
-
|
|
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 {
|