@os-eco/overstory-cli 0.8.0 → 0.8.2

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.
@@ -110,6 +110,30 @@ export interface RuntimeConnection {
110
110
  close(): void;
111
111
  }
112
112
 
113
+ // === Headless Spawn ===
114
+
115
+ /** Options for spawning a headless (non-tmux) agent subprocess directly. */
116
+ export interface DirectSpawnOpts {
117
+ /** Working directory for the spawned process. */
118
+ cwd: string;
119
+ /** Environment variables for the subprocess. */
120
+ env: Record<string, string>;
121
+ /** Model ref (alias or provider-qualified). */
122
+ model: string;
123
+ /** Path to the instruction/overlay file for this agent. */
124
+ instructionPath: string;
125
+ }
126
+
127
+ /** Structured event emitted by a headless agent on stdout (NDJSON). */
128
+ export interface AgentEvent {
129
+ /** Event type (e.g. 'tool_start', 'tool_end', 'result', 'error', 'ready'). */
130
+ type: string;
131
+ /** ISO 8601 timestamp. */
132
+ timestamp: string;
133
+ /** Event-specific payload. */
134
+ [key: string]: unknown;
135
+ }
136
+
113
137
  // === Runtime Interface ===
114
138
 
115
139
  /**
@@ -187,4 +211,25 @@ export interface AgentRuntime {
187
211
  * Orchestrator checks `if (runtime.connect)` before calling, falls back to tmux when absent.
188
212
  */
189
213
  connect?(process: RpcProcessHandle): RuntimeConnection;
214
+
215
+ /**
216
+ * Whether this runtime is headless (no tmux, direct subprocess).
217
+ * Headless runtimes bypass all tmux session management and use Bun.spawn directly.
218
+ * Default: false (absent means interactive/tmux-based).
219
+ */
220
+ readonly headless?: boolean;
221
+
222
+ /**
223
+ * Build the argv array for Bun.spawn() to launch a headless agent subprocess.
224
+ * Only headless runtimes implement this method.
225
+ * The returned array is passed directly to Bun.spawn() — no shell interpolation.
226
+ */
227
+ buildDirectSpawn?(opts: DirectSpawnOpts): string[];
228
+
229
+ /**
230
+ * Parse NDJSON stdout from a headless agent subprocess into typed AgentEvent objects.
231
+ * Only headless runtimes implement this method.
232
+ * The caller provides the raw stdout ReadableStream from Bun.spawn().
233
+ */
234
+ parseEvents?(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent>;
190
235
  }
package/src/types.ts CHANGED
@@ -477,7 +477,11 @@ export type EventType =
477
477
  | "mail_received"
478
478
  | "spawn"
479
479
  | "error"
480
- | "custom";
480
+ | "custom"
481
+ | "turn_start"
482
+ | "turn_end"
483
+ | "progress"
484
+ | "result";
481
485
 
482
486
  /** Severity levels for events. */
483
487
  export type EventLevel = "debug" | "info" | "warn" | "error";
@@ -24,6 +24,8 @@ import { join } from "node:path";
24
24
  import { nudgeAgent } from "../commands/nudge.ts";
25
25
  import { createEventStore } from "../events/store.ts";
26
26
  import { createMulchClient } from "../mulch/client.ts";
27
+ import { getConnection, removeConnection } from "../runtimes/connections.ts";
28
+ import type { RuntimeConnection } from "../runtimes/types.ts";
27
29
  import { openSessionStore } from "../sessions/compat.ts";
28
30
  import type { AgentSession, EventStore, HealthCheck } from "../types.ts";
29
31
  import { isSessionAlive, killSession } from "../worktree/tmux.ts";
@@ -282,6 +284,10 @@ export interface DaemonOptions {
282
284
  tier: 0 | 1,
283
285
  triageSuggestion?: string,
284
286
  ) => Promise<void>;
287
+ /** Dependency injection for testing. Uses real getConnection when omitted. */
288
+ _getConnection?: (name: string) => RuntimeConnection | undefined;
289
+ /** Dependency injection for testing. Uses real removeConnection when omitted. */
290
+ _removeConnection?: (name: string) => void;
285
291
  }
286
292
 
