pi-jar 0.1.6 → 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 +17 -5
- package/docs/DIFF-REVIEW.md +11 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- 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/delegate-worktree.ts +72 -57
- package/src/delegate.ts +284 -87
- package/src/diff-view.ts +141 -30
- 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/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
|
}
|