pi-crew 0.9.21 → 0.9.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -137,7 +137,7 @@ function isAllowedRpcRunParams(params: TeamToolParamsValue): {
137
137
  return { ok: true };
138
138
  }
139
139
 
140
- function on(events: EventBusLike, channel: string, handler: (raw: unknown) => void): () => void {
140
+ function on(events: EventBusLike, channel: string, handler: (raw: any) => unknown): () => void {
141
141
  const unsub = events.on(channel, handler);
142
142
  return typeof unsub === "function" ? unsub : () => {};
143
143
  }
@@ -1,6 +1,6 @@
1
1
  import type { AgentConfig } from "../agents/agent-config.ts";
2
2
  import { errors } from "../errors.ts";
3
- import { appendEventAsync } from "../state/event-log.ts";
3
+ import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
4
4
  import type { TeamTaskState } from "../state/types.ts";
5
5
  import type { TeamConfig } from "../teams/team-config.ts";
6
6
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
@@ -107,142 +107,151 @@ export class PipelineRunner {
107
107
  let previousResults: unknown[] = [];
108
108
  const startTime = Date.now();
109
109
 
110
- await appendEventAsync(eventsPath, {
111
- type: "pipeline:started",
112
- runId,
113
- message: `Pipeline '${workflow.name}' started`,
114
- data: { stages: workflow.stages.map((s) => s.name) },
115
- });
116
-
117
- for (let i = 0; i < workflow.stages.length; i++) {
118
- const stage = workflow.stages[i];
119
- const stageStartTime = Date.now();
120
-
121
- // Determine stop behavior for this stage
122
- const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
123
-
110
+ try {
124
111
  await appendEventAsync(eventsPath, {
125
- type: "pipeline:stage_started",
112
+ type: "pipeline:started",
126
113
  runId,
127
- message: `Stage '${stage.name}' started`,
128
- data: { stageIndex: i, stageName: stage.name },
114
+ message: `Pipeline '${workflow.name}' started`,
115
+ data: { stages: workflow.stages.map((s) => s.name) },
129
116
  });
130
117
 
131
- try {
132
- // Build stage context
133
- const stageContext: PipelineContext = {
134
- stageIndex: i,
135
- stageName: stage.name,
136
- previousResults,
137
- totalStages: workflow.stages.length,
138
- runId,
139
- };
140
-
141
- // Resolve inputs
142
- const inputs = this.resolveInputs(stage.inputs, previousResults, context);
143
-
144
- // Execute stage (handle fan-out if enabled)
145
- const results = await this.executeStageInternal(stage, inputs, stageContext, executeStage);
118
+ for (let i = 0; i < workflow.stages.length; i++) {
119
+ const stage = workflow.stages[i];
120
+ const stageStartTime = Date.now();
146
121
 
147
- const duration = Date.now() - stageStartTime;
148
- stages.push({
149
- name: stage.name,
150
- status: "completed",
151
- results,
152
- duration,
153
- fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
154
- });
155
-
156
- previousResults = results;
122
+ // Determine stop behavior for this stage
123
+ const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
157
124
 
158
125
  await appendEventAsync(eventsPath, {
159
- type: "pipeline:stage_completed",
126
+ type: "pipeline:stage_started",
160
127
  runId,
161
- message: `Stage '${stage.name}' completed`,
162
- data: {
163
- stageIndex: i,
164
- stageName: stage.name,
165
- duration,
166
- resultCount: results.length,
167
- },
128
+ message: `Stage '${stage.name}' started`,
129
+ data: { stageIndex: i, stageName: stage.name },
168
130
  });
169
- } catch (error) {
170
- const duration = Date.now() - stageStartTime;
171
- const errorMessage = error instanceof Error ? error.message : String(error);
172
131
 
173
- if (effectiveStopOnError) {
174
- stages.push({
175
- name: stage.name,
176
- status: "failed",
177
- results: [],
178
- error: errorMessage,
179
- duration,
180
- });
181
-
182
- await appendEventAsync(eventsPath, {
183
- type: "pipeline:stage_failed",
132
+ try {
133
+ // Build stage context
134
+ const stageContext: PipelineContext = {
135
+ stageIndex: i,
136
+ stageName: stage.name,
137
+ previousResults,
138
+ totalStages: workflow.stages.length,
184
139
  runId,
185
- message: `Stage '${stage.name}' failed: ${errorMessage}`,
186
- data: {
187
- stageIndex: i,
188
- stageName: stage.name,
189
- duration,
190
- error: errorMessage,
191
- },
192
- });
140
+ };
193
141
 
194
- await appendEventAsync(eventsPath, {
195
- type: "pipeline:failed",
196
- runId,
197
- message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
198
- data: { failedStage: stage.name, error: errorMessage },
199
- });
142
+ // Resolve inputs
143
+ const inputs = this.resolveInputs(stage.inputs, previousResults, context);
200
144
 
201
- return {
202
- stages,
203
- totalDuration: Date.now() - startTime,
204
- finalResults: previousResults,
205
- status: "failed",
206
- };
207
- } else {
145
+ // Execute stage (handle fan-out if enabled)
146
+ const results = await this.executeStageInternal(stage, inputs, stageContext, executeStage);
147
+
148
+ const duration = Date.now() - stageStartTime;
208
149
  stages.push({
209
150
  name: stage.name,
210
- status: "failed",
211
- results: [],
212
- error: errorMessage,
151
+ status: "completed",
152
+ results,
213
153
  duration,
154
+ fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
214
155
  });
215
156
 
157
+ previousResults = results;
158
+
216
159
  await appendEventAsync(eventsPath, {
217
- type: "pipeline:stage_skipped",
160
+ type: "pipeline:stage_completed",
218
161
  runId,
219
- message: `Stage '${stage.name}' skipped due to error`,
162
+ message: `Stage '${stage.name}' completed`,
220
163
  data: {
221
164
  stageIndex: i,
222
165
  stageName: stage.name,
223
166
  duration,
224
- error: errorMessage,
167
+ resultCount: results.length,
225
168
  },
226
169
  });
170
+ } catch (error) {
171
+ const duration = Date.now() - stageStartTime;
172
+ const errorMessage = error instanceof Error ? error.message : String(error);
173
+
174
+ if (effectiveStopOnError) {
175
+ stages.push({
176
+ name: stage.name,
177
+ status: "failed",
178
+ results: [],
179
+ error: errorMessage,
180
+ duration,
181
+ });
182
+
183
+ await appendEventAsync(eventsPath, {
184
+ type: "pipeline:stage_failed",
185
+ runId,
186
+ message: `Stage '${stage.name}' failed: ${errorMessage}`,
187
+ data: {
188
+ stageIndex: i,
189
+ stageName: stage.name,
190
+ duration,
191
+ error: errorMessage,
192
+ },
193
+ });
194
+
195
+ await appendEventAsync(eventsPath, {
196
+ type: "pipeline:failed",
197
+ runId,
198
+ message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
199
+ data: { failedStage: stage.name, error: errorMessage },
200
+ });
201
+
202
+ return {
203
+ stages,
204
+ totalDuration: Date.now() - startTime,
205
+ finalResults: previousResults,
206
+ status: "failed",
207
+ };
208
+ } else {
209
+ stages.push({
210
+ name: stage.name,
211
+ status: "failed",
212
+ results: [],
213
+ error: errorMessage,
214
+ duration,
215
+ });
216
+
217
+ await appendEventAsync(eventsPath, {
218
+ type: "pipeline:stage_skipped",
219
+ runId,
220
+ message: `Stage '${stage.name}' skipped due to error`,
221
+ data: {
222
+ stageIndex: i,
223
+ stageName: stage.name,
224
+ duration,
225
+ error: errorMessage,
226
+ },
227
+ });
228
+ }
227
229
  }
228
230
  }
229
- }
230
231
 
231
- await appendEventAsync(eventsPath, {
232
- type: "pipeline:completed",
233
- runId,
234
- message: `Pipeline '${workflow.name}' completed`,
235
- data: {
236
- stages: stages.map((s) => ({ name: s.name, status: s.status })),
237
- },
238
- });
232
+ await appendEventAsync(eventsPath, {
233
+ type: "pipeline:completed",
234
+ runId,
235
+ message: `Pipeline '${workflow.name}' completed`,
236
+ data: {
237
+ stages: stages.map((s) => ({ name: s.name, status: s.status })),
238
+ },
239
+ });
239
240
 
240
- return {
241
- stages,
242
- totalDuration: Date.now() - startTime,
243
- finalResults: previousResults,
244
- status: stages.some((s) => s.status === "failed") ? "partial" : "completed",
245
- };
241
+ return {
242
+ stages,
243
+ totalDuration: Date.now() - startTime,
244
+ finalResults: previousResults,
245
+ status: stages.some((s) => s.status === "failed") ? "partial" : "completed",
246
+ };
247
+ } finally {
248
+ // FIX (P0 follow-up): Drain the event-log buffer before returning so
249
+ // --test-force-exit does not detect "Promise resolution is still
250
+ // pending". The buffered flush uses a 20ms ref'd timer that fires
251
+ // asynchronously; without this explicit drain, the test runner sees
252
+ // pending appendEventAsync promises and cancels the test file.
253
+ await flushEventLogBuffer(eventsPath);
254
+ }
246
255
  }
247
256
 
248
257
  /**
@@ -90,7 +90,7 @@ export interface PruneResult {
90
90
  // Token estimation (rough char/4 heuristic, matching gajae-code)
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
- import { countTokens } from "../utils/token-counter";
93
+ import { countTokens } from "../utils/token-counter.ts";
94
94
 
95
95
  function estimateTokens(text: string): number {
96
96
  return countTokens(text);
@@ -412,15 +412,11 @@ export function resetEventLogMode(): void {
412
412
  export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
413
413
  // Non-terminal events: route through buffer for coalesced writes (20ms default).
414
414
  // This eliminates per-event fsync + persistSequence overhead for high-frequency events.
415
+ // The buffer timer is kept alive (ref'd) so standalone contexts (tests, short-lived
416
+ // scripts) get their events flushed before the loop drains — no per-event keepAlive
417
+ // intervals needed (which previously starved the event loop at high fan-out).
415
418
  if (!TERMINAL_EVENT_TYPES.has(event.type)) {
416
- const result = appendEventBuffered(eventsPath, event);
417
- // FIX: appendEventBuffered uses unref()'d timers. Keep the event loop alive
418
- // so the promise resolves in standalone/test contexts where nothing else
419
- // keeps the loop running. Without this, the event loop drains before the
420
- // timer fires and the promise never resolves.
421
- const keepAlive = setInterval(() => {}, 200);
422
- void result.finally(() => clearInterval(keepAlive));
423
- return result;
419
+ return appendEventBuffered(eventsPath, event);
424
420
  }
425
421
  // Terminal events: direct async path for immediate durability guarantee
426
422
  const queueKey = eventsPath;
@@ -618,6 +614,131 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
618
614
  * `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
619
615
  * write a whole batch of pending events under a single lock acquire.
620
616
  */
617
+ /**
618
+ * Batch variant used by the buffered flush path. Computes metadata for each
619
+ * event, writes the whole batch in a single appendFileSync + fsync, persists
620
+ * the sequence sidecar once with the last seq, and updates the sequence cache
621
+ * once. Resolves each item with its finalized event (carrying the assigned
622
+ * seq). This collapses N fsyncs into 1 for the buffered write path, which is
623
+ * the entire point of buffering — the previous per-event fsync made buffer
624
+ * coalescing useless and added ~30ms/event on tmpfs.
625
+ */
626
+ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
627
+ if (queue.length === 0) return;
628
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
629
+
630
+ // Pre-flight size check (mirrors appendEventInsideLock). We do it once for
631
+ // the batch instead of once per event.
632
+ try {
633
+ if (fs.existsSync(eventsPath)) {
634
+ const stat = fs.statSync(eventsPath);
635
+ if (stat.size > MAX_EVENTS_BYTES) {
636
+ try {
637
+ const prepared = prepareCompaction(eventsPath);
638
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
639
+ } catch (error) {
640
+ logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
641
+ }
642
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
643
+ rotateEventLogUnlocked(eventsPath);
644
+ }
645
+ }
646
+ }
647
+ } catch (error) {
648
+ logInternalError("event-log.batch-size-check", error, `eventsPath=${eventsPath}`);
649
+ }
650
+
651
+ // Phase 1: compute metadata + JSON lines for every event in the batch.
652
+ // Initialize nextSeq ONCE from nextSequence (or the first event's baseMetadata.seq),
653
+ // then increment locally for each subsequent event in the batch. Calling
654
+ // nextSequence() per-event would re-read file stat/sidecar with no writes
655
+ // in between — every call would see the same file state and return the same
656
+ // seq, breaking the "unique monotonic seq" contract. The cache update +
657
+ // persistSequence at the end refreshes the sidecar to the last assigned seq.
658
+ const startingSeq = queue[0]?.event.metadata?.seq ?? nextSequence(eventsPath);
659
+ let nextSeq = startingSeq;
660
+ const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
661
+ let lastSeq = 0;
662
+ for (const item of queue) {
663
+ const baseMetadata = item.event.metadata;
664
+ const seq = baseMetadata?.seq ?? nextSeq++;
665
+ let metadata: TeamEventMetadata = {
666
+ seq,
667
+ provenance: baseMetadata?.provenance ?? "team_runner",
668
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
669
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
670
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
671
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
672
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
673
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
674
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
675
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
676
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
677
+ };
678
+ const fullEvent: TeamEvent = {
679
+ time: new Date().toISOString(),
680
+ ...item.event,
681
+ metadata,
682
+ };
683
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
684
+ metadata = {
685
+ ...metadata,
686
+ fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
687
+ };
688
+ fullEvent.metadata = metadata;
689
+ }
690
+ finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
691
+ lastSeq = seq;
692
+ }
693
+
694
+ // Phase 2: single appendFileSync + single fsync + single persistSequence.
695
+ // Before this fix, each event in the batch triggered its own fsync, which
696
+ // was the dominant cost on tmpfs and CI runners.
697
+ try {
698
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
699
+ logInternalError(
700
+ "event-log.size-limit",
701
+ new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
702
+ `eventsPath=${eventsPath}`,
703
+ );
704
+ // Reject the batch — caller will surface the error per item.
705
+ for (const { item } of finalized) item.reject(new Error("event log size limit exceeded"));
706
+ return;
707
+ }
708
+ } catch (error) {
709
+ logInternalError("event-log.batch-size-check-post", error, `eventsPath=${eventsPath}`);
710
+ }
711
+
712
+ fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
713
+ const fd = fs.openSync(eventsPath, "r+");
714
+ try {
715
+ fs.fsyncSync(fd);
716
+ } catch {
717
+ // EPERM on Windows CI: best-effort flush
718
+ } finally {
719
+ fs.closeSync(fd);
720
+ }
721
+ persistSequence(eventsPath, lastSeq);
722
+
723
+ // Phase 3: cache update + resolve all promises.
724
+ try {
725
+ const stat = fs.statSync(eventsPath);
726
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
727
+ evictOldestSequenceCacheEntries();
728
+ }
729
+ sequenceCache.set(eventsPath, {
730
+ size: stat.size,
731
+ mtimeMs: stat.mtimeMs,
732
+ seq: lastSeq,
733
+ lastAccessMs: Date.now(),
734
+ });
735
+ } catch (error) {
736
+ logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
737
+ }
738
+
739
+ for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
740
+ }
741
+
621
742
  function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