287
293
  /**
@@ -345,6 +351,8 @@ export async function runDaemonTick(options: DaemonOptions): Promise<void> {
345
351
  const triage = options._triage ?? triageAgent;
346
352
  const nudge = options._nudge ?? nudgeAgent;
347
353
  const recordFailureFn = options._recordFailure ?? recordFailure;
354
+ const getConn = options._getConnection ?? getConnection;
355
+ const removeConn = options._removeConnection ?? removeConnection;
348
356
 
349
357
  const overstoryDir = join(root, ".overstory");
350
358
  const { store } = openSessionStore(overstoryDir);
@@ -386,6 +394,32 @@ export async function runDaemonTick(options: DaemonOptions): Promise<void> {
386
394
  // ZFC: Don't skip zombies. Re-check tmux liveness on every tick.
387
395
  // A zombie with a live tmux session needs investigation, not silence.
388
396
 
397
+ // RPC health check: for headless agents with an active connection,
398
+ // call getState() to refresh lastActivity before evaluateHealth().
399
+ // This prevents false-positive stale/zombie classification for agents
400
+ // that are actively working but haven't updated lastActivity via hooks.
401
+ if (session.tmuxSession === "" && session.pid !== null) {
402
+ const conn = getConn(session.agentName);
403
+ if (conn) {
404
+ try {
405
+ const state = await Promise.race([
406
+ conn.getState(),
407
+ new Promise<never>((_, reject) =>
408
+ setTimeout(() => reject(new Error("getState timed out")), 5000),
409
+ ),
410
+ ]);
411
+ if (state.status === "idle" || state.status === "working") {
412
+ store.updateLastActivity(session.agentName);
413
+ // Refresh the session object so evaluateHealth sees updated lastActivity
414
+ session.lastActivity = new Date().toISOString();
415
+ }
416
+ } catch {
417
+ // getState() failed or timed out — remove stale connection
418
+ removeConn(session.agentName);
419
+ }
420
+ }
421
+ }
422
+
389
423
  const tmuxAlive = await tmux.isSessionAlive(session.tmuxSession);
390
424
  const check = evaluateHealth(session, tmuxAlive, thresholds);
391
425
 
@@ -316,6 +316,108 @@ describe("evaluateHealth", () => {
316
316
  });
317
317
  });
318
318
 
319
+ // === Headless agents (tmuxSession === '', PID-based lifecycle) ===
320
+
321
+ describe("headless agents (tmuxSession empty, PID-based lifecycle)", () => {
322
+ // Headless agents always have tmuxAlive=false passed by the caller (no tmux).
323
+ // PID is the primary liveness signal.
324
+
325
+ test("headless agent with alive PID → working (NOT zombie)", () => {
326
+ const session = makeSession({ tmuxSession: "", pid: ALIVE_PID, state: "working" });
327
+ const check = evaluateHealth(session, false, THRESHOLDS);
328
+
329
+ expect(check.state).toBe("working");
330
+ expect(check.action).toBe("none");
331
+ expect(check.processAlive).toBe(true);
332
+ expect(check.pidAlive).toBe(true);
333
+ // tmuxAlive is always false for headless
334
+ expect(check.tmuxAlive).toBe(false);
335
+ });
336
+
337
+ test("headless agent with dead PID → zombie, terminate", () => {
338
+ const session = makeSession({ tmuxSession: "", pid: DEAD_PID, state: "working" });
339
+ const check = evaluateHealth(session, false, THRESHOLDS);
340
+
341
+ expect(check.state).toBe("zombie");
342
+ expect(check.action).toBe("terminate");
343
+ expect(check.processAlive).toBe(false);
344
+ expect(check.pidAlive).toBe(false);
345
+ expect(check.reconciliationNote).toContain("ZFC");
346
+ expect(check.reconciliationNote).toContain("headless");
347
+ expect(check.reconciliationNote).toContain("dead");
348
+ });
349
+
350
+ test("headless agent with alive PID + state=zombie → investigate (don't auto-kill)", () => {
351
+ const session = makeSession({ tmuxSession: "", pid: ALIVE_PID, state: "zombie" });
352
+ const check = evaluateHealth(session, false, THRESHOLDS);
353
+
354
+ expect(check.state).toBe("zombie");
355
+ expect(check.action).toBe("investigate");
356
+ expect(check.processAlive).toBe(true);
357
+ expect(check.reconciliationNote).toContain("ZFC");
358
+ expect(check.reconciliationNote).toContain("don't auto-kill");
359
+ });
360
+
361
+ test("headless booting agent with alive PID → transitions to working", () => {
362
+ const session = makeSession({ tmuxSession: "", pid: ALIVE_PID, state: "booting" });
363
+ const check = evaluateHealth(session, false, THRESHOLDS);
364
+
365
+ expect(check.state).toBe("working");
366
+ expect(check.action).toBe("none");
367
+ });
368
+
369
+ test("headless agent with stale activity → stalled", () => {
370
+ const staleActivity = new Date(Date.now() - 60_000).toISOString();
371
+ const session = makeSession({
372
+ tmuxSession: "",
373
+ pid: ALIVE_PID,
374
+ state: "working",
375
+ lastActivity: staleActivity,
376
+ });
377
+ const check = evaluateHealth(session, false, THRESHOLDS);
378
+
379
+ expect(check.state).toBe("stalled");
380
+ expect(check.action).toBe("escalate");
381
+ });
382
+
383
+ test("headless agent with zombie-level staleness → zombie", () => {
384
+ const oldActivity = new Date(Date.now() - 200_000).toISOString();
385
+ const session = makeSession({
386
+ tmuxSession: "",
387
+ pid: ALIVE_PID,
388
+ state: "working",
389
+ lastActivity: oldActivity,
390
+ });
391
+ const check = evaluateHealth(session, false, THRESHOLDS);
392
+
393
+ expect(check.state).toBe("zombie");
394
+ expect(check.action).toBe("terminate");
395
+ });
396
+
397
+ test("headless persistent capability (coordinator) with stale activity → still working", () => {
398
+ const staleActivity = new Date(Date.now() - 60_000).toISOString();
399
+ const session = makeSession({
400
+ tmuxSession: "",
401
+ pid: ALIVE_PID,
402
+ capability: "coordinator",
403
+ state: "working",
404
+ lastActivity: staleActivity,
405
+ });
406
+ const check = evaluateHealth(session, false, THRESHOLDS);
407
+
408
+ expect(check.state).toBe("working");
409
+ expect(check.action).toBe("none");
410
+ });
411
+
412
+ test("headless completed agent → skips monitoring", () => {
413
+ const session = makeSession({ tmuxSession: "", pid: ALIVE_PID, state: "completed" });
414
+ const check = evaluateHealth(session, false, THRESHOLDS);
415
+
416
+ expect(check.state).toBe("completed");
417
+ expect(check.action).toBe("none");
418
+ });
419
+ });
420
+
319
421
  // === transitionState ===
320
422
 
321
423
  describe("transitionState", () => {
@@ -20,6 +20,11 @@
20
20
  * - pid alive + tmux dead → should not happen (tmux owns the pid), but if it
21
21
  * does, trust tmux (the session is gone).
22
22
  *
23
+ * Headless agents (tmuxSession === ''):
24
+ * Headless agents have no tmux session. For these, PID is the PRIMARY liveness
25
+ * signal. The tmuxAlive parameter is meaningless and ignored. ZFC rules are
26
+ * applied using PID liveness instead of tmux liveness.
27
+ *
23
28
  * The rationale: sessions.json is updated asynchronously by hooks and can become
24
29
  * stale if the agent crashes between hook invocations. tmux and the OS process
25
30
  * table are always up-to-date because they reflect real kernel state.
@@ -65,6 +70,92 @@ export function isProcessRunning(pid: number): boolean {
65
70
  }
66
71
  }
67
72
 
73
+ /**
74
+ * Detect whether a session is a headless agent.
75
+ *
76
+ * Headless agents are spawned without a tmux session (tmuxSession === '') and
77
+ * are tracked solely by PID. For these agents, PID is the primary liveness signal.
78
+ */
79
+ function isHeadlessSession(session: AgentSession): boolean {
80
+ return session.tmuxSession === "" && session.pid !== null;
81
+ }
82
+
83
+ /**
84
+ * Evaluate time-based health (persistent capability exemptions, stale, zombie thresholds,
85
+ * booting→working transition). Called after liveness is confirmed for both TUI and headless paths.
86
+ *
87
+ * Assumes that by the time this is called:
88
+ * - The agent is not completed
89
+ * - The agent is not in a liveness-based zombie state
90
+ * - The agent is not in a zombie state that needs investigation
91
+ */
92
+ function evaluateTimeBased(
93
+ session: AgentSession,
94
+ base: Pick<HealthCheck, "agentName" | "timestamp" | "tmuxAlive" | "pidAlive" | "lastActivity">,
95
+ elapsedMs: number,
96
+ thresholds: { staleMs: number; zombieMs: number },
97
+ ): HealthCheck {
98
+ // Persistent capabilities (coordinator, monitor) are expected to have long idle
99
+ // periods waiting for mail/events. Skip time-based stale/zombie detection for
100
+ // them — only tmux/pid liveness matters (checked above).
101
+ if (PERSISTENT_CAPABILITIES.has(session.capability)) {
102
+ // Transition booting → working if we reach here (process alive)
103
+ const state = session.state === "booting" ? "working" : session.state;
104
+ return {
105
+ ...base,
106
+ processAlive: true,
107
+ state: state === "stalled" ? "working" : state,
108
+ action: "none",
109
+ reconciliationNote:
110
+ session.state === "stalled"
111
+ ? `Persistent capability "${session.capability}" exempted from stale detection — resetting to working`
112
+ : null,
113
+ };
114
+ }
115
+
116
+ // lastActivity older than zombieMs → zombie
117
+ if (elapsedMs > thresholds.zombieMs) {
118
+ return {
119
+ ...base,
120
+ processAlive: true,
121
+ state: "zombie",
122
+ action: "terminate",
123
+ reconciliationNote: null,
124
+ };
125
+ }
126
+
127
+ // lastActivity older than staleMs → stalled
128
+ if (elapsedMs > thresholds.staleMs) {
129
+ return {
130
+ ...base,
131
+ processAlive: true,
132
+ state: "stalled",
133
+ action: "escalate",
134
+ reconciliationNote: null,
135
+ };
136
+ }
137
+
138
+ // booting → transition to working once there's recent activity
139
+ if (session.state === "booting") {
140
+ return {
141
+ ...base,
142
+ processAlive: true,
143
+ state: "working",
144
+ action: "none",
145
+ reconciliationNote: null,
146
+ };
147
+ }
148
+
149
+ // Default: healthy and working
150
+ return {
151
+ ...base,
152
+ processAlive: true,
153
+ state: "working",
154
+ action: "none",
155
+ reconciliationNote: null,
156
+ };
157
+ }
158
+
68
159
  /**
69
160
  * Evaluate the health of an agent session.
70
161
  *
@@ -74,18 +165,23 @@ export function isProcessRunning(pid: number): boolean {
74
165
  * Decision logic (in priority order):
75
166
  *
76
167
  * 1. Completed agents skip monitoring entirely.
77
- * 2. tmux dead zombie, terminate (regardless of what sessions.json says).
78
- * 3. tmux alive + sessions.json says zombie → investigate (don't auto-kill).
168
+ * 2. Headless agents (tmuxSession === ''): PID is primary liveness signal.
169
+ * - pid dead zombie, terminate.
170
+ * - pid alive + state zombie → investigate.
171
+ * - pid alive → fall through to time-based checks.
172
+ * 3. tmux dead → zombie, terminate (regardless of what sessions.json says).
173
+ * 4. tmux alive + sessions.json says zombie → investigate (don't auto-kill).
79
174
  * Something external marked this zombie, but the process is still running.
80
- * 4. pid dead + tmux alive → zombie, terminate. The agent process exited but
175
+ * 5. pid dead + tmux alive → zombie, terminate. The agent process exited but
81
176
  * the tmux pane shell survived. The agent is not doing work.
82
- * 5. lastActivity older than zombieMs → zombie, terminate.
83
- * 6. lastActivity older than staleMs → stalled, escalate.
84
- * 7. booting with recent activity → working.
85
- * 8. Otherwise → working, healthy.
177
+ * 6. lastActivity older than zombieMs → zombie, terminate.
178
+ * 7. lastActivity older than staleMs → stalled, escalate.
179
+ * 8. booting with recent activity → working.
180
+ * 9. Otherwise → working, healthy.
86
181
  *
87
182
  * @param session - The agent session to evaluate
88
183
  * @param tmuxAlive - Whether the agent's tmux session is still running
184
+ * (ignored for headless agents where tmuxSession === '')
89
185
  * @param thresholds - Staleness and zombie time thresholds in milliseconds
90
186
  * @returns A HealthCheck describing the agent's current state and recommended action
91
187
  */
