pi-crew 0.10.2 → 0.10.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.
Files changed (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -177,6 +177,14 @@ class CrewWidgetComponent implements WidgetComponent {
177
177
  private cachedWidth = 0;
178
178
  private cachedLines: string[] = [];
179
179
  private cachedBaseLines: string[] = [];
180
+ /**
181
+ * PERF (2026-08-24): truncate at build time, re-truncate only on width
182
+ * change — cache hits returned a fresh array + ANSI-aware width measurement
183
+ * per line on every frame. `cachedLines` stays raw (unclipped) so a width
184
+ * change can re-clip without a full rebuild; -1 marks "not truncated yet".
185
+ */
186
+ private truncatedLines: string[] = [];
187
+ private lastTruncateWidth = -1;
180
188
  private cachedTheme: CrewTheme;
181
189
  private readonly tui: unknown;
182
190
  private readonly unsubscribeTheme: () => void;
@@ -274,10 +282,23 @@ class CrewWidgetComponent implements WidgetComponent {
274
282
  this.cacheSignature = "";
275
283
  this.cachedBaseLines = [];
276
284
  this.cachedLines = [];
285
+ this.truncatedLines = [];
286
+ this.lastTruncateWidth = -1;
277
287
  this.cachedBuildSignature = "";
278
288
  this.cachedBuildSignatureAt = 0;
279
289
  }
280
290
 
291
+ /**
292
+ * PERF (2026-08-24): clip `cachedLines` to `width` only when the width
293
+ * changed since the last clip — the raw lines are stored unclipped so a
294
+ * resize re-clips them without rebuilding the whole cache.
295
+ */
296
+ private ensureTruncated(width: number): void {
297
+ if (width === this.lastTruncateWidth) return;
298
+ this.truncatedLines = this.cachedLines.map((line) => truncate(line, width));
299
+ this.lastTruncateWidth = width;
300
+ }
301
+
281
302
  /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
282
303
  requestRepaint(): void {
283
304
  requestRenderTarget(this.tui);
@@ -326,12 +347,14 @@ class CrewWidgetComponent implements WidgetComponent {
326
347
  this.cachedWidth = width;
327
348
  this.cachedTheme = this.theme;
328
349
  this.cacheSignature = signature;
350
+ this.lastTruncateWidth = -1;
329
351
  }
330
352
  if (runs.length === 0) {
331
353
  this.invalidate();
332
354
  return [];
333
355
  }
334
- return this.cachedLines.map((line) => truncate(line, width));
356
+ this.ensureTruncated(width);
357
+ return this.truncatedLines;
335
358
  }
336
359
 
337
360
  // Panel cursor/pane state is part of the rendered output, so it belongs in
@@ -361,6 +384,7 @@ class CrewWidgetComponent implements WidgetComponent {
361
384
  this.cachedWidth = width;
362
385
  this.cachedTheme = this.theme;
363
386
  this.cacheSignature = signatureWithPanel;
387
+ this.lastTruncateWidth = -1;
364
388
  }
365
389
 
366
390
  if (runs.length === 0) {
@@ -373,11 +397,13 @@ class CrewWidgetComponent implements WidgetComponent {
373
397
  return [];
374
398
  }
375
399
 
400
+ this.ensureTruncated(width);
376
401
  if (!compactDock) {
377
402
  const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
378
403
  this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
404
+ this.truncatedLines[0] = truncate(this.cachedLines[0], width);
379
405
  }
380
- return this.cachedLines.map((line) => truncate(line, width));
406
+ return this.truncatedLines;
381
407
  }
382
408
  }
383
409
 
@@ -441,10 +467,6 @@ export function updateCrewWidget(
441
467
  // the only place already holding the manifest/snapshot caches — so the row
442
468
  // projection is registered here instead of re-reading state on every keypress.
443
469
  setPanelRowsProvider(() => panelRowsFromRuns(activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId)));
444
- const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth(), {
445
- rowStyle,
446
- ...panelDisplayState(),
447
- });
448
470
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
449
471
  // `bottom` is not a pi widget slot: the dock then renders inside the
450
472
  // crew-vibes footer (dock-footer registry). pi's slot calls always use a
