pi-crew 0.2.20 → 0.2.21

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +23 -10
  2. package/README.md +4 -2
  3. package/docs/PROJECT_REVIEW.md +271 -0
  4. package/docs/PROJECT_REVIEW_FIXES.md +343 -0
  5. package/docs/PROJECT_REVIEW_ROUND4.md +156 -0
  6. package/docs/PROJECT_REVIEW_ROUND5.md +86 -0
  7. package/docs/fixes/BATCH_A_H1_H2.md +86 -0
  8. package/docs/fixes/bug-006-foreground-cancel-concurrent.md +78 -0
  9. package/docs/fixes/bug-007-async-notifier-stale-ctx.md +112 -0
  10. package/docs/fixes/bug-008-child-process-silent-timeout.md +100 -0
  11. package/docs/fixes/bug-009-executor-yield-limit-needs-attention.md +75 -0
  12. package/docs/fixes/bug-010-child-process-api-key-filtered.md +109 -0
  13. package/docs/fixes/bug-011-spawn-pi-enoent.md +92 -0
  14. package/docs/fixes/bug-012-essential-env-stripped.md +89 -0
  15. package/docs/fixes/bug-013-background-runner-death.md +84 -0
  16. package/docs/fixes/bug-014-infinite-retry-loop-needs-attention.md +82 -0
  17. package/docs/fixes/bug-015-background-runner-sigterm.md +65 -0
  18. package/docs/fixes/bug-017-background-runner-session-shutdown.md +66 -0
  19. package/docs/fixes/bug-017-background-runner-sigkill-double-fork.md +28 -0
  20. package/docs/fixes/bug-018-child-pi-worker-stdin-hang.md +61 -0
  21. package/docs/fixes/bug-019-phantom-runs-temp-workspace.md +52 -0
  22. package/docs/pi-crew-bugs.md +954 -0
  23. package/docs/pi-crew-investigation-report.md +411 -0
  24. package/docs/pi-crew-test-final.md +120 -0
  25. package/docs/pi-crew-test-results.md +260 -0
  26. package/docs/pi-crew-test-round2.md +136 -0
  27. package/docs/pi-crew-test-round4.md +100 -0
  28. package/docs/pi-crew-test-round5.md +70 -0
  29. package/docs/pi-crew-test-round6.md +110 -0
  30. package/docs/usage.md +14 -0
  31. package/package.json +4 -2
  32. package/src/adapters/export-util.ts +12 -6
  33. package/src/agents/agent-config.ts +2 -0
  34. package/src/config/defaults.ts +1 -1
  35. package/src/config/markers.ts +22 -17
  36. package/src/config/resilient-parser.ts +1 -1
  37. package/src/extension/async-notifier.ts +4 -2
  38. package/src/extension/management.ts +52 -0
  39. package/src/extension/register.ts +47 -10
  40. package/src/extension/run-index.ts +20 -2
  41. package/src/extension/run-maintenance.ts +2 -2
  42. package/src/extension/team-tool/parallel-dispatch.ts +1 -1
  43. package/src/extension/team-tool/run.ts +3 -6
  44. package/src/extension/team-tool.ts +67 -11
  45. package/src/observability/event-to-metric.ts +2 -1
  46. package/src/runtime/async-runner.ts +42 -34
  47. package/src/runtime/background-runner.ts +165 -7
  48. package/src/runtime/child-pi.ts +111 -18
  49. package/src/runtime/code-summary.ts +1 -1
  50. package/src/runtime/crash-recovery.ts +1 -1
  51. package/src/runtime/crew-agent-runtime.ts +2 -1
  52. package/src/runtime/heartbeat-watcher.ts +4 -0
  53. package/src/runtime/live-agent-manager.ts +1 -1
  54. package/src/runtime/live-session-runtime.ts +2 -1
  55. package/src/runtime/manifest-cache.ts +2 -2
  56. package/src/runtime/model-fallback.ts +2 -1
  57. package/src/runtime/phase-progress.ts +1 -1
  58. package/src/runtime/pi-args.ts +3 -1
  59. package/src/runtime/pi-spawn.ts +6 -0
  60. package/src/runtime/prose-compressor.ts +1 -1
  61. package/src/runtime/result-extractor.ts +0 -1
  62. package/src/runtime/retry-executor.ts +1 -1
  63. package/src/runtime/runtime-resolver.ts +1 -1
  64. package/src/runtime/skill-instructions.ts +0 -1
  65. package/src/runtime/stale-reconciler.ts +30 -3
  66. package/src/runtime/subagent-manager.ts +2 -0
  67. package/src/runtime/task-display.ts +1 -1
  68. package/src/runtime/task-graph-scheduler.ts +1 -1
  69. package/src/runtime/task-runner/tail-read.ts +26 -0
  70. package/src/runtime/task-runner.ts +1007 -383
  71. package/src/runtime/team-runner.ts +9 -5
  72. package/src/runtime/worker-startup.ts +3 -1
  73. package/src/schema/team-tool-schema.ts +2 -1
  74. package/src/state/active-run-registry.ts +8 -2
  75. package/src/state/atomic-write.ts +17 -0
  76. package/src/state/contracts.ts +5 -2
  77. package/src/state/event-log-rotation.ts +118 -31
  78. package/src/state/event-log.ts +33 -5
  79. package/src/state/event-reconstructor.ts +4 -2
  80. package/src/state/mailbox.ts +5 -1
  81. package/src/state/schedule.ts +146 -0
  82. package/src/state/types.ts +40 -0
  83. package/src/state/usage.ts +20 -0
  84. package/src/ui/crew-widget.ts +2 -2
  85. package/src/ui/run-event-bus.ts +1 -1
  86. package/src/ui/run-snapshot-cache.ts +2 -1
  87. package/src/ui/snapshot-types.ts +1 -0
  88. package/src/utils/gh-protocol.ts +2 -2
  89. package/src/utils/names.ts +1 -1
  90. package/src/utils/sse-parser.ts +0 -2
  91. package/src/worktree/branch-freshness.ts +1 -1
  92. package/src/worktree/cleanup.ts +54 -14
  93. package/src/worktree/worktree-manager.ts +19 -9