@@ -101,13 +197,16 @@ export function evaluateHealth(
101
197
  // Check pid liveness as secondary signal (null if pid unavailable)
102
198
  const pidAlive = session.pid !== null ? isProcessRunning(session.pid) : null;
103
199
 
200
+ // Headless agents have no tmux session; tmuxAlive is always false for them.
201
+ const effectiveTmuxAlive = isHeadlessSession(session) ? false : tmuxAlive;
202
+
104
203
  const base: Pick<
105
204
  HealthCheck,
106
205
  "agentName" | "timestamp" | "tmuxAlive" | "pidAlive" | "lastActivity"
107
206
  > = {
108
207
  agentName: session.agentName,
109
208
  timestamp: now.toISOString(),
110
- tmuxAlive,
209
+ tmuxAlive: effectiveTmuxAlive,
111
210
  pidAlive,
112
211
  lastActivity: session.lastActivity,
113
212
  };
@@ -116,13 +215,44 @@ export function evaluateHealth(
116
215
  if (session.state === "completed") {
117
216
  return {
118
217
  ...base,
119
- processAlive: tmuxAlive,
218
+ processAlive: effectiveTmuxAlive,
120
219
  state: "completed",
121
220
  action: "none",
122
221
  reconciliationNote: null,
123
222
  };
124
223
  }
125
224
 
225
+ // === Headless path: PID is the primary liveness signal ===
226
+ if (isHeadlessSession(session)) {
227
+ // pid dead → zombie immediately (equivalent to ZFC Rule 1 for headless)
228
+ if (pidAlive === false) {
229
+ return {
230
+ ...base,
231
+ processAlive: false,
232
+ state: "zombie",
233
+ action: "terminate",
234
+ reconciliationNote: `ZFC: headless agent pid ${session.pid} dead — marking zombie`,
235
+ };
236
+ }
237
+
238
+ // pid alive + state zombie → investigate (equivalent to ZFC Rule 2 for headless)
239
+ if (session.state === "zombie") {
240
+ return {
241
+ ...base,
242
+ processAlive: true,
243
+ state: "zombie",
244
+ action: "investigate",
245
+ reconciliationNote:
246
+ "ZFC: headless pid alive but sessions.json says zombie — investigation needed (don't auto-kill)",
247
+ };
248
+ }
249
+
250
+ // pid alive → fall through to time-based checks
251
+ return evaluateTimeBased(session, base, elapsedMs, thresholds);
252
+ }
253
+
254
+ // === TUI/tmux path ===
255
+
126
256
  // ZFC Rule 1: tmux dead → zombie immediately, regardless of recorded state.
127
257
  // Observable state says the process is gone.
128
258
  if (!tmuxAlive) {
@@ -166,67 +296,8 @@ export function evaluateHealth(
166
296
  };
167
297
  }
168
298
 
169
- // Persistent capabilities (coordinator, monitor) are expected to have long idle
170
- // periods waiting for mail/events. Skip time-based stale/zombie detection for
171
- // them — only tmux/pid liveness matters (checked above).
172
- if (PERSISTENT_CAPABILITIES.has(session.capability)) {
173
- // Transition booting → working if we reach here (tmux alive, pid alive)
174
- const state = session.state === "booting" ? "working" : session.state;
175
- return {
176
- ...base,
177
- processAlive: true,
178
- state: state === "stalled" ? "working" : state,
179
- action: "none",
180
- reconciliationNote:
181
- session.state === "stalled"
182
- ? `Persistent capability "${session.capability}" exempted from stale detection — resetting to working`
183
- : null,
184
- };
185
- }
186
-
187
299
  // Time-based checks (both tmux and pid confirmed alive, or pid unavailable)
188
-
189
- // lastActivity older than zombieMs → zombie
190
- if (elapsedMs > thresholds.zombieMs) {
191
- return {
192
- ...base,
193
- processAlive: true,
194
- state: "zombie",
195
- action: "terminate",
196
- reconciliationNote: null,
197
- };
198
- }
199
-
200
- // lastActivity older than staleMs → stalled
201
- if (elapsedMs > thresholds.staleMs) {
202
- return {
203
- ...base,
204
- processAlive: true,
205
- state: "stalled",
206
- action: "escalate",
207
- reconciliationNote: null,
208
- };
209
- }
210
-
211
- // booting → transition to working once there's recent activity
212
- if (session.state === "booting") {
213
- return {
214
- ...base,
215
- processAlive: true,
216
- state: "working",
217
- action: "none",
218
- reconciliationNote: null,
219
- };
220
- }
221
-
222
- // Default: healthy and working
223
- return {
224
- ...base,
225
- processAlive: true,
226
- state: "working",
227
- action: "none",
228
- reconciliationNote: null,
229
- };
300
+ return evaluateTimeBased(session, base, elapsedMs, thresholds);
230
301
  }
231
302
 
232
303
  /**
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { spawnHeadlessAgent } from "./process.ts";
6
+
7
+ describe("spawnHeadlessAgent", () => {
8
+ it("spawns a command and returns a valid PID", async () => {
9
+ const proc = await spawnHeadlessAgent(["echo", "hello"], {
10
+ cwd: process.cwd(),
11
+ env: { ...(process.env as Record<string, string>) },
12
+ });
13
+ expect(typeof proc.pid).toBe("number");
14
+ expect(proc.pid).toBeGreaterThan(0);
15
+ expect(proc.stdout).toBeDefined();
16
+ expect(proc.stdin).toBeDefined();
17
+ });
18
+
19
+ it("throws AgentError when argv is empty", async () => {
20
+ await expect(spawnHeadlessAgent([], { cwd: process.cwd(), env: {} })).rejects.toThrow(
21
+ "empty argv",
22
+ );
23
+ });
24
+
25
+ describe("file redirect mode", () => {
26
+ let tmpDir: string;
27
+
28
+ beforeEach(async () => {
29
+ tmpDir = await mkdtemp(join(tmpdir(), "ov-process-test-"));
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await rm(tmpDir, { recursive: true, force: true });
34
+ });
35
+
36
+ it("redirects stdout to file when stdoutFile is provided", async () => {
37
+ const stdoutFile = join(tmpDir, "stdout.log");
38
+ const proc = await spawnHeadlessAgent(["echo", "hello from file"], {
39
+ cwd: process.cwd(),
40
+ env: { ...(process.env as Record<string, string>) },
41
+ stdoutFile,
42
+ });
43
+
44
+ expect(typeof proc.pid).toBe("number");
45
+ expect(proc.pid).toBeGreaterThan(0);
46
+ // stdout is null when redirected to file — no pipe, no backpressure
47
+ expect(proc.stdout).toBeNull();
48
+ expect(proc.stdin).toBeDefined();
49
+
50
+ // Wait for process to finish, then check file content
51
+ const exitProc = Bun.spawn(["sh", "-c", "true"], { stdout: "pipe" });
52
+ await exitProc.exited;
53
+ // Give echo a moment to flush
54
+ await Bun.sleep(100);
55
+
56
+ const content = await Bun.file(stdoutFile).text();
57
+ expect(content.trim()).toBe("hello from file");
58
+ });
59
+
60
+ it("redirects stderr to file when stderrFile is provided", async () => {
61
+ const stderrFile = join(tmpDir, "stderr.log");
62
+ // Write to stderr via sh -c
63
+ const proc = await spawnHeadlessAgent(["sh", "-c", "echo error output >&2"], {
64
+ cwd: process.cwd(),
65
+ env: { ...(process.env as Record<string, string>) },
66
+ stderrFile,
67
+ });
68
+
69
+ expect(typeof proc.pid).toBe("number");
70
+ // stdout still piped (no stdoutFile provided)
71
+ expect(proc.stdout).not.toBeNull();
72
+
73
+ // Drain stdout to let process exit cleanly
74
+ if (proc.stdout) {
75
+ const reader = proc.stdout.getReader();
76
+ while (!(await reader.read()).done) {
77
+ // drain
78
+ }
79
+ reader.releaseLock();
80
+ }
81
+
82
+ await Bun.sleep(100);
83
+ const content = await Bun.file(stderrFile).text();
84
+ expect(content.trim()).toBe("error output");
85
+ });
86
+
87
+ it("stdout remains a ReadableStream when no stdoutFile provided (default mode)", async () => {
88
+ const proc = await spawnHeadlessAgent(["echo", "piped"], {
89
+ cwd: process.cwd(),
90
+ env: { ...(process.env as Record<string, string>) },
91
+ });
92
+
93
+ expect(proc.stdout).not.toBeNull();
94
+ expect(proc.stdout).toBeInstanceOf(ReadableStream);
95
+
96
+ // Read the content via the stream
97
+ const text = await new Response(proc.stdout as ReadableStream<Uint8Array>).text();
98
+ expect(text.trim()).toBe("piped");
99
+ });
100
+ });
101
+ });