@@ -453,7 +475,9 @@ export function updateCrewWidget(
453
475
  const dockInFooter = bottomMode && isFooterDockSinkActive();
454
476
  const piPlacement: "aboveEditor" | "belowEditor" = bottomMode ? "belowEditor" : placement;
455
477
 
456
- ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
478
+ // PERF (2026-08-24): the persistent CrewWidgetComponent renders itself from
479
+ // state.frame; the built line set was only ever used for its .length.
480
+ ctx.ui.setStatus(STATUS_KEY, runs.length > 0 ? statusSummary(runs) : undefined);
457
481
 
458
482
  const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
459
483
  if (shouldClearLegacy) {
@@ -461,7 +485,7 @@ export function updateCrewWidget(
461
485
  state.legacyCleared = true;
462
486
  }
463
487
 
464
- if (!lines.length) {
488
+ if (runs.length === 0) {
465
489
  if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
466
490
  setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
467
491
  setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
@@ -4,6 +4,12 @@ export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
4
4
 
5
5
  const WIDTH_CACHE_LIMIT = 256;
6
6
  const widthCache = new Map<string, number>();
7
+ // PERF (2026-08-24): truncateToWidth/wrapHard measure ONE codepoint at a time;
8
+ // those 1-2 char keys thrashed the 256-entry LRU, evicting every long-string
9
+ // entry that could actually hit. Short keys get their own cache (bounded
10
+ // alphabet — hit rate ~100%) so the LRU serves only whole lines/segments.
11
+ const SHORT_WIDTH_CACHE_LIMIT = 1024;
12
+ const shortWidthCache = new Map<string, number>();
7
13
 
8
14
  // NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
9
15
  // pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
@@ -20,6 +26,17 @@ export function visibleWidth(value: string): number {
20
26
  // LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
21
27
  // hot render path).
22
28
  if (value.length > 4096) return tuiVisibleWidth(value);
29
+ if (value.length <= 2) {
30
+ const s = shortWidthCache.get(value);
31
+ if (s !== undefined) return s;
32
+ const w = tuiVisibleWidth(value);
33
+ if (shortWidthCache.size >= SHORT_WIDTH_CACHE_LIMIT) {
34
+ const first = shortWidthCache.keys().next().value;
35
+ if (first !== undefined) shortWidthCache.delete(first);
36
+ }
37
+ shortWidthCache.set(value, w);
38
+ return w;
39
+ }
23
40
  const cached = widthCache.get(value);
24
41
  if (cached !== undefined) return cached;
25
42
  const length = tuiVisibleWidth(value);
@@ -33,6 +50,7 @@ export function visibleWidth(value: string): number {
33
50
 
34
51
  export function __test__clearVisibleWidthCache(): void {
35
52
  widthCache.clear();
53
+ shortWidthCache.clear();
36
54
  }
37
55
 
38
56
  export function __test__visibleWidthCacheSize(): number {
@@ -205,3 +223,28 @@ export function truncateToVisualLines(text: string, maxVisualLines: number, widt
205
223
  const truncated = visualLines.slice(-limit);
206
224
  return { visualLines: truncated, skippedCount: visualLines.length - limit };
207
225
  }
226
+
227
+ /**
228
+ * PERF (2026-08-24): tail-windowed twin of truncateToVisualLines. Wraps lines
229
+ * from the END backwards and stops as soon as `maxVisualLines` visual lines
230
+ * exist — O(visible window) instead of O(whole transcript). `skippedCount`
231
+ * counts skipped SOURCE lines (a lower bound on skipped visual lines; the
232
+ * caller's total display therefore reads "≥ N lines" when scrolled to bottom).
233
+ */
234
+ export function truncateToVisualLinesTail(text: string, maxVisualLines: number, width: number, paddingX = 0): VisualTruncateResult {
235
+ if (!text) return { visualLines: [], skippedCount: 0 };
236
+ const effectiveWidth = Math.max(1, width - paddingX * 2);
237
+ const limit = Math.max(1, maxVisualLines);
238
+ const sourceLines = text.split("\n");
239
+ const wrapped: string[][] = [];
240
+ let collected = 0;
241
+ let index = sourceLines.length - 1;
242
+ while (index >= 0 && collected < limit) {
243
+ const w = wrapHard(pad(sourceLines[index]!, Math.max(0, effectiveWidth)).trimEnd(), effectiveWidth);
244
+ wrapped.push(w);
245
+ collected += w.length;
246
+ index -= 1;
247
+ }
248
+ const visualLines = wrapped.reverse().flat().slice(-limit);
249
+ return { visualLines, skippedCount: index + 1 };
250
+ }
@@ -129,19 +129,59 @@ function sanitizeBranchPart(value: string): string {
129
129
  );
130
130
  }
131
131
 
132
+ // PERF (2026-08-24): the sync path (prepareTaskWorkspace — per task!) ran
133
+ // findGitRoot (rev-parse spawn) + assertCleanLeader (status --porcelain spawn)
134
+ // on every call, and the reuse path ran assertCleanLeader twice. Mirror the
135
+ // async caches below (_gitRootCache/_cleanLeaderCache): same repoRoot → same
136
+ // answer within a run.
137
+ //
138
+ // Mirrored invalidation semantics (from the async caches below):
139
+ // - gitRoot: plain Map, NO TTL — the async _gitRootCache is process-lifetime with
140
+ // a FIFO cap; a cwd's repo root cannot change without moving .git itself.
141
+ // - cleanLeader: ONLY clean verdicts are cached — the async _cleanLeaderCache is
142
+ // a Set of verified-clean repoRoots and a dirty verdict throws BEFORE caching,
143
+ // so every call while dirty re-probes (mirrored here). The async cache has NO
144
+ // TTL (a clean verdict is trusted for the whole process), so the 30s TTL below
145
+ // is strictly MORE conservative than the async path: a leader dirtied after a
146
+ // cached clean verdict can slip through for at most 30s here vs. forever on
147
+ // the async path. The reuse-path re-check in prepareTaskWorkspace deletes the
148
+ // entry first, mirroring `_cleanLeaderCache.delete(repoRoot)` in the async variant.
149
+ const SYNC_WT_CACHE_TTL_MS = 30_000;
150
+ const MAX_SYNC_GIT_ROOT_CACHE = 256;
151
+ const MAX_SYNC_CLEAN_LEADER_CACHE = 256;
152
+ const syncGitRootCache = new Map<string, string>();
153
+ const syncCleanLeaderCache = new Map<string, { clean: boolean; expiresAt: number }>();
154
+
132
155
  export function findGitRoot(cwd: string): string {
133
- return git(cwd, ["rev-parse", "--show-toplevel"]);
156
+ const cached = syncGitRootCache.get(cwd);
157
+ if (cached) return cached;
158
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
159
+ syncGitRootCache.set(cwd, root);
160
+ if (syncGitRootCache.size > MAX_SYNC_GIT_ROOT_CACHE) {
161
+ const oldest = syncGitRootCache.keys().next().value;
162
+ if (oldest !== undefined) syncGitRootCache.delete(oldest);
163
+ }
164
+ return root;
134
165
  }
135
166
 
136
167
  export function assertCleanLeader(repoRoot: string): void {
168
+ const cached = syncCleanLeaderCache.get(repoRoot);
169
+ if (cached?.clean && Date.now() < cached.expiresAt) return;
137
170
  // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
138
171
  // (and any other untracked files the user hasn't staged) doesn't block worktree mode.
139
172
  // The worktree contract is "no tracked changes" — untracked files are safe since
140
173
  // they're either in .gitignore or the user can decide later.
141
174
  const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
142
175
  if (status.trim()) {
176
+ // Dirty verdicts are never cached (async mirror): the user may commit/stash
177
+ // seconds later, so the next call must re-probe.
143
178
  throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
144
179
  }
180
+ syncCleanLeaderCache.set(repoRoot, { clean: true, expiresAt: Date.now() + SYNC_WT_CACHE_TTL_MS });
181
+ if (syncCleanLeaderCache.size > MAX_SYNC_CLEAN_LEADER_CACHE) {
182
+ const oldest = syncCleanLeaderCache.keys().next().value;
183
+ if (oldest !== undefined) syncCleanLeaderCache.delete(oldest);
184
+ }
145
185
  }
146
186
 
147
187
  // --- Async versions ---
@@ -453,6 +493,25 @@ function pruneStaleWorktrees(repoRoot: string): void {
453
493
  }
454
494
  }
455
495
 
496
+ // PERF (2026-08-24): worktree prune is a repo-level write; running it per
497
+ // task in a 50-task run is 50 prunes. At most once per repoRoot per minute.
498
+ // (The async variant memoizes at-most-once per process — see P1-11 above —
499
+ // so a 60s window prunes strictly more often than the async path already does.)
500
+ // R5-L2 mirror: FIFO-cap the key set so the map cannot grow unbounded.
501
+ const PRUNE_MIN_INTERVAL_MS = 60_000;
502
+ const MAX_PRUNE_THROTTLE_KEYS = 128;
503
+ const lastPruneAt = new Map<string, number>();
504
+ function pruneStaleWorktreesThrottled(repoRoot: string): void {
505
+ const last = lastPruneAt.get(repoRoot) ?? 0;
506
+ if (Date.now() - last < PRUNE_MIN_INTERVAL_MS) return;
507
+ lastPruneAt.set(repoRoot, Date.now());
508
+ if (lastPruneAt.size > MAX_PRUNE_THROTTLE_KEYS) {
509
+ const oldest = lastPruneAt.keys().next().value;
510
+ if (oldest !== undefined) lastPruneAt.delete(oldest);
511
+ }
512
+ pruneStaleWorktrees(repoRoot); // original body — best-effort, failures swallowed
513
+ }
514
+
456
515
  async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
457
516
  let local = false;
458
517
  try {
@@ -818,11 +877,13 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
818
877
  if (mergedReused.length > 0) {
819
878
  overlaySeedPaths(repoRoot, worktreePath, mergedReused);
820
879
  }
821
- // Re-validate leader is still clean before reusing — leader state may have changed since first preparation
880
+ // Re-validate leader is still clean before reusing — leader state may have changed since first preparation.
881
+ // Mirrors prepareTaskWorkspaceAsync: delete the cached verdict so this re-check probes live state.
882
+ syncCleanLeaderCache.delete(repoRoot);
822
883
  assertCleanLeader(repoRoot);
823
884
  return { cwd: worktreePath, worktreePath, branch, reused: true };
824
885
  }
825
- pruneStaleWorktrees(repoRoot);
886
+ pruneStaleWorktreesThrottled(repoRoot);
826
887
  const exists = branchExists(repoRoot, branch);
827
888
  let worktreeCreated = false;
828
889
  try {
@@ -1141,7 +1202,7 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
1141
1202
  const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`;
1142
1203
  const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`);
1143
1204
  const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`;
1144
- pruneStaleWorktrees(repoRoot);
1205
+ pruneStaleWorktreesThrottled(repoRoot);
1145
1206
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
1146
1207
  const nodeModulesLinked =
1147
1208
  loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;