pi-umbra-subagents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ // The one runnable check for the producer's spawning half. It runs real child processes —
2
+ // a nine-line stub standing in for pi — because everything worth getting wrong here is a
3
+ // process fact: does phase two wait for phase one, does a killed branch actually die, does a
4
+ // branch that ignores the clock get cut off at the timeout, and does the run directory say
5
+ // so afterwards. None of that is observable in a mocked spawn.
6
+ //
7
+ // Run it with: bun run store.check.ts (or: node --experimental-strip-types store.check.ts)
8
+ // It never starts pi: PI_BIN points at the stub, and the stub is the only thing spawned.
9
+
10
+ import { strict as assert } from "node:assert";
11
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const root = mkdtempSync(join(tmpdir(), "fan-check-"));
16
+
17
+ // A branch that appends its task to one shared file, so the ORDER phases ran in is a fact on
18
+ // disk rather than a guess from timestamps. "hang" never exits, which is how the stop and the
19
+ // timeout paths get something real to kill.
20
+ const stub = join(root, "fake-pi.mjs");
21
+ writeFileSync(
22
+ stub,
23
+ [
24
+ 'import { appendFileSync } from "node:fs";',
25
+ "const task = process.argv[process.argv.length - 1];",
26
+ 'if (process.env.FAN_CHECK_ORDER) appendFileSync(process.env.FAN_CHECK_ORDER, `${task}\\n`);',
27
+ 'if (task === "hang") setInterval(() => {}, 1000);',
28
+ 'else process.stdout.write(`report for ${task}\\nSTATUS: OK\\n`);',
29
+ "",
30
+ ].join("\n"),
31
+ );
32
+
33
+ process.env.PI_BIN = stub;
34
+ // Read once at module load, so it has to be set before the import below. Longer than the whole
35
+ // kill ladder, or the stop below would be recorded as a timeout instead of a stop.
36
+ process.env.FAN_TIMEOUT_MS = "4000";
37
+ process.env.FAN_LOAD = "";
38
+
39
+ const { reportOf, store } = await import("./store.ts");
40
+ const { readRun } = await import("../skills/delegate/state.ts");
41
+
42
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
43
+
44
+ const until = async (label: string, ready: () => boolean, ms = 15_000) => {
45
+ const deadline = Date.now() + ms;
46
+ while (!ready()) {
47
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`);
48
+ await delay(25);
49
+ }
50
+ };
51
+
52
+ const phase = (title: string, branches: { label: string; task: string }[]) => ({
53
+ title,
54
+ branches: branches.map((branch) => ({ ...branch, model: "stub/one" })),
55
+ });
56
+
57
+ // ---------- phases run in order, and the run directory is complete before it is read ----------
58
+
59
+ const runCwd = mkdtempSync(join(tmpdir(), "fan-run-"));
60
+ const order = join(runCwd, "order.txt");
61
+ writeFileSync(order, "");
62
+ process.env.FAN_CHECK_ORDER = order;
63
+
64
+ // Verbatim, including the blank line and the colon — the two things a paraphrase loses first.
65
+ const BRIEF = ["Map how pi renders tool calls.", "", "The part a task line cannot carry: colon: in it, and a second paragraph."].join("\n");
66
+
67
+ const dir = store.start(
68
+ { name: "toolcall render", description: "Map how pi renders tool calls", phases: [
69
+ phase("Map", [{ label: "core-render", task: "alpha" }, { label: "omp-intercept", task: "beta" }]),
70
+ phase("Design", [{ label: "proposal", task: "gamma" }]),
71
+ ] },
72
+ "stub/default",
73
+ runCwd,
74
+ BRIEF,
75
+ );
76
+ assert.ok(dir, "start returns the run directory it owns");
77
+
78
+ // The brief goes to disk untouched: no model in the path, so the bytes out are the bytes in.
79
+ assert.equal(existsSync(join(dir as string, "brief.md")), true, "the run wrote a brief.md");
80
+ assert.equal(readFileSync(join(dir as string, "brief.md"), "utf8"), BRIEF, "brief.md is byte-for-byte");
81
+
82
+ // Seeded whole, before anything is spawned: the M in "N/M agents" is final in frame one.
83
+ const seeded = readRun(runCwd);
84
+ assert.equal(seeded?.total, 3, "every branch of every phase is seeded up front");
85
+ assert.equal(seeded?.done, 0, "nothing is settled before it has run");
86
+ assert.deepEqual(
87
+ seeded?.branches.map((branch) => branch.stem),
88
+ ["map-core-render", "map-omp-intercept", "design-proposal"],
89
+ "stems are phase-name, slugged, in launch order",
90
+ );
91
+ assert.equal(seeded?.branches[0]?.label, "map:core-render", "the colon is a render-time join");
92
+ assert.equal(seeded?.branches[0]?.model, "One", "the seed carries a display model until the beacon replaces it");
93
+ assert.equal(seeded?.name, "toolcall render", "the run name is the spec's, not the slug");
94
+
95
+ await until("the run to finish", () => {
96
+ store.refresh(runCwd);
97
+ return store.run()?.live === false;
98
+ });
99
+
100
+ const finished = store.run();
101
+ assert.equal(finished?.done, 3, "every branch settles");
102
+ assert.deepEqual(readFileSync(order, "utf8").trim().split("\n"), ["alpha", "beta", "gamma"], "phase two waits for phase one");
103
+ assert.equal(readFileSync(join(dir, "state", "map-core-render.exit"), "utf8"), "0", "a clean exit is recorded as 0");
104
+ assert.equal(finished?.branches.every((branch) => !branch.timedOut), true, "nothing timed out");
105
+ assert.match(reportOf(finished!), /report for alpha[\s\S]*report for gamma/, "the reports are read back in launch order");
106
+
107
+ // ---------- a branch that will not exit is killed, and the row never lies about it ----------
108
+
109
+ const stopCwd = mkdtempSync(join(tmpdir(), "fan-stop-"));
110
+ delete process.env.FAN_CHECK_ORDER;
111
+ store.start({ name: "stop", description: "", phases: [phase("Map", [{ label: "stuck", task: "hang" }])] }, "stub/one", stopCwd);
112
+ store.refresh(stopCwd);
113
+ assert.equal(store.run()?.branches[0]?.settled, false, "a live branch is not settled");
114
+
115
+ const killed = await store.stop("map-stuck");
116
+ assert.equal(killed, true, "the kill ladder reports what actually happened to the process");
117
+ store.refresh(stopCwd);
118
+ const stopped = store.run()?.branches[0];
119
+ assert.equal(stopped?.settled, true, "a killed branch settles");
120
+ assert.equal(stopped?.alive, false);
121
+ assert.notEqual(stopped?.exitCode, 0, "a killed branch did not succeed");
122
+ assert.equal(stopped?.timedOut, false, "a user stop is not a timeout");
123
+
124
+ // ---------- the same branch left alone is cut off by the timeout, as 124 ----------
125
+
126
+ const slowCwd = mkdtempSync(join(tmpdir(), "fan-slow-"));
127
+ store.start({ name: "slow", description: "", phases: [phase("Map", [{ label: "stuck", task: "hang" }])] }, "stub/one", slowCwd);
128
+ await until(
129
+ "the timeout to fire",
130
+ () => {
131
+ store.refresh(slowCwd);
132
+ return store.run()?.live === false;
133
+ },
134
+ 20_000,
135
+ );
136
+ assert.equal(store.run()?.branches[0]?.timedOut, true, "exit code 124 is a timeout, not a crash");
137
+ assert.equal(existsSync(join(slowCwd, ".pi-out")), true, "the run directory outlives the run");
138
+
139
+ store.stopAll();
140
+ console.log("store.check.ts ok");
@@ -0,0 +1,491 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { LINGER_MS, OUT_DIR, pidLive, readRun, type BranchState, type RunState } from "../skills/delegate/state.ts";
6
+
7
+ // The producer. It launches branches, keeps their run directory honest, and hands the panel
8
+ // one snapshot to render.
9
+ //
10
+ // It writes delegate v2's on-disk shape — one seeded `state/<stem>.json` per branch, then
11
+ // `-e beacon.ts` inside the branch as that file's only writer — instead of holding branches
12
+ // in RAM. Three things fall out of that and none of them are reachable in memory: a branch
13
+ // given `bash` can fan out again and its children land in the SAME run directory through
14
+ // PI_BRANCH_RUN, so reference 3's `└` nesting renders two deep; a run started from bash by
15
+ // the delegate skill is the same run this file would have started, so there is one reader
16
+ // and one format; and the reports survive quitting pi.
17
+ //
18
+ // Nothing here ever writes a branch's status. Status is derived by the reader from the
19
+ // `.exit` file and the pid, so a kill that silently fails leaves a row reading "running",
20
+ // which is the truth, rather than "stopped", which would be a lie.
21
+
22
+ /** What `/umb-fan` and the model's ```fan block both parse into. */
23
+ export type RunSpec = {
24
+ name: string;
25
+ description: string;
26
+ phases: { title: string; branches: { label: string; model?: string; task: string }[] }[];
27
+ };
28
+
29
+ // Every branch gets the same tail on its system prompt. Same contract as the delegate skill's
30
+ // report.md, kept here as a string because a branch launched from this extension must not
31
+ // depend on a skill folder being installed.
32
+ const CONTRACT =
33
+ "Your final message is the only thing the caller reads. Answer it directly in markdown, " +
34
+ "cite every claim as file:line, and write no preamble. End with a single line " +
35
+ "'STATUS: OK' when you answered fully, 'STATUS: PARTIAL' when you answered part of it, or " +
36
+ "'STATUS: NEED_STRONGER' when the task needs a stronger model. When a decision only the " +
37
+ "caller can make blocks you, stop at once and end with 'STATUS: ASKING', then 'QUESTION: <one " +
38
+ "question>' and 'OPTIONS: <choices separated by \" | \", recommended first>'; your session is " +
39
+ "kept and the answer arrives as your next message.";
40
+
41
+ // Read-only by default: a branch that can run bash can start branches of its own, and nothing
42
+ // here caps the depth. Reference 3's `└` nesting needs exactly that, so it is a knob rather
43
+ // than a constant — set FAN_TOOLS="read,grep,find,ls,bash" to allow it, and watch the panel.
44
+ const READ_ONLY_TOOLS = process.env.FAN_TOOLS || "read,grep,find,ls";
45
+ // `--no-extensions` drops provider extensions too, so a branch on a model that only exists
46
+ // because of one cannot start. Same knob delegate.env carries as $LOAD, e.g.
47
+ // FAN_LOAD="-e $HOME/.pi/agent/npm/node_modules/pi-commandcode-provider".
48
+ const LOAD = (process.env.FAN_LOAD ?? "").split(/\s+/).filter(Boolean);
49
+ const DEFAULT_TIMEOUT_MS = Number(process.env.FAN_TIMEOUT_MS) || 300_000;
50
+ // The reader costs one readdir and a handful of 300-byte files, so the tick is set by what
51
+ // the eye wants rather than by what the disk can take: the activity sentence changes several
52
+ // times a second, and 250 ms is the slowest rate at which it still reads as live.
53
+ // ponytail: one interval, no fs.watch. Add a watcher only if the sentence starts lagging.
54
+ const TICK_MS = 250;
55
+ // A stop is soft first so the branch's last words reach its .md, then forced. Anything that
56
+ // survives both keeps its row and its "running" status until it really dies.
57
+ // The grace is short on Windows on purpose: a soft taskkill posts WM_CLOSE, which a console
58
+ // process has no message loop to answer, so waiting three seconds for it buys nothing but a
59
+ // row that looks stuck. SIGTERM off Windows is real, and gets the full window.
60
+ const GRACE_MS = process.platform === "win32" ? 1_000 : 3_000;
61
+ const FORCE_MS = 1_500;
62
+ const KILL_POLL_MS = 150;
63
+
64
+ // pi is installed as a shim that runs `node <bundle>/cli.js` (~/.bun/bin/pi.bunx), so a branch
65
+ // is launched as the same interpreter and the same entry file as this process. That sidesteps
66
+ // PATH lookup, .cmd shims and quoting on Windows. A compiled single-file build has no script
67
+ // argument, and then execPath alone is the whole command. PI_BIN is how the check substitutes
68
+ // a stub for pi without putting an executable on PATH. Real node leaves argv[1] as the npm bin
69
+ // symlink (/usr/local/bin/pi, no .js), so it is followed to the file it points at first.
70
+ const piCommand = (): string[] => {
71
+ const override = process.env.PI_BIN;
72
+ if (override) return /\.[cm]?js$/.test(override) ? [process.execPath, override] : [override];
73
+ const entry = process.argv[1] && existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : undefined;
74
+ return entry && /\.[cm]?js$/.test(entry) ? [process.execPath, entry] : [process.execPath];
75
+ };
76
+
77
+ // The beacon ships beside this file. Resolved from import.meta.url rather than from cwd,
78
+ // because a branch is started from the session cwd and this extension lives elsewhere.
79
+ const BEACON = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "delegate", "beacon.ts");
80
+
81
+ /** Filenames, so `^[a-z0-9][a-z0-9._-]*$` and nothing else: a stem is an NTFS filename. */
82
+ const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|-+$/g, "") || "x";
83
+
84
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
85
+
86
+ type Launch = { stem: string; phase: string; name: string; model: string; task: string; timeoutMs: number };
87
+
88
+ let current: RunState | undefined;
89
+ let sessionCwd = "";
90
+ let runDir = "";
91
+ // Set beside runDir and read at spawn time, so a branch launched in a later phase still
92
+ // finds the brief the run started with.
93
+ let briefPath: string | undefined;
94
+ let tick: ReturnType<typeof setInterval> | undefined;
95
+ let lastKey = "";
96
+ // Phases launch one after another; each entry is one phase's branches, in launch order.
97
+ let pending: Launch[][] = [];
98
+ // Only branches this process spawned. A run started from bash by the delegate skill is read
99
+ // and stopped through its pid instead, which is why nothing below assumes membership.
100
+ const children = new Map<string, ChildProcess>();
101
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
102
+ const timedOut = new Set<string>();
103
+ const listeners = new Set<() => void>();
104
+ // A bash tool call may be the delegate skill mid-run, so the tick keeps going for the rest of
105
+ // the turn even when this extension has spawned nothing itself. A flag rather than a counter:
106
+ // a bash call cannot outlive the turn, so the turn's end is a reset that cannot leak, while a
107
+ // counter would stick above zero for ever the first time a tool is aborted without an end.
108
+ let armed = false;
109
+
110
+ // Everything visible, in one string. A frame that would draw the same pixels is never
111
+ // requested: pi coalesces renders at 16 ms, but the cheapest frame is the one nobody asks
112
+ // for. Elapsed is in the key at second resolution, which is what makes an idle run notify
113
+ // exactly once a second instead of four times.
114
+ const keyOf = (run: RunState | undefined, now: number): string => {
115
+ if (!run) return "";
116
+ return [
117
+ run.dir,
118
+ Math.round((now - run.startedAt) / 1000),
119
+ run.done,
120
+ run.total,
121
+ run.tokens,
122
+ run.activePhase,
123
+ ...run.branches.map(
124
+ (b) => `${b.stem}|${b.status}|${b.alive ? 1 : 0}|${b.activity}|${b.tokens}|${Math.round(b.idleMs / 1000)}`,
125
+ ),
126
+ ].join("\u0001");
127
+ };
128
+
129
+ const notify = () => {
130
+ for (const fn of listeners) fn();
131
+ };
132
+
133
+ const refresh = () => {
134
+ // Before the first run there is no directory to read, and a relative ".pi-out" would be
135
+ // resolved against whatever this process happens to be sitting in.
136
+ if (!sessionCwd) return;
137
+ const now = Date.now();
138
+ current = readRun(sessionCwd, now);
139
+ const key = keyOf(current, now);
140
+ if (key === lastKey) return;
141
+ lastKey = key;
142
+ notify();
143
+ };
144
+
145
+ // Two different questions that used to share one answer. `busy` is "keep the tick running",
146
+ // and `armed` belongs in it: a bash call may be the delegate skill mid-run. `running` is "is
147
+ // one of OUR runs in flight", and `armed` must NOT be in it, or one bash call anywhere in a
148
+ // turn refuses every /umb-fan for the rest of that turn.
149
+ const running = () => children.size > 0 || pending.length > 0 || current?.live === true;
150
+ const busy = () => armed || running();
151
+
152
+ // The last frame the widget will draw for this run has already been drawn. Mirrors faded()
153
+ // in bar/bar-line.ts, which is why LINGER_MS lives in state.ts where both can reach it.
154
+ const lingerOver = () => {
155
+ if (!current || current.total === 0) return true;
156
+ const ended = Math.max(0, ...current.branches.map((branch) => branch.updatedAt));
157
+ return ended > 0 && Date.now() - ended > LINGER_MS;
158
+ };
159
+
160
+ const startTick = () => {
161
+ if (tick) return;
162
+ tick = setInterval(() => {
163
+ refresh();
164
+ // Keep ticking through the widget's linger window, not just to the last settle. The bar
165
+ // holds a finished run for LINGER_MS and its clock has to keep moving until it drops it;
166
+ // stopping at !busy() froze the final frame on screen and left it there until some
167
+ // unrelated render happened to clear it. With nothing on screen this costs no CPU.
168
+ if (!busy() && lingerOver()) stopTick();
169
+ }, TICK_MS);
170
+ // A pending repaint must never be the reason pi cannot exit.
171
+ tick.unref?.();
172
+ };
173
+
174
+ const stopTick = () => {
175
+ if (!tick) return;
176
+ clearInterval(tick);
177
+ tick = undefined;
178
+ };
179
+
180
+ // Windows has no process groups: TerminateProcess reaches the branch and leaves the tools it
181
+ // spawned behind, so the tree has to be named explicitly. taskkill's own exit code is ignored
182
+ // on purpose — 0, "already gone" (128) and "access denied" are all answered by whether the pid
183
+ // is still alive a moment later, which is the one thing that cannot be wrong.
184
+ const kill = (pid: number, force: boolean) => {
185
+ if (!pid) return;
186
+ try {
187
+ if (process.platform === "win32") {
188
+ const root = process.env.SystemRoot ?? process.env.WINDIR ?? "C:\\Windows";
189
+ const args = force ? ["/PID", String(pid), "/T", "/F"] : ["/PID", String(pid), "/T"];
190
+ spawn(join(root, "System32", "taskkill.exe"), args, { stdio: "ignore", windowsHide: true }).once("error", () => {});
191
+ } else {
192
+ // Negative pid: branches this process spawns are detached, so this reaches the
193
+ // group and takes the branch's own tool processes with it. A branch the delegate
194
+ // skill started from bash leads no group of its own (non-interactive bash has no
195
+ // job control), so -pid is ESRCH there and only the pid itself can be reached.
196
+ const signal = force ? "SIGKILL" : "SIGTERM";
197
+ try {
198
+ process.kill(-pid, signal);
199
+ } catch {
200
+ process.kill(pid, signal);
201
+ }
202
+ }
203
+ } catch {
204
+ // ESRCH. The process is already gone, which is the outcome we wanted.
205
+ }
206
+ };
207
+
208
+ // A branch we own is gone when its close handler removed it; a branch from a bash-started run
209
+ // is only observable through its pid. One expression covers both, because a stem this process
210
+ // never spawned is never in the map.
211
+ const gone = (stem: string, pid: number) => !children.has(stem) && !pidLive(pid);
212
+
213
+ const waitGone = async (stem: string, pid: number, ms: number) => {
214
+ const deadline = Date.now() + ms;
215
+ while (!gone(stem, pid)) {
216
+ if (Date.now() >= deadline) return false;
217
+ await delay(KILL_POLL_MS);
218
+ }
219
+ return true;
220
+ };
221
+
222
+ // The stream reports a provider id like "anthropic/claude-opus-5"; the panel wants "Opus 5".
223
+ // The beacon replaces this with pi's own display name at session_start — this is only what a
224
+ // row shows in the second before the branch boots, and what it keeps if it never does.
225
+ export const prettyModel = (model: string): string =>
226
+ (model.split("/").pop() ?? model)
227
+ .split(":")[0]
228
+ .replace(/^claude-/, "")
229
+ .split("-")
230
+ .map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)))
231
+ .join(" ");
232
+
233
+ const launch = (branch: Launch) => {
234
+ const stateDir = join(runDir, "state");
235
+ const out = openSync(join(runDir, `${branch.stem}.md`), "a");
236
+ const err = openSync(join(runDir, `${branch.stem}.err`), "a");
237
+ const [command, ...prefix] = piCommand();
238
+ const args = [
239
+ ...prefix,
240
+ "-p",
241
+ // Kept, not --no-session: a branch that ends on ASKING is continued in this session.
242
+ "--session-dir",
243
+ join(runDir, "sessions", branch.stem),
244
+ "--no-skills",
245
+ "--no-extensions",
246
+ ...LOAD,
247
+ "-e",
248
+ BEACON,
249
+ "--tools",
250
+ READ_ONLY_TOOLS,
251
+ "--model",
252
+ branch.model,
253
+ "--append-system-prompt",
254
+ // The path rides in the system prompt rather than in the task, so it is written once per
255
+ // branch instead of being pasted into every task line, and a branch that was given a
256
+ // one-line task still knows where the rest of the thought is.
257
+ briefPath ? `${CONTRACT} The full brief this task was cut from is at ${briefPath}; read it when the task alone leaves something open.` : CONTRACT,
258
+ // A task that begins with "-" would otherwise be read as an unknown flag
259
+ // (cli/args.js:217). After "--" every remaining word is the message.
260
+ "--",
261
+ branch.task,
262
+ ];
263
+ const child = spawn(command as string, args, {
264
+ cwd: sessionCwd,
265
+ windowsHide: true,
266
+ // A process group off Windows, so a stop reaches the branch's own tool processes.
267
+ detached: process.platform !== "win32",
268
+ stdio: ["ignore", out, err],
269
+ env: {
270
+ ...process.env,
271
+ // The beacon's only argument, and what makes `-e beacon.ts` inert everywhere else.
272
+ PI_BRANCH_STATE: join(stateDir, `${branch.stem}.json`),
273
+ // Set for the CHILD: a branch that fans out again names this branch as its parent
274
+ // and joins this run directory, which is the whole of reference 3's nesting.
275
+ PI_BRANCH_PARENT: branch.stem,
276
+ PI_BRANCH_RUN: runDir,
277
+ PI_BRANCH_PHASE: branch.phase,
278
+ // Read by nothing here — it is for a branch that fans out again and wants to hand
279
+ // the same brief down, and for anything the user writes against the run directory.
280
+ ...(briefPath ? { PI_BRANCH_BRIEF: briefPath } : {}),
281
+ },
282
+ });
283
+ children.set(branch.stem, child);
284
+
285
+ const timer = setTimeout(() => {
286
+ timedOut.add(branch.stem);
287
+ void store.stop(branch.stem);
288
+ }, branch.timeoutMs);
289
+ timer.unref?.();
290
+ timers.set(branch.stem, timer);
291
+
292
+ const settle = (code: number | null) => {
293
+ clearTimeout(timers.get(branch.stem));
294
+ timers.delete(branch.stem);
295
+ children.delete(branch.stem);
296
+ try {
297
+ closeSync(out);
298
+ closeSync(err);
299
+ } catch {
300
+ // Already closed: "error" and "close" can both fire for one child.
301
+ }
302
+ // The one signal that survives a branch dying before its extensions ever bound, and the
303
+ // only place a timeout can be told apart from a crash. 124 is what `timeout(1)` reports,
304
+ // which is what the delegate skill's branches write, so one reader covers both.
305
+ writeFileSync(join(stateDir, `${branch.stem}.exit`), String(timedOut.has(branch.stem) ? 124 : (code ?? 1)));
306
+ timedOut.delete(branch.stem);
307
+ // Phases run in order, and every branch of a phase is launched at once, so an empty
308
+ // map means this phase is over.
309
+ if (children.size === 0) startPhase();
310
+ refresh();
311
+ };
312
+
313
+ // "error" fires instead of "close" when the binary itself cannot be spawned. Without it the
314
+ // phase never advances and every row sits at "starting" for ever.
315
+ child.once("error", (error) => {
316
+ appendFileSync(join(runDir, `${branch.stem}.err`), `${error.message}\n`);
317
+ settle(127);
318
+ });
319
+ child.once("close", settle);
320
+ };
321
+
322
+ const startPhase = () => {
323
+ const phase = pending.shift();
324
+ if (!phase) return;
325
+ for (const branch of phase) launch(branch);
326
+ };
327
+
328
+ /** The branch reports, in launch order. Read from disk, so a branch killed halfway still
329
+ * contributes whatever it had written. */
330
+ export const reportOf = (run: RunState): string =>
331
+ run.branches
332
+ .map((branch) => {
333
+ let text = "";
334
+ try {
335
+ text = readFileSync(join(run.dir, `${branch.stem}.md`), "utf8").trim();
336
+ } catch {
337
+ // Killed before pi wrote anything, or a spawn that never started.
338
+ }
339
+ const note = branch.timedOut ? ", timed out" : "";
340
+ return `## ${branch.label} (${branch.status}${note})\n\n${text || branch.error || "no output"}`;
341
+ })
342
+ .join("\n\n");
343
+
344
+ export const store = {
345
+ /** undefined until a run exists. The panel and the bar read this and nothing else. */
346
+ run: () => current,
347
+
348
+ /** Returns the unsubscribe. Call it from the component's dispose(). */
349
+ subscribe(fn: () => void) {
350
+ listeners.add(fn);
351
+ return () => listeners.delete(fn);
352
+ },
353
+
354
+ /** Seeds the whole run, then launches its first phase. One run at a time. Returns the run
355
+ * directory, which is how the caller tells a run it started from one the delegate skill
356
+ * started in bash and has already read for itself. */
357
+ start(spec: RunSpec, defaultModel: string, cwd: string, brief?: string): string | undefined {
358
+ if (running()) return undefined;
359
+ sessionCwd = cwd;
360
+ // LOCAL time, because the reader picks the newest run by NAME and the delegate skill
361
+ // stamps its directories with `date +%Y%m%d-%H%M%S`. A UTC name here would sort a fresh
362
+ // run behind an hours-old one on any machine east of Greenwich.
363
+ const local = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000);
364
+ const stamp = local.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
365
+ runDir = join(cwd, OUT_DIR, `${stamp}-${slug(spec.name)}`);
366
+ const stateDir = join(runDir, "state");
367
+ mkdirSync(stateDir, { recursive: true });
368
+
369
+ // The brief goes to disk byte-for-byte, with no model between the user's words and the
370
+ // file. Every branch is then pointed at the path rather than at a paraphrase of it: a
371
+ // task line is one sentence cut out of a longer thought, and the rest of that thought is
372
+ // what a branch usually turns out to need. Written before the seeds, because a branch
373
+ // could in principle read it the moment its process starts.
374
+ briefPath = brief?.trim() ? join(runDir, "brief.md") : undefined;
375
+ if (briefPath) writeFileSync(briefPath, brief as string);
376
+
377
+ const now = Date.now();
378
+ const taken = new Set<string>();
379
+ const phases: string[] = [];
380
+ let index = 0;
381
+ pending = [];
382
+ for (const phase of spec.phases) {
383
+ const phaseName = slug(phase.title);
384
+ phases.push(phaseName);
385
+ const launches: Launch[] = [];
386
+ for (const branch of phase.branches) {
387
+ // Two branches sharing a stem would share a state file, and a file with two
388
+ // writers is the one corruption this design cannot detect. The caller here is
389
+ // the model, so a collision is renamed rather than refused: a run that starts
390
+ // with "explore-2" beats a run that does not start.
391
+ const base = slug(branch.label);
392
+ let name = base;
393
+ for (let n = 2; taken.has(`${phaseName}-${name}`); n++) name = `${base}-${n}`;
394
+ const stem = `${phaseName}-${name}`;
395
+ taken.add(stem);
396
+ const model = branch.model || defaultModel;
397
+ index += 1;
398
+ // Every field is populated at seed time, so no column appears for the first time
399
+ // three seconds in and reflows the row.
400
+ const state: BranchState = {
401
+ phase: phaseName,
402
+ name,
403
+ // Set when this pi is itself a branch, which is how a nested run keeps its
404
+ // place in the tree.
405
+ parent: process.env.PI_BRANCH_PARENT ?? null,
406
+ index,
407
+ model: prettyModel(model),
408
+ pid: null,
409
+ status: "starting",
410
+ activity: "Starting",
411
+ tokens: null,
412
+ startedAt: now,
413
+ updatedAt: now,
414
+ report: null,
415
+ error: null,
416
+ };
417
+ writeFileSync(join(stateDir, `${stem}.json`), JSON.stringify(state));
418
+ launches.push({ stem, phase: phaseName, name, model, task: branch.task, timeoutMs: DEFAULT_TIMEOUT_MS });
419
+ }
420
+ pending.push(launches);
421
+ }
422
+
423
+ // run.json LAST, after every seed exists: the reader treats its absence as "not a run",
424
+ // so the row set and the M in "N/M agents" are final in the first frame ever drawn.
425
+ writeFileSync(
426
+ join(runDir, "run.json"),
427
+ // pid: this pi is the only thing that will ever launch the phases seeded but not
428
+ // yet started, so the reader needs it to tell "queued" from "abandoned".
429
+ JSON.stringify({ name: spec.name, description: spec.description, cwd, startedAt: now, phases, pid: process.pid }),
430
+ );
431
+ startPhase();
432
+ startTick();
433
+ refresh();
434
+ return runDir;
435
+ },
436
+
437
+ /** The panel's `x`. Resolves false when the branch outlived both kills, and writes nothing
438
+ * either way: the row keeps saying "running" until the process is actually gone. */
439
+ async stop(stem: string): Promise<boolean> {
440
+ const pid = children.get(stem)?.pid ?? current?.branches.find((branch) => branch.stem === stem)?.pid ?? 0;
441
+ if (!pid || gone(stem, pid)) return true;
442
+ // Soft first, so the branch's last assistant message still reaches its .md. A forced
443
+ // kill costs the answer the run already paid for.
444
+ kill(pid, false);
445
+ if (await waitGone(stem, pid, GRACE_MS)) return true;
446
+ kill(pid, true);
447
+ return waitGone(stem, pid, FORCE_MS);
448
+ },
449
+
450
+ /** Session shutdown. Forced and unawaited: pi is leaving, and a branch left behind keeps
451
+ * spending money with nothing watching it. The reports already on disk survive. */
452
+ stopAll() {
453
+ for (const [stem, child] of children) {
454
+ clearTimeout(timers.get(stem));
455
+ kill(child.pid ?? 0, true);
456
+ }
457
+ children.clear();
458
+ timers.clear();
459
+ pending = [];
460
+ stopTick();
461
+ },
462
+
463
+ /** A bash tool call may be the delegate skill starting a run this extension did not spawn.
464
+ * Arming the tick is what makes such a run appear on the bar from its first frame. */
465
+ arm(cwd: string) {
466
+ if (!sessionCwd) sessionCwd = cwd;
467
+ armed = true;
468
+ startTick();
469
+ },
470
+
471
+ /** End of the turn. Whatever the bash call started is either live — and the tick keeps
472
+ * itself going for that — or it is over. */
473
+ disarm() {
474
+ armed = false;
475
+ },
476
+
477
+ /** Session start. Without it a fresh or reloaded pi knows no cwd until the first /umb-fan or bash
478
+ * call, so the bar, alt+a, /agents and Down all report "no run" while .pi-out holds one.
479
+ * The tick only starts for a run still live or inside its linger window, and stops itself. */
480
+ watch(cwd: string) {
481
+ sessionCwd = cwd;
482
+ refresh();
483
+ if (current?.live || !lingerOver()) startTick();
484
+ },
485
+
486
+ /** The check's seam. */
487
+ refresh(cwd?: string) {
488
+ if (cwd) sessionCwd = cwd;
489
+ refresh();
490
+ },
491
+ };