pi-subagents 0.12.2 → 0.12.4

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/worktree.ts CHANGED
@@ -15,6 +15,7 @@ export interface WorktreeInfo {
15
15
  branch: string;
16
16
  index: number;
17
17
  nodeModulesLinked: boolean;
18
+ syntheticPaths: string[];
18
19
  }
19
20
 
20
21
  export interface WorktreeDiff {
@@ -34,6 +35,37 @@ export interface WorktreeTaskCwdConflict {
34
35
  cwd: string;
35
36
  }
36
37
 
38
+ export interface WorktreeSetupHookConfig {
39
+ hookPath: string;
40
+ timeoutMs?: number;
41
+ }
42
+
43
+ export interface CreateWorktreesOptions {
44
+ agents?: string[];
45
+ setupHook?: WorktreeSetupHookConfig;
46
+ }
47
+
48
+ interface ResolvedWorktreeSetupHook {
49
+ hookPath: string;
50
+ timeoutMs: number;
51
+ }
52
+
53
+ interface WorktreeSetupHookInput {
54
+ version: 1;
55
+ repoRoot: string;
56
+ worktreePath: string;
57
+ agentCwd: string;
58
+ branch: string;
59
+ index: number;
60
+ runId: string;
61
+ baseCommit: string;
62
+ agent?: string;
63
+ }
64
+
65
+ interface WorktreeSetupHookOutput {
66
+ syntheticPaths?: string[];
67
+ }
68
+
37
69
  interface GitResult {
38
70
  stdout: string;
39
71
  stderr: string;
@@ -46,6 +78,8 @@ interface RepoState {
46
78
  baseCommit: string;
47
79
  }
48
80
 
81
+ const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
82
+
49
83
  function runGit(cwd: string, args: string[]): GitResult {
50
84
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
51
85
  return {
@@ -139,7 +173,140 @@ function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boole
139
173
  }
140
174
  }
141
175
 
142
- function createSingleWorktree(toplevel: string, cwdRelative: string, runId: string, index: number): WorktreeInfo {
176
+ function parseHookTimeout(timeoutMs: number | undefined): number {
177
+ if (timeoutMs === undefined) return DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS;
178
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
179
+ throw new Error("worktree setup hook timeout must be an integer greater than 0");
180
+ }
181
+ return timeoutMs;
182
+ }
183
+
184
+ function resolveWorktreeSetupHook(
185
+ repoRoot: string,
186
+ config: WorktreeSetupHookConfig | undefined,
187
+ ): ResolvedWorktreeSetupHook | undefined {
188
+ if (!config) return undefined;
189
+ const hookPath = config.hookPath.trim();
190
+ if (!hookPath) {
191
+ throw new Error("worktree setup hook path cannot be empty");
192
+ }
193
+
194
+ const expandedHookPath = hookPath.startsWith("~/") ? path.join(os.homedir(), hookPath.slice(2)) : hookPath;
195
+ let resolvedPath: string;
196
+ if (path.isAbsolute(expandedHookPath)) {
197
+ resolvedPath = expandedHookPath;
198
+ } else if (expandedHookPath.includes("/") || expandedHookPath.includes("\\")) {
199
+ resolvedPath = path.resolve(repoRoot, expandedHookPath);
200
+ } else {
201
+ throw new Error("worktree setup hook must be an absolute path or a repo-relative path");
202
+ }
203
+
204
+ if (!fs.existsSync(resolvedPath)) {
205
+ throw new Error(`worktree setup hook not found: ${resolvedPath}`);
206
+ }
207
+ if (fs.statSync(resolvedPath).isDirectory()) {
208
+ throw new Error(`worktree setup hook must be a file, got directory: ${resolvedPath}`);
209
+ }
210
+
211
+ return {
212
+ hookPath: resolvedPath,
213
+ timeoutMs: parseHookTimeout(config.timeoutMs),
214
+ };
215
+ }
216
+
217
+ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
218
+ const trimmed = rawPath.trim();
219
+ if (!trimmed) throw new Error("synthetic path cannot be empty");
220
+ if (path.isAbsolute(trimmed)) throw new Error(`synthetic path must be relative: ${rawPath}`);
221
+
222
+ const resolved = path.resolve(worktreePath, trimmed);
223
+ const relative = path.relative(worktreePath, resolved);
224
+ if (!relative || relative === ".") {
225
+ throw new Error(`synthetic path cannot target the worktree root: ${rawPath}`);
226
+ }
227
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
228
+ throw new Error(`synthetic path escapes the worktree root: ${rawPath}`);
229
+ }
230
+ return path.normalize(relative);
231
+ }
232
+
233
+ function hasTrackedEntries(worktreePath: string, relativePath: string): boolean {
234
+ const result = runGit(worktreePath, ["ls-files", "--", relativePath]);
235
+ return result.status === 0 && result.stdout.trim().length > 0;
236
+ }
237
+
238
+ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
239
+ const trimmed = rawStdout.trim();
240
+ if (!trimmed) {
241
+ throw new Error("worktree setup hook returned empty stdout; expected JSON object");
242
+ }
243
+ let parsed: unknown;
244
+ try {
245
+ parsed = JSON.parse(trimmed);
246
+ } catch (error) {
247
+ const message = error instanceof Error ? error.message : String(error);
248
+ throw new Error(`worktree setup hook returned invalid JSON: ${message}`);
249
+ }
250
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
251
+ throw new Error("worktree setup hook stdout must be a JSON object");
252
+ }
253
+ return parsed as WorktreeSetupHookOutput;
254
+ }
255
+
256
+ function runWorktreeSetupHook(
257
+ hook: ResolvedWorktreeSetupHook,
258
+ input: WorktreeSetupHookInput,
259
+ ): string[] {
260
+ const result = spawnSync(hook.hookPath, [], {
261
+ cwd: input.worktreePath,
262
+ encoding: "utf-8",
263
+ input: JSON.stringify(input),
264
+ timeout: hook.timeoutMs,
265
+ shell: false,
266
+ });
267
+
268
+ if (result.error) {
269
+ const code = "code" in result.error ? result.error.code : undefined;
270
+ if (code === "ETIMEDOUT") {
271
+ throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`);
272
+ }
273
+ throw new Error(`worktree setup hook failed: ${result.error.message}`);
274
+ }
275
+
276
+ if (result.status !== 0) {
277
+ const details = result.stderr.trim() || result.stdout.trim() || "no output";
278
+ throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`);
279
+ }
280
+
281
+ const output = parseWorktreeSetupHookOutput(result.stdout);
282
+ if (output.syntheticPaths === undefined) return [];
283
+ if (!Array.isArray(output.syntheticPaths)) {
284
+ throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
285
+ }
286
+
287
+ const uniquePaths = new Set<string>();
288
+ for (const candidate of output.syntheticPaths) {
289
+ if (typeof candidate !== "string") {
290
+ throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
291
+ }
292
+ const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
293
+ if (hasTrackedEntries(input.worktreePath, normalizedPath)) {
294
+ throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
295
+ }
296
+ uniquePaths.add(normalizedPath);
297
+ }
298
+ return [...uniquePaths];
299
+ }
300
+
301
+ function createSingleWorktree(
302
+ toplevel: string,
303
+ cwdRelative: string,
304
+ runId: string,
305
+ index: number,
306
+ baseCommit: string,
307
+ setupHook: ResolvedWorktreeSetupHook | undefined,
308
+ agent: string | undefined,
309
+ ): WorktreeInfo {
143
310
  const branch = buildWorktreeBranch(runId, index);
144
311
  const worktreePath = buildWorktreePath(runId, index);
145
312
  const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
@@ -148,29 +315,76 @@ function createSingleWorktree(toplevel: string, cwdRelative: string, runId: stri
148
315
  throw new Error(message);
149
316
  }
150
317
 
151
- return {
152
- path: worktreePath,
153
- agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
154
- branch,
155
- index,
156
- nodeModulesLinked: linkNodeModulesIfPresent(toplevel, worktreePath),
157
- };
318
+ const agentCwd = cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
319
+ try {
320
+ const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktreePath);
321
+ const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
322
+
323
+ if (setupHook) {
324
+ const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
325
+ version: 1,
326
+ repoRoot: toplevel,
327
+ worktreePath,
328
+ agentCwd,
329
+ branch,
330
+ index,
331
+ runId,
332
+ baseCommit,
333
+ agent,
334
+ });
335
+ syntheticPaths.push(...hookSyntheticPaths);
336
+ }
337
+
338
+ return {
339
+ path: worktreePath,
340
+ agentCwd,
341
+ branch,
342
+ index,
343
+ nodeModulesLinked,
344
+ syntheticPaths,
345
+ };
346
+ } catch (error) {
347
+ try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {}
348
+ try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {}
349
+ throw error;
350
+ }
158
351
  }
