pi-jar 0.1.5 → 0.1.7

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/changes.ts CHANGED
@@ -6,6 +6,8 @@ export const MAX_TRACKED_BYTES = 1024 * 1024;
6
6
  export const MAX_TRACKED_FILES = 200;
7
7
  /** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
8
8
  export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
9
+ /** Explicit full-review choice supports bounded matrix-free previews beyond the exact-diff budget. */
10
+ export const MAX_REVIEW_DIFF_LINES = 100_000;
9
11
  /** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
10
12
  const MAX_DIFF_CELLS = 500_000;
11
13
  const MAX_DIFF_MIDDLE_LINES = 4_000;
@@ -27,6 +29,29 @@ function readText(path: string): string | null | undefined {
27
29
 
28
30
  const splitLines = (text: string) => text === "" ? [] : text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
29
31
 
32
+ export function diffLineCount(text: string): number {
33
+ if (!text) return 0;
34
+ let count = 1;
35
+ for (let index = 0; index < text.length; index++) if (text.charCodeAt(index) === 10) count++;
36
+ if (text.endsWith("\n")) count--;
37
+ return count;
38
+ }
39
+
40
+ /** Safe O(n) fallback that preserves common prefix/suffix and clearly marks coarse replacements. */
41
+ export function coarseLineDiff(before: string, after: string): DiffOp[] {
42
+ const a = splitLines(before), b = splitLines(after);
43
+ let start = 0;
44
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
45
+ let endA = a.length, endB = b.length;
46
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
47
+ return [
48
+ ...a.slice(0, start).map(text => ({ op: " " as const, text })),
49
+ ...a.slice(start, endA).map(text => ({ op: "-" as const, text })),
50
+ ...b.slice(start, endB).map(text => ({ op: "+" as const, text })),
51
+ ...a.slice(endA).map(text => ({ op: " " as const, text }))
52
+ ];
53
+ }
54
+
30
55
  /** Line diff: trims the common prefix/suffix, then an LCS over the changed middle. */
