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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/extensions/umbra-loop.ts +102 -0
- package/extensions/umbra-subagents/bar/bar-line.ts +344 -0
- package/extensions/umbra-subagents/bar.check.ts +229 -0
- package/extensions/umbra-subagents/bar.ts +173 -0
- package/extensions/umbra-subagents/fan/index.ts +110 -0
- package/extensions/umbra-subagents/fan/spec.ts +81 -0
- package/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/extensions/umbra-subagents/fan/store.ts +491 -0
- package/extensions/umbra-subagents/panel.check.ts +193 -0
- package/extensions/umbra-subagents/panel.ts +333 -0
- package/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/extensions/umbra-subagents.ts +4 -0
- package/package.json +43 -0
- package/patch.mjs +97 -0
|
@@ -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
|
+
};
|