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,137 @@
|
|
|
1
|
+
// The one runnable check for the producer. It builds a run directory by hand — two runs, a
|
|
2
|
+
// half-built third, a nested branch, a timeout, a torn file and a leftover .tmp — and asserts
|
|
3
|
+
// what the panel reads back out of it. It exists as a separate file so state.ts, which the
|
|
4
|
+
// panel imports on every tick, never carries node:assert or a tmpdir fixture with it.
|
|
5
|
+
//
|
|
6
|
+
// Run it with: bun run state.check.ts (or: node --experimental-strip-types state.check.ts)
|
|
7
|
+
// It never starts pi.
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from "node:assert";
|
|
10
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import beacon, { describe } from "./beacon.ts";
|
|
14
|
+
import { type BranchState, readRun } from "./state.ts";
|
|
15
|
+
|
|
16
|
+
const NOW = 1_700_000_000_000;
|
|
17
|
+
// Pids on Windows are small multiples of 4, so this one has never existed and process.kill
|
|
18
|
+
// answers ESRCH for it. Verified on this machine before it was written down.
|
|
19
|
+
const DEAD = 2_147_483_644;
|
|
20
|
+
|
|
21
|
+
const seed = (stateDir: string, over: Partial<BranchState> & Pick<BranchState, "phase" | "name" | "index">) => {
|
|
22
|
+
const state: BranchState = {
|
|
23
|
+
parent: null,
|
|
24
|
+
model: "commandcode/minimax/minimax-m3-free",
|
|
25
|
+
pid: null,
|
|
26
|
+
status: "starting",
|
|
27
|
+
activity: "Starting",
|
|
28
|
+
tokens: null,
|
|
29
|
+
startedAt: NOW - 195_000,
|
|
30
|
+
updatedAt: NOW - 195_000,
|
|
31
|
+
report: null,
|
|
32
|
+
error: null,
|
|
33
|
+
...over,
|
|
34
|
+
};
|
|
35
|
+
writeFileSync(join(stateDir, `${state.phase}-${state.name}.json`), JSON.stringify(state));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const run = (dir: string, name: string, phases: string[]) =>
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(dir, "run.json"),
|
|
41
|
+
JSON.stringify({ name, description: "Map how pi renders tool calls", cwd: dir, startedAt: NOW - 196_000, phases }),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const cwd = mkdtempSync(join(tmpdir(), "delegate-check-"));
|
|
45
|
+
assert.equal(readRun(cwd, NOW), undefined, "no .pi-out at all is not a run");
|
|
46
|
+
|
|
47
|
+
const older = join(cwd, ".pi-out", "20260101-000000-older");
|
|
48
|
+
const newest = join(cwd, ".pi-out", "20260101-010000-newest");
|
|
49
|
+
const building = join(cwd, ".pi-out", "20260101-020000-building");
|
|
50
|
+
for (const dir of [older, newest, building]) mkdirSync(join(dir, "state"), { recursive: true });
|
|
51
|
+
|
|
52
|
+
run(older, "older", ["map"]);
|
|
53
|
+
seed(join(older, "state"), { phase: "map", name: "leftover", index: 1 });
|
|
54
|
+
|
|
55
|
+
run(newest, "newest", ["map", "design"]);
|
|
56
|
+
const state = join(newest, "state");
|
|
57
|
+
seed(state, { phase: "map", name: "core-render", index: 1, pid: process.pid, status: "running", tokens: 72_700, updatedAt: NOW - 53_000 });
|
|
58
|
+
seed(state, { phase: "map", name: "omp-intercept", index: 2, pid: DEAD, status: "running", tokens: 89_600 });
|
|
59
|
+
seed(state, { phase: "design", name: "review", index: 3, parent: "map-core-render" });
|
|
60
|
+
seed(state, { phase: "map", name: "flicker", index: 4, pid: process.pid, status: "running" });
|
|
61
|
+
writeFileSync(join(state, "map-flicker.exit"), "124");
|
|
62
|
+
// Neither of these may ever become a row: one is a file we did not write, the other is the
|
|
63
|
+
// unfinished half of a tmp + rename.
|
|
64
|
+
writeFileSync(join(state, "map-broken.json"), "{oops");
|
|
65
|
+
writeFileSync(join(state, "map-core-render.json.tmp"), "{");
|
|
66
|
+
|
|
67
|
+
// The newest directory has no run.json yet, so the panel must fall through to the newest
|
|
68
|
+
// complete run rather than blanking for a frame.
|
|
69
|
+
seed(join(building, "state"), { phase: "map", name: "unseen", index: 1 });
|
|
70
|
+
|
|
71
|
+
const view = readRun(cwd, NOW);
|
|
72
|
+
assert.ok(view, "a complete run directory is a run");
|
|
73
|
+
assert.equal(view.name, "newest", "newest run by name, never by mtime, and never a half-built one");
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
view.branches.map((branch) => `${branch.stem}@${branch.depth}`),
|
|
76
|
+
["map-core-render@0", "design-review@1", "map-omp-intercept@0", "map-flicker@0"],
|
|
77
|
+
"parents immediately above their children, siblings by launch index",
|
|
78
|
+
);
|
|
79
|
+
assert.equal(view.branches[0]?.label, "map:core-render", "the colon is a render-time join");
|
|
80
|
+
assert.equal(view.total, 4, "the torn file and the .tmp are not rows");
|
|
81
|
+
// core-render is alive (our own pid), review has not booted yet; omp-intercept's pid is gone
|
|
82
|
+
// and flicker has an exit file, so both are finished without ever saying so themselves.
|
|
83
|
+
assert.equal(view.done, 2, "a dead pid and an exit file both settle a branch that still reads 'running'");
|
|
84
|
+
assert.equal(view.branches[1]?.settled, false, "a seeded branch with no pid yet is pending, not done");
|
|
85
|
+
assert.equal(view.branches[3]?.timedOut, true, "exit code 124 is a timeout, not a crash");
|
|
86
|
+
assert.equal(view.tokens, 162_300, "null token counts contribute nothing rather than a fake 0");
|
|
87
|
+
assert.equal(view.branches[0]?.idleMs, 53_000, "idle is measured from updatedAt");
|
|
88
|
+
assert.equal(view.activePhase, 0, "the first phase still holding an unsettled branch");
|
|
89
|
+
assert.equal(view.live, true);
|
|
90
|
+
|
|
91
|
+
// Finish the map phase and the sidebar marker has to move on by itself.
|
|
92
|
+
seed(state, { phase: "map", name: "core-render", index: 1, pid: DEAD, status: "done", report: "OK", tokens: 72_700 });
|
|
93
|
+
const advanced = readRun(cwd, NOW);
|
|
94
|
+
assert.equal(advanced?.activePhase, 1, "the marker follows the first unsettled phase");
|
|
95
|
+
assert.equal(advanced?.done, 3);
|
|
96
|
+
assert.equal(advanced?.live, true, "the nested branch is still pending");
|
|
97
|
+
|
|
98
|
+
assert.equal(describe("read", { path: "src/hooks.server.ts" }), "Reading hooks.server.ts");
|
|
99
|
+
assert.equal(describe("grep", { pattern: "localStorage", path: "src/lib/office-persist.ts" }), "Grepping localStorage in office-persist.ts");
|
|
100
|
+
assert.equal(describe("grep", { pattern: "localStorage", glob: "**/*.ts" }), "Grepping localStorage in **/*.ts");
|
|
101
|
+
assert.equal(describe("grep", { pattern: "x".repeat(50) }), `Grepping ${"x".repeat(31)}…`, "a runaway pattern cannot widen the row");
|
|
102
|
+
assert.equal(describe("find", { pattern: "**/*.svelte" }), "Finding **/*.svelte");
|
|
103
|
+
assert.equal(describe("ls", {}), "Listing .", "a default argument still reads as a sentence");
|
|
104
|
+
assert.equal(describe("bash", { command: "npm test -- --run" }), "Running npm");
|
|
105
|
+
assert.equal(describe("read", {}), "read", "a missing argument falls back to the tool name");
|
|
106
|
+
assert.equal(describe("web_search", { query: "pi extension api" }), "web_search pi extension api", "an unknown tool still names its subject");
|
|
107
|
+
assert.equal(describe("edit", { file_path: "src/lib/office-persist.ts" }), "edit office-persist.ts", "a path-shaped argument shows as its basename");
|
|
108
|
+
assert.equal(describe("web_search", {}), "web_search", "an unknown tool with nothing readable is named, not narrated");
|
|
109
|
+
|
|
110
|
+
// The beacon itself, driven by hand. Two tool calls overlap and the older one outlives the
|
|
111
|
+
// newer: clearing the sentence on the first `end` is the bug this asserts against, because it
|
|
112
|
+
// leaves the row reading "Thinking" while the branch is still mid-grep.
|
|
113
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => void>();
|
|
114
|
+
const branchFile = join(cwd, "branch.json");
|
|
115
|
+
seed(cwd, { phase: "map", name: "beacon", index: 1 });
|
|
116
|
+
writeFileSync(branchFile, readFileSync(join(cwd, "map-beacon.json"), "utf8"));
|
|
117
|
+
process.env.PI_BRANCH_STATE = branchFile;
|
|
118
|
+
// SAFETY: the beacon only ever calls pi.on, so this is the whole surface it uses.
|
|
119
|
+
beacon({ on: (event: string, handler: (e: unknown, c: unknown) => void) => handlers.set(event, handler) } as never);
|
|
120
|
+
const fire = (event: string, payload: unknown, ctx: unknown = {}) => handlers.get(event)?.(payload, ctx);
|
|
121
|
+
|
|
122
|
+
fire("session_start", {}, { model: { name: "Opus 5" } });
|
|
123
|
+
fire("tool_execution_start", { toolCallId: "a", toolName: "grep", args: { pattern: "localStorage", path: "src/lib/office-persist.ts" } });
|
|
124
|
+
fire("tool_execution_start", { toolCallId: "b", toolName: "read", args: { path: "src/hooks.server.ts" } });
|
|
125
|
+
fire("tool_execution_end", { toolCallId: "b", toolName: "read" });
|
|
126
|
+
// Tool writes are coalesced at 100 ms, so the row is read after that window rather than inside it.
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
128
|
+
const live = JSON.parse(readFileSync(branchFile, "utf8")) as BranchState;
|
|
129
|
+
assert.equal(live.pid, process.pid, "the pid is pi's own, not the launching shell's");
|
|
130
|
+
assert.equal(live.model, "Opus 5", "session_start replaces the requested id with the display name");
|
|
131
|
+
assert.equal(live.activity, "Grepping localStorage in office-persist.ts", "an end clears its own call, never the row");
|
|
132
|
+
|
|
133
|
+
fire("tool_execution_end", { toolCallId: "a", toolName: "grep" });
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
135
|
+
assert.equal((JSON.parse(readFileSync(branchFile, "utf8")) as BranchState).activity, "Thinking", "with nothing running the row says so");
|
|
136
|
+
|
|
137
|
+
console.log("state.check.ts ok");
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// What a delegate run looks like on disk, and the one function that folds a run directory
|
|
2
|
+
// into the shape the agent panel renders. It ships inside the skill folder rather than
|
|
3
|
+
// beside the panel because run.sh's seed heredoc and beacon.ts are its only two writers,
|
|
4
|
+
// and a schema living next to its writers cannot drift away from them.
|
|
5
|
+
//
|
|
6
|
+
// Nothing here registers anything with pi, so importing it costs zero prompt tokens.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Scratch root, relative to the SESSION cwd. Already gitignored by the old delegate. */
|
|
12
|
+
export const OUT_DIR = ".pi-out";
|
|
13
|
+
|
|
14
|
+
/** How long the widget keeps showing a finished run before dropping back to `○ main`.
|
|
15
|
+
* It lives here rather than in the renderer because the producer has to keep its clock
|
|
16
|
+
* ticking for exactly this long after the last branch settles - two copies of this number
|
|
17
|
+
* in two files is precisely the drift that froze the last frame on screen. */
|
|
18
|
+
export const LINGER_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
// ---------- ON DISK ----------
|
|
21
|
+
|
|
22
|
+
/** `<run>/run.json` — written once by the skill, AFTER every seed exists. Its absence is
|
|
23
|
+
* what makes a half-built run directory invisible, so the panel's `M` in "N/M agents" is
|
|
24
|
+
* final from the first frame it ever draws. */
|
|
25
|
+
export type Run = {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
/** Absolute session cwd, captured before the skill `cd`s into a subtree. */
|
|
29
|
+
cwd: string;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
/** Sidebar order. A later wave appends. */
|
|
32
|
+
phases: string[];
|
|
33
|
+
/** The process that owns this run and is the only thing that will ever launch its
|
|
34
|
+
* remaining phases. Without it a seed for a phase that never got to run is
|
|
35
|
+
* indistinguishable from a branch that is about to boot, so one Ctrl-C left a directory
|
|
36
|
+
* reporting `live: true` for ever. Absent in a run written before this field existed,
|
|
37
|
+
* which reads as "cannot tell" and keeps the old behaviour. */
|
|
38
|
+
pid?: number | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** `<run>/state/<phase>-<name>.json` — seeded by the skill with every field populated, then
|
|
42
|
+
* written only by beacon.ts, always whole-object through tmp + rename.
|
|
43
|
+
*
|
|
44
|
+
* The stem is `${phase}-${name}` and never carries a colon: a colon is illegal in an NTFS
|
|
45
|
+
* filename. The `map:core-render` the panel shows is a render-time join, never a stored
|
|
46
|
+
* string. Both halves match ^[a-z0-9][a-z0-9._-]*$, which is why the seed heredoc contains
|
|
47
|
+
* no free text and cannot emit invalid JSON. */
|
|
48
|
+
export type BranchState = {
|
|
49
|
+
phase: string;
|
|
50
|
+
name: string;
|
|
51
|
+
/** Stem of the branch that spawned this one, or null at the top level. Branches run with
|
|
52
|
+
* `--tools read,grep,find,ls` and so cannot nest today; the field is here because the
|
|
53
|
+
* panel's nesting marker needs it the moment one of them is given `bash`. */
|
|
54
|
+
parent: string | null;
|
|
55
|
+
/** 1-based launch order. The panel's ONLY sort key — never readdir order, never mtime. */
|
|
56
|
+
index: number;
|
|
57
|
+
/** Seed: the requested "provider/id". Beacon: pi's own display name, e.g. "Opus 5". */
|
|
58
|
+
model: string;
|
|
59
|
+
/** null in the seed, the real OS pid at session_start. */
|
|
60
|
+
pid: number | null;
|
|
61
|
+
status: "starting" | "running" | "done" | "error";
|
|
62
|
+
/** The whole point of the panel: a short present-tense sentence, rewritten at every tool
|
|
63
|
+
* boundary. "Grepping localStorage in office-persist.ts", not "running". Never empty —
|
|
64
|
+
* an empty string reflows the row. */
|
|
65
|
+
activity: string;
|
|
66
|
+
/** null until the provider reports usage; the panel renders an en dash rather than a
|
|
67
|
+
* fake 0. Once non-null it is never written back to null or to 0. */
|
|
68
|
+
tokens: number | null;
|
|
69
|
+
/** The shell's launch instant, stamped before pi boots. */
|
|
70
|
+
startedAt: number;
|
|
71
|
+
/** Stamped on every beacon write. The only input to "idle Ns". */
|
|
72
|
+
updatedAt: number;
|
|
73
|
+
report: "OK" | "PARTIAL" | "NEED_STRONGER" | "ASKING" | null;
|
|
74
|
+
error: string | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// `<run>/state/<stem>.exit` holds `$?` from the launching subshell. Its presence means the
|
|
78
|
+
// process is gone whatever the JSON still says, which is the only thing that catches a
|
|
79
|
+
// branch killed before its extensions ever bound. "124" means `timeout 300` fired.
|
|
80
|
+
//
|
|
81
|
+
// Siblings, contract unchanged from the old delegate:
|
|
82
|
+
// <run>/<stem>.md branch stdout, the report the parent session reads
|
|
83
|
+
// <run>/<stem>.err branch stderr
|
|
84
|
+
|
|
85
|
+
// ---------- WHAT THE PANEL CONSUMES ----------
|
|
86
|
+
|
|
87
|
+
export type BranchView = BranchState & {
|
|
88
|
+
/** `${phase}-${name}`, the row key and the file stem. */
|
|
89
|
+
stem: string;
|
|
90
|
+
/** `${phase}:${name}`, what the panel prints. */
|
|
91
|
+
label: string;
|
|
92
|
+
/** 0 at the top level, 1 under a parent, and so on. Drives the nesting marker. */
|
|
93
|
+
depth: number;
|
|
94
|
+
elapsedMs: number;
|
|
95
|
+
idleMs: number;
|
|
96
|
+
exitCode: number | null;
|
|
97
|
+
timedOut: boolean;
|
|
98
|
+
alive: boolean;
|
|
99
|
+
/** Finished for any reason. The N in "N/M agents". */
|
|
100
|
+
settled: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type RunState = {
|
|
104
|
+
dir: string;
|
|
105
|
+
name: string;
|
|
106
|
+
description: string;
|
|
107
|
+
startedAt: number;
|
|
108
|
+
phases: string[];
|
|
109
|
+
/** First phase still holding an unsettled branch; the last phase once everything is done. */
|
|
110
|
+
activePhase: number;
|
|
111
|
+
/** Pre-ordered: a parent immediately above its children, siblings by launch order. Render
|
|
112
|
+
* top to bottom; the component never walks a tree. */
|
|
113
|
+
branches: BranchView[];
|
|
114
|
+
done: number;
|
|
115
|
+
total: number;
|
|
116
|
+
tokens: number;
|
|
117
|
+
live: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ---------- READER ----------
|
|
121
|
+
|
|
122
|
+
const stemOf = (state: BranchState) => `${state.phase}-${state.name}`;
|
|
123
|
+
|
|
124
|
+
// EPERM means the pid exists and belongs to someone else, which is still "not dead". Only
|
|
125
|
+
// ESRCH proves the branch is gone — and on Windows that is the sole signal, because stopping
|
|
126
|
+
// a branch is TerminateProcess and it never gets to write "done" into its own file.
|
|
127
|
+
export const pidLive = (pid: number): boolean => {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// SAFETY: process.kill only ever throws a system error; reading .code off anything
|
|
133
|
+
// else yields undefined, which falls through to "dead".
|
|
134
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type Exit = { code: number | null; at: number };
|
|
139
|
+
|
|
140
|
+
const readExit = (path: string): Exit | undefined => {
|
|
141
|
+
try {
|
|
142
|
+
// The mtime is the instant the process actually died, which beats updatedAt for
|
|
143
|
+
// elapsed time on a branch killed before the beacon ever wrote.
|
|
144
|
+
const at = statSync(path).mtimeMs;
|
|
145
|
+
const code = Number(readFileSync(path, "utf8").trim());
|
|
146
|
+
return { code: Number.isFinite(code) ? code : null, at };
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Newest by name, not by mtime: the YYYYMMDD-HHMMSS prefix is monotonic, while a run
|
|
153
|
+
// directory's mtime churns as its branches write and can flip the panel to another run
|
|
154
|
+
// mid-flight. A directory with no run.json is still being built, so the previous complete
|
|
155
|
+
// run stays on screen instead of the panel blanking for a frame.
|
|
156
|
+
const latestRun = (cwd: string): { dir: string; run: Run } | undefined => {
|
|
157
|
+
let names: string[];
|
|
158
|
+
try {
|
|
159
|
+
names = readdirSync(join(cwd, OUT_DIR), { withFileTypes: true })
|
|
160
|
+
.filter((entry) => entry.isDirectory())
|
|
161
|
+
.map((entry) => entry.name)
|
|
162
|
+
.sort();
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
for (let i = names.length - 1; i >= 0; i--) {
|
|
167
|
+
// SAFETY: i is bounded by names.length, so the index is always populated.
|
|
168
|
+
const dir = join(cwd, OUT_DIR, names[i] as string);
|
|
169
|
+
try {
|
|
170
|
+
// SAFETY: run.json is written by run.sh through json(), which escapes the only
|
|
171
|
+
// two free-text fields, so it either parses to Run or throws into the catch.
|
|
172
|
+
const run = JSON.parse(readFileSync(join(dir, "run.json"), "utf8")) as Run;
|
|
173
|
+
if (Array.isArray(run.phases)) return { dir, run };
|
|
174
|
+
} catch {
|
|
175
|
+
// Half-built, hand-deleted, or from an older delegate. Try the one before it.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const readStates = (stateDir: string): BranchState[] => {
|
|
182
|
+
let files: string[];
|
|
183
|
+
try {
|
|
184
|
+
files = readdirSync(stateDir);
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const states: BranchState[] = [];
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
// ".json.tmp" deliberately fails this test: it is the unfinished half of a
|
|
191
|
+
// tmp + rename write and must never be parsed.
|
|
192
|
+
if (!file.endsWith(".json")) continue;
|
|
193
|
+
try {
|
|
194
|
+
// SAFETY: same contract as run.json — the seed heredoc holds constrained
|
|
195
|
+
// identifiers only, so this parses to BranchState or throws.
|
|
196
|
+
states.push(JSON.parse(readFileSync(join(stateDir, file), "utf8")) as BranchState);
|
|
197
|
+
} catch {
|
|
198
|
+
// Not a seed we wrote. Dropping the row beats throwing inside a render loop.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return states;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Depth-first by launch order, so a parent sits immediately above its children and the
|
|
205
|
+
// component can render the list top to bottom.
|
|
206
|
+
const order = (states: BranchState[]): { state: BranchState; depth: number }[] => {
|
|
207
|
+
const sorted = [...states].sort((a, b) => a.index - b.index);
|
|
208
|
+
const stems = new Set(sorted.map(stemOf));
|
|
209
|
+
const byParent = new Map<string, BranchState[]>();
|
|
210
|
+
for (const state of sorted) {
|
|
211
|
+
// A parent naming a branch that is not in this run is treated as a root, so a stale
|
|
212
|
+
// PI_BRANCH_PARENT hides no rows.
|
|
213
|
+
const key = state.parent !== null && stems.has(state.parent) ? state.parent : "";
|
|
214
|
+
const siblings = byParent.get(key);
|
|
215
|
+
if (siblings) siblings.push(state);
|
|
216
|
+
else byParent.set(key, [state]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const out: { state: BranchState; depth: number }[] = [];
|
|
220
|
+
const seen = new Set<string>();
|
|
221
|
+
const walk = (key: string, depth: number) => {
|
|
222
|
+
if (depth > 8) return;
|
|
223
|
+
for (const state of byParent.get(key) ?? []) {
|
|
224
|
+
const stem = stemOf(state);
|
|
225
|
+
if (seen.has(stem)) continue;
|
|
226
|
+
seen.add(stem);
|
|
227
|
+
out.push({ state, depth });
|
|
228
|
+
walk(stem, depth + 1);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
walk("", 0);
|
|
232
|
+
// Anything the walk could not reach — a hand-edited parent cycle, a stem nested deeper
|
|
233
|
+
// than the cap — still gets a row. A branch the panel cannot see is worse than one drawn
|
|
234
|
+
// at the top level.
|
|
235
|
+
for (const state of sorted) {
|
|
236
|
+
const stem = stemOf(state);
|
|
237
|
+
if (seen.has(stem)) continue;
|
|
238
|
+
seen.add(stem);
|
|
239
|
+
out.push({ state, depth: 0 });
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The whole panel-facing surface. One readdir, one run.json and one small JSON per branch:
|
|
245
|
+
* cheap enough for a 1 s tick, which the panel needs anyway because elapsed and "idle Ns"
|
|
246
|
+
* move with the clock rather than with the files. */
|
|
247
|
+
export function readRun(cwd: string, now = Date.now()): RunState | undefined {
|
|
248
|
+
const found = latestRun(cwd);
|
|
249
|
+
if (!found) return undefined;
|
|
250
|
+
|
|
251
|
+
// Written by whoever owns the run. `undefined` is a run.json from before the field
|
|
252
|
+
// existed: unknowable, so treat it as still owned and keep the previous behaviour.
|
|
253
|
+
const orphaned = typeof found.run.pid === "number" && !pidLive(found.run.pid);
|
|
254
|
+
|
|
255
|
+
const stateDir = join(found.dir, "state");
|
|
256
|
+
const branches: BranchView[] = order(readStates(stateDir)).map(({ state, depth }) => {
|
|
257
|
+
const stem = stemOf(state);
|
|
258
|
+
const exit = readExit(join(stateDir, `${stem}.exit`));
|
|
259
|
+
// A seeded branch whose pid is still null has not booted yet: it is pending, not
|
|
260
|
+
// finished, or every row would count as done the instant the run started. Pending is
|
|
261
|
+
// only honest while the owner is still around to launch it — an abandoned run's later
|
|
262
|
+
// phases would otherwise read as alive for ever and pin the whole run to `live`.
|
|
263
|
+
const alive = exit === undefined && (state.pid === null ? !orphaned : pidLive(state.pid));
|
|
264
|
+
const settled = !alive || state.status === "done" || state.status === "error";
|
|
265
|
+
const endedAt = exit?.at ?? (settled ? state.updatedAt : now);
|
|
266
|
+
return {
|
|
267
|
+
...state,
|
|
268
|
+
stem,
|
|
269
|
+
label: `${state.phase}:${state.name}`,
|
|
270
|
+
depth,
|
|
271
|
+
elapsedMs: Math.max(0, endedAt - state.startedAt),
|
|
272
|
+
idleMs: Math.max(0, now - state.updatedAt),
|
|
273
|
+
exitCode: exit?.code ?? null,
|
|
274
|
+
timedOut: exit?.code === 124,
|
|
275
|
+
alive,
|
|
276
|
+
settled,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const phases = found.run.phases;
|
|
281
|
+
const running = phases.findIndex((phase) => branches.some((b) => b.phase === phase && !b.settled));
|
|
282
|
+
return {
|
|
283
|
+
dir: found.dir,
|
|
284
|
+
name: found.run.name,
|
|
285
|
+
description: found.run.description,
|
|
286
|
+
startedAt: found.run.startedAt,
|
|
287
|
+
phases,
|
|
288
|
+
activePhase: running >= 0 ? running : Math.max(0, phases.length - 1),
|
|
289
|
+
branches,
|
|
290
|
+
done: branches.filter((b) => b.settled).length,
|
|
291
|
+
total: branches.length,
|
|
292
|
+
tokens: branches.reduce((sum, b) => sum + (b.tokens ?? 0), 0),
|
|
293
|
+
live: branches.some((b) => b.alive),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fan
|
|
3
|
+
description: Fan independent read-only work out to parallel pi branches and watch them live in the agent panel. Use whenever a task splits into 2+ lookups that do not need each other, or when the user writes "fan", "delegate", "subagent", or "in parallel".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Fan
|
|
7
|
+
|
|
8
|
+
There is no subagent tool and nothing is added to this prompt. A branch is a
|
|
9
|
+
separate `pi -p` process the fan extension spawns and owns; each one writes a
|
|
10
|
+
small live state file, so the panel above the input box shows what every branch
|
|
11
|
+
is doing while it does it, not only what it concluded.
|
|
12
|
+
|
|
13
|
+
## Run
|
|
14
|
+
|
|
15
|
+
Write one fenced `fan` block at the end of your message and stop. The extension
|
|
16
|
+
starts the branches when the message ends, and the answers arrive as a follow-up
|
|
17
|
+
message at the top of the next turn. Do not run bash for this, and do not write
|
|
18
|
+
anything after the block.
|
|
19
|
+
|
|
20
|
+
````
|
|
21
|
+
```fan
|
|
22
|
+
name: toolcall-render
|
|
23
|
+
desc: Map how pi renders tool calls
|
|
24
|
+
# Map
|
|
25
|
+
core-render: Read <paths> and report where a tool call becomes lines. Cite file:line.
|
|
26
|
+
omp-intercept: Check whether an extension can override it. Cite file:line.
|
|
27
|
+
# Design
|
|
28
|
+
proposal: Given the Map results, write the design.
|
|
29
|
+
```
|
|
30
|
+
````
|
|
31
|
+
|
|
32
|
+
- `name` and `desc` are the panel header. `# Title` starts a phase.
|
|
33
|
+
- `label: task` is one branch. Phases run in order; branches inside a phase run
|
|
34
|
+
at the same time. Use a second phase only when it genuinely needs the first
|
|
35
|
+
phase's answers.
|
|
36
|
+
- `label@provider/model: task` picks a model for that branch. Without it a
|
|
37
|
+
branch runs on this session's model.
|
|
38
|
+
- 2 to 6 branches per phase. Each task names the files or directory and says
|
|
39
|
+
exactly what to return.
|
|
40
|
+
|
|
41
|
+
## Rules
|
|
42
|
+
|
|
43
|
+
- Branches are read-only: `read`, `grep`, `find`, `ls`, nothing else. Work that
|
|
44
|
+
edits files stays in this session.
|
|
45
|
+
- A branch has an empty context. Repeating what you already know in the task
|
|
46
|
+
text is the only way it learns it.
|
|
47
|
+
- One run at a time. A second block while a run is live is ignored.
|
|
48
|
+
- Every branch writes its answer to `.pi-out/<run>/<phase>-<name>.md` as it goes,
|
|
49
|
+
and its errors to the matching `.err`. Quitting pi kills the branches, but what
|
|
50
|
+
they had already written stays on disk. `.pi-out/` is gitignored scratch.
|
|
51
|
+
- A branch gets 5 minutes and is then cut off, so one wedged branch cannot
|
|
52
|
+
hold the run open. Keep each task small enough to finish in that time.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The live agent view: a widget above the input box, a full-screen panel, and the fan producer
|
|
2
|
+
// that feeds them. Everything is under ./umbra-subagents/; this file exists because pi scans
|
|
3
|
+
// extensions/*.ts and the entry point is nested.
|
|
4
|
+
export { default } from "./umbra-subagents/fan/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-umbra-subagents",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Parallel read-only pi branches with a live panel above the input box, the delegate skill that starts them, and /umb-loop, which resubmits a prompt on a count or a timer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-skill",
|
|
10
|
+
"subagents",
|
|
11
|
+
"parallel",
|
|
12
|
+
"loop"
|
|
13
|
+
],
|
|
14
|
+
"author": "grkn",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/grknbyk/pi-umbra.git",
|
|
19
|
+
"directory": "pi-umbra-subagents"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-subagents#readme",
|
|
22
|
+
"bugs": "https://github.com/grknbyk/pi-umbra/issues",
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"patch.mjs"
|
|
28
|
+
],
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions/umbra-subagents.ts",
|
|
32
|
+
"./extensions/umbra-loop.ts"
|
|
33
|
+
],
|
|
34
|
+
"skills": [
|
|
35
|
+
"./extensions/umbra-subagents/skills"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-ai": "*",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/patch.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// /umb-loop needs one seam pi's extension API does not expose: the editor's own submit path,
|
|
2
|
+
// which is the only way an extension can send a prompt the way the user does. This publishes it
|
|
3
|
+
// as globalThis.__piSubmit. Run it after installing, and again after every pi update:
|
|
4
|
+
//
|
|
5
|
+
// node <this file> apply
|
|
6
|
+
// node <this file> --check report only, write nothing, exit 1 if it is missing
|
|
7
|
+
//
|
|
8
|
+
// pi runs dist/bundle/cli.js, a self-contained bundle, and the chunk names are content-hashed,
|
|
9
|
+
// so the patch finds its place by matching its own text rather than by line number.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here is destructive. The patch is additive, text that no longer matches is reported
|
|
12
|
+
// instead of forced, and reinstalling pi returns the bundle to stock. A pi upgrade replaces the
|
|
13
|
+
// bundle and drops the patch silently, which is what --check is for.
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { existsSync, realpathSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
// PI_UMBRA_PI points this at another pi tree. Otherwise the pi that runs is the one to patch:
|
|
21
|
+
// the `pi` on PATH, followed through its symlink. Windows puts a .cmd shim there instead, so
|
|
22
|
+
// npm's global root (%APPDATA%\npm\node_modules) comes next, then bun's. The copy npm installs
|
|
23
|
+
// beside this package as a peer dependency is last: pi never runs it. The first that has a built
|
|
24
|
+
// bundle wins.
|
|
25
|
+
const resolvePi = () => {
|
|
26
|
+
if (process.env.PI_UMBRA_PI) return process.env.PI_UMBRA_PI;
|
|
27
|
+
const quiet = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 };
|
|
28
|
+
const candidates = [];
|
|
29
|
+
try {
|
|
30
|
+
const bin = execSync(process.platform === "win32" ? "where pi" : "command -v pi", quiet).split(/\r?\n/)[0].trim();
|
|
31
|
+
for (let dir = dirname(realpathSync(bin)); dir !== dirname(dir); dir = dirname(dir)) candidates.push(dir);
|
|
32
|
+
} catch {}
|
|
33
|
+
try {
|
|
34
|
+
const root = execSync("npm root -g", quiet).trim();
|
|
35
|
+
if (root) candidates.push(join(root, "@earendil-works", "pi-coding-agent"));
|
|
36
|
+
} catch {}
|
|
37
|
+
candidates.push(join(homedir(), ".bun", "install", "global", "node_modules", "@earendil-works", "pi-coding-agent"));
|
|
38
|
+
try {
|
|
39
|
+
candidates.push(dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json")));
|
|
40
|
+
} catch {}
|
|
41
|
+
return candidates.find((dir) => existsSync(join(dir, "dist", "bundle"))) ?? candidates[0];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const BUNDLE = join(resolvePi(), "dist/bundle");
|
|
45
|
+
if (!existsSync(BUNDLE)) {
|
|
46
|
+
console.error(`no pi bundle at ${BUNDLE} - set PI_UMBRA_PI to your pi install`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PATCHES = [
|
|
51
|
+
{
|
|
52
|
+
// The extension API cannot submit a prompt, so /umb-loop borrows the editor's own path.
|
|
53
|
+
reason: "publish the editor submit handler for /umb-loop",
|
|
54
|
+
from: "this.setupEditorSubmitHandler(),",
|
|
55
|
+
to: "this.setupEditorSubmitHandler(),globalThis.__piSubmit=text=>this.defaultEditor.onSubmit?.(text),",
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const files = [join(BUNDLE, "cli.js"), ...readdirSync(join(BUNDLE, "chunks")).map((name) => join(BUNDLE, "chunks", name))];
|
|
60
|
+
const CHECK = process.argv.includes("--check");
|
|
61
|
+
const unapplied = [];
|
|
62
|
+
const gone = [];
|
|
63
|
+
|
|
64
|
+
for (const patch of PATCHES) {
|
|
65
|
+
let done = false;
|
|
66
|
+
for (const path of files) {
|
|
67
|
+
const source = readFileSync(path, "utf8");
|
|
68
|
+
// A multi-line patch is written with newline escapes; match whatever line ending the file uses.
|
|
69
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n";
|
|
70
|
+
const from = patch.from.split("\n").join(eol);
|
|
71
|
+
const to = patch.to.split("\n").join(eol);
|
|
72
|
+
if (source.includes(to)) {
|
|
73
|
+
if (!CHECK) console.log(`already patched: ${patch.reason}`);
|
|
74
|
+
done = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (!source.includes(from)) continue;
|
|
78
|
+
if (CHECK) {
|
|
79
|
+
unapplied.push(patch.reason);
|
|
80
|
+
done = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
writeFileSync(path, source.replace(from, to));
|
|
84
|
+
console.log(`patched: ${patch.reason}`);
|
|
85
|
+
done = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
// Neither the original text nor the patched text is there, so pi changed the code this
|
|
89
|
+
// patch names. Report it rather than force anything.
|
|
90
|
+
if (!done) gone.push(patch.reason);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const reason of unapplied) console.log(`NOT APPLIED: ${reason}`);
|
|
94
|
+
for (const reason of gone) console.log(`SEAM GONE: ${reason}`);
|
|
95
|
+
const bad = unapplied.length + gone.length;
|
|
96
|
+
console.log(bad ? `${bad} of ${PATCHES.length} not in place` : `all ${PATCHES.length} patches applied`);
|
|
97
|
+
process.exit(bad ? 1 : 0);
|