pi-jar 0.1.4 → 0.1.6

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/src/ask-tool.ts CHANGED
@@ -26,13 +26,18 @@ type PickResult = { kind: "answer"; selected: string[] } | { kind: "custom" } |
26
26
 
27
27
  const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
28
28
 
29
- async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<PickResult> {
29
+ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number, signal?: AbortSignal): Promise<PickResult> {
30
30
  const options = (question.options ?? []).map((option) => ({
31
31
  label: cleanText(option.label, 120),
32
32
  description: option.description ? cleanText(option.description, 240) : undefined
33
33
  })).filter((option) => !!option.label);
34
- if (!ctx.hasUI || ctx.mode !== "tui") return { kind: "cancel" };
35
- return ctx.ui.custom<PickResult>((tui, theme, _keys, done) => {
34
+ if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return { kind: "cancel" };
35
+ let abort: (() => void) | undefined;
36
+ try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
37
+ const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
38
+ abort = () => done({ kind: "cancel" });
39
+ signal?.addEventListener("abort", abort, { once: true });
40
+ if (signal?.aborted) queueMicrotask(abort);
36
41
  const multi = !!question.multi;
37
42
  const allowCustom = question.allowCustom !== false;
38
43
  const actions = [
@@ -131,13 +136,13 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
131
136
  return lines.map((line) => visibleWidth(line) <= width ? line : fit(line, width));
132
137
  }
133
138
  };
134
- });
139
+ }); } finally { if (abort) signal?.removeEventListener("abort", abort); }
135
140
  }
136
141
 
