From 9f4c92255df14a4fc1d23aa266bf835d4bf070e3 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 17 Jan 2026 16:20:49 -0800 Subject: [PATCH 1/3] Support different output formats: text, json, streaming json --- apps/cli/src/agent/extension-host.ts | 33 +- apps/cli/src/agent/index.ts | 1 + apps/cli/src/agent/json-event-emitter.ts | 464 +++++++++++++++++++++++ apps/cli/src/commands/cli/run.ts | 73 +++- apps/cli/src/index.ts | 5 + apps/cli/src/types/index.ts | 1 + apps/cli/src/types/json-events.ts | 120 ++++++ apps/cli/src/types/types.ts | 2 + 8 files changed, 667 insertions(+), 32 deletions(-) create mode 100644 apps/cli/src/agent/json-event-emitter.ts create mode 100644 apps/cli/src/types/json-events.ts diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index a3ceec132fd..b423de8ea72 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -74,6 +74,11 @@ export interface ExtensionHostOptions { * running in an integration test and we want to see the output. */ integrationTest?: boolean + /** + * When true, suppress all console output from the extension. + * Use this in --print mode to ensure clean output. + */ + quietMode?: boolean } interface ExtensionModule { @@ -153,7 +158,12 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac super() this.options = options - this.options.integrationTest = true + + // Set up quiet mode early, before any extension code runs + // This suppresses console output from the extension during load + if (this.options.quietMode) { + this.setupQuietMode() + } // Initialize client - single source of truth for agent state (including mode). this.client = new ExtensionClient({ @@ -222,8 +232,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.initialSettings.reasoningEffort = this.options.reasoningEffort } } - - this.setupQuietMode() } // ========================================================================== @@ -267,7 +275,8 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // ========================================================================== private setupQuietMode(): void { - if (this.options.integrationTest) { + // Skip if already set up or if integrationTest mode + if (this.originalConsole || this.options.integrationTest) { return } @@ -292,18 +301,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac } private restoreConsole(): void { - if (this.options.integrationTest) { + if (!this.originalConsole) { return } - if (this.originalConsole) { - console.log = this.originalConsole.log - console.warn = this.originalConsole.warn - console.error = this.originalConsole.error - console.debug = this.originalConsole.debug - console.info = this.originalConsole.info - this.originalConsole = null - } + console.log = this.originalConsole.log + console.warn = this.originalConsole.warn + console.error = this.originalConsole.error + console.debug = this.originalConsole.debug + console.info = this.originalConsole.info + this.originalConsole = null if (this.originalProcessEmitWarning) { process.emitWarning = this.originalProcessEmitWarning diff --git a/apps/cli/src/agent/index.ts b/apps/cli/src/agent/index.ts index 23cbaacb4d1..7298d506e9d 100644 --- a/apps/cli/src/agent/index.ts +++ b/apps/cli/src/agent/index.ts @@ -1 +1,2 @@ export * from "./extension-host.js" +export * from "./json-event-emitter.js" diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts new file mode 100644 index 00000000000..a1a404e5556 --- /dev/null +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -0,0 +1,464 @@ +/** + * JsonEventEmitter - Handles structured JSON output for the CLI + * + * This class transforms internal CLI events (ClineMessage, state changes, etc.) + * into structured JSON events and outputs them to stdout. + * + * Supports two output modes: + * - "stream-json": NDJSON format (one JSON object per line) for real-time streaming + * - "json": Single JSON object at the end with accumulated events + * + * Schema is optimized for efficiency with high message volume: + * - Minimal fields per event + * - No redundant wrappers + * - `done` flag instead of partial:false + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js" + +import type { ExtensionClient } from "./extension-client.js" +import type { TaskCompletedEvent } from "./events.js" + +/** + * Options for JsonEventEmitter. + */ +export interface JsonEventEmitterOptions { + /** Output mode: "json" or "stream-json" */ + mode: "json" | "stream-json" + /** Output stream (defaults to process.stdout) */ + stdout?: NodeJS.WriteStream +} + +/** + * Parse tool information from a ClineMessage text field. + * Tool messages are JSON with a `tool` field containing the tool name. + */ +function parseToolInfo(text: string | undefined): { name: string; input: Record } | null { + if (!text) return null + try { + const parsed = JSON.parse(text) + return parsed.tool ? { name: parsed.tool, input: parsed } : null + } catch { + return null + } +} + +/** + * Parse API request cost information from api_req_started message text. + */ +function parseApiReqCost(text: string | undefined): JsonEventCost | undefined { + if (!text) return undefined + try { + const parsed = JSON.parse(text) + return parsed.cost !== undefined + ? { + totalCost: parsed.cost, + inputTokens: parsed.tokensIn, + outputTokens: parsed.tokensOut, + cacheWrites: parsed.cacheWrites, + cacheReads: parsed.cacheReads, + } + : undefined + } catch { + return undefined + } +} + +/** Internal events that should not be emitted */ +const SKIP_SAY_TYPES = new Set([ + "api_req_finished", + "api_req_retried", + "api_req_retry_delayed", + "api_req_rate_limit_wait", + "api_req_deleted", + "checkpoint_saved", + "condense_context", + "condense_context_error", + "sliding_window_truncation", +]) + +/** Key offset for reasoning content to avoid collision with text content delta tracking */ +const REASONING_KEY_OFFSET = 1_000_000_000 + +export class JsonEventEmitter { + private mode: "json" | "stream-json" + private stdout: NodeJS.WriteStream + private events: JsonEvent[] = [] + private unsubscribers: (() => void)[] = [] + private lastCost: JsonEventCost | undefined + private seenMessageIds = new Set() + // Track previous content for delta computation + private previousContent = new Map() + // Track the completion result content + private completionResultContent: string | undefined + + constructor(options: JsonEventEmitterOptions) { + this.mode = options.mode + this.stdout = options.stdout ?? process.stdout + } + + /** + * Attach to an ExtensionClient and subscribe to its events. + */ + attachToClient(client: ExtensionClient): void { + // Subscribe to message events + const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false)) + const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true)) + const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event)) + const unsubError = client.on("error", (error) => this.handleError(error)) + + this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubTaskCompleted, unsubError) + + // Emit init event + this.emitEvent({ + type: "system", + subtype: "init", + content: "Task started", + }) + } + + /** + * Detach from the client and clean up subscriptions. + */ + detach(): void { + for (const unsub of this.unsubscribers) { + unsub() + } + this.unsubscribers = [] + } + + /** + * Compute the delta (new content) for a streaming message. + * Returns null if there's no new content. + */ + private computeDelta(msgId: number, fullContent: string | undefined): string | null { + if (!fullContent) return null + + const previous = this.previousContent.get(msgId) || "" + if (fullContent === previous) return null + + this.previousContent.set(msgId, fullContent) + // If content is appended, return only the new part + return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent + } + + /** + * Check if this is a streaming partial message with no new content. + */ + private isEmptyStreamingDelta(content: string | null): boolean { + return this.mode === "stream-json" && content === null + } + + /** + * Get content to send for a message (delta for streaming, full for json mode). + */ + private getContentToSend(msgId: number, text: string | undefined, isPartial: boolean): string | null { + if (this.mode === "stream-json" && isPartial) { + return this.computeDelta(msgId, text) + } + return text ?? null + } + + /** + * Build a base event with optional done flag. + */ + private buildTextEvent( + type: "assistant" | "thinking" | "user", + id: number, + content: string | null, + isDone: boolean, + subtype?: string, + ): JsonEvent { + const event: JsonEvent = { type, id } + if (content !== null) { + event.content = content + } + if (subtype) { + event.subtype = subtype + } + if (isDone) { + event.done = true + } + return event + } + + /** + * Handle a ClineMessage and emit the appropriate JSON event. + */ + private handleMessage(msg: ClineMessage, _isUpdate: boolean): void { + const isDone = !msg.partial + + // In json mode, only emit complete (non-partial) messages + if (this.mode === "json" && msg.partial) { + return + } + + // Skip duplicate complete messages + if (isDone && this.seenMessageIds.has(msg.ts)) { + return + } + + if (isDone) { + this.seenMessageIds.add(msg.ts) + this.previousContent.delete(msg.ts) + } + + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + + if (msg.type === "say" && msg.say) { + this.handleSayMessage(msg, contentToSend, isDone) + } + + if (msg.type === "ask" && msg.ask) { + this.handleAskMessage(msg, contentToSend, isDone) + } + } + + /** + * Handle "say" type messages. + */ + private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { + switch (msg.say) { + case "text": + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + break + + case "reasoning": + this.handleReasoningMessage(msg, isDone) + break + + case "error": + this.emitEvent({ type: "error", id: msg.ts, content: contentToSend ?? undefined }) + break + + case "command_output": + this.emitEvent({ + type: "tool_result", + tool_result: { name: "execute_command", output: msg.text }, + }) + break + + case "user_feedback": + case "user_feedback_diff": + this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + break + + case "api_req_started": { + const cost = parseApiReqCost(msg.text) + if (cost) { + this.lastCost = cost + } + break + } + + case "browser_action": + case "browser_action_result": + this.emitEvent({ + type: "tool_result", + subtype: "browser", + tool_result: { name: "browser_action", output: msg.text }, + }) + break + + case "mcp_server_response": + this.emitEvent({ + type: "tool_result", + subtype: "mcp", + tool_result: { name: "mcp_server", output: msg.text }, + }) + break + + case "completion_result": + if (msg.text && !msg.partial) { + this.completionResultContent = msg.text + } + break + + default: + if (SKIP_SAY_TYPES.has(msg.say!)) { + break + } + if (msg.text) { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.say)) + } + break + } + } + + /** + * Handle reasoning/thinking messages with separate delta tracking. + */ + private handleReasoningMessage(msg: ClineMessage, isDone: boolean): void { + const reasoningContent = msg.reasoning || msg.text + const reasoningKey = msg.ts + REASONING_KEY_OFFSET + const reasoningDelta = this.getContentToSend(reasoningKey, reasoningContent, msg.partial ?? false) + + if (msg.partial && this.isEmptyStreamingDelta(reasoningDelta)) { + return + } + + if (!msg.partial) { + this.previousContent.delete(reasoningKey) + } + + this.emitEvent(this.buildTextEvent("thinking", msg.ts, reasoningDelta, isDone)) + } + + /** + * Handle "ask" type messages. + */ + private handleAskMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { + switch (msg.ask) { + case "tool": { + const toolInfo = parseToolInfo(msg.text) + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "tool", + tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } }, + }) + break + } + + case "command": + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "command", + tool_use: { name: "execute_command", input: { command: msg.text } }, + }) + break + + case "browser_action_launch": + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "browser", + tool_use: { name: "browser_action", input: { raw: msg.text } }, + }) + break + + case "use_mcp_server": + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "mcp", + tool_use: { name: "mcp_server", input: { raw: msg.text } }, + }) + break + + case "followup": + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup")) + break + + case "command_output": + // Handled in say type + break + + case "completion_result": + if (msg.text && !msg.partial) { + this.completionResultContent = msg.text + } + break + + default: + if (msg.text) { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask)) + } + break + } + } + + /** + * Handle task completion and emit result event. + */ + private handleTaskCompleted(event: TaskCompletedEvent): void { + // Use tracked completion result content, falling back to event message + const resultContent = this.completionResultContent || event.message?.text + + this.emitEvent({ + type: "result", + id: event.message?.ts ?? Date.now(), + content: resultContent, + done: true, + success: event.success, + cost: this.lastCost, + }) + + // For "json" mode, output the final accumulated result + if (this.mode === "json") { + this.outputFinalResult(event.success, resultContent) + } + } + + /** + * Handle errors and emit error event. + */ + private handleError(error: Error): void { + this.emitEvent({ + type: "error", + id: Date.now(), + content: error.message, + }) + } + + /** + * Emit a JSON event. + * For stream-json mode: immediately output to stdout + * For json mode: accumulate for final output + */ + private emitEvent(event: JsonEvent): void { + this.events.push(event) + + if (this.mode === "stream-json") { + this.outputLine(event) + } + } + + /** + * Output a single JSON line (NDJSON format). + */ + private outputLine(data: unknown): void { + this.stdout.write(JSON.stringify(data) + "\n") + } + + /** + * Output the final accumulated result (for "json" mode). + */ + private outputFinalResult(success: boolean, content?: string): void { + const output: JsonFinalOutput = { + type: "result", + success, + content, + cost: this.lastCost, + events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself + } + + this.stdout.write(JSON.stringify(output, null, 2) + "\n") + } + + /** + * Get accumulated events (for testing or external use). + */ + getEvents(): JsonEvent[] { + return [...this.events] + } + + /** + * Clear accumulated events and state. + */ + clear(): void { + this.events = [] + this.lastCost = undefined + this.seenMessageIds.clear() + this.previousContent.clear() + this.completionResultContent = undefined + } +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 86ede4c8133..6cf04bde6bd 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -11,11 +11,13 @@ import { isSupportedProvider, OnboardingProviderChoice, supportedProviders, - ASCII_ROO, DEFAULT_FLAGS, REASONING_EFFORTS, SDK_BASE_URL, + OutputFormat, } from "@/types/index.js" +import { isValidOutputFormat } from "@/types/json-events.js" +import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js" @@ -164,6 +166,23 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.exit(1) } + // Validate output format + const outputFormat: OutputFormat = (flagOptions.outputFormat as OutputFormat) || "text" + + if (!isValidOutputFormat(outputFormat)) { + console.error( + `[CLI] Error: Invalid output format: ${flagOptions.outputFormat}; must be one of: text, json, stream-json`, + ) + process.exit(1) + } + + // Output format only works with --print mode + if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) { + console.error("[CLI] Error: --output-format requires --print mode") + console.error("[CLI] Usage: roo --print --output-format json") + process.exit(1) + } + if (!isTuiEnabled) { if (!prompt) { console.error("[CLI] Error: prompt is required in print mode") @@ -204,38 +223,54 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.exit(1) } } else { - console.log(ASCII_ROO) - console.log() - console.log( - `[roo] Running ${extensionHostOptions.model || "default"} (${extensionHostOptions.reasoningEffort || "default"}) on ${extensionHostOptions.provider} in ${extensionHostOptions.mode || "default"} mode in ${extensionHostOptions.workspacePath} [debug = ${extensionHostOptions.debug}]`, - ) + const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json" + + extensionHostOptions.quietMode = true + extensionHostOptions.disableOutput = useJsonOutput const host = new ExtensionHost(extensionHostOptions) - process.on("SIGINT", async () => { - console.log("\n[CLI] Received SIGINT, shutting down...") - await host.dispose() - process.exit(130) - }) + const jsonEmitter = useJsonOutput + ? new JsonEventEmitter({ mode: outputFormat as "json" | "stream-json" }) + : null - process.on("SIGTERM", async () => { - console.log("\n[CLI] Received SIGTERM, shutting down...") + async function shutdown(signal: string, exitCode: number): Promise { + if (!useJsonOutput) { + console.log(`\n[CLI] Received ${signal}, shutting down...`) + } + jsonEmitter?.detach() await host.dispose() - process.exit(143) - }) + process.exit(exitCode) + } + + process.on("SIGINT", () => shutdown("SIGINT", 130)) + process.on("SIGTERM", () => shutdown("SIGTERM", 143)) try { await host.activate() + + if (jsonEmitter) { + jsonEmitter.attachToClient(host.client) + } + await host.runTask(prompt!) + jsonEmitter?.detach() await host.dispose() process.exit(0) } catch (error) { - console.error("[CLI] Error:", error instanceof Error ? error.message : String(error)) - - if (error instanceof Error) { - console.error(error.stack) + const errorMessage = error instanceof Error ? error.message : String(error) + + if (useJsonOutput) { + const errorEvent = { type: "error", id: Date.now(), content: errorMessage } + console.log(JSON.stringify(errorEvent)) + } else { + console.error("[CLI] Error:", errorMessage) + if (error instanceof Error) { + console.error(error.stack) + } } + jsonEmitter?.detach() await host.dispose() process.exit(1) } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e6644225622..5b663c2bdcd 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -30,6 +30,11 @@ program ) .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--oneshot", "Exit upon task completion", false) + .option( + "--output-format ", + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + "text", + ) .action(run) const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") diff --git a/apps/cli/src/types/index.ts b/apps/cli/src/types/index.ts index 0ed3db23507..14e5ccf6ec4 100644 --- a/apps/cli/src/types/index.ts +++ b/apps/cli/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./types.js" export * from "./constants.js" +export * from "./json-events.js" diff --git a/apps/cli/src/types/json-events.ts b/apps/cli/src/types/json-events.ts new file mode 100644 index 00000000000..f18f3b27684 --- /dev/null +++ b/apps/cli/src/types/json-events.ts @@ -0,0 +1,120 @@ +/** + * JSON Event Types for Structured CLI Output + * + * This module defines the types for structured JSON output from the CLI. + * The output format is NDJSON (newline-delimited JSON) for stream-json mode, + * or a single JSON object for json mode. + * + * Schema is optimized for efficiency with high message volume: + * - Minimal fields per event + * - No redundant wrappers + * - `done` flag instead of partial:false + */ + +/** + * Output format options for the CLI. + */ +export const OUTPUT_FORMATS = ["text", "json", "stream-json"] as const + +export type OutputFormat = (typeof OUTPUT_FORMATS)[number] + +export function isValidOutputFormat(format: string): format is OutputFormat { + return (OUTPUT_FORMATS as readonly string[]).includes(format) +} + +/** + * Event type discriminators for JSON output. + */ +export type JsonEventType = + | "system" // System messages (init, ready, shutdown) + | "assistant" // Assistant text messages + | "user" // User messages (echoed input) + | "tool_use" // Tool invocations (file ops, commands, browser, MCP) + | "tool_result" // Results from tool execution + | "thinking" // Reasoning/thinking content + | "error" // Errors + | "result" // Final task result + +/** + * Tool use information for tool_use events. + */ +export interface JsonEventToolUse { + /** Tool name (e.g., "read_file", "write_to_file", "execute_command") */ + name: string + /** Tool input parameters */ + input?: Record +} + +/** + * Tool result information for tool_result events. + */ +export interface JsonEventToolResult { + /** Tool name that produced this result */ + name: string + /** Tool output (for successful execution) */ + output?: string + /** Error message (for failed execution) */ + error?: string +} + +/** + * Cost and token usage information. + */ +export interface JsonEventCost { + /** Total cost in USD */ + totalCost?: number + /** Input tokens used */ + inputTokens?: number + /** Output tokens generated */ + outputTokens?: number + /** Cache write tokens */ + cacheWrites?: number + /** Cache read tokens */ + cacheReads?: number +} + +/** + * Base JSON event structure. + * Optimized for minimal payload size. + * + * For streaming deltas: + * - Each delta includes `id` for easy correlation + * - Final message has `done: true` + */ +export interface JsonEvent { + /** Event type discriminator */ + type: JsonEventType + /** Message ID - included on first delta and final message */ + id?: number + /** Content text (for text-based events) */ + content?: string + /** True when this is the final message (stream complete) */ + done?: boolean + /** Optional subtype for more specific categorization */ + subtype?: string + /** Tool use information (for tool_use events) */ + tool_use?: JsonEventToolUse + /** Tool result information (for tool_result events) */ + tool_result?: JsonEventToolResult + /** Whether the task succeeded (for result events) */ + success?: boolean + /** Cost and token usage (for result events) */ + cost?: JsonEventCost +} + +/** + * Final JSON output for "json" mode (single object at end). + * Contains the result and accumulated messages. + */ +export interface JsonFinalOutput { + /** Final result type */ + type: "result" + /** Whether the task succeeded */ + success: boolean + /** Result content/message */ + content?: string + /** Cost and token usage */ + cost?: JsonEventCost + /** All events that occurred during the task */ + events: JsonEvent[] +} diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index d5c71a330f6..05392ccca86 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -1,4 +1,5 @@ import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types" +import type { OutputFormat } from "./json-events.js" export const supportedProviders = [ "anthropic", @@ -32,6 +33,7 @@ export type FlagOptions = { reasoningEffort?: ReasoningEffortFlagOptions ephemeral: boolean oneshot: boolean + outputFormat?: OutputFormat } export enum OnboardingProviderChoice { From 5b36b05d5572465b0bd930b507a62f5a746730ed Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 17 Jan 2026 17:51:44 -0800 Subject: [PATCH 2/3] More cleanup --- apps/cli/src/agent/extension-host.ts | 17 ++++------------- apps/cli/src/commands/cli/run.ts | 1 - 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index b423de8ea72..e1f55a30d1f 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -74,11 +74,6 @@ export interface ExtensionHostOptions { * running in an integration test and we want to see the output. */ integrationTest?: boolean - /** - * When true, suppress all console output from the extension. - * Use this in --print mode to ensure clean output. - */ - quietMode?: boolean } interface ExtensionModule { @@ -159,11 +154,9 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.options = options - // Set up quiet mode early, before any extension code runs - // This suppresses console output from the extension during load - if (this.options.quietMode) { - this.setupQuietMode() - } + // Set up quiet mode early, before any extension code runs. + // This suppresses console output from the extension during load. + this.setupQuietMode() // Initialize client - single source of truth for agent state (including mode). this.client = new ExtensionClient({ @@ -172,9 +165,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac }) // Initialize output manager. - this.outputManager = new OutputManager({ - disabled: options.disableOutput, - }) + this.outputManager = new OutputManager({ disabled: options.disableOutput }) // Initialize prompt manager with console mode callbacks. this.promptManager = new PromptManager({ diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 6cf04bde6bd..61f9d62c18f 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -225,7 +225,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } else { const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json" - extensionHostOptions.quietMode = true extensionHostOptions.disableOutput = useJsonOutput const host = new ExtensionHost(extensionHostOptions) From d9805c48e6f2a537a0181181f7a8a7aca6cb4e68 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 18 Jan 2026 01:55:41 +0000 Subject: [PATCH 3/3] fix: use process.stdout.write for JSON error output instead of suppressed console.log --- apps/cli/src/commands/cli/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 61f9d62c18f..663ed5cf750 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -261,7 +261,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (useJsonOutput) { const errorEvent = { type: "error", id: Date.now(), content: errorMessage } - console.log(JSON.stringify(errorEvent)) + process.stdout.write(JSON.stringify(errorEvent) + "\n") } else { console.error("[CLI] Error:", errorMessage) if (error instanceof Error) {