pi-umbra 0.3.1 → 0.4.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/README.md +3 -4
- package/node_modules/pi-umbra-copy-chat/README.md +4 -7
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
- package/node_modules/pi-umbra-copy-chat/package.json +1 -1
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/README.md +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
- package/node_modules/pi-umbra-inputbar/package.json +2 -2
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
- package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
- package/node_modules/pi-umbra-subagents/README.md +19 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +9 -2
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +10 -1
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +50 -5
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +119 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +138 -58
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
- package/node_modules/pi-umbra-subagents/package.json +2 -2
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
- package/node_modules/pi-umbra-theme/package.json +3 -3
- package/package.json +9 -12
- package/node_modules/pi-umbra-rename/LICENSE +0 -21
- package/node_modules/pi-umbra-rename/README.md +0 -18
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
- package/node_modules/pi-umbra-rename/package.json +0 -36
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
1
2
|
import type { RunSpec } from "./store.ts";
|
|
2
3
|
|
|
3
4
|
// The one text format a run is described in. `/umb-fan` opens it in an editor, and the fan
|
|
@@ -11,8 +12,10 @@ import type { RunSpec } from "./store.ts";
|
|
|
11
12
|
// omp-intercept@openai/gpt-5: Check whether an extension can override it.
|
|
12
13
|
// # Design
|
|
13
14
|
// proposal: Given the Map phase, write the design.
|
|
15
|
+
// prototype [write]: Build the smallest version of it and run the tests.
|
|
14
16
|
//
|
|
15
|
-
// Phases run in order; every branch inside one phase runs at the same time.
|
|
17
|
+
// Phases run in order; every branch inside one phase runs at the same time. A branch is
|
|
18
|
+
// read-only unless its key carries `[write]`: then it works in its own git worktree.
|
|
16
19
|
|
|
17
20
|
export const TEMPLATE = ["name: ", "desc: ", "# Phase one", "label: task", ""].join("\n");
|
|
18
21
|
|
|
@@ -40,8 +43,9 @@ export const parseSpec = (text: string): RunSpec | undefined => {
|
|
|
40
43
|
// A branch before any "#" gets an implicit first phase, so the common
|
|
41
44
|
// single-phase run needs no header at all.
|
|
42
45
|
const phase = spec.phases[spec.phases.length - 1] ?? pushPhase(spec, "Branches");
|
|
43
|
-
const
|
|
44
|
-
|
|
46
|
+
const write = /\s*\[write\]$/i.test(key);
|
|
47
|
+
const [label, model] = key.replace(/\s*\[write\]$/i, "").split("@");
|
|
48
|
+
if (label) phase.branches.push({ label, model, task: value, ...(write ? { write } : {}) });
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
spec.phases = spec.phases.filter((phase) => phase.branches.length > 0);
|
|
@@ -51,7 +55,7 @@ export const parseSpec = (text: string): RunSpec | undefined => {
|
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
const pushPhase = (spec: RunSpec, title: string) => {
|
|
54
|
-
const phase = { title, branches: [] as
|
|
58
|
+
const phase = { title, branches: [] as RunSpec["phases"][number]["branches"] };
|
|
55
59
|
spec.phases.push(phase);
|
|
56
60
|
return phase;
|
|
57
61
|
};
|
|
@@ -69,19 +73,25 @@ lone: Read tui.d.ts:68 and report.
|
|
|
69
73
|
# Design
|
|
70
74
|
plan@openai/gpt-5: Write it up.
|
|
71
75
|
free@openrouter/nvidia/nemotron-3-super-120b-a12b:free: Check src/a.ts:12.
|
|
76
|
+
fix [write]: Patch src/a.ts:12.
|
|
77
|
+
fast@openai/gpt-5 [write]: Patch it again.
|
|
72
78
|
`);
|
|
73
79
|
if (!spec) throw new Error("parseSpec returned undefined for a valid spec");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
assert(spec.name === "toolcall", "name");
|
|
81
|
+
assert(spec.description === "Map how pi renders tool calls", "desc");
|
|
82
|
+
assert(spec.phases.length === 2, `expected 2 phases, got ${spec.phases.length}`);
|
|
83
|
+
assert(spec.phases[0]?.title === "Branches", "implicit first phase");
|
|
84
|
+
assert(spec.phases[0]?.branches[0]?.task === "Read tui.d.ts:68 and report.", "colon in task");
|
|
85
|
+
assert(spec.phases[1]?.branches[0]?.model === "openai/gpt-5", "per-branch model");
|
|
80
86
|
const free = spec.phases[1]?.branches[1];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
assert(free?.model === "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "colon in model");
|
|
88
|
+
assert(free?.task === "Check src/a.ts:12.", "task after a model with a colon");
|
|
89
|
+
const [fix, fast] = [spec.phases[1]?.branches[2], spec.phases[1]?.branches[3]];
|
|
90
|
+
assert(fix?.label === "fix" && fix.write === true && fix.task === "Patch src/a.ts:12.", "[write]");
|
|
91
|
+
assert(fast?.label === "fast" && fast.model === "openai/gpt-5" && fast.write === true, "[write] after a model");
|
|
92
|
+
assert(spec.phases[0]?.branches[0]?.write === undefined, "read-only by default");
|
|
93
|
+
assert(parseSpec("name: empty") === undefined, "a spec with no branches is not a run");
|
|
94
|
+
assert(findBlock("blah\n```fan\nx: y\n```\nblah") === "x: y\n", "block extraction");
|
|
85
95
|
console.log("spec.ts ok");
|
|
86
96
|
};
|
|
87
97
|
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// It never starts pi: PI_BIN points at the stub, and the stub is the only thing spawned.
|
|
9
9
|
|
|
10
10
|
import { strict as assert } from "node:assert";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
12
13
|
import { tmpdir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
14
15
|
|
|
15
16
|
const root = mkdtempSync(join(tmpdir(), "fan-check-"));
|
|
16
17
|
|
|
@@ -21,10 +22,20 @@ const stub = join(root, "fake-pi.mjs");
|
|
|
21
22
|
writeFileSync(
|
|
22
23
|
stub,
|
|
23
24
|
[
|
|
24
|
-
'import {
|
|
25
|
+
'import { execFileSync } from "node:child_process";',
|
|
26
|
+
'import { appendFileSync, writeFileSync } from "node:fs";',
|
|
27
|
+
'import { join } from "node:path";',
|
|
25
28
|
"const task = process.argv[process.argv.length - 1];",
|
|
26
29
|
'if (process.env.FAN_CHECK_ORDER) appendFileSync(process.env.FAN_CHECK_ORDER, `${task}\\n`);',
|
|
27
30
|
'if (task === "hang") setInterval(() => {}, 1000);',
|
|
31
|
+
// A write branch's edits: two in its cwd, which must be the session's folder inside its
|
|
32
|
+
// worktree, one binary, and one at the worktree's root, outside that folder.
|
|
33
|
+
'else if (task === "edit") {',
|
|
34
|
+
' writeFileSync("edited.txt", "from the branch\\n");',
|
|
35
|
+
' writeFileSync("logo.bin", Buffer.from([0, 255, 0, 10, 13, 0]));',
|
|
36
|
+
' const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();',
|
|
37
|
+
' appendFileSync(join(top, "a.txt"), "three\\n");',
|
|
38
|
+
"}",
|
|
28
39
|
'else process.stdout.write(`report for ${task}\\nSTATUS: OK\\n`);',
|
|
29
40
|
"",
|
|
30
41
|
].join("\n"),
|
|
@@ -38,6 +49,18 @@ process.env.FAN_LOAD = "";
|
|
|
38
49
|
|
|
39
50
|
const { reportOf, store } = await import("./store.ts");
|
|
40
51
|
const { readRun } = await import("../skills/delegate/state.ts");
|
|
52
|
+
const { mergeCommand } = await import("./worktree.ts");
|
|
53
|
+
|
|
54
|
+
// A throw inside a child-process listener never reaches an assert; it is counted here instead.
|
|
55
|
+
const uncaught: string[] = [];
|
|
56
|
+
process.on("uncaughtException", (error) => uncaught.push(String(error)));
|
|
57
|
+
// A promise chain that stalls lets the loop drain and bun exit 0 without a word.
|
|
58
|
+
let completed = false;
|
|
59
|
+
process.on("exit", () => {
|
|
60
|
+
if (completed) return;
|
|
61
|
+
console.error("store.check.ts did not finish");
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
41
64
|
|
|
42
65
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
66
|
|
|
@@ -99,7 +122,9 @@ await until("the run to finish", () => {
|
|
|
99
122
|
|
|
100
123
|
const finished = store.run();
|
|
101
124
|
assert.equal(finished?.done, 3, "every branch settles");
|
|
102
|
-
|
|
125
|
+
// The two phase-one branches race each other; only gamma's place is a fact.
|
|
126
|
+
const ran = readFileSync(order, "utf8").trim().split("\n");
|
|
127
|
+
assert.deepEqual([...ran.slice(0, 2).sort(), ran[2]], ["alpha", "beta", "gamma"], "phase two waits for phase one");
|
|
103
128
|
assert.equal(readFileSync(join(dir, "state", "map-core-render.exit"), "utf8"), "0", "a clean exit is recorded as 0");
|
|
104
129
|
assert.equal(finished?.branches.every((branch) => !branch.timedOut), true, "nothing timed out");
|
|
105
130
|
assert.match(reportOf(finished!), /report for alpha[\s\S]*report for gamma/, "the reports are read back in launch order");
|
|
@@ -148,5 +173,95 @@ await until(
|
|
|
148
173
|
assert.equal(store.run()?.branches[0]?.timedOut, true, "exit code 124 is a timeout, not a crash");
|
|
149
174
|
assert.equal(existsSync(join(slowCwd, ".pi-out")), true, "the run directory outlives the run");
|
|
150
175
|
|
|
176
|
+
// ---------- a [write] branch edits its own worktree, and its work waits on a git branch ----------
|
|
177
|
+
|
|
178
|
+
assert.throws(
|
|
179
|
+
() => store.start({ name: "nogit", description: "", phases: [{ title: "Build", branches: [{ label: "fix", task: "edit", model: "stub/one", write: true }] }] }, "stub/one", mkdtempSync(join(tmpdir(), "fan-nogit-"))),
|
|
180
|
+
/need a git repository/,
|
|
181
|
+
"a write branch outside git refuses to start",
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const repo = mkdtempSync(join(tmpdir(), "fan-write-"));
|
|
185
|
+
const git = (...args: string[]) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
186
|
+
git("init", "-q", "-b", "main");
|
|
187
|
+
git("config", "user.name", "check");
|
|
188
|
+
git("config", "user.email", "check@localhost");
|
|
189
|
+
writeFileSync(join(repo, "a.txt"), "one\n");
|
|
190
|
+
git("add", "-A");
|
|
191
|
+
git("commit", "-q", "-m", "init");
|
|
192
|
+
writeFileSync(join(repo, "a.txt"), "one\ntwo\n"); // the user's uncommitted work
|
|
193
|
+
// What used to stop a write run before it started: an untracked nested repo, a symlink to a
|
|
194
|
+
// folder, a post-checkout hook that fails. And a post-commit hook that must never run.
|
|
195
|
+
execFileSync("git", ["init", "-q", join(repo, "vendor", "lib")]);
|
|
196
|
+
symlinkSync("vendor", join(repo, "link"));
|
|
197
|
+
writeFileSync(join(repo, ".git", "hooks", "post-checkout"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
|
198
|
+
writeFileSync(join(repo, ".git", "hooks", "post-commit"), `#!/bin/sh\ntouch '${repo}/hooked'\n`, { mode: 0o755 });
|
|
199
|
+
// The session sits in a folder no commit has, so no checkout has it either.
|
|
200
|
+
const session = join(repo, "newpkg");
|
|
201
|
+
mkdirSync(session);
|
|
202
|
+
|
|
203
|
+
const writeDir = store.start(
|
|
204
|
+
{
|
|
205
|
+
name: "write",
|
|
206
|
+
description: "",
|
|
207
|
+
phases: [
|
|
208
|
+
{ title: "Build", branches: [{ label: "fix", task: "edit", model: "stub/one", write: true }, { label: "look", task: "read", model: "stub/one" }] },
|
|
209
|
+
// Dots in a label made a branch name git refuses.
|
|
210
|
+
{ title: "Later", branches: [{ label: "v1..lock", task: "edit", model: "stub/one", write: true }] },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
"stub/one",
|
|
214
|
+
session,
|
|
215
|
+
) as string;
|
|
216
|
+
const worktrees = join(repo, ".git", "fan-worktrees", basename(writeDir));
|
|
217
|
+
assert.ok(existsSync(join(worktrees, "later-v1-lock")), "every write branch gets its worktree at start, under .git");
|
|
218
|
+
assert.equal(await store.stop("later-v1-lock"), true);
|
|
219
|
+
assert.equal(existsSync(join(worktrees, "later-v1-lock")), false, "a stopped queued write branch drops its worktree");
|
|
220
|
+
await until("the write run to end", () => {
|
|
221
|
+
store.refresh(session);
|
|
222
|
+
return store.run()?.live === false;
|
|
223
|
+
});
|
|
224
|
+
const fix = store.run()?.branches.find((branch) => branch.stem === "build-fix")?.git;
|
|
225
|
+
assert.ok(fix && !fix.error, `the branch's work is committed: ${JSON.stringify(fix)}`);
|
|
226
|
+
assert.equal(fix.stat, "+2 −0 · 3 files", "the branch's own change only");
|
|
227
|
+
assert.equal(fix.root, repo);
|
|
228
|
+
assert.equal(existsSync(join(session, "edited.txt")), false, "the user's tree is untouched");
|
|
229
|
+
assert.equal(git("show", `${fix.branch}:newpkg/edited.txt`), "from the branch", "the branch worked in the session's folder");
|
|
230
|
+
assert.equal(git("show", `${fix.branch}:a.txt`), "one\ntwo\nthree", "the branch started from the uncommitted work");
|
|
231
|
+
assert.match(git("ls-tree", fix.base, "link"), /^120000 /, "a symlink stays a symlink");
|
|
232
|
+
assert.equal(existsSync(join(repo, "hooked")), false, "no hook ran");
|
|
233
|
+
assert.equal(existsSync(join(worktrees, "build-fix")), false, "a finished worktree is removed");
|
|
234
|
+
assert.doesNotMatch(git("status", "--porcelain"), /pi-out|fan-worktrees/, "neither the run nor the worktrees show in git status");
|
|
235
|
+
assert.equal(store.run()?.branches.find((branch) => branch.stem === "build-look")?.git, undefined, "a read-only branch has no git");
|
|
236
|
+
assert.equal(git("branch", "--list", "fan/*/later-*"), "", "the stopped one leaves no branch");
|
|
237
|
+
assert.ok(reportOf(store.run()!).includes(mergeCommand(fix)), "the report carries the merge command");
|
|
238
|
+
|
|
239
|
+
// The merge the session is offered, run where the session's bash runs: from the subfolder, over
|
|
240
|
+
// the user's own unstaged edit to the same file, with a binary file, staging nothing.
|
|
241
|
+
execFileSync("bash", ["-c", mergeCommand(fix)], { cwd: session, stdio: "pipe" });
|
|
242
|
+
assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "one\ntwo\nthree\n", "a change outside the subfolder lands on the user's edit");
|
|
243
|
+
assert.equal(readFileSync(join(session, "edited.txt"), "utf8"), "from the branch\n");
|
|
244
|
+
assert.deepEqual([...readFileSync(join(session, "logo.bin"))], [0, 255, 0, 10, 13, 0], "a binary change lands");
|
|
245
|
+
assert.equal(git("diff", "--cached", "--name-only"), "", "nothing is staged");
|
|
246
|
+
assert.equal(git("branch", "--list", fix.branch), "", "the merged branch is gone");
|
|
247
|
+
|
|
248
|
+
// A branch whose process never starts gets "error" and "close" both; its worktree is closed once.
|
|
249
|
+
process.env.PI_BIN = join(root, "missing-pi");
|
|
250
|
+
// The repo root, not the session folder: a run in the same second there would sort by name.
|
|
251
|
+
store.start({ name: "nospawn", description: "", phases: [{ title: "Build", branches: [{ label: "fix", task: "edit", model: "stub/one", write: true }] }] }, "stub/one", repo);
|
|
252
|
+
await until("the failed spawn to settle", () => {
|
|
253
|
+
store.refresh(repo);
|
|
254
|
+
return store.run()?.live === false;
|
|
255
|
+
});
|
|
256
|
+
// "close" comes a tick after "error"; give it the time to do damage.
|
|
257
|
+
await delay(300);
|
|
258
|
+
store.refresh(repo);
|
|
259
|
+
const failed = store.run()?.branches[0];
|
|
260
|
+
assert.equal(failed?.exitCode, 127, "a spawn that failed reads as 127");
|
|
261
|
+
assert.equal(failed?.git?.error, undefined, `a spawn that failed closes its worktree once: ${JSON.stringify(failed?.git)}`);
|
|
262
|
+
assert.deepEqual(uncaught, [], "nothing threw outside a handler: in pi that ends the process");
|
|
263
|
+
process.env.PI_BIN = stub;
|
|
264
|
+
|
|
151
265
|
store.stopAll();
|
|
266
|
+
completed = true;
|
|
152
267
|
console.log("store.check.ts ok");
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { envSettings, type FanSettings } from "./models.ts";
|
|
6
|
+
import { closeWorktree, discardCommand, dropWorktree, gitRepo, mergeCommand, openWorktree, snapshot, type Worktree } from "./worktree.ts";
|
|
6
7
|
import { LINGER_MS, OUT_DIR, pidLive, readRun, type BranchState, type RunState } from "../skills/delegate/state.ts";
|
|
7
8
|
|
|
8
9
|
// The producer. It launches branches, keeps their run directory honest, and hands the panel
|
|
@@ -24,7 +25,7 @@ import { LINGER_MS, OUT_DIR, pidLive, readRun, type BranchState, type RunState }
|
|
|
24
25
|
export type RunSpec = {
|
|
25
26
|
name: string;
|
|
26
27
|
description: string;
|
|
27
|
-
phases: { title: string; branches: { label: string; model?: string; task: string }[] }[];
|
|
28
|
+
phases: { title: string; branches: { label: string; model?: string; task: string; write?: boolean }[] }[];
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
// Every branch gets the same tail on its system prompt. Same contract as the delegate skill's
|
|
@@ -34,15 +35,30 @@ const CONTRACT =
|
|
|
34
35
|
"Your final message is the only thing the caller reads. Answer it directly in markdown, " +
|
|
35
36
|
"cite every claim as file:line, and write no preamble. End with a single line " +
|
|
36
37
|
"'STATUS: OK' when you answered fully, 'STATUS: PARTIAL' when you answered part of it, or " +
|
|
37
|
-
"'STATUS: NEED_STRONGER' when the task needs a stronger model.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
38
|
+
"'STATUS: NEED_STRONGER' when the task needs a stronger model.";
|
|
39
|
+
// Read-only branches only: a write branch's worktree closes when it ends, so it cannot be continued.
|
|
40
|
+
const ASKING =
|
|
41
|
+
"When a decision only the caller can make blocks you, stop at once and end with 'STATUS: ASKING', " +
|
|
42
|
+
"then 'QUESTION: <one question>' and 'OPTIONS: <choices separated by \" | \", recommended first>'; " +
|
|
43
|
+
"your session is kept and the answer arrives as your next message.";
|
|
41
44
|
|
|
42
45
|
// Read-only by default: a branch that can run bash can start branches of its own, and nothing
|
|
43
46
|
// here caps the depth. Reference 3's `└` nesting needs exactly that, so it is a knob rather
|
|
44
47
|
// than a constant — set FAN_TOOLS="read,grep,find,ls,bash" to allow it, and watch the panel.
|
|
45
48
|
const READ_ONLY_TOOLS = process.env.FAN_TOOLS || "read,grep,find,ls";
|
|
49
|
+
// A `[write]` branch edits and runs tests, so it gets the full set, and its own worktree to do
|
|
50
|
+
// it in. bash reaches past the worktree, so the rules below are the fence; pi has no sandbox.
|
|
51
|
+
const WRITE_TOOLS = "read,grep,find,ls,edit,write,bash";
|
|
52
|
+
const writeRules = (worktree: Worktree) =>
|
|
53
|
+
`You work in your own git worktree at ${worktree.path}, on branch ${worktree.branch}, and you may ` +
|
|
54
|
+
"edit files and run commands there. Never touch files outside it, never push, publish, deploy " +
|
|
55
|
+
"or delete branches, and do not commit: your changes are committed for you when you finish, " +
|
|
56
|
+
"and the user decides whether they are merged. Say in your answer what you changed and how " +
|
|
57
|
+
"you checked it. When a decision blocks you, take the recommended option and say which. " +
|
|
58
|
+
// The brief and the task were written about the user's tree, and edit and write go wherever
|
|
59
|
+
// a path points: an absolute path into the repo would land in the user's files.
|
|
60
|
+
`Every path under ${worktree.root} in your task or the brief means the same file under ${worktree.path}: ` +
|
|
61
|
+
`edit ${worktree.path}/<rest>, never ${worktree.root}/<rest>.`;
|
|
46
62
|
// `--no-extensions` drops provider extensions too, so a branch on a model that only exists
|
|
47
63
|
// because of one cannot start without it loaded back: that list ($LOAD) and the time limit come
|
|
48
64
|
// in with each run as FanSettings (models.ts), read from the same delegate.env run.sh reads.
|
|
@@ -77,12 +93,22 @@ export const piCommand = (): string[] => {
|
|
|
77
93
|
// because a branch is started from the session cwd and this extension lives elsewhere.
|
|
78
94
|
const BEACON = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "delegate", "beacon.ts");
|
|
79
95
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
96
|
+
/** `^[a-z0-9][a-z0-9_-]*$` and nothing else: a stem is an NTFS filename and part of a git
|
|
97
|
+
* branch name, and a dot is what git refuses there (`..`, `.lock`, a trailing dot). */
|
|
98
|
+
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "x";
|
|
82
99
|
|
|
83
100
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
101
|
|
|
85
|
-
type Launch = {
|
|
102
|
+
type Launch = {
|
|
103
|
+
stem: string;
|
|
104
|
+
phase: string;
|
|
105
|
+
name: string;
|
|
106
|
+
model: string;
|
|
107
|
+
task: string;
|
|
108
|
+
timeoutMs: number;
|
|
109
|
+
load: string[];
|
|
110
|
+
worktree?: Worktree;
|
|
111
|
+
};
|
|
86
112
|
|
|
87
113
|
let current: RunState | undefined;
|
|
88
114
|
let sessionCwd = "";
|
|
@@ -247,21 +273,29 @@ const launch = (branch: Launch) => {
|
|
|
247
273
|
"-e",
|
|
248
274
|
BEACON,
|
|
249
275
|
"--tools",
|
|
250
|
-
READ_ONLY_TOOLS,
|
|
276
|
+
branch.worktree ? WRITE_TOOLS : READ_ONLY_TOOLS,
|
|
251
277
|
"--model",
|
|
252
278
|
branch.model,
|
|
253
279
|
"--append-system-prompt",
|
|
254
280
|
// The path rides in the system prompt rather than in the task, so it is written once per
|
|
255
281
|
// branch instead of being pasted into every task line, and a branch that was given a
|
|
256
282
|
// one-line task still knows where the rest of the thought is.
|
|
257
|
-
|
|
283
|
+
[
|
|
284
|
+
CONTRACT,
|
|
285
|
+
branch.worktree ? "" : ASKING,
|
|
286
|
+
briefPath ? `The full brief this task was cut from is at ${briefPath}; read it when the task alone leaves something open.` : "",
|
|
287
|
+
branch.worktree ? writeRules(branch.worktree) : "",
|
|
288
|
+
]
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.join(" "),
|
|
258
291
|
// A task that begins with "-" would otherwise be read as an unknown flag
|
|
259
292
|
// (cli/args.js:217). After "--" every remaining word is the message.
|
|
260
293
|
"--",
|
|
261
294
|
branch.task,
|
|
262
295
|
];
|
|
263
296
|
const child = spawn(command as string, args, {
|
|
264
|
-
|
|
297
|
+
// The same folder inside the worktree that the session sits in inside the repo.
|
|
298
|
+
cwd: branch.worktree?.cwd ?? sessionCwd,
|
|
265
299
|
windowsHide: true,
|
|
266
300
|
// A process group off Windows, so a stop reaches the branch's own tool processes.
|
|
267
301
|
detached: process.platform !== "win32",
|
|
@@ -289,16 +323,25 @@ const launch = (branch: Launch) => {
|
|
|
289
323
|
timer.unref?.();
|
|
290
324
|
timers.set(branch.stem, timer);
|
|
291
325
|
|
|
326
|
+
// "error" and "close" both fire when the spawn itself fails; the second must not close the
|
|
327
|
+
// worktree again and overwrite what the first one recorded.
|
|
328
|
+
let settled = false;
|
|
292
329
|
const settle = (code: number | null) => {
|
|
330
|
+
if (settled) return;
|
|
331
|
+
settled = true;
|
|
293
332
|
clearTimeout(timers.get(branch.stem));
|
|
294
333
|
timers.delete(branch.stem);
|
|
295
334
|
children.delete(branch.stem);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
335
|
+
closeSync(out);
|
|
336
|
+
closeSync(err);
|
|
337
|
+
// What a write branch did is committed even when it was stopped or timed out: it is the
|
|
338
|
+
// user's to keep or throw away, not this file's. Written before the exit, so the row
|
|
339
|
+
// never settles without it.
|
|
340
|
+
if (branch.worktree)
|
|
341
|
+
writeFileSync(
|
|
342
|
+
join(stateDir, `${branch.stem}.git`),
|
|
343
|
+
JSON.stringify(closeWorktree(branch.worktree, `${branch.name}: ${branch.task.split("\n")[0]?.slice(0, 72)}`)),
|
|
344
|
+
);
|
|
302
345
|
// The one signal that survives a branch dying before its extensions ever bound, and the
|
|
303
346
|
// only place a timeout can be told apart from a crash. 124 is what `timeout(1)` reports,
|
|
304
347
|
// which is what the delegate skill's branches write, so one reader covers both.
|
|
@@ -337,7 +380,14 @@ export const reportOf = (run: RunState): string =>
|
|
|
337
380
|
// Killed before pi wrote anything, or a spawn that never started.
|
|
338
381
|
}
|
|
339
382
|
const note = branch.timedOut ? ", timed out" : "";
|
|
340
|
-
|
|
383
|
+
const { git } = branch;
|
|
384
|
+
const changes = git?.error
|
|
385
|
+
? `\n\nIts changes could not be committed (${git.error}); they are still in its worktree on ${git.branch}.`
|
|
386
|
+
: git?.stat
|
|
387
|
+
? `\n\nIts changes are on git branch ${git.branch} (${git.stat}). Nothing is merged. ` +
|
|
388
|
+
`To merge: \`${mergeCommand(git)}\`. To discard: \`${discardCommand(git)}\`.`
|
|
389
|
+
: "";
|
|
390
|
+
return `## ${branch.label} (${branch.status}${note})\n\n${text || branch.error || "no output"}${changes}`;
|
|
341
391
|
})
|
|
342
392
|
.join("\n\n");
|
|
343
393
|
|
|
@@ -356,6 +406,11 @@ export const store = {
|
|
|
356
406
|
* started in bash and has already read for itself. */
|
|
357
407
|
start(spec: RunSpec, defaultModel: string, cwd: string, brief?: string, settings: FanSettings = envSettings()): string | undefined {
|
|
358
408
|
if (running()) return undefined;
|
|
409
|
+
// Checked before anything is written: a run that cannot give its write branches a
|
|
410
|
+
// worktree does not start at all, rather than starting without them.
|
|
411
|
+
const writes = spec.phases.some((phase) => phase.branches.some((branch) => branch.write));
|
|
412
|
+
const repo = writes ? gitRepo(cwd) : undefined;
|
|
413
|
+
if (writes && !repo) throw new Error(`[write] branches need a git repository, and ${cwd} is not in one`);
|
|
359
414
|
sessionCwd = cwd;
|
|
360
415
|
// LOCAL time, because the reader picks the newest run by NAME and the delegate skill
|
|
361
416
|
// stamps its directories with `date +%Y%m%d-%H%M%S`. A UTC name here would sort a fresh
|
|
@@ -365,6 +420,9 @@ export const store = {
|
|
|
365
420
|
runDir = join(cwd, OUT_DIR, `${stamp}-${slug(spec.name)}`);
|
|
366
421
|
const stateDir = join(runDir, "state");
|
|
367
422
|
mkdirSync(stateDir, { recursive: true });
|
|
423
|
+
// Scratch: reports and sessions stay out of git status and out of a write run's snapshot.
|
|
424
|
+
const ignore = join(cwd, OUT_DIR, ".gitignore");
|
|
425
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
368
426
|
|
|
369
427
|
// The brief goes to disk byte-for-byte, with no model between the user's words and the
|
|
370
428
|
// file. Every branch is then pointed at the path rather than at a paraphrase of it: a
|
|
@@ -379,45 +437,61 @@ export const store = {
|
|
|
379
437
|
const phases: string[] = [];
|
|
380
438
|
let index = 0;
|
|
381
439
|
pending = [];
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
for (const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
440
|
+
// One snapshot of the user's tree for every write branch, taken after the .gitignore above so
|
|
441
|
+
// the run directory is never part of it.
|
|
442
|
+
const from = repo ? snapshot(repo) : "";
|
|
443
|
+
try {
|
|
444
|
+
for (const phase of spec.phases) {
|
|
445
|
+
const phaseName = slug(phase.title);
|
|
446
|
+
phases.push(phaseName);
|
|
447
|
+
// Queued before it is filled, so a worktree that fails halfway undoes the ones before it.
|
|
448
|
+
const launches: Launch[] = [];
|
|
449
|
+
pending.push(launches);
|
|
450
|
+
for (const branch of phase.branches) {
|
|
451
|
+
// Two branches sharing a stem would share a state file, and a file with two
|
|
452
|
+
// writers is the one corruption this design cannot detect. The caller here is
|
|
453
|
+
// the model, so a collision is renamed rather than refused: a run that starts
|
|
454
|
+
// with "explore-2" beats a run that does not start.
|
|
455
|
+
const base = slug(branch.label);
|
|
456
|
+
let name = base;
|
|
457
|
+
for (let n = 2; taken.has(`${phaseName}-${name}`); n++) name = `${base}-${n}`;
|
|
458
|
+
const stem = `${phaseName}-${name}`;
|
|
459
|
+
taken.add(stem);
|
|
460
|
+
const model = branch.model || defaultModel;
|
|
461
|
+
index += 1;
|
|
462
|
+
// Every field is populated at seed time, so no column appears for the first time
|
|
463
|
+
// three seconds in and reflows the row.
|
|
464
|
+
const state: BranchState = {
|
|
465
|
+
phase: phaseName,
|
|
466
|
+
name,
|
|
467
|
+
// Set when this pi is itself a branch, which is how a nested run keeps its
|
|
468
|
+
// place in the tree.
|
|
469
|
+
parent: process.env.PI_BRANCH_PARENT ?? null,
|
|
470
|
+
index,
|
|
471
|
+
model: prettyModel(model),
|
|
472
|
+
pid: null,
|
|
473
|
+
status: "starting",
|
|
474
|
+
activity: "Starting",
|
|
475
|
+
tokens: null,
|
|
476
|
+
startedAt: now,
|
|
477
|
+
updatedAt: now,
|
|
478
|
+
report: null,
|
|
479
|
+
error: null,
|
|
480
|
+
};
|
|
481
|
+
writeFileSync(join(stateDir, `${stem}.json`), JSON.stringify(state));
|
|
482
|
+
const worktree =
|
|
483
|
+
branch.write && repo
|
|
484
|
+
? openWorktree(repo, from, join(repo.common, "fan-worktrees", basename(runDir), stem), `fan/${basename(runDir)}/${stem}`)
|
|
485
|
+
: undefined;
|
|
486
|
+
launches.push({ stem, phase: phaseName, name, model, task: branch.task, ...settings, worktree });
|
|
487
|
+
}
|
|
419
488
|
}
|
|
420
|
-
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// No run.json yet, so the reader never sees this directory as a run; only the
|
|
491
|
+
// worktrees already made need undoing.
|
|
492
|
+
for (const launch of pending.flat()) if (launch.worktree) dropWorktree(launch.worktree);
|
|
493
|
+
pending = [];
|
|
494
|
+
throw error;
|
|
421
495
|
}
|
|
422
496
|
|
|
423
497
|
// run.json LAST, after every seed exists: the reader treats its absence as "not a run",
|
|
@@ -442,8 +516,10 @@ export const store = {
|
|
|
442
516
|
const ours = current?.dir === runDir;
|
|
443
517
|
// A branch of a later phase has no process yet. Taken out of the queue, or the next phase
|
|
444
518
|
// would start it anyway, and given the exit a killed branch gets, so it reads as stopped.
|
|
445
|
-
|
|
519
|
+
const queued = ours ? pending.flat().find((launch) => launch.stem === stem) : undefined;
|
|
520
|
+
if (queued) {
|
|
446
521
|
pending = pending.map((phase) => phase.filter((launch) => launch.stem !== stem)).filter((phase) => phase.length > 0);
|
|
522
|
+
if (queued.worktree) dropWorktree(queued.worktree);
|
|
447
523
|
writeFileSync(join(runDir, "state", `${stem}.exit`), "130");
|
|
448
524
|
refresh();
|
|
449
525
|
return true;
|
|
@@ -467,6 +543,10 @@ export const store = {
|
|
|
467
543
|
}
|
|
468
544
|
children.clear();
|
|
469
545
|
timers.clear();
|
|
546
|
+
// Queued write branches never ran, so their worktrees go. A killed one keeps its worktree:
|
|
547
|
+
// its close never runs once pi is gone, and what it wrote stays on disk for the user.
|
|
548
|
+
// ponytail: `git worktree list` shows such leftovers; add a cleanup command if they pile up.
|
|
549
|
+
for (const launch of pending.flat()) if (launch.worktree) dropWorktree(launch.worktree);
|
|
470
550
|
pending = [];
|
|
471
551
|
stopTick();
|
|
472
552
|
},
|