159
352
 
160
- function removeSyntheticNodeModulesSymlink(worktree: WorktreeInfo): void {
161
- if (!worktree.nodeModulesLinked) return;
353
+ function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
354
+ const resolved = path.resolve(worktree.path, syntheticPath);
355
+ const relative = path.relative(worktree.path, resolved);
356
+ if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
357
+ return;
358
+ }
162
359
 
163
- const nodeModulesPath = path.join(worktree.path, "node_modules");
164
360
  let stat: fs.Stats;
165
361
  try {
166
- stat = fs.lstatSync(nodeModulesPath);
362
+ stat = fs.lstatSync(resolved);
167
363
  } catch (error) {
168
364
  const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
169
365
  if (code === "ENOENT") return;
170
366
  throw error;
171
367
  }
172
- if (!stat.isSymbolicLink()) return;
173
- fs.unlinkSync(nodeModulesPath);
368
+
369
+ if (stat.isSymbolicLink()) {
370
+ fs.unlinkSync(resolved);
371
+ return;
372
+ }
373
+ if (stat.isDirectory()) {
374
+ fs.rmSync(resolved, { recursive: true, force: true });
375
+ return;
376
+ }
377
+ fs.rmSync(resolved, { force: true });
378
+ }
379
+
380
+ function removeSyntheticPathsBeforeDiff(worktree: WorktreeInfo): void {
381
+ if (worktree.syntheticPaths.length === 0) return;
382
+ const seen = new Set<string>();
383
+ for (const syntheticPath of worktree.syntheticPaths) {
384
+ if (seen.has(syntheticPath)) continue;
385
+ seen.add(syntheticPath);
386
+ removeSyntheticPath(worktree, syntheticPath);
387
+ }
174
388
  }
