@nathapp/nax 0.49.3 → 0.49.6

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.
@@ -261,23 +261,37 @@ function acpSessionsPath(workdir: string, featureName: string): string {
261
261
  return join(workdir, "nax", "features", featureName, "acp-sessions.json");
262
262
  }
263
263
 
264
+ /** Sidecar entry — session name + agent name for correct sweep/close. */
265
+ type SidecarEntry = string | { sessionName: string; agentName: string };
266
+
267
+ /** Extract sessionName from a sidecar entry (handles legacy string format). */
268
+ function sidecarSessionName(entry: SidecarEntry): string {
269
+ return typeof entry === "string" ? entry : entry.sessionName;
270
+ }
271
+
272
+ /** Extract agentName from a sidecar entry (defaults to "claude" for legacy entries). */
273
+ function sidecarAgentName(entry: SidecarEntry): string {
274
+ return typeof entry === "string" ? "claude" : entry.agentName;
275
+ }
276
+
264
277
  /** Persist a session name to the sidecar file. Best-effort — errors are swallowed. */
265
278
  export async function saveAcpSession(
266
279
  workdir: string,
267
280
  featureName: string,
268
281
  storyId: string,
269
282
  sessionName: string,
283
+ agentName = "claude",
270
284
  ): Promise<void> {
271
285
  try {
272
286
  const path = acpSessionsPath(workdir, featureName);
273
- let data: Record<string, string> = {};
287
+ let data: Record<string, SidecarEntry> = {};
274
288
  try {
275
289
  const existing = await Bun.file(path).text();
276
290
  data = JSON.parse(existing);
277
291
  } catch {
278
292
  // File doesn't exist yet — start fresh
279
293
  }
280
- data[storyId] = sessionName;
294
+ data[storyId] = { sessionName, agentName };
281
295
  await Bun.write(path, JSON.stringify(data, null, 2));
282
296
  } catch (err) {
283
297
  getSafeLogger()?.warn("acp-adapter", "Failed to save session to sidecar", { error: String(err) });
@@ -307,8 +321,9 @@ export async function readAcpSession(workdir: string, featureName: string, story
307
321
  try {
308
322
  const path = acpSessionsPath(workdir, featureName);
309
323
  const existing = await Bun.file(path).text();
310
- const data: Record<string, string> = JSON.parse(existing);
311
- return data[storyId] ?? null;
324
+ const data: Record<string, SidecarEntry> = JSON.parse(existing);
325
+ const entry = data[storyId];
326
+ return entry ? sidecarSessionName(entry) : null;
312
327
  } catch {
313
328
  return null;
314
329
  }
@@ -326,10 +341,10 @@ const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
326
341
  */
327
342
  export async function sweepFeatureSessions(workdir: string, featureName: string): Promise<void> {
328
343
  const path = acpSessionsPath(workdir, featureName);
329
- let sessions: Record<string, string>;
344
+ let sessions: Record<string, SidecarEntry>;
330
345
  try {
331
346
  const text = await Bun.file(path).text();
332
- sessions = JSON.parse(text) as Record<string, string>;
347
+ sessions = JSON.parse(text) as Record<string, SidecarEntry>;
333
348
  } catch {
334
349
  return; // No sidecar — nothing to sweep
335
350
  }
@@ -340,24 +355,35 @@ export async function sweepFeatureSessions(workdir: string, featureName: string)
340
355
  const logger = getSafeLogger();
341
356
  logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
342
357
 
343
- const cmdStr = "acpx claude";
344
- const client = _acpAdapterDeps.createClient(cmdStr, workdir);
345
- try {
346
- await client.start();
347
- for (const [, sessionName] of entries) {
348
- try {
349
- if (client.loadSession) {
350
- const session = await client.loadSession(sessionName, "claude", "approve-reads");
351
- if (session) {
352
- await session.close().catch(() => {});
358
+ // Group sessions by agent name so we create one client per agent
359
+ const byAgent = new Map<string, string[]>();
360
+ for (const [, entry] of entries) {
361
+ const agent = sidecarAgentName(entry);
362
+ const name = sidecarSessionName(entry);
363
+ if (!byAgent.has(agent)) byAgent.set(agent, []);
364
+ byAgent.get(agent)?.push(name);
365
+ }
366
+
367
+ for (const [agentName, sessionNames] of byAgent) {
368
+ const cmdStr = `acpx ${agentName}`;
369
+ const client = _acpAdapterDeps.createClient(cmdStr, workdir);
370
+ try {
371
+ await client.start();
372
+ for (const sessionName of sessionNames) {
373
+ try {
374
+ if (client.loadSession) {
375
+ const session = await client.loadSession(sessionName, agentName, "approve-reads");
376
+ if (session) {
377
+ await session.close().catch(() => {});
378
+ }
353
379
  }
380
+ } catch (err) {
381
+ logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
354
382
  }
355
- } catch (err) {
356
- logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
357
383
  }
384
+ } finally {
385
+ await client.close().catch(() => {});
358
386
  }
359
- } finally {
360
- await client.close().catch(() => {});
361
387
  }
362
388
 
363
389
  // Clear sidecar after sweep
@@ -554,7 +580,7 @@ export class AcpAgentAdapter implements AgentAdapter {
554
580
 
555
581
  // 4. Persist for plan→run continuity
556
582
  if (options.featureName && options.storyId) {
557
- await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
583
+ await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName, this.name);
558
584
  }
559
585
 
560
586
  let lastResponse: AcpSessionResponse | null = null;
@@ -635,13 +661,17 @@ export class AcpAgentAdapter implements AgentAdapter {
635
661
  } finally {
636
662
  // 6. Cleanup — close session and clear sidecar only on success.
637
663
  // On failure, keep session open so retry can resume with full context.
638
- if (runState.succeeded) {
664
+ // When keepSessionOpen=true (e.g. rectification loop), skip close even on success
665
+ // so all attempts share the same conversation context.
666
+ if (runState.succeeded && !options.keepSessionOpen) {
639
667
  await closeAcpSession(session);
640
668
  if (options.featureName && options.storyId) {
641
669
  await clearAcpSession(options.workdir, options.featureName, options.storyId);
642
670
  }
643
- } else {
671
+ } else if (!runState.succeeded) {
644
672
  getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
673
+ } else {
674
+ getSafeLogger()?.debug("acp-adapter", "Keeping session open (keepSessionOpen=true)", { sessionName });
645
675
  }
646
676
  await client.close().catch(() => {});
647
677
  }
@@ -272,7 +272,6 @@ export class SpawnAcpClient implements AcpClient {
272
272
  private readonly model: string;
273
273
  private readonly cwd: string;
274
274
  private readonly timeoutSeconds: number;
275
- private readonly permissionMode: string;
276
275
  private readonly env: Record<string, string | undefined>;
277
276
  private readonly pidRegistry?: PidRegistry;
278
277
 
@@ -289,7 +288,6 @@ export class SpawnAcpClient implements AcpClient {
289
288
  this.agentName = lastToken;
290
289
  this.cwd = cwd || process.cwd();
291
290
  this.timeoutSeconds = timeoutSeconds || 1800;
292
- this.permissionMode = "approve-reads";
293
291
  this.env = buildAllowedEnv();
294
292
  this.pidRegistry = pidRegistry;
295
293
  }
@@ -126,6 +126,20 @@ export async function executeOnce(
126
126
  const cmd = _runOnceDeps.buildCmd(binary, options);
127
127
  const startTime = Date.now();
128
128
 
129
+ // Log session-related options for traceability. CLI adapter doesn't use sessions,
130
+ // but the pipeline passes these uniformly. Logged so future CLI session support
131
+ // can verify they're threaded correctly.
132
+ if (options.sessionRole || options.acpSessionName || options.keepSessionOpen) {
133
+ const logger = getLogger();
134
+ logger.debug("agent", "CLI mode: session options received (unused)", {
135
+ sessionRole: options.sessionRole,
136
+ acpSessionName: options.acpSessionName,
137
+ keepSessionOpen: options.keepSessionOpen,
138
+ featureName: options.featureName,
139
+ storyId: options.storyId,
140
+ });
141
+ }
142
+
129
143
  const proc = Bun.spawn(cmd, {
130
144
  cwd: options.workdir,
131
145
  stdout: "pipe",
@@ -84,6 +84,13 @@ export interface AgentRunOptions {
84
84
  pipelineStage?: import("../config/permissions").PipelineStage;
85
85
  /** Full nax config — passed through so adapters can call resolvePermissions() */
86
86
  config?: NaxConfig;
87
+ /**
88
+ * When true, the adapter will NOT close the session after a successful run.
89
+ * Use this for rectification loops where the same session must persist across
90
+ * multiple attempts so the agent retains full conversation context.
91
+ * The caller is responsible for closing the session when the loop is done.
92
+ */
93
+ keepSessionOpen?: boolean;
87
94
  }
88
95
 
89
96
  /**
@@ -13,7 +13,11 @@ import type { PipelineContext } from "../pipeline";
13
13
  import { constitutionStage, contextStage, promptStage, routingStage } from "../pipeline/stages";
14
14
  import type { UserStory } from "../prd";
15
15
  import { loadPRD } from "../prd";
16
+ // buildFrontmatter lives in prompts-shared to avoid circular import with prompts-tdd.
17
+ // Import for local use + re-export to preserve the public API via prompts.ts.
18
+ import { buildFrontmatter } from "./prompts-shared";
16
19
  import { handleThreeSessionTddPrompts } from "./prompts-tdd";
20
+ export { buildFrontmatter };
17
21
 
18
22
  export interface PromptsCommandOptions {
19
23
  /** Feature name */
@@ -177,62 +181,3 @@ export async function promptsCommand(options: PromptsCommandOptions): Promise<st
177
181
 
178
182
  return processedStories;
179
183
  }
180
-
181
- /**
182
- * Build YAML frontmatter for a story prompt.
183
- *
184
- * Uses actual token counts from BuiltContext elements (computed by context builder
185
- * using CHARS_PER_TOKEN=3) rather than re-estimating independently.
186
- *
187
- * @param story - User story
188
- * @param ctx - Pipeline context after running prompt assembly
189
- * @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
190
- * @returns YAML frontmatter string (without delimiters)
191
- */
192
- export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
193
- const lines: string[] = [];
194
-
195
- lines.push(`storyId: ${story.id}`);
196
- lines.push(`title: "${story.title}"`);
197
- lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
198
- lines.push(`modelTier: ${ctx.routing.modelTier}`);
199
-
200
- if (role) {
201
- lines.push(`role: ${role}`);
202
- }
203
-
204
- // Use actual token counts from BuiltContext if available
205
- const builtContext = ctx.builtContext;
206
- const contextTokens = builtContext?.totalTokens ?? 0;
207
- const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
208
-
209
- lines.push(`contextTokens: ${contextTokens}`);
210
- lines.push(`promptTokens: ${promptTokens}`);
211
-
212
- // Dependencies
213
- if (story.dependencies && story.dependencies.length > 0) {
214
- lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
215
- }
216
-
217
- // Context elements breakdown from actual BuiltContext
218
- lines.push("contextElements:");
219
-
220
- if (builtContext) {
221
- for (const element of builtContext.elements) {
222
- lines.push(` - type: ${element.type}`);
223
- if (element.storyId) {
224
- lines.push(` storyId: ${element.storyId}`);
225
- }
226
- if (element.filePath) {
227
- lines.push(` filePath: ${element.filePath}`);
228
- }
229
- lines.push(` tokens: ${element.tokens}`);
230
- }
231
- }
232
-
233
- if (builtContext?.truncated) {
234
- lines.push("truncated: true");
235
- }
236
-
237
- return `${lines.join("\n")}\n`;
238
- }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Shared Prompts Utilities
3
+ *
4
+ * Functions shared between prompts-main and prompts-tdd to avoid circular imports.
5
+ * Both modules need buildFrontmatter; keeping it here breaks the cycle:
6
+ * prompts-main → prompts-tdd (was circular)
7
+ * now both → prompts-shared
8
+ */
9
+
10
+ import type { PipelineContext } from "../pipeline";
11
+ import type { UserStory } from "../prd";
12
+
13
+ /**
14
+ * Build YAML frontmatter for a prompt file.
15
+ *
16
+ * Token counts use actual BuiltContext values (computed during pipeline execution,
17
+ * using CHARS_PER_TOKEN=3) rather than re-estimating independently.
18
+ *
19
+ * @param story - User story
20
+ * @param ctx - Pipeline context after running prompt assembly
21
+ * @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
22
+ * @returns YAML frontmatter string (without delimiters)
23
+ */
24
+ export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
25
+ const lines: string[] = [];
26
+
27
+ lines.push(`storyId: ${story.id}`);
28
+ lines.push(`title: "${story.title}"`);
29
+ lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
30
+ lines.push(`modelTier: ${ctx.routing.modelTier}`);
31
+
32
+ if (role) {
33
+ lines.push(`role: ${role}`);
34
+ }
35
+
36
+ // Use actual token counts from BuiltContext if available
37
+ const builtContext = ctx.builtContext;
38
+ const contextTokens = builtContext?.totalTokens ?? 0;
39
+ const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
40
+
41
+ lines.push(`contextTokens: ${contextTokens}`);
42
+ lines.push(`promptTokens: ${promptTokens}`);
43
+
44
+ // Dependencies
45
+ if (story.dependencies && story.dependencies.length > 0) {
46
+ lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
47
+ }
48
+
49
+ // Context elements breakdown from actual BuiltContext
50
+ lines.push("contextElements:");
51
+
52
+ if (builtContext) {
53
+ for (const element of builtContext.elements) {
54
+ lines.push(` - type: ${element.type}`);
55
+ if (element.storyId) {
56
+ lines.push(` storyId: ${element.storyId}`);
57
+ }
58
+ if (element.filePath) {
59
+ lines.push(` filePath: ${element.filePath}`);
60
+ }
61
+ lines.push(` tokens: ${element.tokens}`);
62
+ }
63
+ }
64
+
65
+ if (builtContext?.truncated) {
66
+ lines.push("truncated: true");
67
+ }
68
+
69
+ return `${lines.join("\n")}\n`;
70
+ }
@@ -9,7 +9,7 @@ import type { getLogger } from "../logger";
9
9
  import type { PipelineContext } from "../pipeline";
10
10
  import type { UserStory } from "../prd";
11
11
  import { PromptBuilder } from "../prompts";
12
- import { buildFrontmatter } from "./prompts-main";
12
+ import { buildFrontmatter } from "./prompts-shared";
13
13
 
14
14
  /**
15
15
  * Handle three-session TDD prompts by building separate prompts for each role.
@@ -55,6 +55,24 @@ export function mergePackageConfig(root: NaxConfig, packageOverride: Partial<Nax
55
55
  ...packageOverride.review,
56
56
  commands: {
57
57
  ...root.review.commands,
58
+ // PKG-006: Bridge quality.commands → review.commands for per-package overrides.
59
+ // Users naturally put per-package commands in quality.commands (the intuitive
60
+ // place), but the review runner reads review.commands. Bridge them here so
61
+ // packages don't need to define the same commands in two places.
62
+ // Explicit review.commands still take precedence (applied after).
63
+ ...(packageOverride.quality?.commands?.lint !== undefined && {
64
+ lint: packageOverride.quality.commands.lint,
65
+ }),
66
+ ...(packageOverride.quality?.commands?.lintFix !== undefined && {
67
+ lintFix: packageOverride.quality.commands.lintFix,
68
+ }),
69
+ ...(packageOverride.quality?.commands?.typecheck !== undefined && {
70
+ typecheck: packageOverride.quality.commands.typecheck,
71
+ }),
72
+ ...(packageOverride.quality?.commands?.test !== undefined && {
73
+ test: packageOverride.quality.commands.test,
74
+ }),
75
+ // Explicit review.commands override bridged quality values
58
76
  ...packageOverride.review?.commands,
59
77
  },
60
78
  },
@@ -11,8 +11,8 @@ import { z } from "zod";
11
11
  import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
12
12
 
13
13
  /**
14
- * Injectable sleep for WebhookInteractionPlugin.receive() polling loop.
15
- * Replace in tests to avoid real backoff delays.
14
+ * Injectable sleep — kept for backward compat with existing tests that override it.
15
+ * No longer used internally by receive() (replaced by event-driven delivery).
16
16
  * @internal
17
17
  */
18
18
  export const _webhookPluginDeps = {
@@ -56,7 +56,10 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
56
56
  private config: WebhookConfig = {};
57
57
  private server: Server | null = null;
58
58
  private serverStartPromise: Promise<void> | null = null;
59
+ /** Legacy map for responses that arrive before receive() is called */
59
60
  private pendingResponses = new Map<string, InteractionResponse>();
61
+ /** Event-driven callbacks: requestId → resolve fn (set by receive(), called by handleRequest) */
62
+ private receiveCallbacks = new Map<string, (response: InteractionResponse) => void>();
60
63
 
61
64
  async init(config: Record<string, unknown>): Promise<void> {
62
65
  const cfg = WebhookConfigSchema.parse(config);
@@ -117,33 +120,49 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
117
120
  // Start HTTP server to receive callback
118
121
  await this.startServer();
119
122
 
120
- const startTime = Date.now();
121
- let backoffMs = 100; // Initial poll interval
122
- const maxBackoffMs = 2000; // Max 2 seconds between polls
123
-
124
- // Poll for response with exponential backoff
125
- while (Date.now() - startTime < timeout) {
126
- const response = this.pendingResponses.get(requestId);
127
- if (response) {
128
- this.pendingResponses.delete(requestId);
129
- return response;
130
- }
131
- await _webhookPluginDeps.sleep(backoffMs);
132
- // Exponential backoff: double interval up to max
133
- backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
123
+ // Check if a response already arrived before receive() was called
124
+ const early = this.pendingResponses.get(requestId);
125
+ if (early) {
126
+ this.pendingResponses.delete(requestId);
127
+ return early;
134
128
  }
135
129
 
136
- // Timeout
137
- return {
138
- requestId,
139
- action: "skip",
140
- respondedBy: "timeout",
141
- respondedAt: Date.now(),
142
- };
130
+ // Event-driven: resolve immediately when handleRequest delivers the response
131
+ return new Promise<InteractionResponse>((resolve) => {
132
+ const timer = setTimeout(() => {
133
+ this.receiveCallbacks.delete(requestId);
134
+ resolve({
135
+ requestId,
136
+ action: "skip",
137
+ respondedBy: "timeout",
138
+ respondedAt: Date.now(),
139
+ });
140
+ }, timeout);
141
+
142
+ this.receiveCallbacks.set(requestId, (response) => {
143
+ clearTimeout(timer);
144
+ this.receiveCallbacks.delete(requestId);
145
+ resolve(response);
146
+ });
147
+ });
143
148
  }
144
149
 
145
150
  async cancel(requestId: string): Promise<void> {
146
151
  this.pendingResponses.delete(requestId);
152
+ this.receiveCallbacks.delete(requestId);
153
+ }
154
+
155
+ /**
156
+ * Deliver a response to a waiting receive() callback, or store for later pickup.
157
+ */
158
+ private deliverResponse(requestId: string, response: InteractionResponse): void {
159
+ const cb = this.receiveCallbacks.get(requestId);
160
+ if (cb) {
161
+ cb(response);
162
+ } else {
163
+ // receive() hasn't been called yet — store for early-pickup path
164
+ this.pendingResponses.set(requestId, response);
165
+ }
147
166
  }
148
167
 
149
168
  /**
@@ -220,7 +239,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
220
239
  try {
221
240
  const parsed = JSON.parse(body);
222
241
  const response = InteractionResponseSchema.parse(parsed);
223
- this.pendingResponses.set(requestId, response);
242
+ this.deliverResponse(requestId, response);
224
243
  } catch {
225
244
  // Sanitize error - do not leak parse/validation details
226
245
  return new Response("Bad Request: Invalid response format", { status: 400 });
@@ -230,7 +249,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
230
249
  try {
231
250
  const parsed = await req.json();
232
251
  const response = InteractionResponseSchema.parse(parsed);
233
- this.pendingResponses.set(requestId, response);
252
+ this.deliverResponse(requestId, response);
234
253
  } catch {
235
254
  // Sanitize error - do not leak parse/validation details
236
255
  return new Response("Bad Request: Invalid response format", { status: 400 });
@@ -7,6 +7,13 @@
7
7
 
8
8
  import { getLogger } from "../logger";
9
9
 
10
+ /** Injectable deps for testability — mock _cleanupDeps instead of global Bun.spawn/process.kill */
11
+ export const _cleanupDeps = {
12
+ spawn: Bun.spawn as typeof Bun.spawn,
13
+ sleep: Bun.sleep as typeof Bun.sleep,
14
+ kill: process.kill.bind(process) as typeof process.kill,
15
+ };
16
+
10
17
  /**
11
18
  * Get process group ID (PGID) for a given process ID.
12
19
  *
@@ -24,17 +31,19 @@ import { getLogger } from "../logger";
24
31
  export async function getPgid(pid: number): Promise<number | null> {
25
32
  try {
26
33
  // Use ps to get PGID for the process
27
- const proc = Bun.spawn(["ps", "-o", "pgid=", "-p", String(pid)], {
34
+ const proc = _cleanupDeps.spawn(["ps", "-o", "pgid=", "-p", String(pid)], {
28
35
  stdout: "pipe",
29
36
  stderr: "pipe",
30
37
  });
31
38
 
39
+ // Read stdout BEFORE awaiting exit — stream may be closed after exit in Bun 1.3.9.
40
+ // Bun.readableStreamToText is more reliable than new Response(stream).text()
41
+ // with both real pipes and mocked streams.
42
+ const output = await Bun.readableStreamToText(proc.stdout);
32
43
  const exitCode = await proc.exited;
33
44
  if (exitCode !== 0) {
34
45
  return null;
35
46
  }
36
-
37
- const output = await new Response(proc.stdout).text();
38
47
  const pgid = Number.parseInt(output.trim(), 10);
39
48
 
40
49
  return Number.isNaN(pgid) ? null : pgid;
@@ -72,7 +81,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
72
81
 
73
82
  // Send SIGTERM to all processes in the group (negative PGID)
74
83
  try {
75
- process.kill(-pgid, "SIGTERM");
84
+ _cleanupDeps.kill(-pgid, "SIGTERM");
76
85
  } catch (error) {
77
86
  // ESRCH means no such process — already dead
78
87
  const err = error as NodeJS.ErrnoException;
@@ -83,7 +92,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
83
92
  }
84
93
 
85
94
  // Wait for graceful shutdown
86
- await Bun.sleep(gracePeriodMs);
95
+ await _cleanupDeps.sleep(gracePeriodMs);
87
96
 
88
97
  // Re-check PGID before SIGKILL to prevent race condition
89
98
  // If the original process exited and a new process inherited its PID,
@@ -95,7 +104,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
95
104
  // 2. PGID hasn't changed (still the same process group)
96
105
  if (pgidAfterWait && pgidAfterWait === pgid) {
97
106
  try {
98
- process.kill(-pgid, "SIGKILL");
107
+ _cleanupDeps.kill(-pgid, "SIGKILL");
99
108
  } catch {
100
109
  // Ignore errors — processes may have exited during the wait
101
110
  }
@@ -8,6 +8,9 @@
8
8
 
9
9
  import type { IsolationCheck } from "./types";
10
10
 
11
+ /** Injectable deps for testability — mock _isolationDeps.spawn instead of global Bun.spawn */
12
+ export const _isolationDeps = { spawn: Bun.spawn as typeof Bun.spawn };
13
+
11
14
  /** Common test directory patterns */
12
15
  const TEST_PATTERNS = [/^test\//, /^tests\//, /^__tests__\//, /\.spec\.\w+$/, /\.test\.\w+$/, /\.e2e-spec\.\w+$/];
13
16
 
@@ -26,14 +29,18 @@ export function isSourceFile(filePath: string): boolean {
26
29
 
27
30
  /** Get changed files from git diff */
28
31
  export async function getChangedFiles(workdir: string, fromRef = "HEAD"): Promise<string[]> {
29
- const proc = Bun.spawn(["git", "diff", "--name-only", fromRef], {
32
+ const proc = _isolationDeps.spawn(["git", "diff", "--name-only", fromRef], {
30
33
  cwd: workdir,
31
34
  stdout: "pipe",
32
35
  stderr: "pipe",
33
36
  });
34
37
 
38
+ // Use Bun.readableStreamToText — more reliable than new Response(stream).text()
39
+ // with both real pipes and mocked ReadableStreams across Bun versions.
40
+ // Must read BEFORE awaiting proc.exited to avoid stream-closed-on-exit issues.
41
+ const output = await Bun.readableStreamToText(proc.stdout);
35
42
  await proc.exited;
36
- const output = await new Response(proc.stdout).text();
43
+
37
44
  return output.trim().split("\n").filter(Boolean);
38
45
  }
39
46