622
743
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
623
744
  const baseMetadata = event.metadata;
@@ -784,8 +905,15 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
784
905
  queue.push({ event, resolve, reject });
785
906
  bufferedQueues.set(eventsPath, queue);
786
907
  if (!bufferedTimers.has(eventsPath)) {
787
- const timer = setTimeout(() => flushOneEventLogBuffer(eventsPath), bufferMs);
788
- timer.unref();
908
+ // Wrap flush in async IIFE so the returned Promise is awaited (avoids
909
+ // "floating promise" warnings under --test-force-exit and prevents
910
+ // the timer from being treated as done before the flush actually
911
+ // completes its async work).
912
+ const timer = setTimeout(() => {
913
+ flushOneEventLogBuffer(eventsPath).catch((error) => {
914
+ logInternalError("event-log.buffered-flush", error, `eventsPath=${eventsPath}`);
915
+ });
916
+ }, bufferMs);
789
917
  bufferedTimers.set(eventsPath, timer);
790
918
  }
791
919
  });
@@ -830,15 +958,12 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
830
958
  // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
831
959
  // blocking the event loop. The sync lock uses sleepSync which blocks for
832
960
  // up to 5s and prevents AbortSignal handlers from firing.
961
+ // FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
962
+ // the whole queue. Previously each event triggered its own fsyncSync,
963
+ // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
964
+ // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
833
965
  await withEventLogLockAsync(eventsPath, async () => {
834
- for (const item of queue) {
835
- try {
836
- const ev = appendEventInsideLock(eventsPath, item.event);
837
- item.resolve(ev);
838
- } catch (error) {
839
- item.reject(error);
840
- }
841
- }
966
+ await appendEventBatchInsideLock(eventsPath, queue);
842
967
  });
