diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index a7b1e210..97e526ce 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -74,6 +74,7 @@ program .option("--attach ", "Attach to existing opencode server URL") .option("--on-complete ", "Shell command to run after completion") .option("--json", "Output structured JSON result to stdout") + .option("--no-timestamp", "Disable timestamp prefix in run output") .option("--verbose", "Show full event stream (default: messages/tools only)") .option("--session-id ", "Resume existing session instead of creating new one") .addHelpText("after", ` @@ -112,6 +113,7 @@ Unlike 'opencode run', this command waits until: attach: options.attach, onComplete: options.onComplete, json: options.json ?? false, + timestamp: options.timestamp ?? true, verbose: options.verbose ?? false, sessionId: options.sessionId, } diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 86f07834..c56d0702 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -10,6 +10,7 @@ import { resolveRunAgent } from "./agent-resolver" import { pollForCompletion } from "./poll-for-completion" import { loadAgentProfileColors } from "./agent-profile-colors" import { suppressRunInput } from "./stdin-suppression" +import { createTimestampedStdoutController } from "./timestamp-output" export { resolveRunAgent } @@ -38,6 +39,10 @@ export async function run(options: RunOptions): Promise { const jsonManager = options.json ? createJsonOutputManager() : null if (jsonManager) jsonManager.redirectToStderr() + const timestampOutput = options.json || options.timestamp === false + ? null + : createTimestampedStdoutController() + timestampOutput?.enable() const pluginConfig = loadPluginConfig(directory, { command: "run" }) const resolvedAgent = resolveRunAgent(options, pluginConfig) @@ -138,10 +143,13 @@ export async function run(options: RunOptions): Promise { } } catch (err) { if (jsonManager) jsonManager.restore() + timestampOutput?.restore() if (err instanceof Error && err.name === "AbortError") { return 130 } console.error(pc.red(`Error: ${serializeError(err)}`)) return 1 + } finally { + timestampOutput?.restore() } } diff --git a/src/cli/run/timestamp-output.test.ts b/src/cli/run/timestamp-output.test.ts new file mode 100644 index 00000000..cf981cf8 --- /dev/null +++ b/src/cli/run/timestamp-output.test.ts @@ -0,0 +1,127 @@ +/// + +import { describe, expect, it } from "bun:test" +import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output" + +interface MockWriteStream { + write: ( + chunk: Uint8Array | string, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) => boolean + writes: string[] +} + +function createMockWriteStream(): MockWriteStream { + const writes: string[] = [] + + const write: MockWriteStream["write"] = ( + chunk, + encodingOrCallback, + callback, + ) => { + const text = typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined) + + writes.push(text) + + if (typeof encodingOrCallback === "function") { + encodingOrCallback(null) + } else if (callback) { + callback(null) + } + + return true + } + + return { write, writes } +} + +describe("createTimestampTransformer", () => { + it("prefixes each output line with timestamp", () => { + // given + const now = () => new Date("2026-02-19T12:34:56.000Z") + const transform = createTimestampTransformer(now) + + // when + const output = transform("hello\nworld") + + // then + expect(output).toBe("[12:34:56] hello\n[12:34:56] world") + }) + + it("keeps line-start state across chunk boundaries", () => { + // given + const now = () => new Date("2026-02-19T01:02:03.000Z") + const transform = createTimestampTransformer(now) + + // when + const first = transform("hello") + const second = transform(" world") + const third = transform("\nnext") + + // then + expect(first).toBe("[01:02:03] hello") + expect(second).toBe(" world") + expect(third).toBe("\n[01:02:03] next") + }) + + it("returns empty string for empty chunk", () => { + // given + const transform = createTimestampTransformer(() => new Date("2026-02-19T01:02:03.000Z")) + + // when + const output = transform("") + + // then + expect(output).toBe("") + }) +}) + +describe("createTimestampedStdoutController", () => { + it("prefixes stdout writes when enabled", () => { + // given + const stdout = createMockWriteStream() + const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + + // when + controller.enable() + stdout.write("hello\nworld") + + // then + expect(stdout.writes).toHaveLength(1) + expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] hello\n\[\d{2}:\d{2}:\d{2}\] world$/) + }) + + it("restores original write function", () => { + // given + const stdout = createMockWriteStream() + const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + controller.enable() + + // when + stdout.write("before restore") + controller.restore() + stdout.write("after restore") + + // then + expect(stdout.writes).toHaveLength(2) + expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] before restore$/) + expect(stdout.writes[1]).toBe("after restore") + }) + + it("supports Uint8Array chunks and encoding", () => { + // given + const stdout = createMockWriteStream() + const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream) + + // when + controller.enable() + stdout.write(Buffer.from("byte line"), "utf8") + + // then + expect(stdout.writes).toHaveLength(1) + expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] byte line$/) + }) +}) diff --git a/src/cli/run/timestamp-output.ts b/src/cli/run/timestamp-output.ts new file mode 100644 index 00000000..3eb4413f --- /dev/null +++ b/src/cli/run/timestamp-output.ts @@ -0,0 +1,70 @@ +function formatTimestamp(date: Date): string { + const hh = String(date.getHours()).padStart(2, "0") + const mm = String(date.getMinutes()).padStart(2, "0") + const ss = String(date.getSeconds()).padStart(2, "0") + return `${hh}:${mm}:${ss}` +} + +export function createTimestampTransformer(now: () => Date = () => new Date()): (chunk: string) => string { + let atLineStart = true + + return (chunk: string): string => { + if (!chunk) return "" + + let output = "" + for (let i = 0; i < chunk.length; i++) { + const ch = chunk[i] + if (atLineStart) { + output += `[${formatTimestamp(now())}] ` + atLineStart = false + } + + output += ch + + if (ch === "\n") { + atLineStart = true + } + } + + return output + } +} + +type WriteFn = NodeJS.WriteStream["write"] + +export function createTimestampedStdoutController(stdout: NodeJS.WriteStream = process.stdout): { + enable: () => void + restore: () => void +} { + const originalWrite = stdout.write.bind(stdout) + const transform = createTimestampTransformer() + + function enable(): void { + const write: WriteFn = ( + chunk: Uint8Array | string, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): boolean => { + const text = typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined) + const stamped = transform(text) + + if (typeof encodingOrCallback === "function") { + return originalWrite(stamped, encodingOrCallback) + } + if (encodingOrCallback !== undefined) { + return originalWrite(stamped, encodingOrCallback, callback) + } + return originalWrite(stamped) + } + + stdout.write = write + } + + function restore(): void { + stdout.write = originalWrite + } + + return { enable, restore } +} diff --git a/src/cli/run/types.ts b/src/cli/run/types.ts index fb0082cb..98c452a9 100644 --- a/src/cli/run/types.ts +++ b/src/cli/run/types.ts @@ -4,6 +4,7 @@ export type { OpencodeClient } export interface RunOptions { message: string agent?: string + timestamp?: boolean verbose?: boolean directory?: string port?: number