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.
Files changed (34) hide show
  1. package/README.md +3 -4
  2. package/node_modules/pi-umbra-copy-chat/README.md +4 -7
  3. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
  4. package/node_modules/pi-umbra-copy-chat/package.json +1 -1
  5. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
  6. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
  7. package/node_modules/pi-umbra-help/package.json +1 -1
  8. package/node_modules/pi-umbra-inputbar/README.md +1 -1
  9. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
  10. package/node_modules/pi-umbra-inputbar/package.json +2 -2
  11. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  12. package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
  13. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  14. package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
  15. package/node_modules/pi-umbra-subagents/README.md +19 -4
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +9 -2
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +10 -1
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +50 -5
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +119 -4
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +138 -58
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
  25. package/node_modules/pi-umbra-subagents/package.json +2 -2
  26. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
  27. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
  28. package/node_modules/pi-umbra-theme/package.json +3 -3
  29. package/package.json +9 -12
  30. package/node_modules/pi-umbra-rename/LICENSE +0 -21
  31. package/node_modules/pi-umbra-rename/README.md +0 -18
  32. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
  33. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
  34. 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 [label, model] = key.split("@");
44
- if (label) phase.branches.push({ label, model, task: value });
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 { label: string; model?: string; task: string }[] };
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
- console.assert(spec.name === "toolcall", "name");
75
- console.assert(spec.description === "Map how pi renders tool calls", "desc");
76
- console.assert(spec.phases.length === 2, `expected 2 phases, got ${spec.phases.length}`);
77
- console.assert(spec.phases[0]?.title === "Branches", "implicit first phase");
78
- console.assert(spec.phases[0]?.branches[0]?.task === "Read tui.d.ts:68 and report.", "colon in task");
79
- console.assert(spec.phases[1]?.branches[0]?.model === "openai/gpt-5", "per-branch model");
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
- console.assert(free?.model === "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "colon in model");
82
- console.assert(free?.task === "Check src/a.ts:12.", "task after a model with a colon");
83
- console.assert(parseSpec("name: empty") === undefined, "a spec with no branches is not a run");
84
- console.assert(findBlock("blah\n```fan\nx: y\n```\nblah") === "x: y\n", "block extraction");
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 { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
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 { appendFileSync } from "node:fs";',
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
- assert.deepEqual(readFileSync(order, "utf8").trim().split("\n"), ["alpha", "beta", "gamma"], "phase two waits for phase one");
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. When a decision only the " +
38
- "caller can make blocks you, stop at once and end with 'STATUS: ASKING', then 'QUESTION: <one " +
39
- "question>' and 'OPTIONS: <choices separated by \" | \", recommended first>'; your session is " +
40
- "kept and the answer arrives as your next message.";
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
- /** Filenames, so `^[a-z0-9][a-z0-9._-]*$` and nothing else: a stem is an NTFS filename. */
81
- const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|-+$/g, "") || "x";
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 = { stem: string; phase: string; name: string; model: string; task: string; timeoutMs: number; load: string[] };
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
- briefPath ? `${CONTRACT} The full brief this task was cut from is at ${briefPath}; read it when the task alone leaves something open.` : CONTRACT,
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
- cwd: sessionCwd,
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
- try {
297
- closeSync(out);
298
- closeSync(err);
299
- } catch {
300
- // Already closed: "error" and "close" can both fire for one child.
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
- return `## ${branch.label} (${branch.status}${note})\n\n${text || branch.error || "no output"}`;
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
- 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, ...settings });
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
- pending.push(launches);
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
- if (ours && pending.some((phase) => phase.some((launch) => launch.stem === stem))) {
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
  },