@@ -276,6 +276,14 @@ export interface TeamTaskState {
276
276
 
277
277
  /** Parsed metric key-values from worker output (CREW_METRIC lines). */
278
278
  metrics?: Record<string, number>;
279
+
280
+ /** Lifetime token usage accumulated via message_end events. Survives compaction
281
+ * (session.stats reset on compaction, but this is an independent accumulator). */
282
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
283
+
284
+ /** Steering messages queued before the task's session was ready.
285
+ * Delivered when the session initializes (mirrors pi-subagents3 pendingSteers pattern). */
286
+ pendingSteers?: string[];
279
287
  }
280
288
 
281
289
  export interface ControlReservation {
@@ -283,3 +291,35 @@ export interface ControlReservation {
283
291
  controllerId: string;
284
292
  acceptsControlEvents: boolean;
285
293
  }
294
+
295
+ /**
296
+ * A task scheduled to fire on a cron expression, interval, or one-shot.
297
+ * Persisted at `<cwd>/.crew/state/schedules/<sessionId>.json`.
298
+ * Session-scoped: survives /resume, resets on /new.
299
+ */
300
+ export interface ScheduledTask {
301
+ id: string;
302
+ name: string;
303
+ description: string;
304
+ /** Raw schedule: cron expr | "+10m" | "5m" | ISO timestamp */
305
+ schedule: string;
306
+ scheduleType: "cron" | "interval" | "once";
307
+ intervalMs?: number;
308
+ /** Workflow/step to execute when the schedule fires */
309
+ workflowName: string;
310
+ stepId?: string;
311
+ /** Resolved at create time from workflow/step config */
312
+ agentName: string;
313
+ model?: string;
314
+ enabled: boolean;
315
+ createdAt: string;
316
+ lastRun?: string;
317
+ lastStatus?: "success" | "error" | "running";
318
+ nextRun?: string;
319
+ runCount: number;
320
+ }
321
+
322
+ export interface ScheduleStoreData {
323
+ version: 1;
324
+ jobs: ScheduledTask[];
325
+ }
@@ -1,5 +1,25 @@
1
1
  import type { TeamTaskState, UsageState } from "./types.ts";
2
2
 
3
+ /**
4
+ * Lifetime usage — accumulated via message_end events, survives compaction.
5
+ * cacheRead is excluded because each turn's cacheRead is the cumulative cached
6
+ * prefix re-read on that one call — summing across turns would count it N times.
7
+ * See: https://github.com/nichekate/pi-subagents3/issues/38
8
+ */
9
+ export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
10
+
11
+ /** Sum of lifetime usage components, or 0 if undefined. */
12
+ export function getLifetimeTotal(u?: LifetimeUsage): number {
13
+ return u ? u.input + u.output + u.cacheWrite : 0;
14
+ }
15
+
16
+ /** Add a usage delta into a target accumulator (mutates target). */
17
+ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
18
+ into.input += delta.input;
19
+ into.output += delta.output;
20
+ into.cacheWrite += delta.cacheWrite;
21
+ }
22
+
3
23
  export function aggregateUsage(tasks: TeamTaskState[]): UsageState | undefined {
4
24
  const total: UsageState = {};
5
25
  let found = false;
@@ -40,7 +40,7 @@ const MAX_AGENTS_DISPLAY = 3;
40
40
  /** R1: How many turns finished agents linger before disappearing. */
41
41
  const FINISHED_LINGER_MAX_AGE = 1;
42
42
  const ERROR_LINGER_MAX_AGE = 2;
43
- const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped"]);
43
+ const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
44
44
  /** R3: Faster refresh when live agents are running. Aligned with spinner frame. */
45
45
  const LIVE_REFRESH_MS = 160;
46
46
 
@@ -303,7 +303,7 @@ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8, provi
303
303
  for (const agent of finishedAgents.slice(0, 2)) {
304
304
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
305
305
  const name = liveHandle?.agent ?? agent.agent;
306
- const icon = agent.status === "completed" ? "\u2713" : agent.status === "failed" ? "\u2717" : "\u25AA";
306
+ const icon = agent.status === "completed" ? "\u2713" : agent.status === "failed" ? "\u2717" : agent.status === "needs_attention" ? "\u26A0" : "\u25AA";
307
307
  const stats = agentStats(agent, liveHandle);
308
308
  const desc = liveHandle?.description ?? agent.role;
309
309
  lines.push(`\u2502 \u251C\u2500 ${icon} ${name} \u00B7 ${desc}${stats ? ` \u00B7 ${stats}` : ""}`);
@@ -26,7 +26,7 @@ const WORKER_PROGRESS_TYPES = new Set([
26
26
  "tool_execution_start", "tool_result", "agent_progress", "worker_status",
27
27
  ]);
28
28
  const WORKER_LIFECYCLE_TYPES = new Set([
29
- "task.started", "task.completed", "task.failed",
29
+ "task.started", "task.completed", "task.failed", "task.needs_attention",
30
30
  "task_started", "task_completed", "task_failed", "task_cancelled",
31
31
  "run.started", "run.completed", "run.cancelled", "run.failed",
32
32
  "run_started", "run_completed", "run_cancelled", "run_blocked",
@@ -307,7 +307,7 @@ async function recentOutputLinesAsync(manifest: TeamRunManifest, agents: CrewAge
307
307
  }
308
308
 
309
309
  function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
310
- const progress: RunUiProgress = { total: tasks.length, completed: 0, running: 0, failed: 0, queued: 0, waiting: 0, cancelled: 0, skipped: 0 };
310
+ const progress: RunUiProgress = { total: tasks.length, completed: 0, running: 0, failed: 0, queued: 0, waiting: 0, cancelled: 0, skipped: 0, needsAttention: 0 };
311
311
  for (const task of tasks) {
312
312
  if (task.status === "completed") progress.completed += 1;
313
313
  else if (task.status === "running") progress.running += 1;
@@ -316,6 +316,7 @@ function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
316
316
  else if (task.status === "waiting") progress.waiting = (progress.waiting ?? 0) + 1;
317
317
  else if (task.status === "cancelled") progress.cancelled = (progress.cancelled ?? 0) + 1;
318
318
  else if (task.status === "skipped") progress.skipped = (progress.skipped ?? 0) + 1;
319
+ else if (task.status === "needs_attention") progress.needsAttention = (progress.needsAttention ?? 0) + 1;
319
320
  }
320
321
  return progress;
321
322
  }
@@ -11,6 +11,7 @@ export interface RunUiProgress {
11
11
  waiting?: number;
12
12
  cancelled?: number;
13
13
  skipped?: number;
14
+ needsAttention?: number;
14
15
  }
15
16
 
16
17
  export interface RunUiUsage {
@@ -386,7 +386,7 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
386
386
  if (parsed.mode === "list") {
387
387
  try {
388
388
  const raw = execFileSync(
389
- "gh", ["pr", "view", String(parsed.number), "--repo", repo, "--json", "files", "--jq", ".files[] | \"\(.filename) +\(.additions) -\(.deletions) [\(.status)]\""],
389
+ "gh", ["pr", "view", String(parsed.number), "--repo", repo, "--json", "files", "--jq", ".files[] | \"(.filename) +(.additions) -(.deletions) [(.status)]\""],
390
390
  { cwd, encoding: "utf-8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] },
391
391
  );
392
392
  const fileLines = raw.split("\n").filter(Boolean);
@@ -408,7 +408,7 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
408
408
  if (parsed.mode === "slice" && parsed.index !== undefined) {
409
409
  try {
410
410
  const raw = execFileSync(
411
- "gh", ["pr", "view", String(parsed.number), "--repo", repo, "--json", "files", "--jq", ".files[] | \"\(.filename) +\(.additions) -\(.deletions) [\(.status)]\""],
411
+ "gh", ["pr", "view", String(parsed.number), "--repo", repo, "--json", "files", "--jq", ".files[] | \"(.filename) +(.additions) -(.deletions) [(.status)]\""],
412
412
  { cwd, encoding: "utf-8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] },
413
413
  );
414
414
  const fileLines = raw.split("\n").filter(Boolean);
@@ -23,5 +23,5 @@ export function parseConfigObject(config: unknown): { value?: Record<string, unk
23
23
  }
24
24
 
25
25
  export function hasOwn(obj: Record<string, unknown>, key: string): boolean {
26
- return Object.prototype.hasOwnProperty.call(obj, key);
26
+ return Object.hasOwn(obj, key);
27
27
  }
@@ -127,8 +127,6 @@ export async function* readSseJson<T>(
127
127
  const parsed: T = JSON.parse(evt.data) as T;
128
128
  yield parsed;
129
129
  } catch {
130
- // Skip non-JSON SSE data events (e.g., ping, error messages, sentinels)
131
- continue;
132
130
  }
133
131
  }
134
132
  }
@@ -15,7 +15,7 @@ export interface BranchFreshness {
15
15
  }
16
16
 
17
17
  function git(cwd: string, args: string[]): string {
18
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
18
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
19
19
  }
20
20
 
21
21
  function count(cwd: string, range: string): number {
@@ -10,10 +10,12 @@ export interface WorktreeCleanupResult {
10
10
  removed: string[];
11
11
  preserved: Array<{ path: string; reason: string }>;
12
12
  artifactPaths: string[];
13
+ /** Branch names created from dirty worktrees that were committed. */
14
+ committedBranches: string[];
13
15
  }
14
16
 
15
17
  function git(cwd: string, args: string[]): string {
16
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" } }).trim();
18
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
17
19
  }
18
20
 
19
21
  function isDirty(worktreePath: string): boolean {
@@ -35,23 +37,61 @@ function captureDiff(worktreePath: string): string {
35
37
 
36
38
  export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean; signal?: AbortSignal } = {}): WorktreeCleanupResult {
37
39
  const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, manifest.runId);
38
- const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [] };
40
+ const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [], committedBranches: [] };
39
41
  if (!fs.existsSync(worktreeRoot)) return result;
40
42
 
41
- for (const entry of fs.readdirSync(worktreeRoot)) {
43
+ // M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
44
+ const withFileTypes = fs.readdirSync(worktreeRoot, { withFileTypes: true });
45
+ for (const entry of withFileTypes) {
42
46
  if (options.signal?.aborted) break;
43
- const worktreePath = path.join(worktreeRoot, entry);
44
- if (!fs.statSync(worktreePath).isDirectory()) continue;
47
+ if (!entry.isDirectory()) continue;
48
+ const worktreePath = path.join(worktreeRoot, entry.name);
49
+ try {
50
+ const stat = fs.statSync(worktreePath);
51
+ if (!stat.isDirectory()) continue;
52
+ } catch {
53
+ // Entry deleted between readdir and stat — skip safely.
54
+ continue;
55
+ }
45
56
  const dirty = isDirty(worktreePath);
46
- if (dirty && !options.force) {
47
- const artifact = writeArtifact(manifest.artifactsRoot, {
48
- kind: "diff",
49
- relativePath: `cleanup/${entry}.diff`,
50
- content: captureDiff(worktreePath),
51
- producer: "worktree-cleanup",
52
- });
53
- result.artifactPaths.push(artifact.path);
54
- result.preserved.push({ path: worktreePath, reason: "dirty worktree preserved" });
57
+ const branchName = `pi-crew/${manifest.runId}/${entry.name}`;
58
+ if (dirty) {
59
+ // Commit changes to a branch instead of just preserving the worktree
60
+ try {
61
+ execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
62
+ const safeDesc = entry.name.slice(0, 200);
63
+ execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
64
+ // Create branch in the main repo pointing to this worktree's HEAD
65
+ try {
66
+ execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
67
+ } catch {
68
+ // Branch already exists — use timestamp suffix
69
+ const tsBranch = `${branchName}-${Date.now()}`;
70
+ execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
71
+ }
72
+ result.committedBranches.push(branchName);
73
+ // Remove the worktree (branch persists)
74
+ const removeArgs = ["worktree", "remove", "--force", worktreePath];
75
+ git(manifest.cwd, removeArgs);
76
+ result.removed.push(worktreePath);
77
+ const artifact = writeArtifact(manifest.artifactsRoot, {
78
+ kind: "metadata",
79
+ relativePath: `metadata/worktree-branch-${entry.name}.json`,
80
+ content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}` }, null, 2),
81
+ producer: "worktree-cleanup",
82
+ });
83
+ result.artifactPaths.push(artifact.path);
84
+ } catch (error) {
85
+ // Fallback to preserving dirty worktree
86
+ const artifact = writeArtifact(manifest.artifactsRoot, {
87
+ kind: "diff",
88
+ relativePath: `cleanup/${entry}.diff`,
89
+ content: captureDiff(worktreePath),
90
+ producer: "worktree-cleanup",
91
+ });
92
+ result.artifactPaths.push(artifact.path);
93
+ result.preserved.push({ path: worktreePath, reason: `dirty worktree preserved (commit failed: ${error instanceof Error ? error.message : String(error)})` });
94
+ }
55
95
  continue;
56
96
  }
57
97
  const args = ["worktree", "remove"];
@@ -25,7 +25,7 @@ export interface WorktreeDiffStat {
25
25
  }
26
26
 
27
27
  function git(cwd: string, args: string[]): string {
28
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" } }).trim();
28
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
29
29
  }
30
30
 
31
31
  function sanitizeBranchPart(value: string): string {
@@ -50,10 +50,13 @@ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boole
50
50
  try { sourceStat = fs.statSync(source); } catch { return false; }
51
51
  if (!sourceStat.isDirectory()) return false;
52
52
  if (fs.existsSync(target)) return false;
53
+ // M5 fix: log symlink failure reason, especially on Windows non-admin.
53
54
  try {
54
55
  fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir");
55
56
  return true;
56
- } catch {
57
+ } catch (error) {
58
+ const isWindows = process.platform === "win32";
59
+ logInternalError("worktree.symlink-fail", error, isWindows ? "Windows non-admin: SeCreateSymbolicLinkPrivilege needed for node_modules symlink" : String(error));
57
60
  return false;
58
61
  }
59
62
  }
@@ -86,12 +89,19 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
86
89
  const trimmed = result.stdout.trim();
87
90
  if (!trimmed) return [];
88
91
  try {
89
- // Extract JSON from last line — hooks may output debug logging before JSON
90
- const lines = trimmed.split(/\r?\n/);
91
- const lastLine = lines[lines.length - 1] ?? trimmed;
92
- const parsed = JSON.parse(lastLine) as { syntheticPaths?: unknown };
93
- if (!Array.isArray(parsed.syntheticPaths)) return [];
94
- return [...new Set(parsed.syntheticPaths.filter((entry): entry is string => typeof entry === "string").map((entry) => normalizeSyntheticPath(worktreePath, entry)))];
92
+ // Extract JSON — hooks may output debug logging before JSON.
93
+ // M4 fix: try full trimmed (multi-line JSON object) before falling back to last line.
94
+ const lines = trimmed.split(/\r?\n/);
95
+ let parsed: { syntheticPaths?: unknown } | null = null;
96
+ try {
97
+ parsed = JSON.parse(trimmed) as { syntheticPaths?: unknown };
98
+ } catch { /* fall through — try last line */ }
99
+ if (!parsed && lines.length > 0) {
100
+ const lastLine = lines[lines.length - 1];
101
+ try { parsed = JSON.parse(lastLine) as { syntheticPaths?: unknown }; } catch { /* give up */ }
102
+ }
103
+ if (!parsed || !Array.isArray(parsed.syntheticPaths)) return [];
104
+ return [...new Set(parsed.syntheticPaths.filter((entry): entry is string => typeof entry === "string").map((entry) => normalizeSyntheticPath(worktreePath, entry)))];
95
105
  } catch (error) {
96
106
  logInternalError("worktree.setupHook.parse", error, `lastLine=${(trimmed.split(/\r?\n/).pop() ?? "").slice(0, 200)}`);
97
107
  return [];
@@ -105,7 +115,7 @@ function branchExists(repoRoot: string, branch: string): { local: boolean; remot
105
115
  // Check remote-tracking branch
106
116
  try {
107
117
  const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`],
108
- { cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
118
+ { cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
109
119
  return { local: false, remoteOnly: out.length > 0 };
110
120
  } catch { return { local: false, remoteOnly: false }; }
111
121
  }