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/README.md +20 -8
- package/docs/DIFF-REVIEW.md +11 -0
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +6 -4
- package/extensions/index.ts +36 -27
- package/package.json +1 -1
- package/src/activity-view.ts +11 -2
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +98 -26
- package/src/commit.ts +5 -6
- package/src/compact-tools.ts +66 -28
- package/src/delegate-worktree.ts +72 -57
- package/src/delegate.ts +432 -133
- package/src/democracy.ts +44 -22
- package/src/diff-view.ts +141 -30
- package/src/discussion.ts +55 -19
- package/src/goal-loop.ts +3 -15
- package/src/plan.ts +4 -10
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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 &&
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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]
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
34
|
-
|
|
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");
|
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);
|
package/src/delegate-worktree.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
else
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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) || !
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
await rm(worktree.tempRoot, { recursive: true, force: true });
|
|
360
375
|
}
|