31
56
  export function lineDiff(before: string, after: string): DiffOp[] | undefined {
32
57
  const a = splitLines(before), b = splitLines(after);
@@ -57,38 +82,89 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
57
82
 
58
83
  /** Unified hunks with `context` lines around each change. */
59
84
  export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
60
- const rows: DiffRow[] = [];
61
- const changed = ops.map((op) => op.op !== " ");
62
- let oldLine = 1, newLine = 1;
63
- const positions = ops.map((op) => {
64
- const at = { oldLine, newLine };
65
- if (op.op !== "+") oldLine++;
66
- if (op.op !== "-") newLine++;
67
- return at;
68
- });
69
- let index = 0;
85
+ return diffRowsWindow(ops, context, 0, Number.MAX_SAFE_INTEGER);
86
+ }
87
+
88
+ const linePositionCache = new WeakMap<readonly DiffOp[], { old: Uint32Array; next: Uint32Array }>();
89
+
90
+ type DiffHunk = { from: number; end: number; offset: number };
91
+ const hunkCache = new WeakMap<readonly DiffOp[], { context: number; offsets: number[]; rows: number; hunks: DiffHunk[] }>();
92
+
93
+ /** Logical rendered row offsets for hunk navigation, without materializing unchanged rows. */
94
+ export function diffHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
95
+ const cached = hunkCache.get(ops);
96
+ if (cached?.context === context) return cached.offsets.slice();
97
+ const offsets: number[] = [];
98
+ const hunks: DiffHunk[] = [];
99
+ let rendered = 0, index = 0;
70
100
  while (index < ops.length) {
71
- if (!changed[index]) { index++; continue; }
101
+ if (ops[index]!.op === " ") { index++; continue; }
72
102
  const from = Math.max(0, index - context);
73
103
  let to = index;
74
- // Extend the hunk while the next change is within 2 × context lines.
75
104
  while (to < ops.length) {
76
105
  let next = to + 1;
77
- while (next < ops.length && !changed[next]) next++;
106
+ while (next < ops.length && ops[next]!.op === " ") next++;
78
107
  if (next < ops.length && next - to <= context * 2) { to = next; continue; }
79
108
  break;
80
109
  }
81
110
  const end = Math.min(ops.length, to + context + 1);
82
- const slice = ops.slice(from, end);
83
- const oldCount = slice.filter((op) => op.op !== "+").length;
84
- const newCount = slice.filter((op) => op.op !== "-").length;
85
- rows.push({ kind: "hunk", text: `@@ -${positions[from]!.oldLine},${oldCount} +${positions[from]!.newLine},${newCount} @@` });
86
- for (let at = from; at < end; at++) {
111
+ offsets.push(rendered);
112
+ hunks.push({ from, end, offset: rendered });
113
+ rendered += 1 + end - from;
114
+ index = end;
115
+ }
116
+ hunkCache.set(ops, { context, offsets, rows: rendered, hunks });
117
+ return offsets.slice();
118
+ }
119
+
120
+ export function diffRowCount(ops: readonly DiffOp[], context = 3): number {
121
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
122
+ return hunkCache.get(ops)!.rows;
123
+ }
124
+
125
+ /** Compute only the requested output window; diff operations stay indexed without a second full row array. */
126
+ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
127
+ const rows: DiffRow[] = [];
128
+ const firstRow = Math.max(0, offset);
129
+ const lastRow = firstRow + Math.max(0, limit);
130
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
131
+ const layout = hunkCache.get(ops)!;
132
+ if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
133
+ let positions = linePositionCache.get(ops);
134
+ if (!positions) {
135
+ const old = new Uint32Array(ops.length + 1), next = new Uint32Array(ops.length + 1);
136
+ let oldLine = 1, newLine = 1;
137
+ for (let at = 0; at < ops.length; at++) {
138
+ const op = ops[at]!;
139
+ old[at] = oldLine; next[at] = newLine;
140
+ if (op.op !== "+") oldLine++;
141
+ if (op.op !== "-") newLine++;
142
+ }
143
+ old[ops.length] = oldLine;
144
+ next[ops.length] = newLine;
145
+ positions = { old, next };
146
+ linePositionCache.set(ops, positions);
147
+ }
148
+ // Binary-search the first intersecting hunk, then visit only visible operations. In
149
+ // particular, a 100k-line replacement must not slice/filter/allocate 100k rows per frame.
150
+ let lo = 0, hi = layout.hunks.length;
151
+ while (lo < hi) {
152
+ const mid = (lo + hi) >>> 1;
153
+ const hunk = layout.hunks[mid]!;
154
+ if (hunk.offset + 1 + hunk.end - hunk.from <= firstRow) lo = mid + 1;
155
+ else hi = mid;
156
+ }
157
+ for (let index = lo; index < layout.hunks.length; index++) {
158
+ const { from, end, offset: start } = layout.hunks[index]!;
159
+ if (start >= lastRow) break;
160
+ if (start >= firstRow) rows.push({ kind: "hunk", text: `@@ -${positions.old[from]!},${positions.old[end]! - positions.old[from]!} +${positions.next[from]!},${positions.next[end]! - positions.next[from]!} @@` });
161
+ const visibleFrom = from + Math.max(0, firstRow - start - 1);
162
+ const visibleEnd = Math.min(end, from + lastRow - start - 1);
163
+ for (let at = visibleFrom; at < visibleEnd; at++) {
87
164
  const op = ops[at]!;
88
165
  rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
89
- ...(op.op !== "+" ? { oldLine: positions[at]!.oldLine } : {}), ...(op.op !== "-" ? { newLine: positions[at]!.newLine } : {}) });
166
+ ...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[at]! } : {}) });
90
167
  }
91
- index = end;
92
168
  }
93
169
  return rows;
94
170
  }
