@d3ara1n/pi-subagent 1.0.1 → 1.1.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 +4 -2
- package/package.json +1 -1
- package/src/index.ts +112 -43
- package/src/reminder.test.ts +175 -0
- package/src/reminder.ts +93 -0
- package/src/render-async.ts +7 -6
- package/src/render.ts +3 -2
- package/src/run.test.ts +57 -0
- package/src/run.ts +33 -6
- package/src/spawn.ts +27 -0
- package/src/types.ts +9 -0
- package/src/utils.ts +19 -0
package/README.md
CHANGED
|
@@ -181,7 +181,7 @@ Foreground and background delegation share one async run engine — foreground i
|
|
|
181
181
|
|------|---------|---------------------|
|
|
182
182
|
| `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
|
|
183
183
|
| `subagent_wait(ids?, timeout_ms?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | Statuses only, one `id (role): finished/failed` line per run — never results; errors when the timeout hits with runs unfinished |
|
|
184
|
-
| `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output |
|
|
184
|
+
| `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output. Checking a terminal run **collects** it: the output is returned once and the run leaves the registry |
|
|
185
185
|
|
|
186
186
|
Typical flow:
|
|
187
187
|
|
|
@@ -203,10 +203,12 @@ Typical flow:
|
|
|
203
203
|
Semantics worth knowing:
|
|
204
204
|
|
|
205
205
|
- **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
|
|
206
|
+
- **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it — the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
|
|
207
|
+
- **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the unclaimed runs (queued, running, and finished-but-unchecked alike), injected at a cache-stable head position. Runs missing from the list were already collected — so a finished run the model forgot to check keeps surfacing until it does.
|
|
206
208
|
- **`timeout_ms` is optional.** Without it, `subagent_wait` blocks until every run finishes; each run is still bounded by its own role timeout.
|
|
207
209
|
- Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
|
|
208
210
|
- **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
|
|
209
|
-
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run and its current state.
|
|
211
|
+
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run (active + collected) and its current state.
|
|
210
212
|
|
|
211
213
|
### Background TUI display
|
|
212
214
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
CollectedRun,
|
|
21
|
+
SubagentConfig,
|
|
22
|
+
SubagentResult,
|
|
23
|
+
SubagentRole,
|
|
24
|
+
} from "./types.ts";
|
|
20
25
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
21
26
|
import { loadSubagentConfig } from "./config.ts";
|
|
22
27
|
import { BUILTIN_ROLES } from "./roles.ts";
|
|
@@ -37,6 +42,7 @@ import {
|
|
|
37
42
|
taskPreview,
|
|
38
43
|
} from "./utils.ts";
|
|
39
44
|
import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
45
|
+
import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
40
46
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
41
47
|
import {
|
|
42
48
|
renderBackgroundDelegateCall,
|
|
@@ -88,12 +94,26 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
88
94
|
refreshAvailableRoles();
|
|
89
95
|
|
|
90
96
|
// ── Background run registry ────────────────────────────────────
|
|
91
|
-
// Process-lifetime map of background runs
|
|
92
|
-
//
|
|
93
|
-
//
|
|
97
|
+
// Process-lifetime map of unclaimed background runs (queued, running, and
|
|
98
|
+
// finished/failed alike). Foreground delegate runs are NOT registered —
|
|
99
|
+
// their lifecycle is the tool call itself. subagent_check on a terminal run
|
|
100
|
+
// collects it: the handle is freed and a lightweight CollectedRun tombstone
|
|
101
|
+
// takes its place, so ids stay resolvable while memory does not grow with
|
|
102
|
+
// full results.
|
|
94
103
|
const backgroundRuns = new Map<string, RunHandle>();
|
|
104
|
+
const collectedRuns = new Map<string, CollectedRun>();
|
|
95
105
|
let runCounter = 0;
|
|
96
106
|
|
|
107
|
+
// ── Live-run reaping ─────────────────────────────────────────
|
|
108
|
+
// Every in-flight run (foreground and background alike), removed once
|
|
109
|
+
// settled. session_shutdown aborts whatever is still here so no child
|
|
110
|
+
// process outlives the parent — quit, reload, or session replacement.
|
|
111
|
+
const liveRuns = new Set<RunHandle>();
|
|
112
|
+
function trackRun(run: RunHandle): void {
|
|
113
|
+
liveRuns.add(run);
|
|
114
|
+
void run.promise.then(() => liveRuns.delete(run));
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
// Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
|
|
98
118
|
const guidelines: string[] = [];
|
|
99
119
|
|
|
@@ -115,10 +135,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
115
135
|
guidelines.push(
|
|
116
136
|
"WHEN TO DELEGATE — offload substantial work when you only need the result:",
|
|
117
137
|
"",
|
|
118
|
-
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps.",
|
|
119
|
-
"- DO NOT delegate simple tasks
|
|
120
|
-
"- DO NOT delegate straightforward file modifications touching 1-2 files. Use edit/write directly.",
|
|
121
|
-
"- Delegation has overhead (spawning a child process). Reserve it for tasks that would genuinely clutter your context with 3+ turns of raw tool output.",
|
|
138
|
+
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
|
|
139
|
+
"- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
|
|
122
140
|
"",
|
|
123
141
|
"AVAILABLE ROLES:",
|
|
124
142
|
...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
|
|
@@ -132,17 +150,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
132
150
|
...exampleLines,
|
|
133
151
|
"",
|
|
134
152
|
"For multiple independent substantial tasks, emit multiple subagent_delegate calls in one turn — they run in parallel.",
|
|
135
|
-
"Subagents have no access to this conversation — everything they need must come through `task`, `context`, and `files`.",
|
|
136
|
-
'Pass reference files via the `files` parameter (e.g. files: ["src/auth.ts"]) instead of pasting their contents into `context` — the subagent reads them directly without consuming your context window.',
|
|
137
|
-
"Override the model per-call with the `model` parameter for one-off vision or model-specific jobs.",
|
|
138
153
|
"",
|
|
139
154
|
"BACKGROUND DELEGATION — start runs now, collect results later:",
|
|
140
155
|
"",
|
|
141
|
-
"- subagent_delegate(background: true)
|
|
142
|
-
"-
|
|
143
|
-
"-
|
|
144
|
-
"- subagent_check(id) is the result-fetcher: for a finished run it returns the full output; mid-run it returns a snapshot (queued/running + current activity). One id per call — results can be large.",
|
|
145
|
-
"- Typical flow: subagent_delegate(background: true) ×N → work on something else → subagent_wait([id1, id2, ...]) → subagent_check(id) for each finished run.",
|
|
156
|
+
"- Typical flow: subagent_delegate(background: true) ×N → keep working → subagent_wait(ids) to block until every listed run finishes (omit ids to wait for all) → subagent_check(id) for each finished run.",
|
|
157
|
+
"- A terminal check collects the run — the output is returned once and the run leaves the registry. Mid-run checks are free: peek at progress as often as you like; only terminal checks collect.",
|
|
158
|
+
"- Every LLM call carries a [background subagent runs] reminder listing your unclaimed runs (queued, running, and finished-but-unchecked alike). Runs missing from that list were already collected.",
|
|
146
159
|
"- Background delegation works only in the top-level session.",
|
|
147
160
|
);
|
|
148
161
|
}
|
|
@@ -198,6 +211,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
198
211
|
rebuildGuidelines(availableRoles);
|
|
199
212
|
});
|
|
200
213
|
|
|
214
|
+
pi.on("context", async (event) => {
|
|
215
|
+
// The model's inbox: every unclaimed background run, injected at a
|
|
216
|
+
// cache-stable head position before every provider call. Empty inbox →
|
|
217
|
+
// zero injection (context untouched, cache fully stable).
|
|
218
|
+
const reminder = buildInboxReminder(backgroundRuns.values());
|
|
219
|
+
if (!reminder) return;
|
|
220
|
+
return { messages: injectReminder(event.messages, reminder) };
|
|
221
|
+
});
|
|
222
|
+
|
|
201
223
|
pi.on("tool_result", (event) => {
|
|
202
224
|
if (event.toolName === "subagent_delegate" && hasFailedSubagentResult(event.details)) {
|
|
203
225
|
return { isError: true };
|
|
@@ -207,6 +229,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
207
229
|
}
|
|
208
230
|
});
|
|
209
231
|
|
|
232
|
+
// Fires before the extension runtime is torn down (quit, reload, or
|
|
233
|
+
// session replacement). Aborting funnels through the standard abort path:
|
|
234
|
+
// children get SIGTERM → their own handlers kill grandchildren, aborted
|
|
235
|
+
// runs are audited to history, gates release. Without this, background
|
|
236
|
+
// children would burn tokens as unwaitable orphans after /reload or /new.
|
|
237
|
+
pi.on("session_shutdown", () => {
|
|
238
|
+
for (const run of liveRuns) run.abort("session shutdown");
|
|
239
|
+
});
|
|
240
|
+
|
|
210
241
|
pi.registerTool({
|
|
211
242
|
name: "subagent_delegate",
|
|
212
243
|
label: "Delegate to subagent",
|
|
@@ -236,7 +267,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
236
267
|
background: Type.Optional(
|
|
237
268
|
Type.Boolean({
|
|
238
269
|
description:
|
|
239
|
-
"Run asynchronously: returns an id (sub-N) immediately instead of blocking.
|
|
270
|
+
"Run asynchronously: returns an id (sub-N) immediately instead of blocking. The run survives turn cancellation.",
|
|
240
271
|
}),
|
|
241
272
|
),
|
|
242
273
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
|
|
@@ -295,6 +326,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
295
326
|
getRolesApi: getModelRolesAPI,
|
|
296
327
|
getSessionId: () => ctx.sessionManager?.getSessionId(),
|
|
297
328
|
});
|
|
329
|
+
trackRun(run);
|
|
298
330
|
|
|
299
331
|
// ── Background: return the id immediately; the pipeline keeps running. ──
|
|
300
332
|
if (params.background) {
|
|
@@ -431,10 +463,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
431
463
|
}
|
|
432
464
|
const unknown = ids.filter((id) => !backgroundRuns.has(id));
|
|
433
465
|
if (unknown.length > 0) {
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
466
|
+
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
467
|
+
const collectedNotes = unknown
|
|
468
|
+
.filter((id) => collectedRuns.has(id))
|
|
469
|
+
.map((id) => `${id} was already collected (result is in your history)`);
|
|
470
|
+
const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
|
|
471
|
+
const parts = [
|
|
472
|
+
`Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
|
|
473
|
+
collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
|
|
474
|
+
`Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
475
|
+
].filter(Boolean);
|
|
476
|
+
throw new Error(parts.join(" "));
|
|
438
477
|
}
|
|
439
478
|
const runs = ids.map((id) => backgroundRuns.get(id)!);
|
|
440
479
|
const timeoutMs =
|
|
@@ -538,7 +577,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
538
577
|
name: "subagent_check",
|
|
539
578
|
label: "Check a background subagent",
|
|
540
579
|
description:
|
|
541
|
-
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. One id per call because results can be large.",
|
|
580
|
+
"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.",
|
|
542
581
|
promptSnippet: "Inspect a background subagent run",
|
|
543
582
|
parameters: Type.Object({
|
|
544
583
|
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
@@ -547,14 +586,34 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
547
586
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
548
587
|
const run = backgroundRuns.get(params.id);
|
|
549
588
|
if (!run) {
|
|
550
|
-
const
|
|
589
|
+
const collected = collectedRuns.get(params.id);
|
|
590
|
+
if (collected) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
551
596
|
throw new Error(
|
|
552
|
-
`Unknown subagent id: ${params.id}.
|
|
597
|
+
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
553
598
|
);
|
|
554
599
|
}
|
|
555
600
|
|
|
556
601
|
// Freeze live frames so the snapshot's elapsed time stays static.
|
|
557
602
|
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
603
|
+
|
|
604
|
+
// Read-once collection: a terminal check returns the result AND frees
|
|
605
|
+
// the run — the output now lives in the conversation history, so the
|
|
606
|
+
// registry keeps only a lightweight tombstone for id resolution.
|
|
607
|
+
if (run.state === "finished" || run.state === "failed") {
|
|
608
|
+
backgroundRuns.delete(run.id);
|
|
609
|
+
collectedRuns.set(run.id, {
|
|
610
|
+
id: run.id,
|
|
611
|
+
role: run.role,
|
|
612
|
+
task: taskPreview(run.task),
|
|
613
|
+
state: run.state,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
558
617
|
return {
|
|
559
618
|
content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
|
|
560
619
|
details: { id: run.id, role: run.role, result: snap },
|
|
@@ -656,30 +715,40 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
656
715
|
pi.registerCommand("subagent:status", {
|
|
657
716
|
description: "List background subagent runs and their current state",
|
|
658
717
|
handler: async (_args, ctx) => {
|
|
659
|
-
if (backgroundRuns.size === 0) {
|
|
718
|
+
if (backgroundRuns.size === 0 && collectedRuns.size === 0) {
|
|
660
719
|
ctx.ui.notify("No background runs.", "info");
|
|
661
720
|
return;
|
|
662
721
|
}
|
|
663
722
|
const lines: string[] = [];
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
723
|
+
if (backgroundRuns.size > 0) {
|
|
724
|
+
lines.push("Active:");
|
|
725
|
+
for (const run of backgroundRuns.values()) {
|
|
726
|
+
// Freeze live frames so elapsed-dependent details don't drift in the listing.
|
|
727
|
+
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
728
|
+
let icon: string;
|
|
729
|
+
let detail: string;
|
|
730
|
+
if (run.state === "failed") {
|
|
731
|
+
icon = "\u2717";
|
|
732
|
+
detail = snap.errorMessage || "unknown error";
|
|
733
|
+
} else if (run.state === "finished") {
|
|
734
|
+
icon = "\u2713";
|
|
735
|
+
detail = snap.summary || taskPreview(snap.output) || "(no output)";
|
|
736
|
+
} else if (run.state === "queued") {
|
|
737
|
+
icon = "\u23F8";
|
|
738
|
+
detail = "queued — waiting for a concurrency slot";
|
|
739
|
+
} else {
|
|
740
|
+
icon = "\u23F3";
|
|
741
|
+
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
742
|
+
}
|
|
743
|
+
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (collectedRuns.size > 0) {
|
|
747
|
+
if (lines.length > 0) lines.push("");
|
|
748
|
+
lines.push("Collected (result already returned via subagent_check):");
|
|
749
|
+
for (const c of collectedRuns.values()) {
|
|
750
|
+
lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
|
|
681
751
|
}
|
|
682
|
-
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
683
752
|
}
|
|
684
753
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
685
754
|
},
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the background-run inbox reminder.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency: runs on node's built-in test runner.
|
|
5
|
+
* node --test packages/pi-subagent/src/reminder.test.ts
|
|
6
|
+
*
|
|
7
|
+
* Coverage: row formatting per state (queued/running/finished/failed/budget),
|
|
8
|
+
* byte-stability between calls (the cache-prefix contract), empty-inbox
|
|
9
|
+
* no-op, and cache-stable head injection (string content, block content,
|
|
10
|
+
* non-user first message, empty transcript).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { buildInboxReminder, injectReminder, type InboxEntry } from "./reminder.ts";
|
|
16
|
+
import type { SubagentResult } from "./types.ts";
|
|
17
|
+
import { emptyUsage } from "./utils.ts";
|
|
18
|
+
|
|
19
|
+
function frame(partial: Partial<SubagentResult>): SubagentResult {
|
|
20
|
+
return {
|
|
21
|
+
role: "worker",
|
|
22
|
+
task: "task",
|
|
23
|
+
exitCode: 0,
|
|
24
|
+
output: "",
|
|
25
|
+
stderr: "",
|
|
26
|
+
usage: emptyUsage(),
|
|
27
|
+
activityLog: [],
|
|
28
|
+
...partial,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">): InboxEntry {
|
|
33
|
+
return {
|
|
34
|
+
role: "worker",
|
|
35
|
+
task: "Investigate flaky tests in packages/pi-subagent",
|
|
36
|
+
snapshot: frame({}),
|
|
37
|
+
...partial,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("buildInboxReminder", () => {
|
|
42
|
+
test("empty inbox returns undefined (zero injection)", () => {
|
|
43
|
+
assert.equal(buildInboxReminder([]), undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("queued and running rows carry no time-derived detail", () => {
|
|
47
|
+
const text = buildInboxReminder([
|
|
48
|
+
entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
|
|
49
|
+
entry({
|
|
50
|
+
id: "sub-2",
|
|
51
|
+
state: "running",
|
|
52
|
+
// Live frame: startTime present — a naive formatter would derive elapsed from it.
|
|
53
|
+
snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
|
|
54
|
+
}),
|
|
55
|
+
])!;
|
|
56
|
+
assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
|
|
57
|
+
assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
|
|
58
|
+
// No seconds anywhere on live rows — byte-stability contract.
|
|
59
|
+
assert.doesNotMatch(text, /\d+s/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("finished row freezes duration from elapsedMs", () => {
|
|
63
|
+
const text = buildInboxReminder([
|
|
64
|
+
entry({
|
|
65
|
+
id: "sub-1",
|
|
66
|
+
state: "finished",
|
|
67
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
|
|
68
|
+
}),
|
|
69
|
+
])!;
|
|
70
|
+
assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("budget-stopped finished row is flagged partial", () => {
|
|
74
|
+
const text = buildInboxReminder([
|
|
75
|
+
entry({
|
|
76
|
+
id: "sub-1",
|
|
77
|
+
state: "finished",
|
|
78
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
|
|
79
|
+
}),
|
|
80
|
+
])!;
|
|
81
|
+
assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("failed row carries the error preview, first line only", () => {
|
|
85
|
+
const text = buildInboxReminder([
|
|
86
|
+
entry({
|
|
87
|
+
id: "sub-4",
|
|
88
|
+
state: "failed",
|
|
89
|
+
snapshot: frame({
|
|
90
|
+
exitCode: 1,
|
|
91
|
+
elapsedMs: 5_000,
|
|
92
|
+
errorMessage: "provider timeout\nretry hint: check quota",
|
|
93
|
+
}),
|
|
94
|
+
}),
|
|
95
|
+
])!;
|
|
96
|
+
assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
|
|
97
|
+
assert.doesNotMatch(text, /retry hint/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("byte-stable: identical state produces identical text", () => {
|
|
101
|
+
const entries = [
|
|
102
|
+
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
103
|
+
entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
|
|
104
|
+
];
|
|
105
|
+
assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("long task text is truncated to the shared 70-char preview cap", () => {
|
|
109
|
+
const long = "x".repeat(120);
|
|
110
|
+
const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
|
|
111
|
+
assert.ok(text.includes(`"${"x".repeat(70)}..."`));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("header explains collection semantics", () => {
|
|
115
|
+
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
|
|
116
|
+
assert.match(text, /^\[background subagent runs — subagent_check claims/);
|
|
117
|
+
assert.match(text, /already collected\]/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("injectReminder", () => {
|
|
122
|
+
const reminder = "[background subagent runs]\n- sub-1 (worker) — running";
|
|
123
|
+
|
|
124
|
+
test("string content: reminder prepended, rest of transcript identical", () => {
|
|
125
|
+
const messages = [
|
|
126
|
+
{ role: "user" as const, content: "original prompt", timestamp: 1 },
|
|
127
|
+
{ role: "assistant" as const, content: [{ type: "text" as const, text: "answer" }] },
|
|
128
|
+
];
|
|
129
|
+
const out = injectReminder(messages as any, reminder);
|
|
130
|
+
assert.equal(out.length, 2);
|
|
131
|
+
assert.equal(out[0].role, "user");
|
|
132
|
+
assert.equal((out[0] as any).content, `${reminder}\n\noriginal prompt`);
|
|
133
|
+
assert.deepEqual(out[1], messages[1]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("block content: reminder becomes the first text block", () => {
|
|
137
|
+
const messages = [
|
|
138
|
+
{
|
|
139
|
+
role: "user" as const,
|
|
140
|
+
content: [
|
|
141
|
+
{ type: "text" as const, text: "original" },
|
|
142
|
+
{ type: "text" as const, text: "blocks" },
|
|
143
|
+
],
|
|
144
|
+
timestamp: 1,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const out = injectReminder(messages as any, reminder);
|
|
148
|
+
const content = (out[0] as any).content;
|
|
149
|
+
assert.equal(content[0].type, "text");
|
|
150
|
+
assert.equal(content[0].text, reminder);
|
|
151
|
+
assert.equal(content.length, 3);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("non-user first message: synthetic leading user message", () => {
|
|
155
|
+
const messages = [{ role: "assistant" as const, content: [{ type: "text" as const, text: "hi" }] }];
|
|
156
|
+
const out = injectReminder(messages as any, reminder);
|
|
157
|
+
assert.equal(out.length, 2);
|
|
158
|
+
assert.equal(out[0].role, "user");
|
|
159
|
+
assert.equal((out[0] as any).content, reminder);
|
|
160
|
+
assert.deepEqual(out[1], messages[0]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("empty transcript: single injected user message", () => {
|
|
164
|
+
const out = injectReminder([] as any, reminder);
|
|
165
|
+
assert.equal(out.length, 1);
|
|
166
|
+
assert.equal(out[0].role, "user");
|
|
167
|
+
assert.equal((out[0] as any).content, reminder);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("input messages are not mutated", () => {
|
|
171
|
+
const messages = [{ role: "user" as const, content: "original", timestamp: 1 }];
|
|
172
|
+
injectReminder(messages as any, reminder);
|
|
173
|
+
assert.equal(messages[0].content, "original");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/reminder.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model's inbox of unclaimed background subagent runs.
|
|
3
|
+
*
|
|
4
|
+
* Injected into the LLM context before every provider call via the `context`
|
|
5
|
+
* event. The reminder lists every delegated run whose result has not been
|
|
6
|
+
* claimed yet — queued, running, and finished/failed alike — so the model
|
|
7
|
+
* cannot forget about them. `subagent_check` on a terminal run returns the
|
|
8
|
+
* output AND removes the run from this list (read-once collection).
|
|
9
|
+
*
|
|
10
|
+
* Cache discipline: the reminder is prepended to the FIRST user message, so
|
|
11
|
+
* it sits at a stable position in the message prefix. Its content must stay
|
|
12
|
+
* byte-stable between state transitions — that rules out elapsed time on
|
|
13
|
+
* running rows and any other live-derived detail. Only terminal rows may
|
|
14
|
+
* carry a duration (frozen in elapsedMs at completion).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { RunState, SubagentResult } from "./types.ts";
|
|
19
|
+
import { elapsedSeconds, taskPreview } from "./utils.ts";
|
|
20
|
+
|
|
21
|
+
/** One row of the inbox. RunHandle satisfies this shape structurally. */
|
|
22
|
+
export interface InboxEntry {
|
|
23
|
+
id: string;
|
|
24
|
+
role: string;
|
|
25
|
+
task: string;
|
|
26
|
+
state: RunState;
|
|
27
|
+
snapshot: SubagentResult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const INBOX_HEADER =
|
|
31
|
+
"[background subagent runs — subagent_check claims a terminal run's output and removes it from this list; runs missing here were already collected]";
|
|
32
|
+
|
|
33
|
+
/** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
|
|
34
|
+
function formatDuration(totalSec: number): string {
|
|
35
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
36
|
+
const m = Math.floor(totalSec / 60);
|
|
37
|
+
const s = totalSec % 60;
|
|
38
|
+
return s > 0 ? `${m}m${s}s` : `${m}m`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Status segment of one row: state plus its stable extras (duration, error). */
|
|
42
|
+
function inboxStatus(entry: InboxEntry): string {
|
|
43
|
+
if (entry.state === "queued") return "queued";
|
|
44
|
+
if (entry.state === "running") return "running";
|
|
45
|
+
const secs = elapsedSeconds(entry.snapshot);
|
|
46
|
+
const ran = secs != null ? ` (ran ${formatDuration(secs)})` : "";
|
|
47
|
+
if (entry.state === "failed") {
|
|
48
|
+
const reason = taskPreview(entry.snapshot.errorMessage || entry.snapshot.stderr || "unknown error");
|
|
49
|
+
return `failed — ${reason}${ran}`;
|
|
50
|
+
}
|
|
51
|
+
const partial = entry.snapshot.stopReason === "budget_exceeded" ? ", partial — budget exceeded" : "";
|
|
52
|
+
return `finished${partial}${ran}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the inbox reminder text, or undefined when every delegated run has
|
|
57
|
+
* been collected (nothing to remind about — inject nothing, keep the context
|
|
58
|
+
* untouched and the provider cache fully stable).
|
|
59
|
+
*/
|
|
60
|
+
export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
|
|
61
|
+
const rows: string[] = [];
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
|
|
64
|
+
}
|
|
65
|
+
if (rows.length === 0) return undefined;
|
|
66
|
+
return `${INBOX_HEADER}\n${rows.join("\n")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Message array type of the `context` event (AgentMessage[]). */
|
|
70
|
+
type ContextMessages = ContextEvent["messages"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Prepend the reminder at a cache-stable position: the first text block of
|
|
74
|
+
* the first user message (or a synthetic leading user message when the
|
|
75
|
+
* transcript does not start with one).
|
|
76
|
+
*/
|
|
77
|
+
export function injectReminder(messages: ContextMessages, reminder: string): ContextMessages {
|
|
78
|
+
if (messages.length === 0) {
|
|
79
|
+
return [{ role: "user", content: reminder, timestamp: 0 } as ContextMessages[number]];
|
|
80
|
+
}
|
|
81
|
+
const [first, ...rest] = messages;
|
|
82
|
+
if (first.role === "user") {
|
|
83
|
+
const content =
|
|
84
|
+
typeof first.content === "string"
|
|
85
|
+
? `${reminder}\n\n${first.content}`
|
|
86
|
+
: [{ type: "text", text: reminder }, ...first.content];
|
|
87
|
+
return [{ ...first, content } as ContextMessages[number], ...rest];
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
{ role: "user", content: reminder, timestamp: 0 } as ContextMessages[number],
|
|
91
|
+
...messages,
|
|
92
|
+
];
|
|
93
|
+
}
|
package/src/render-async.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
import {
|
|
33
33
|
buildDisplayItems,
|
|
34
34
|
clearElapsedTimer,
|
|
35
|
+
collapsedText,
|
|
35
36
|
contentText,
|
|
36
37
|
deriveRunState,
|
|
37
38
|
ensureElapsedTimer,
|
|
@@ -234,7 +235,7 @@ export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
|
|
|
234
235
|
|
|
235
236
|
export const renderBackgroundDelegateResult: RenderResultFn = (result, { expanded }, theme) => {
|
|
236
237
|
const details = result.details as BackgroundDelegateDetails | undefined;
|
|
237
|
-
if (!details) return
|
|
238
|
+
if (!details) return collapsedText(contentText(result));
|
|
238
239
|
|
|
239
240
|
const fg = theme.fg.bind(theme) as Fg;
|
|
240
241
|
// One-line anchor: marker + id + task preview. The run's live state is NOT
|
|
@@ -242,7 +243,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
|
|
|
242
243
|
// progresses invisibly until a wait/check row picks it up.
|
|
243
244
|
const summaryLine = `${fg("accent", "\u25B6")} ${fg("dim", details.id)} ${fg("text", taskPreview(details.task))}`;
|
|
244
245
|
|
|
245
|
-
if (!expanded) return
|
|
246
|
+
if (!expanded) return collapsedText(summaryLine);
|
|
246
247
|
|
|
247
248
|
// Expanded: full input — reference files, context size, task text.
|
|
248
249
|
const container = new Container();
|
|
@@ -272,7 +273,7 @@ export const renderWaitCall: RenderCallFn = (args, theme) => {
|
|
|
272
273
|
export const renderWaitResult: RenderResultFn = (result, { expanded }, theme, context) => {
|
|
273
274
|
const details = result.details as WaitDetails | undefined;
|
|
274
275
|
if (!details || details.entries.length === 0) {
|
|
275
|
-
return
|
|
276
|
+
return collapsedText(contentText(result));
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
// Tick while any watched run is still live. A timed-out wait freezes the
|
|
@@ -301,7 +302,7 @@ export const renderWaitResult: RenderResultFn = (result, { expanded }, theme, co
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
const text = details.entries.map((e) => waitEntryCollapsedText(e, fg)).join("\n\n");
|
|
304
|
-
return
|
|
305
|
+
return collapsedText(text);
|
|
305
306
|
};
|
|
306
307
|
|
|
307
308
|
// ── check: frozen single-run snapshot ──────────────────────────
|
|
@@ -314,11 +315,11 @@ export const renderCheckCall: RenderCallFn = (args, theme) => {
|
|
|
314
315
|
|
|
315
316
|
export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _context) => {
|
|
316
317
|
const details = result.details as CheckDetails | undefined;
|
|
317
|
-
if (!details) return
|
|
318
|
+
if (!details) return collapsedText(contentText(result));
|
|
318
319
|
|
|
319
320
|
const fg = theme.fg.bind(theme) as Fg;
|
|
320
321
|
// Static snapshot — never starts the animation timer (the execute layer
|
|
321
322
|
// freezes the frame before handing it over).
|
|
322
323
|
if (expanded) return checkEntryExpandedContainer(details.result, fg);
|
|
323
|
-
return
|
|
324
|
+
return collapsedText(checkEntryCollapsedText(details.result, fg));
|
|
324
325
|
};
|
package/src/render.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { SubagentDetails } from "./types.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
buildDisplayItems,
|
|
13
13
|
clearElapsedTimer,
|
|
14
|
+
collapsedText,
|
|
14
15
|
contentText,
|
|
15
16
|
ensureElapsedTimer,
|
|
16
17
|
formatFallback,
|
|
@@ -57,7 +58,7 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
if (!details || details.results.length === 0) {
|
|
60
|
-
return
|
|
61
|
+
return collapsedText(contentText(result));
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const r = details.results[0];
|
|
@@ -192,5 +193,5 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
192
193
|
}
|
|
193
194
|
if (fallbackLine) text += `\n${fallbackLine}`;
|
|
194
195
|
if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
|
|
195
|
-
return
|
|
196
|
+
return collapsedText(text);
|
|
196
197
|
};
|
package/src/run.test.ts
CHANGED
|
@@ -256,6 +256,63 @@ test("abort while queued fails the run and exposes thrown for the foreground pat
|
|
|
256
256
|
gate.release();
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
test("handle.abort() reaps a queued background run (no caller signal)", async () => {
|
|
260
|
+
const gate = new AsyncSemaphore(1);
|
|
261
|
+
await gate.acquire();
|
|
262
|
+
const spawnImpl: SpawnImpl = async () => makeResult({ output: "never" });
|
|
263
|
+
|
|
264
|
+
const run = startSubagentRun(makeDeps({ gate, spawnImpl }));
|
|
265
|
+
run.abort("session shutdown");
|
|
266
|
+
const result = await run.promise;
|
|
267
|
+
|
|
268
|
+
assert.strictEqual(run.state, "failed");
|
|
269
|
+
assert.ok(run.thrown instanceof Error);
|
|
270
|
+
assert.match(result.errorMessage!, /cancelled while queued/);
|
|
271
|
+
assert.match(result.errorMessage!, /session shutdown/);
|
|
272
|
+
gate.release();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("handle.abort(reason) fails a running run with the reason in the error message", async () => {
|
|
276
|
+
const signals: AbortSignal[] = [];
|
|
277
|
+
// Mirrors real spawn's abort handling: pre-aborted signals settle immediately
|
|
278
|
+
// (an "abort" listener alone would never fire — the event already happened).
|
|
279
|
+
const honoringSpawn: SpawnImpl = (_m, _t, options) =>
|
|
280
|
+
new Promise((_resolve, reject) => {
|
|
281
|
+
signals.push(options.signal!);
|
|
282
|
+
const die = () => reject(new Error("Subagent was aborted"));
|
|
283
|
+
if (options.signal?.aborted) die();
|
|
284
|
+
else options.signal?.addEventListener("abort", die, { once: true });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const run = startSubagentRun(makeDeps({ spawnImpl: honoringSpawn }));
|
|
288
|
+
run.abort("session shutdown");
|
|
289
|
+
const result = await run.promise;
|
|
290
|
+
|
|
291
|
+
assert.strictEqual(run.state, "failed");
|
|
292
|
+
assert.match(result.errorMessage!, /Subagent was aborted \(session shutdown\)/);
|
|
293
|
+
assert.ok(run.thrown instanceof Error);
|
|
294
|
+
// The internal controller the spawn honored is the same channel abort() used.
|
|
295
|
+
assert.ok(signals[0].aborted);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a pre-aborted caller signal chains into the run before spawn", async () => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
controller.abort();
|
|
301
|
+
const spawnImpl: SpawnImpl = async (_m, _t, options) => {
|
|
302
|
+
if (options.signal?.aborted) throw new Error("Subagent was aborted");
|
|
303
|
+
return makeResult({ output: "done" });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const run = startSubagentRun(makeDeps({ signal: controller.signal, spawnImpl }));
|
|
307
|
+
const result = await run.promise;
|
|
308
|
+
|
|
309
|
+
assert.strictEqual(run.state, "failed");
|
|
310
|
+
assert.strictEqual(result.errorMessage, "Subagent was aborted");
|
|
311
|
+
// abort() after settle is a no-op — the terminal state never flips.
|
|
312
|
+
run.abort("session shutdown");
|
|
313
|
+
assert.strictEqual(run.state, "failed");
|
|
314
|
+
});
|
|
315
|
+
|
|
259
316
|
test("subscribers are notified on progress and terminal frames", async () => {
|
|
260
317
|
let notifications = 0;
|
|
261
318
|
const spawnImpl: SpawnImpl = async (_m, _t, options) => {
|
package/src/run.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* exposed via `thrown`), and a subscriber list the `wait` tool uses to mirror
|
|
10
10
|
* live progress into its own tool row.
|
|
11
11
|
*
|
|
12
|
+
* Every run owns an AbortController. The foreground tool signal chains into
|
|
13
|
+
* it; runs started without a caller signal (background) are still abortable
|
|
14
|
+
* via handle.abort() — session_shutdown reaps every live run that way, so no
|
|
15
|
+
* child process outlives the parent.
|
|
16
|
+
*
|
|
12
17
|
* All post-processing (fallback retry, output compression, summary
|
|
13
18
|
* generation, history persistence) runs inside the pipeline, so background
|
|
14
19
|
* runs finish exactly like foreground ones.
|
|
@@ -52,6 +57,8 @@ export interface RunHandle {
|
|
|
52
57
|
readonly thrown: Error | undefined;
|
|
53
58
|
/** Resolves with the terminal result once the run finishes (always succeeds). */
|
|
54
59
|
readonly promise: Promise<SubagentResult>;
|
|
60
|
+
/** Abort the run — no-op after settle. Tool-cancellation and session-shutdown reaping both funnel here. */
|
|
61
|
+
abort(reason?: string): void;
|
|
55
62
|
/** Get notified on every frame change. Returns an unsubscribe function. */
|
|
56
63
|
subscribe(fn: () => void): () => void;
|
|
57
64
|
}
|
|
@@ -69,7 +76,7 @@ export interface StartRunOptions {
|
|
|
69
76
|
cwd: string;
|
|
70
77
|
/** Nesting depth for the child (CURRENT_DEPTH + 1). */
|
|
71
78
|
depth: number;
|
|
72
|
-
/** Foreground callers
|
|
79
|
+
/** Foreground callers chain the tool's AbortSignal in; background runs pass none and are aborted via handle.abort() instead. */
|
|
73
80
|
signal?: AbortSignal;
|
|
74
81
|
/** Per-call model override ('provider/model-id'), bypassing the role's configured model. */
|
|
75
82
|
modelOverride?: string;
|
|
@@ -106,6 +113,14 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
106
113
|
let snapshot: SubagentResult = inputFrame(-1, true);
|
|
107
114
|
let result: SubagentResult | undefined;
|
|
108
115
|
let thrown: Error | undefined;
|
|
116
|
+
let settled = false;
|
|
117
|
+
let abortReason: string | undefined;
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const onCallerAbort = () => controller.abort();
|
|
120
|
+
if (opts.signal) {
|
|
121
|
+
if (opts.signal.aborted) controller.abort();
|
|
122
|
+
else opts.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
123
|
+
}
|
|
109
124
|
let resolvePromise!: (r: SubagentResult) => void;
|
|
110
125
|
const promise = new Promise<SubagentResult>((resolve) => {
|
|
111
126
|
resolvePromise = resolve;
|
|
@@ -126,11 +141,14 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
126
141
|
notify();
|
|
127
142
|
};
|
|
128
143
|
const finish = (terminal: SubagentResult, error?: Error) => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
129
146
|
result = terminal;
|
|
130
147
|
snapshot = terminal;
|
|
131
148
|
thrown = error;
|
|
132
149
|
currentState = isFailedResult(terminal) ? "failed" : "finished";
|
|
133
150
|
notify();
|
|
151
|
+
opts.signal?.removeEventListener("abort", onCallerAbort);
|
|
134
152
|
resolvePromise(terminal);
|
|
135
153
|
};
|
|
136
154
|
|
|
@@ -158,15 +176,22 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
158
176
|
listeners.delete(fn);
|
|
159
177
|
};
|
|
160
178
|
},
|
|
179
|
+
abort(reason?: string) {
|
|
180
|
+
if (settled) return;
|
|
181
|
+
if (reason) abortReason = reason;
|
|
182
|
+
controller.abort();
|
|
183
|
+
},
|
|
161
184
|
promise,
|
|
162
185
|
};
|
|
163
186
|
|
|
164
187
|
(async () => {
|
|
165
188
|
// ── Concurrency gate (abortable while queued) ──
|
|
166
189
|
try {
|
|
167
|
-
await opts.gate.acquire(
|
|
190
|
+
await opts.gate.acquire(controller.signal);
|
|
168
191
|
} catch {
|
|
169
|
-
const msg =
|
|
192
|
+
const msg =
|
|
193
|
+
"cancelled while queued for a concurrency slot" +
|
|
194
|
+
(abortReason ? ` (${abortReason})` : "");
|
|
170
195
|
finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
|
|
171
196
|
return;
|
|
172
197
|
}
|
|
@@ -262,7 +287,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
262
287
|
maxTurns,
|
|
263
288
|
maxCost,
|
|
264
289
|
depth: opts.depth,
|
|
265
|
-
signal:
|
|
290
|
+
signal: controller.signal,
|
|
266
291
|
onProgress: emitProgress,
|
|
267
292
|
});
|
|
268
293
|
|
|
@@ -294,7 +319,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
294
319
|
maxTurns,
|
|
295
320
|
maxCost,
|
|
296
321
|
depth: opts.depth,
|
|
297
|
-
signal:
|
|
322
|
+
signal: controller.signal,
|
|
298
323
|
onProgress: emitProgress,
|
|
299
324
|
});
|
|
300
325
|
runResult.fallbackFrom = fallbackFrom;
|
|
@@ -348,7 +373,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
348
373
|
activityLog: partial.activityLog,
|
|
349
374
|
budgetMs: partial.budgetMs,
|
|
350
375
|
elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
|
|
351
|
-
errorMessage:
|
|
376
|
+
errorMessage: abortReason
|
|
377
|
+
? `Subagent was aborted (${abortReason})`
|
|
378
|
+
: err?.message || String(err),
|
|
352
379
|
};
|
|
353
380
|
// The run spawned before throwing — audit it like any terminal state.
|
|
354
381
|
// The partial output is raw (compression never ran on it).
|
package/src/spawn.ts
CHANGED
|
@@ -19,6 +19,29 @@ const INLINE_LIMIT = 8000;
|
|
|
19
19
|
|
|
20
20
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
21
21
|
|
|
22
|
+
// ── Parent-exit safety net ─────────────────────────────────────
|
|
23
|
+
// process.on("exit") fires synchronously on every terminal path that goes
|
|
24
|
+
// through process.exit — normal quit, signal-triggered graceful shutdown,
|
|
25
|
+
// emergency terminal exit, uncaught crash. SIGTERM the live children so each
|
|
26
|
+
// pi child runs its own cleanup (killing ITS tracked grandchildren) instead
|
|
27
|
+
// of burning tokens as an orphan. This covers the paths where the graceful
|
|
28
|
+
// session_shutdown reaping never fires; a SIGKILL'd parent is beyond help.
|
|
29
|
+
const liveChildren = new Set<ChildProcess>();
|
|
30
|
+
let exitHookInstalled = false;
|
|
31
|
+
function reapChildrenOnExit(): void {
|
|
32
|
+
if (exitHookInstalled) return;
|
|
33
|
+
exitHookInstalled = true;
|
|
34
|
+
process.on("exit", () => {
|
|
35
|
+
for (const child of liveChildren) {
|
|
36
|
+
try {
|
|
37
|
+
child.kill("SIGTERM");
|
|
38
|
+
} catch {
|
|
39
|
+
/* already dead */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
function isRunnableScript(filePath: string): boolean {
|
|
23
46
|
try {
|
|
24
47
|
if (!fs.existsSync(filePath)) return false;
|
|
@@ -508,6 +531,8 @@ export async function spawnSubagent(
|
|
|
508
531
|
stdio: ["ignore", "pipe", "pipe"],
|
|
509
532
|
});
|
|
510
533
|
proc = p;
|
|
534
|
+
liveChildren.add(p);
|
|
535
|
+
reapChildrenOnExit();
|
|
511
536
|
|
|
512
537
|
p.stdout.on("data", (data: Buffer) => {
|
|
513
538
|
buffer += data.toString();
|
|
@@ -522,6 +547,7 @@ export async function spawnSubagent(
|
|
|
522
547
|
|
|
523
548
|
p.on("exit", () => {
|
|
524
549
|
processExited = true;
|
|
550
|
+
liveChildren.delete(p);
|
|
525
551
|
clearEscalationTimer();
|
|
526
552
|
});
|
|
527
553
|
|
|
@@ -548,6 +574,7 @@ export async function spawnSubagent(
|
|
|
548
574
|
|
|
549
575
|
p.on("error", (err) => {
|
|
550
576
|
processExited = true;
|
|
577
|
+
liveChildren.delete(p);
|
|
551
578
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
552
579
|
clearEscalationTimer();
|
|
553
580
|
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
package/src/types.ts
CHANGED
|
@@ -212,6 +212,15 @@ export interface WaitDetails {
|
|
|
212
212
|
timedOut?: boolean;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
|
|
216
|
+
export interface CollectedRun {
|
|
217
|
+
id: string;
|
|
218
|
+
role: string;
|
|
219
|
+
/** First-line task preview (same 70-char cap as the inbox reminder). */
|
|
220
|
+
task: string;
|
|
221
|
+
state: "finished" | "failed";
|
|
222
|
+
}
|
|
223
|
+
|
|
215
224
|
/** Details for a check tool result — a frozen one-shot snapshot of a single run. */
|
|
216
225
|
export interface CheckDetails {
|
|
217
226
|
id: string;
|
package/src/utils.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as os from "node:os";
|
|
8
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
10
|
import type {
|
|
9
11
|
ActivityEntry,
|
|
10
12
|
FallbackFrom,
|
|
@@ -259,6 +261,23 @@ export function taskPreview(task: string): string {
|
|
|
259
261
|
return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Width-aware collapsed-view component: renders each line truncated with "…"
|
|
266
|
+
* to the actual viewport width (never wraps), padded full-width like Text(0,0).
|
|
267
|
+
*
|
|
268
|
+
* The char caps in taskPreview/formatToolCall/etc. stay as they are — they are
|
|
269
|
+
* content limits shared with the LLM-facing text (check output, error
|
|
270
|
+
* messages), which has no viewport semantics. This component is the TUI-side
|
|
271
|
+
* final guard, applied where the folding affordance exists.
|
|
272
|
+
*/
|
|
273
|
+
export function collapsedText(text: string): Component {
|
|
274
|
+
const lines = text.split("\n");
|
|
275
|
+
return {
|
|
276
|
+
render: (width: number) => lines.map((ln) => truncateToWidth(ln, width, "…", true)),
|
|
277
|
+
invalidate: () => {},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
262
281
|
/** Status icon for a run frame: ⏸ queued / ⏳ running / ⏱ timeout / ⏲ budget / ✗ failed / ✓ ok */
|
|
263
282
|
export function runIcon(
|
|
264
283
|
r: { exitCode: number; queued?: boolean; stopReason?: string },
|