843
968
  } catch (error) {
844
969
  // Lock acquire failed — fail every queued item so callers can fall back.
@@ -866,15 +991,52 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
866
991
  // Auto-flush on process exit so buffered events do not silently leak.
867
992
  // Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
868
993
  // immediately and the main thread is not blocked by sync I/O.
994
+ // FIX (P0 follow-up): Only call flushEventLogBuffer() / drainAsyncQueues() if
995
+ // there is actually pending work. Calling them unconditionally creates a new
996
+ // floating Promise that the test runner detects under --test-force-exit,
997
+ // failing tests with "Promise resolution is still pending but the event loop
998
+ // has already resolved" even when the test body completed cleanly.
869
999
  process.on("exit", () => {
870
- flushEventLogBuffer();
1000
+ if (bufferedQueues.size > 0) {
1001
+ flushEventLogBuffer().catch(() => {
1002
+ /* best-effort flush on exit */
1003
+ });
1004
+ }
871
1005
  // FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
872
1006
  // In-flight async writes are awaited (via Promise.allSettled) before
873
1007
  // the map is cleared. This reduces but does not eliminate event loss
874
1008
  // on crash — SIGKILL (kill -9) cannot be intercepted.
875
- drainAsyncQueues();
1009
+ if (asyncQueues.size > 0) {
1010
+ drainAsyncQueues().catch(() => {
1011
+ /* best-effort drain on exit */
1012
+ });
1013
+ }
876
1014
  asyncQueues.clear();
877
1015
  });
1016
+
1017
+ // FIX (P0 follow-up): Drain buffered events on `beforeExit` (async-aware).
1018
+ // The `exit` handler above is sync-only and cannot await the flush, leaving
1019
+ // pending promises that the test runner detects under --test-force-exit as
1020
+ // "Promise resolution is still pending but the event loop has already
1021
+ // resolved". `beforeExit` fires when the event loop drains naturally and
1022
+ // supports async handlers, so we can await the flush here and let the loop
1023
+ // drain cleanly before process.exit() is called.
1024
+ process.on("beforeExit", async () => {
1025
+ if (bufferedQueues.size > 0) {
1026
+ try {
1027
+ await flushEventLogBuffer();
1028
+ } catch {
1029
+ /* best-effort */
1030
+ }
1031
+ }
1032
+ if (asyncQueues.size > 0) {
1033
+ try {
1034
+ await drainAsyncQueues();
1035
+ } catch {
1036
+ /* best-effort */
1037
+ }
1038
+ }
1039
+ });
878
1040
  process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
879
1041
  process.on("SIGINT", () => setImmediate(() => flushEventLogBuffer()));
880
1042
  // FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
@@ -111,7 +111,6 @@ async function gitAsync(cwd: string, args: string[]): Promise<string> {
111
111
  const { stdout } = await execFileAsync("git", args, {
112
112
  cwd,
113
113
  encoding: "utf-8",
114
- stdio: ["ignore", "pipe", "pipe"],
115
114
  env: gitEnv(),
116
115
  windowsHide: true,
117
116
  });
@@ -445,7 +444,6 @@ async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ lo
445
444
  await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
446
445
  cwd: repoRoot,
447
446
  encoding: "utf-8",
448
- stdio: ["ignore", "pipe", "pipe"],
449
447
  windowsHide: true,
450
448
  })
451
449
  ).stdout.trim();
@@ -459,7 +457,7 @@ async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
459
457
  try {
460
458
  await execFileAsync("git", ["worktree", "prune"], {
461
459
  cwd: repoRoot,
462
- stdio: ["ignore", "ignore", "ignore"],
460
+ windowsHide: true,
463
461
  });
464
462
  } catch {
465
463
  /* best-effort */