@@ -174,13 +250,9 @@ export class ChangeTracker {
174
250
  if (now === undefined) { this.drop(path); continue; }
175
251
  if (now === before) { this.dirty.delete(path); continue; }
176
252
  const ops = lineDiff(before ?? "", now ?? "");
253
+ const summaryOps = ops ?? coarseLineDiff(before ?? "", now ?? "");
177
254
  let added = 0, removed = 0;
178
- if (ops) {
179
- for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
180
- } else {
181
- added = splitLines(now ?? "").length;
182
- removed = splitLines(before ?? "").length;
183
- }
255
+ for (const op of summaryOps) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
184
256
  const rel = relative(this.cwd(), path);
185
257
  const change: FileChange = {
186
258
  path, rel: rel && !rel.startsWith("..") ? rel : path,
package/src/commit.ts CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ModelRoleManager } from "./model-roles.ts";
6
6
  import { askRole, type SideUsage } from "./side-model.ts";
7
+ import { runProcess } from "./async-process.ts";
7
8
 
8
9
  const DIFF_LIMIT = 60_000;
9
10
 
@@ -27,13 +28,11 @@ export function commitPrompt(stat: string, diff: string, log: string, hint: stri
27
28
  }
28
29
 
29
30
  /** `/jar commit [note]`: draft a message for the staged changes with the commit role, review it, commit. Never pushes. */
30
- export async function jarCommit(pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
+ export async function jarCommit(_pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
32
  if (!ctx.hasUI) { ctx.ui.notify("/jar commit needs the interactive UI to review the message", "warning"); return; }
32
- const git = async (...args: string[]) => {
33
- const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 20_000 });
34
- if (result.code !== 0) throw new Error(`git ${args[0]}: ${(result.stderr || result.stdout).trim() || "exit " + result.code}`);
35
- return result.stdout;
36
- };
33
+ const git = async (...args: string[]) => (await runProcess(process.env.PI_JAR_GIT_PATH?.trim() || "git", args, {
34
+ cwd: ctx.cwd, signal: ctx.signal, timeoutMs: 20_000, maxOutputBytes: 64 * 1024 * 1024
35
+ })).toString("utf8");
37
36
  try {
38
37
  await git("rev-parse", "--is-inside-work-tree");
39
38
  let stat = await git("diff", "--cached", "--stat");
@@ -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);
@@ -1,7 +1,6 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { runProcess } from "./async-process.ts";
2
2
  import {
3
3
  chmodSync,
4
- cpSync,
5
4
  existsSync,
6
5
  lstatSync,
7
6
  mkdirSync,
@@ -13,10 +12,10 @@ import {
13
12
  renameSync,
14
13
  rmdirSync,
15
14
  writeFileSync,
16
- symlinkSync,
17
15
  unlinkSync,
18
16
  type Stats
19
17
  } from "node:fs";
18
+ import { chmod, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
20
19
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
20
  import { homedir, tmpdir } from "node:os";
22
21
  import { fileURLToPath } from "node:url";
@@ -25,6 +24,10 @@ import { MAX_TRACKED_BYTES, type ChangeTracker } from "./changes.ts";
25
24
  /** Present only in writable delegated children. The child extension treats this as its filesystem boundary. */
26
25
  export const CHILD_WORKTREE_ENV = "PI_JAR_WORKTREE_ROOT";
27
26
  const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
27
+ const GIT_TIMEOUT_MS = 30_000;
28
+ /** Set PI_JAR_GIT_PATH to bypass wrappers such as git-ai for pi-jar's internal plumbing. */
29
+ export const GIT_EXECUTABLE_ENV = "PI_JAR_GIT_PATH";
30
+ const gitExecutable = () => process.env[GIT_EXECUTABLE_ENV]?.trim() || "git";
28
31
 
29
32
  export interface DelegateWorktree {
30
33
  /** Git repository root in the parent workspace. */
@@ -42,19 +45,14 @@ export interface DelegateWorktree {
42
45
  const safeGitArgs = (cwd: string, args: string[]) => ["-C", cwd, "--literal-pathspecs", "-c", "core.hooksPath=/dev/null",
43
46
  "-c", "core.fsmonitor=false", ...args];
44
47
 
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
- });
48
+ const gitText = async (cwd: string, args: string[], input?: string | Buffer, signal?: AbortSignal): Promise<string> =>
49
+ (await runProcess(gitExecutable(), safeGitArgs(cwd, args), {
50
+ cwd, input, signal, timeoutMs: GIT_TIMEOUT_MS, maxOutputBytes: MAX_GIT_OUTPUT
51
+ })).toString("utf8");
52
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"]
53
+ const gitBuffer = (cwd: string, args: string[], signal?: AbortSignal): Promise<Buffer> =>
54
+ runProcess(gitExecutable(), safeGitArgs(cwd, args), {
55
+ cwd, signal, timeoutMs: GIT_TIMEOUT_MS, maxOutputBytes: MAX_GIT_OUTPUT
58
56
  });
59
57
 
60
58
  const nulList = (value: string): string[] => value.split("\0").filter(Boolean);
@@ -144,13 +142,16 @@ function canonicalWorkspacePathAllowed(root: string, lexical: string): boolean {
144
142
  } catch { return false; }
145
143
  }
146
144
 
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 });
145
+ const writeSnapshot = async (root: string, rel: string, bytes: Buffer, stat: Stats): Promise<void> => {
146
+ const to = resolve(root, rel);
147
+ if (!safeFilePath(root, rel)) throw new Error("unsafe worktree path: " + rel);
148
+ await mkdir(dirname(to), { recursive: true });
149
+ // Use the bytes we hashed, not a second copy of a source the user may edit during the await.
150
+ if (stat.isSymbolicLink()) await symlink(bytes.toString(), to);
151
+ else {
152
+ await writeFile(to, bytes, { mode: stat.mode & 0o777 });
153
+ await chmod(to, stat.mode & 0o777);
154
+ }
154
155
  };
