Skip to content

Commit e5a9fef

Browse files
committed
feat: implement output stream teeing for simultaneous display and capture
Add tee mode to runCommand that pipes output to stdout/stderr in real-time while also capturing it for programmatic use. This enables agents to show live streaming output while still capturing the result for post-processing. Changes: - Add src/stream.ts with teeStream, collectStream, pipeToStdout utilities - Update runCommand to support CaptureMode: "none" | "capture" | "tee" - Add captureStderr option for capturing stderr when needed - Return separate stdout/stderr fields in RunResult - Maintain backward compatibility with boolean captureOutput and output field
1 parent 82204cb commit e5a9fef

4 files changed

Lines changed: 539 additions & 13 deletions

File tree

src/command.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test, describe } from "bun:test";
2-
import { parseCommandFromFilename, resolveCommand, buildArgs, extractPositionalMappings, extractEnvVars, getCurrentChildProcess, killCurrentChildProcess, runCommand } from "./command";
2+
import { parseCommandFromFilename, resolveCommand, buildArgs, extractPositionalMappings, extractEnvVars, getCurrentChildProcess, killCurrentChildProcess, runCommand, type CaptureMode } from "./command";
33

44
describe("parseCommandFromFilename", () => {
55
test("extracts command from filename pattern", () => {
@@ -232,3 +232,120 @@ describe("child process management for signal handling", () => {
232232
expect(result.exitCode).not.toBe(0);
233233
});
234234
});
235+
236+
describe("runCommand capture modes", () => {
237+
test("capture mode 'none' (false) does not capture output", async () => {
238+
const result = await runCommand({
239+
command: "echo",
240+
args: ["silent"],
241+
positionals: [],
242+
positionalMappings: new Map(),
243+
captureOutput: false,
244+
});
245+
246+
expect(result.exitCode).toBe(0);
247+
expect(result.stdout).toBe("");
248+
expect(result.stderr).toBe("");
249+
expect(result.output).toBe(""); // backward compat
250+
});
251+
252+
test("capture mode 'capture' (true) buffers and returns output", async () => {
253+
const result = await runCommand({
254+
command: "echo",
255+
args: ["captured"],
256+
positionals: [],
257+
positionalMappings: new Map(),
258+
captureOutput: true,
259+
});
260+
261+
expect(result.exitCode).toBe(0);
262+
expect(result.stdout.trim()).toBe("captured");
263+
expect(result.output.trim()).toBe("captured"); // backward compat
264+
});
265+
266+
test("capture mode 'tee' streams and captures simultaneously", async () => {
267+
const result = await runCommand({
268+
command: "echo",
269+
args: ["tee-test"],
270+
positionals: [],
271+
positionalMappings: new Map(),
272+
captureOutput: "tee" as CaptureMode,
273+
});
274+
275+
expect(result.exitCode).toBe(0);
276+
expect(result.stdout.trim()).toBe("tee-test");
277+
expect(result.output.trim()).toBe("tee-test"); // backward compat
278+
});
279+
280+
test("capture mode 'none' string equivalent to false", async () => {
281+
const result = await runCommand({
282+
command: "echo",
283+
args: ["none-mode"],
284+
positionals: [],
285+
positionalMappings: new Map(),
286+
captureOutput: "none" as CaptureMode,
287+
});
288+
289+
expect(result.exitCode).toBe(0);
290+
expect(result.stdout).toBe("");
291+
});
292+
293+
test("captureStderr captures stderr when enabled", async () => {
294+
// Use a shell command that writes to stderr
295+
const result = await runCommand({
296+
command: "sh",
297+
args: ["-c", "echo 'stdout line' && echo 'stderr line' >&2"],
298+
positionals: [],
299+
positionalMappings: new Map(),
300+
captureOutput: "tee" as CaptureMode,
301+
captureStderr: true,
302+
});
303+
304+
expect(result.exitCode).toBe(0);
305+
expect(result.stdout.trim()).toBe("stdout line");
306+
expect(result.stderr.trim()).toBe("stderr line");
307+
});
308+
309+
test("captureStderr false keeps stderr on inherit", async () => {
310+
const result = await runCommand({
311+
command: "sh",
312+
args: ["-c", "echo 'stdout line' && echo 'stderr line' >&2"],
313+
positionals: [],
314+
positionalMappings: new Map(),
315+
captureOutput: "tee" as CaptureMode,
316+
captureStderr: false,
317+
});
318+
319+
expect(result.exitCode).toBe(0);
320+
expect(result.stdout.trim()).toBe("stdout line");
321+
expect(result.stderr).toBe(""); // not captured
322+
});
323+
324+
test("tee mode handles multi-line output correctly", async () => {
325+
const result = await runCommand({
326+
command: "sh",
327+
args: ["-c", "echo 'line1' && echo 'line2' && echo 'line3'"],
328+
positionals: [],
329+
positionalMappings: new Map(),
330+
captureOutput: "tee" as CaptureMode,
331+
});
332+
333+
expect(result.exitCode).toBe(0);
334+
expect(result.stdout).toContain("line1");
335+
expect(result.stdout).toContain("line2");
336+
expect(result.stdout).toContain("line3");
337+
});
338+
339+
test("tee mode preserves exit code on command failure", async () => {
340+
const result = await runCommand({
341+
command: "sh",
342+
args: ["-c", "echo 'before exit' && exit 42"],
343+
positionals: [],
344+
positionalMappings: new Map(),
345+
captureOutput: "tee" as CaptureMode,
346+
});
347+
348+
expect(result.exitCode).toBe(42);
349+
expect(result.stdout.trim()).toBe("before exit");
350+
});
351+
});

src/command.ts

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { AgentFrontmatter } from "./types";
77
import { basename } from "path";
8+
import { teeToStdoutAndCollect, teeToStderrAndCollect } from "./stream";
89

910
/**
1011
* Module-level reference to the current child process
@@ -177,6 +178,14 @@ export function extractEnvVars(frontmatter: AgentFrontmatter): Record<string, st
177178
return undefined;
178179
}
179180

181+
/**
182+
* Output capture mode for runCommand
183+
* - "none": Inherit stdout/stderr, no capture (streaming to terminal)
184+
* - "capture": Pipe and buffer output, print after completion
185+
* - "tee": Tee streams - simultaneous display and capture (best of both)
186+
*/
187+
export type CaptureMode = "none" | "capture" | "tee";
188+
180189
export interface RunContext {
181190
/** The command to execute */
182191
command: string;
@@ -186,33 +195,67 @@ export interface RunContext {
186195
positionals: string[];
187196
/** Positional mappings ($1 → flag name) */
188197
positionalMappings: Map<number, string>;
189-
/** Whether to capture output */
190-
captureOutput: boolean;
198+
/**
199+
* Whether to capture output (legacy boolean) or capture mode
200+
* - false / "none": inherit stdout, no capture
201+
* - true / "capture": pipe and buffer, print after completion
202+
* - "tee": stream to stdout while capturing (simultaneous display + capture)
203+
*/
204+
captureOutput: boolean | CaptureMode;
191205
/** Environment variables to add */
192206
env?: Record<string, string>;
207+
/**
208+
* Whether to also capture stderr (only applies when captureOutput is enabled)
209+
* Default: false (stderr goes to inherit)
210+
*/
211+
captureStderr?: boolean;
193212
}
194213

195214
export interface RunResult {
196215
exitCode: number;
216+
/** Captured stdout content (empty string if not capturing) */
217+
stdout: string;
218+
/** Captured stderr content (empty string if not capturing stderr) */
219+
stderr: string;
220+
/**
221+
* @deprecated Use `stdout` instead. Kept for backward compatibility.
222+
*/
197223
output: string;
198224
/** The subprocess reference for signal handling */
199225
process: ReturnType<typeof Bun.spawn>;
200226
}
201227

228+
/**
229+
* Normalize capture mode from boolean or string to CaptureMode
230+
*/
231+
function normalizeCaptureMode(mode: boolean | CaptureMode): CaptureMode {
232+
if (mode === true) return "capture";
233+
if (mode === false) return "none";
234+
return mode;
235+
}
236+
202237
/**
203238
* Execute command with positional arguments
204239
* Positionals are either passed as-is or mapped to flags via $N mappings
240+
*
241+
* Capture modes:
242+
* - "none": Inherit stdout/stderr (streaming to terminal, no capture)
243+
* - "capture": Pipe and buffer output, print after completion
244+
* - "tee": Stream to stdout/stderr while capturing (simultaneous display + capture)
205245
*/
206246
export async function runCommand(ctx: RunContext): Promise<RunResult> {
207-
const { command, args, positionals, positionalMappings, captureOutput, env } = ctx;
247+
const { command, args, positionals, positionalMappings, captureOutput, env, captureStderr = false } = ctx;
248+
249+
const mode = normalizeCaptureMode(captureOutput);
208250

209251
// Pre-flight check: verify the command exists
210252
const binaryPath = Bun.which(command);
211253
if (!binaryPath) {
212254
console.error(`Command not found: '${command}'`);
213255
console.error(`This agent requires '${command}' to be installed and available in your PATH.`);
214256
console.error(`Please install it and try again.`);
215-
return { exitCode: 127, output: "" }; // 127 = command not found
257+
// Return empty process-like object for backward compatibility
258+
return { exitCode: 127, stdout: "", stderr: "", output: "", process: null as unknown as ReturnType<typeof Bun.spawn> };
216259
}
217260

218261
// Build final command args
@@ -238,27 +281,71 @@ export async function runCommand(ctx: RunContext): Promise<RunResult> {
238281
? { ...process.env, ...env }
239282
: undefined;
240283

284+
// Determine stdout/stderr pipe config based on mode
285+
const shouldPipeStdout = mode === "capture" || mode === "tee";
286+
const shouldPipeStderr = (mode === "capture" || mode === "tee") && captureStderr;
287+
241288
const proc = Bun.spawn([command, ...finalArgs], {
242-
stdout: captureOutput ? "pipe" : "inherit",
243-
stderr: "inherit",
289+
stdout: shouldPipeStdout ? "pipe" : "inherit",
290+
stderr: shouldPipeStderr ? "pipe" : "inherit",
244291
stdin: "inherit",
245292
env: runEnv,
246293
});
247294

248295
// Store reference for signal handling
249296
currentChildProcess = proc;
250297

251-
let output = "";
252-
if (captureOutput && proc.stdout) {
253-
output = await new Response(proc.stdout).text();
254-
// Still print to console so user sees it
255-
console.log(output);
298+
let stdout = "";
299+
let stderr = "";
300+
301+
// Handle output based on mode
302+
if (mode === "tee") {
303+
// Tee mode: stream to console while capturing
304+
const promises: Promise<void>[] = [];
305+
306+
if (proc.stdout) {
307+
promises.push(
308+
teeToStdoutAndCollect(proc.stdout).then((content) => {
309+
stdout = content;
310+
})
311+
);
312+
}
313+
314+
if (proc.stderr && shouldPipeStderr) {
315+
promises.push(
316+
teeToStderrAndCollect(proc.stderr).then((content) => {
317+
stderr = content;
318+
})
319+
);
320+
}
321+
322+
await Promise.all(promises);
323+
} else if (mode === "capture") {
324+
// Capture mode: buffer then print
325+
if (proc.stdout) {
326+
stdout = await new Response(proc.stdout).text();
327+
// Still print to console so user sees it
328+
console.log(stdout);
329+
}
330+
331+
if (proc.stderr && shouldPipeStderr) {
332+
stderr = await new Response(proc.stderr).text();
333+
// Print stderr to console
334+
console.error(stderr);
335+
}
256336
}
337+
// mode === "none": stdout/stderr are inherited, nothing to capture
257338

258339
const exitCode = await proc.exited;
259340

260341
// Clear reference after process exits
261342
currentChildProcess = null;
262343

263-
return { exitCode, output, process: proc };
344+
return {
345+
exitCode,
346+
stdout,
347+
stderr,
348+
output: stdout, // backward compatibility
349+
process: proc,
350+
};
264351
}

0 commit comments

Comments
 (0)