137
- async function askOne(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<AskAnswer> {
142
+ export async function askOne(ctx: ExtensionContext, question: AskQuestion, index = 0, total = 1, signal?: AbortSignal): Promise<AskAnswer> {
138
143
  const id = cleanText(question.id ?? "q" + String(index + 1), 64) || "q" + String(index + 1);
139
- const picked = await pickQuestion(ctx, question, index, total);
140
- if (!picked || picked.kind === "cancel") return { id, cancelled: true };
144
+ const picked = await pickQuestion(ctx, question, index, total, signal);
145
+ if (signal?.aborted || !picked || picked.kind === "cancel") return { id, cancelled: true };
141
146
  if (picked.kind === "custom") {
142
147
  const text = await ctx.ui.editor("✎ Your answer · " + cleanText(question.header ?? "Question", 48), "");
143
148
  return text?.trim() ? { id, answer: text.trim() } : { id, cancelled: true };
@@ -8,7 +8,7 @@ import {
8
8
  type ExtensionAPI,
9
9
  type ExtensionContext
10
10
  } from "@earendil-works/pi-coding-agent";
11
- import { Text } from "@earendil-works/pi-tui";
11
+ import { Box, Container, Text } from "@earendil-works/pi-tui";
12
12
 
13
13
  type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
14
14
 
@@ -67,6 +67,61 @@ function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: nu
67
67
 
68
68
  const expandHint = " · [ expand ] Ctrl+O";
69
69
 
70
+ /** Streaming arguments arrive partially: a field can be missing or not yet a string. */
71
+ const str = (value: unknown): string => typeof value === "string" ? value : "";
72
+
73
+ type CardTheme = { bg(color: "toolPendingBg" | "toolErrorBg" | "toolSuccessBg", text: string): string };
74
+ interface CardContext { state?: { compactCard?: Box }; isPartial?: boolean; isError?: boolean; lastComponent?: unknown }
75
+ /**
76
+ * Pi's edit tool renders its own framing (`renderShell: "self"`) so its expanded diff can own the
77
+ * background. Collapsed, pi-jar draws the same padded, state-colored box Pi gives read/write, with the
78
+ * call line and result summary in one card; the result renderer then contributes nothing.
79
+ */
80
+ function compactCard(theme: CardTheme, context: CardContext, header: Text): Box | Text {
81
+ if (!context.state) return header;
82
+ const card = context.state.compactCard ??= new Box(1, 1);
83
+ const color = context.isPartial !== false ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg";
84
+ card.setBgFn((text) => theme.bg(color, text));
85
+ card.clear();
86
+ card.addChild(header);
87
+ return card;
88
+ }
89
+ function intoCard(context: CardContext, line: Text): Container | Text {
90
+ const card = context.state?.compactCard;
91
+ if (!card) return line;
92
+ card.addChild(line);
93
+ const empty = context.lastComponent instanceof Container && !(context.lastComponent instanceof Box) ? context.lastComponent : new Container();
94
+ empty.clear();
95
+ return empty;
96
+ }
97
+
98
+ /** One collapsed edit result line: +/− counts from the diff, or the tool's own message. */
99
+ function editSummary(result: ToolResult, isPartial: boolean | undefined, theme: { fg(color: string, text: string): string }): Text {
100
+ if (isPartial) return new Text(theme.fg("warning", "editing…"), 0, 0);
101
+ const details = result.details as { diff?: string } | undefined;
102
+ const diff = details?.diff ?? "";
103
+ if (!diff) {
104
+ const text = summarizeResult(result).sample;
105
+ return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
106
+ }
107
+ if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
108
+ let counts = diffCountCache.get(details!);
109
+ if (counts?.diff !== diff) {
110
+ counts = { diff, additions: 0, removals: 0 };
111
+ for (let start = 0; start < diff.length;) {
112
+ const end = diff.indexOf("\n", start);
113
+ const stop = end < 0 ? diff.length : end;
114
+ const first = diff[start];
115
+ if (first === "+" && !diff.startsWith("+++", start)) counts.additions++;
116
+ else if (first === "-" && !diff.startsWith("---", start)) counts.removals++;
117
+ start = stop + 1;
118
+ }
119
+ diffCountCache.set(details!, counts);
120
+ }
121
+ return new Text(theme.fg("success", `+${counts.additions}`) + theme.fg("dim", " / ")
122
+ + theme.fg("error", `-${counts.removals}`) + theme.fg("dim", expandHint), 0, 0);
123
+ }
124
+
70
125
  /**
71
126
  * Whitespace-collapsed, trimmed text cut to `max` characters. Only a bounded prefix is scanned: a call
72
127
  * card re-renders on every streamed argument delta, so collapsing a whole heredoc each time was O(n²).
@@ -114,10 +169,10 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
114
169
  ...read,
115
170
  renderCall(args, theme, context) {
116
171
  if (context.expanded && read.renderCall) return read.renderCall(args, theme, context);
117
- const range = args.offset || args.limit
172
+ const range = args?.offset || args?.limit
118
173
  ? theme.fg("dim", ` · ${args.offset ?? 1}${args.limit ? `+${args.limit}` : ""}`)
119
174
  : "";
120
- return new Text(theme.fg("toolTitle", theme.bold("read ")) + theme.fg("accent", args.path) + range, 0, 0);
175
+ return new Text(theme.fg("toolTitle", theme.bold("read ")) + theme.fg("accent", str(args?.path)) + range, 0, 0);
121
176
  },
122
177
  renderResult(result, options, theme, context) {
123
178
  if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
@@ -135,7 +190,7 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
135
190
  },
136
191
  renderCall(args, theme, context) {
137
192
  if (context.expanded && bash.renderCall) return bash.renderCall(args, theme, context);
138
- return new Text(theme.fg("toolTitle", theme.bold("$ ")) + theme.fg("accent", compactText(args.command, 88)), 0, 0);
193
+ return new Text(theme.fg("toolTitle", theme.bold("$ ")) + theme.fg("accent", compactText(str(args?.command), 88)), 0, 0);
139
194
  },
140
195
  renderResult(result, options, theme, context) {
141
196
  if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
@@ -156,30 +211,12 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
156
211
  ...edit,
157
212
  renderCall(args, theme, context) {
158
213
  if (context.expanded && edit.renderCall) return edit.renderCall(args, theme, context);
159
- return new Text(theme.fg("toolTitle", theme.bold("edit ")) + theme.fg("accent", args.path), 0, 0);
214
+ const path = str(args?.path) || str((args as { file_path?: unknown } | undefined)?.file_path);
215
+ return compactCard(theme, context, new Text(theme.fg("toolTitle", theme.bold("edit ")) + theme.fg("accent", path), 0, 0));
160
216
  },
161
217
  renderResult(result, options, theme, context) {
162
218
  if (options.expanded && edit.renderResult) return edit.renderResult(result, options, theme, context);
163
- if (options.isPartial) return new Text(theme.fg("warning", "editing…"), 0, 0);
164
- const details = result.details as { diff?: string } | undefined;
165
- const diff = details?.diff ?? "";
166
- if (!diff) {
167
- const summary = summarizeResult(result);
168
- const text = summary.sample;
169
- return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
170
- }
171
- if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
172
- let counts = diffCountCache.get(details!);
173
- if (counts?.diff !== diff) {
174
- counts = { diff, additions: 0, removals: 0 };
175
- for (const line of diff.split("\n")) {
176
- if (line.startsWith("+") && !line.startsWith("+++")) counts.additions++;
177
- else if (line.startsWith("-") && !line.startsWith("---")) counts.removals++;
178
- }
179
- diffCountCache.set(details!, counts);
180
- }
181
- return new Text(theme.fg("success", `+${counts.additions}`) + theme.fg("dim", " / ")
182
- + theme.fg("error", `-${counts.removals}`) + theme.fg("dim", expandHint), 0, 0);
219
+ return intoCard(context, editSummary(result, options.isPartial, theme));
183
220
  }
184
221
  });
185
222
 
@@ -187,13 +224,14 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
187
224
  ...write,
188
225
  renderCall(args, theme, context) {
189
226
  if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
227
+ const content = str(args?.content);
190
228
  let count = args && typeof args === "object" ? writeCountCache.get(args as object) : undefined;
191
229
  if (!count) {
192
- count = boundedLineCount(args.content);
230
+ count = boundedLineCount(content);
193
231
  if (args && typeof args === "object") writeCountCache.set(args as object, count);
194
232
  }
195
- return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", args.path)
196
- + theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} lines`), 0, 0);
233
+ return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", str(args?.path))
234
+ + theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} line${count.lines === 1 && !count.truncated ? "" : "s"}`), 0, 0);
197
235
  },
198
236
  renderResult(result, options, theme, context) {
199
237
  if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
@@ -0,0 +1,360 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ readlinkSync,
11
+ realpathSync,
12
+ rmSync,
13
+ renameSync,
14
+ rmdirSync,
15
+ writeFileSync,
16
+ symlinkSync,
17
+ unlinkSync,
18
+ type Stats
19
+ } from "node:fs";
20
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import { fileURLToPath } from "node:url";
23
+ import { MAX_TRACKED_BYTES, type ChangeTracker } from "./changes.ts";
24
+
25
+ /** Present only in writable delegated children. The child extension treats this as its filesystem boundary. */
26
+ export const CHILD_WORKTREE_ENV = "PI_JAR_WORKTREE_ROOT";
27
+ const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
28
+
29
+ export interface DelegateWorktree {
30
+ /** Git repository root in the parent workspace. */
31
+ repoRoot: string;
32
+ /** Disposable worktree root. */
33
+ root: string;
34
+ /** Child cwd matching the parent's relative cwd inside the repository. */
35
+ cwd: string;
36
+ /** Snapshot commit that exactly represents the parent workspace when delegation started. */
37
+ baseline: string;
38
+ /** Temporary directory containing the worktree. */
39
+ tempRoot: string;
40
+ }
41
+
42
+ const safeGitArgs = (cwd: string, args: string[]) => ["-C", cwd, "--literal-pathspecs", "-c", "core.hooksPath=/dev/null",
43
+ "-c", "core.fsmonitor=false", ...args];
44
+
45
+ const gitText = (cwd: string, args: string[], input?: string | Buffer): string =>
46
+ execFileSync("git", safeGitArgs(cwd, args), {
47
+ encoding: "utf8",
48
+ ...(input === undefined ? {} : { input }),
49
+ maxBuffer: MAX_GIT_OUTPUT,
50
+ stdio: ["pipe", "pipe", "pipe"]
51
+ });
52
+
53
+ const gitBuffer = (cwd: string, args: string[]): Buffer =>
54
+ execFileSync("git", safeGitArgs(cwd, args), {
55
+ encoding: null,
56
+ maxBuffer: MAX_GIT_OUTPUT,
57
+ stdio: ["ignore", "pipe", "pipe"]
58
+ });
59
+
60
+ const nulList = (value: string): string[] => value.split("\0").filter(Boolean);
61
+
62
+ const inside = (root: string, candidate: string): boolean => {
63
+ const rel = relative(root, candidate);
64
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
65
+ };
66
+
67
+ /** Normalize Pi tool path spellings before applying the filesystem boundary. */
68
+ export function normalizeWorkspacePath(cwd: string, value: string): string | null {
69
+ try {
70
+ let path = value.replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
71
+ if (path.startsWith("@")) path = path.slice(1);
72
+ if (process.platform === "win32" && !path.includes("\\") && !path.startsWith("//")) {
73
+ const drive = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
74
+ if (drive) path = `${drive[1]!.toUpperCase()}:\\${drive[2]?.replaceAll("/", "\\") ?? ""}`;
75
+ }
76
+ // Match Pi exactly: single-slash file: spellings are literal relative filenames.
77
+ if (path.startsWith("file://")) path = fileURLToPath(path);
78
+ if (path === "~") path = homedir();
79
+ else if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) path = join(homedir(), path.slice(2));
80
+ else if (path.startsWith("~")) return null;
81
+ if (!path || path.includes("\0")) return null;
82
+ return resolve(cwd, path);
83
+ } catch { return null; }
84
+ }
85
+
86
+ const statIfPresent = (path: string): Stats | null => {
87
+ try { return lstatSync(path); }
88
+ catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
90
+ throw error;
91
+ }
92
+ };
93
+
94
+ const hasGitMetadata = (root: string, path: string) =>
95
+ relative(root, path).split(sep).some(part => part.toLowerCase() === ".git");
96
+
97
+ // Finalization disallows ancestor symlinks entirely, even aliases back into the tree.
98
+ const safeFilePath = (root: string, rel: string): boolean => {
99
+ const target = resolve(root, rel);
100
+ if (!rel || isAbsolute(rel) || !inside(root, target) || target === root || hasGitMetadata(root, target)) return false;
101
+ try {
102
+ if (lstatSync(root).isSymbolicLink() || realpathSync(root) !== resolve(root)) return false;
103
+ let probe = dirname(target);
104
+ while (probe !== root) {
105
+ const stat = statIfPresent(probe);
106
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) return false;
107
+ probe = dirname(probe);
108
+ }
109
+ return true;
110
+ } catch { return false; }
111
+ };
112
+
113
+ /** Resolve existing and dangling symlinks, and deny Git administrative paths. */
114
+ export function workspacePathAllowed(root: string, cwd: string, value: string, readFallbacks = false): boolean {
115
+ if (readFallbacks) {
116
+ const path = normalizeWorkspacePath(cwd, value);
117
+ if (!path) return false;
118
+ // Pi read tries these alternate spellings after a missing exact path. Check every possible
119
+ // target, including dangling links, rather than guarding a spelling the reader won't use.
120
+ const nfd = path.normalize("NFD");
121
+ const variants = new Set([path, path.replace(/ (AM|PM)\./gi, "\u202f$1."), nfd,
122
+ path.replace(/'/g, "\u2019"), nfd.replace(/'/g, "\u2019")]);
123
+ return [...variants].every((variant) => canonicalWorkspacePathAllowed(root, variant));
124
+ }
125
+ const path = normalizeWorkspacePath(cwd, value);
126
+ return path !== null && canonicalWorkspacePathAllowed(root, path);
127
+ }
128
+
129
+ function canonicalWorkspacePathAllowed(root: string, lexical: string): boolean {
130
+ let physicalRoot: string;
131
+ try { physicalRoot = realpathSync(root); } catch { return false; }
132
+ // macOS /var -> /private/var and other trusted root aliases are equivalent.
133
+ if (inside(resolve(root), lexical)) lexical = resolve(physicalRoot, relative(resolve(root), lexical));
134
+ if (!inside(physicalRoot, lexical) || hasGitMetadata(physicalRoot, lexical)) return false;
135
+ try {
136
+ let probe = lexical;
137
+ while (!statIfPresent(probe)) {
138
+ const parent = dirname(probe);
139
+ if (parent === probe) return false;
140
+ probe = parent;
141
+ }
142
+ const physical = realpathSync(probe);
143
+ return inside(physicalRoot, physical) && !hasGitMetadata(physicalRoot, physical);
144
+ } catch { return false; }
145
+ }
146
+
147
+ const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
148
+ const from = resolve(fromRoot, rel);
149
+ const to = resolve(toRoot, rel);
150
+ if (!safeFilePath(fromRoot, rel) || !safeFilePath(toRoot, rel)) throw new Error("unsafe worktree path: " + rel);
151
+ mkdirSync(dirname(to), { recursive: true });
152
+ if (lstatSync(from).isSymbolicLink()) symlinkSync(readlinkSync(from), to);
153
+ else cpSync(from, to, { force: true, dereference: false });
154
+ };
155
+
156
+ /**
157
+ * Create a detached disposable worktree and commit a private baseline snapshot containing the
158
+ * parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
159
+ * touched. That baseline lets multiple children run against the exact same starting filesystem.
160
+ */
161
+ export function createDelegateWorktree(cwd: string): DelegateWorktree {
162
+ const requested = realpathSync(cwd);
163
+ const repoRoot = realpathSync(gitText(requested, ["rev-parse", "--show-toplevel"]).trim());
164
+ if (!inside(repoRoot, requested)) throw new Error("working directory is outside the git repository");
165
+ const relCwd = relative(repoRoot, requested);
166
+ const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-jar-worktree-")));
167
+ const root = join(tempRoot, "workspace");
168
+ let added = false;
169
+ try {
170
+ // Never checkout/add: they execute clean/smudge filters and alter byte baselines.
171
+ gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"]);
172
+ added = true;
173
+ gitText(root, ["read-tree", "--empty"]);
174
+ const paths = new Set(nulList(gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])));
175
+ for (const rel of paths) {
176
+ if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
177
+ const source = resolve(repoRoot, rel);
178
+ const stat = statIfPresent(source);
179
+ if (!stat) continue;
180
+ if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
181
+ const bytes = stat.isSymbolicLink() ? Buffer.from(readlinkSync(source)) : readFileSync(source);
182
+ const oid = gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes).trim();
183
+ const mode = stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644";
184
+ gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel]);
185
+ copyInto(repoRoot, root, rel);
186
+ }
187
+ const tree = gitText(root, ["write-tree"]).trim();
188
+ const baseline = gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
189
+ "commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"]).trim();
190
+ gitText(root, ["update-ref", "HEAD", baseline]);
191
+ const childCwd = relCwd ? resolve(root, relCwd) : root;
192
+ if (!workspacePathAllowed(root, root, childCwd)) throw new Error("unsafe child working directory");
193
+ if (!existsSync(childCwd)) mkdirSync(childCwd, { recursive: true });
194
+ return { repoRoot, root, cwd: childCwd, baseline, tempRoot };
195
+ } catch (error) {
196
+ if (added) {
197
+ try { gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
198
+ }
199
+ rmSync(tempRoot, { recursive: true, force: true });
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ /** Files whose final worktree state differs from the snapshot, including new untracked files. */
205
+ export function worktreeChangedFiles(worktree: DelegateWorktree): string[] {
206
+ const baselinePaths = nulList(gitText(worktree.root, ["ls-tree", "-r", "--name-only", "-z", worktree.baseline]));
207
+ const currentPaths = nulList(gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]));
208
+ return [...new Set([...baselinePaths, ...currentPaths])]
209
+ .filter(rel => !parentMatchesBaseline(worktree, worktree.root, rel)).sort();
210
+ }
211
+
212
+ type BaselineEntry = { mode: string; content: Buffer } | null;
213
+
214
+ const baselineEntry = (worktree: DelegateWorktree, rel: string): BaselineEntry => {
215
+ const line = gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel]);
216
+ if (!line || line.slice(line.indexOf("\t") + 1, -1) !== rel) return null;
217
+ const [mode, type, oid] = line.slice(0, line.indexOf("\t")).split(" ");
218
+ if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + rel);
219
+ return { mode, content: gitBuffer(worktree.root, ["cat-file", "blob", oid]) };
220
+ };
221
+
222
+ const parentMatchesBaseline = (worktree: DelegateWorktree, parentRoot: string, rel: string): boolean => {
223
+ const target = resolve(parentRoot, rel);
224
+ if (!safeFilePath(parentRoot, rel)) return false;
225
+ const baseline = baselineEntry(worktree, rel);
226
+ const stat = statIfPresent(target);
227
+ if (!baseline) return !stat;
228
+ if (!stat) return false;
229
+ if (baseline.mode === "120000") {
230
+ return stat.isSymbolicLink() && Buffer.from(readlinkSync(target)).equals(baseline.content);
231
+ }
232
+ if (!stat.isFile()) return false;
233
+ if (baseline.mode === "100644" || baseline.mode === "100755") {
234
+ const expectedExecutable = baseline.mode === "100755";
235
+ const actualExecutable = (stat.mode & 0o111) !== 0;
236
+ if (expectedExecutable !== actualExecutable) return false;
237
+ }
238
+ return readFileSync(target).equals(baseline.content);
239
+ };
240
+
241
+ const reviewableBytes = (bytes: Buffer): boolean => bytes.length <= MAX_TRACKED_BYTES
242
+ && !bytes.includes(0) && Buffer.from(bytes.toString("utf8")).equals(bytes);
243
+
244
+ /**
245
+ * Copy a completed child's changed files back to the parent only when every target still equals the
246
+ * baseline snapshot. The parent is therefore never overwritten after another actor changed a file.
247
+ */
248
+ export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
249
+ tracker?: ChangeTracker): string[] {
250
+ const parentRoot = realpathSync(gitText(parentCwd, ["rev-parse", "--show-toplevel"]).trim());
251
+ if (parentRoot !== worktree.repoRoot) throw new Error("parent repository changed while subagent was running");
252
+ // Review identities follow the caller's cwd spelling (e.g. /var rather than /private/var).
253
+ const reviewRoot = resolve(parentCwd, relative(realpathSync(parentCwd), parentRoot));
254
+ for (const rel of files) {
255
+ if (!safeFilePath(parentRoot, rel) || statIfPresent(resolve(parentRoot, rel))?.isSymbolicLink()) {
256
+ throw new Error("unsafe parent changed path: " + rel);
257
+ }
258
+ if (!parentMatchesBaseline(worktree, parentRoot, rel)) {
259
+ throw new Error(`parent changed since delegation started: ${rel}`);
260
+ }
261
+ const parentStat = statIfPresent(resolve(parentRoot, rel));
262
+ if (parentStat?.isFile() && !reviewableBytes(readFileSync(resolve(parentRoot, rel)))) {
263
+ throw new Error("cannot add changed file to /diff review: " + rel);
264
+ }
265
+ const source = resolve(worktree.root, rel);
266
+ if (!safeFilePath(worktree.root, rel)) throw new Error("unsafe changed path: " + rel);
267
+ if (statIfPresent(source)) {
268
+ const stat = lstatSync(source);
269
+ if (stat.isSymbolicLink()) throw new Error("refusing to apply symlink change: " + rel);
270
+ if (!stat.isFile()) throw new Error("refusing to apply non-file change: " + rel);
271
+ const bytes = readFileSync(source);
272
+ if (!reviewableBytes(bytes)) {
273
+ throw new Error("cannot add changed file to /diff review: " + rel);
274
+ }
275
+ }
276
+ }
277
+
278
+ if (tracker) {
279
+ for (const rel of files) {
280
+ const target = resolve(reviewRoot, rel);
281
+ if (!tracker.capture(target)) throw new Error("cannot add changed file to /diff review: " + rel);
282
+ }
283
+ }
284
+
285
+ const staged: Array<{ rel: string; target: string; directory: string; replacement?: string; backup?: string }> = [];
286
+ const createdDirectories: string[] = [];
287
+ const applied: typeof staged = [];
288
+ try {
289
+ // Prepare every replacement and rollback backup before modifying any parent file.
290
+ // Permission/disk failures in later files therefore cannot leave earlier files half-applied.
291
+ for (const rel of files) {
292
+ const source = resolve(worktree.root, rel);
293
+ const target = resolve(parentRoot, rel);
294
+ if (!safeFilePath(parentRoot, rel) || !safeFilePath(worktree.root, rel)
295
+ || statIfPresent(target)?.isSymbolicLink() || statIfPresent(source)?.isSymbolicLink()) {
296
+ throw new Error("unsafe changed path: " + rel);
297
+ }
298
+ const missing: string[] = [];
299
+ for (let path = dirname(target); !statIfPresent(path); path = dirname(path)) missing.push(path);
300
+ mkdirSync(dirname(target), { recursive: true });
301
+ createdDirectories.push(...missing.reverse());
302
+ const directory = mkdtempSync(join(dirname(target), ".pi-jar-apply-"));
303
+ const entry: (typeof staged)[number] = { rel, target, directory };
304
+ staged.push(entry);
305
+ const before = statIfPresent(target);
306
+ if (before) {
307
+ entry.backup = join(directory, "before");
308
+ writeFileSync(entry.backup, readFileSync(target), { mode: before.mode & 0o777 });
309
+ chmodSync(entry.backup, before.mode & 0o777);
310
+ }
311
+ const after = statIfPresent(source);
312
+ if (after) {
313
+ entry.replacement = join(directory, "after");
314
+ writeFileSync(entry.replacement, readFileSync(source), { mode: after.mode & 0o777 });
315
+ chmodSync(entry.replacement, after.mode & 0o777);
316
+ }
317
+ }
318
+ for (const entry of staged) {
319
+ if (!safeFilePath(parentRoot, entry.rel) || !parentMatchesBaseline(worktree, parentRoot, entry.rel)) {
320
+ throw new Error("parent changed while preparing finalization: " + entry.rel);
321
+ }
322
+ if (entry.replacement) renameSync(entry.replacement, entry.target);
323
+ else if (statIfPresent(entry.target)) unlinkSync(entry.target);
324
+ applied.push(entry);
325
+ }
326
+ for (const entry of applied) tracker?.markDirty(resolve(reviewRoot, entry.rel));
327
+ return applied.map((entry) => entry.rel);
328
+ } catch (error) {
329
+ const unrestored: string[] = [];
330
+ for (const entry of [...applied].reverse()) {
331
+ try {
332
+ if (!safeFilePath(parentRoot, entry.rel)) throw new Error("unsafe rollback path");
333
+ if (entry.backup) renameSync(entry.backup, entry.target);
334
+ else if (statIfPresent(entry.target)) unlinkSync(entry.target);
335
+ } catch { unrestored.push(entry.rel); tracker?.markDirty(resolve(reviewRoot, entry.rel)); }
336
+ }
337
+ if (unrestored.length) throw new WorktreeApplyError(String(error) + "; rollback failed for: " + unrestored.join(", "), unrestored);
338
+ throw error;
339
+ } finally {
340
+ for (const entry of staged) { try { rmSync(entry.directory, { recursive: true, force: true }); } catch { /* best effort */ } }
341
+ for (const directory of createdDirectories.reverse()) { try { rmdirSync(directory); } catch { /* keep non-empty/successfully applied directories */ } }
342
+ }
343
+ }
344
+
345
+ /** Exceptional rollback failure: expose any actual parent mutations in the moderator handoff. */
346
+ export class WorktreeApplyError extends Error {
347
+ readonly appliedFiles: string[];
348
+ constructor(message: string, appliedFiles: string[]) { super(message); this.appliedFiles = appliedFiles; }
349
+ }
350
+
351
+ /** Remove a disposable worktree and its dangling snapshot commit reference. */
352
+ export function disposeDelegateWorktree(worktree: DelegateWorktree): void {
353
+ try { gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root]); }
354
+ catch {
355
+ rmSync(worktree.tempRoot, { recursive: true, force: true });
356
+ try { gitText(worktree.repoRoot, ["worktree", "prune"]); } catch { /* best effort */ }
357
+ return;
358
+ }
359
+ rmSync(worktree.tempRoot, { recursive: true, force: true });
360
+ }