155
156
 
156
157
  /**
@@ -158,9 +159,9 @@ const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
158
159
  * parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
159
160
  * touched. That baseline lets multiple children run against the exact same starting filesystem.
160
161
  */
161
- export function createDelegateWorktree(cwd: string): DelegateWorktree {
162
+ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal): Promise<DelegateWorktree> {
162
163
  const requested = realpathSync(cwd);
163
- const repoRoot = realpathSync(gitText(requested, ["rev-parse", "--show-toplevel"]).trim());
164
+ const repoRoot = realpathSync((await gitText(requested, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
164
165
  if (!inside(repoRoot, requested)) throw new Error("working directory is outside the git repository");
165
166
  const relCwd = relative(repoRoot, requested);
166
167
  const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-jar-worktree-")));
@@ -168,61 +169,67 @@ export function createDelegateWorktree(cwd: string): DelegateWorktree {
168
169
  let added = false;
169
170
  try {
170
171
  // Never checkout/add: they execute clean/smudge filters and alter byte baselines.
171
- gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"]);
172
+ await gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"], undefined, signal);
172
173
  added = true;
173
- gitText(root, ["read-tree", "--empty"]);
174
- const paths = new Set(nulList(gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])));
174
+ await gitText(root, ["read-tree", "--empty"], undefined, signal);
175
+ const paths = new Set(nulList(await gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal)));
175
176
  for (const rel of paths) {
176
177
  if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
177
178
  const source = resolve(repoRoot, rel);
178
179
  const stat = statIfPresent(source);
179
180
  if (!stat) continue;
180
181
  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();
182
+ const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
183
+ const oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
183
184
  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);
185
+ await gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel], undefined, signal);
186
+ signal?.throwIfAborted();
187
+ await writeSnapshot(root, rel, bytes, stat);
186
188
  }
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]);
189
+ const tree = (await gitText(root, ["write-tree"], undefined, signal)).trim();
190
+ const baseline = (await gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
191
+ "commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"], undefined, signal)).trim();
192
+ await gitText(root, ["update-ref", "HEAD", baseline], undefined, signal);
191
193
  const childCwd = relCwd ? resolve(root, relCwd) : root;
192
194
  if (!workspacePathAllowed(root, root, childCwd)) throw new Error("unsafe child working directory");
193
195
  if (!existsSync(childCwd)) mkdirSync(childCwd, { recursive: true });
194
196
  return { repoRoot, root, cwd: childCwd, baseline, tempRoot };
