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/README.md +35 -19
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/WORKFLOWS.md +34 -10
- package/extensions/index.ts +87 -15
- package/package.json +1 -1
- package/src/activity-view.ts +23 -7
- package/src/ask-tool.ts +12 -7
- package/src/compact-tools.ts +66 -28
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +934 -130
- package/src/democracy.ts +214 -0
- package/src/discussion.ts +189 -0
- package/src/footer.ts +4 -3
- package/src/goal-loop.ts +11 -16
- package/src/model-roles.ts +58 -29
- package/src/plan.ts +4 -10
- package/src/roles-ui.ts +18 -10
- package/src/settings-ui.ts +41 -15
- package/src/settings.ts +9 -1
- package/src/status.ts +2 -1
- package/src/task-tool.ts +33 -18
- package/src/tasks.ts +7 -7
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
|
-
|
|
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
|
|
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 };
|
package/src/compact-tools.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|