175
389
 
176
390
  function emptyDiff(index: number, agent: string, branch: string, patchPath: string): WorktreeDiff {
@@ -212,7 +426,7 @@ function captureWorktreeDiff(
212
426
  agent: string,
213
427
  patchPath: string,
214
428
  ): WorktreeDiff {
215
- removeSyntheticNodeModulesSymlink(worktree);
429
+ removeSyntheticPathsBeforeDiff(worktree);
216
430
  runGitChecked(worktree.path, ["add", "-A"]);
217
431
  const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
218
432
  const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
@@ -251,13 +465,22 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
251
465
  return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
252
466
  }
253
467
 
254
- export function createWorktrees(cwd: string, runId: string, count: number): WorktreeSetup {
468
+ export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
255
469
  const repo = resolveRepoState(cwd);
470
+ const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
256
471
  const worktrees: WorktreeInfo[] = [];
257
472
 
258
473
  try {
259
474
  for (let index = 0; index < count; index++) {
260
- worktrees.push(createSingleWorktree(repo.toplevel, repo.cwdRelative, runId, index));
475
+ worktrees.push(createSingleWorktree(
476
+ repo.toplevel,
477
+ repo.cwdRelative,
478
+ runId,
479
+ index,
480
+ repo.baseCommit,
481
+ setupHook,
482
+ options?.agents?.[index],
483
+ ));
261
484
  }
262
485
  } catch (error) {
263
486
  cleanupWorktrees({