195
197
  } catch (error) {
196
198
  if (added) {
197
- try { gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
199
+ try { await gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
198
200
  }
199
- rmSync(tempRoot, { recursive: true, force: true });
201
+ await rm(tempRoot, { recursive: true, force: true });
200
202
  throw error;
201
203
  }
202
204
  }
203
205
 
204
206
  /** 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();
207
+ export async function worktreeChangedFiles(worktree: DelegateWorktree, signal?: AbortSignal): Promise<string[]> {
208
+ const baselinePaths = nulList(await gitText(worktree.root, ["ls-tree", "-r", "--name-only", "-z", worktree.baseline], undefined, signal));
209
+ const currentPaths = nulList(await gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal));
210
+ const changed: string[] = [];
211
+ for (const rel of new Set([...baselinePaths, ...currentPaths])) {
212
+ if (!await parentMatchesBaseline(worktree, worktree.root, rel, signal)) changed.push(rel);
213
+ }
214
+ return changed.sort();
210
215
  }
211
216
 
212
217
  type BaselineEntry = { mode: string; content: Buffer } | null;
213
218
 
214
- const baselineEntry = (worktree: DelegateWorktree, rel: string): BaselineEntry => {
215
- const line = gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel]);
219
+ const baselineEntry = async (worktree: DelegateWorktree, rel: string, signal?: AbortSignal): Promise<BaselineEntry> => {
220
+ const line = await gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel], undefined, signal);
216
221
  if (!line || line.slice(line.indexOf("\t") + 1, -1) !== rel) return null;
217
222
  const [mode, type, oid] = line.slice(0, line.indexOf("\t")).split(" ");
218
223
  if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + rel);
219
- return { mode, content: gitBuffer(worktree.root, ["cat-file", "blob", oid]) };
224
+ return { mode, content: await gitBuffer(worktree.root, ["cat-file", "blob", oid], signal) };
220
225
  };
221
226
 
222
- const parentMatchesBaseline = (worktree: DelegateWorktree, parentRoot: string, rel: string): boolean => {
227
+ const parentMatchesBaseline = async (worktree: DelegateWorktree, parentRoot: string, rel: string, signal?: AbortSignal): Promise<boolean> =>
228
+ matchesBaseline(await baselineEntry(worktree, rel, signal), parentRoot, rel);
229
+
230
+ const matchesBaseline = (baseline: BaselineEntry, parentRoot: string, rel: string): boolean => {
223
231
  const target = resolve(parentRoot, rel);
224
232
  if (!safeFilePath(parentRoot, rel)) return false;
225
- const baseline = baselineEntry(worktree, rel);
226
233
  const stat = statIfPresent(target);
227
234
  if (!baseline) return !stat;
228
235
  if (!stat) return false;
@@ -245,17 +252,20 @@ const reviewableBytes = (bytes: Buffer): boolean => bytes.length <= MAX_TRACKED_
245
252
  * Copy a completed child's changed files back to the parent only when every target still equals the
246
253
  * baseline snapshot. The parent is therefore never overwritten after another actor changed a file.
247
254
  */
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());
255
+ export async function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
256
+ tracker?: ChangeTracker, signal?: AbortSignal): Promise<string[]> {
257
+ const parentRoot = realpathSync((await gitText(parentCwd, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
251
258
  if (parentRoot !== worktree.repoRoot) throw new Error("parent repository changed while subagent was running");
252
259
  // Review identities follow the caller's cwd spelling (e.g. /var rather than /private/var).
253
260
  const reviewRoot = resolve(parentCwd, relative(realpathSync(parentCwd), parentRoot));
261
+ const baselines = new Map<string, BaselineEntry>();
254
262
  for (const rel of files) {
255
263
  if (!safeFilePath(parentRoot, rel) || statIfPresent(resolve(parentRoot, rel))?.isSymbolicLink()) {
256
264
  throw new Error("unsafe parent changed path: " + rel);
257
265
  }
258
- if (!parentMatchesBaseline(worktree, parentRoot, rel)) {
266
+ const baseline = await baselineEntry(worktree, rel, signal);
267
+ baselines.set(rel, baseline);
268
+ if (!matchesBaseline(baseline, parentRoot, rel)) {
259
269
  throw new Error(`parent changed since delegation started: ${rel}`);
260
270
  }
261
271
  const parentStat = statIfPresent(resolve(parentRoot, rel));
@@ -315,10 +325,15 @@ export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: str
315
325
  chmodSync(entry.replacement, after.mode & 0o777);
316
326
  }
317
327
  }
328
+ // No await after entering the atomic apply/rollback section: asynchronous Git calls between
329
+ // replacements would let parent edits or a session switch interleave with partial application.
330
+ signal?.throwIfAborted();
318
331
  for (const entry of staged) {
319
- if (!safeFilePath(parentRoot, entry.rel) || !parentMatchesBaseline(worktree, parentRoot, entry.rel)) {
332
+ if (!safeFilePath(parentRoot, entry.rel) || !matchesBaseline(baselines.get(entry.rel)!, parentRoot, entry.rel)) {
320
333
  throw new Error("parent changed while preparing finalization: " + entry.rel);
321
334
  }
335
+ }
336
+ for (const entry of staged) {
322
337
  if (entry.replacement) renameSync(entry.replacement, entry.target);
323
338
  else if (statIfPresent(entry.target)) unlinkSync(entry.target);
324
339
  applied.push(entry);
@@ -349,12 +364,12 @@ export class WorktreeApplyError extends Error {
349
364
  }
350
365
 
351
366
  /** 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]); }
367
+ export async function disposeDelegateWorktree(worktree: DelegateWorktree, signal?: AbortSignal): Promise<void> {
368
+ try { await gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root], undefined, signal); }
354
369
  catch {
355
- rmSync(worktree.tempRoot, { recursive: true, force: true });
356
- try { gitText(worktree.repoRoot, ["worktree", "prune"]); } catch { /* best effort */ }
370
+ await rm(worktree.tempRoot, { recursive: true, force: true });
371
+ try { await gitText(worktree.repoRoot, ["worktree", "prune"], undefined, signal); } catch { /* best effort */ }
357
372
  return;
358
373
  }
359
- rmSync(worktree.tempRoot, { recursive: true, force: true });
374
+ await rm(worktree.tempRoot, { recursive: true, force: true });
360
375
  }