From b89fe0e599fdb62a74b76afc2945ab9cbab7d8c0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 13:44:20 -0700 Subject: [PATCH 1/4] feat: add read_command_output tool for retrieving truncated command output Implements a new tool that allows the LLM to retrieve full command output when execute_command produces output exceeding the preview threshold. Key components: - ReadCommandOutputTool: Reads persisted output with search/pagination - OutputInterceptor: Intercepts and persists large command outputs to disk - Terminal settings UI: Configuration for output interception behavior - Type definitions for output interception settings The tool supports: - Reading full output beyond the truncated preview - Search/filtering with regex patterns (like grep) - Pagination through large outputs using offset/limit Includes comprehensive tests for ReadCommandOutputTool and OutputInterceptor. --- packages/types/src/global-settings.ts | 37 ++ packages/types/src/terminal.ts | 66 ++ packages/types/src/tool.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + pnpm-lock.yaml | 11 + .../presentAssistantMessage.ts | 13 +- src/core/message-manager/index.ts | 53 ++ src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/read_command_output.ts | 77 +++ src/core/task/Task.ts | 12 + src/core/tools/ExecuteCommandTool.ts | 124 +++- src/core/tools/ReadCommandOutputTool.ts | 380 ++++++++++++ .../__tests__/ReadCommandOutputTool.test.ts | 571 ++++++++++++++++++ .../terminal/OutputInterceptor.ts | 272 +++++++++ .../__tests__/OutputInterceptor.test.ts | 481 +++++++++++++++ src/integrations/terminal/index.ts | 57 ++ src/package.json | 1 + src/shared/tools.ts | 8 +- .../src/components/settings/SettingsView.tsx | 5 +- .../components/settings/TerminalSettings.tsx | 94 +-- .../src/context/ExtensionStateContext.tsx | 4 + webview-ui/src/i18n/locales/en/settings.json | 9 + 22 files changed, 2206 insertions(+), 73 deletions(-) create mode 100644 src/core/prompts/tools/native-tools/read_command_output.ts create mode 100644 src/core/tools/ReadCommandOutputTool.ts create mode 100644 src/core/tools/__tests__/ReadCommandOutputTool.test.ts create mode 100644 src/integrations/terminal/OutputInterceptor.ts create mode 100644 src/integrations/terminal/__tests__/OutputInterceptor.test.ts create mode 100644 src/integrations/terminal/index.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 383678059e9..cb785e46069 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -29,6 +29,42 @@ export const DEFAULT_WRITE_DELAY_MS = 1000 */ export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000 +/** + * Terminal output preview size options for persisted command output. + * + * Controls how much command output is kept in memory as a "preview" before + * the LLM decides to retrieve more via `read_command_output`. Larger previews + * mean more immediate context but consume more of the context window. + * + * - `small`: 2KB preview - Best for long-running commands with verbose output + * - `medium`: 4KB preview - Balanced default for most use cases + * - `large`: 8KB preview - Best when commands produce critical info early + * + * @see OutputInterceptor - Uses this setting to determine when to spill to disk + * @see PersistedCommandOutput - Contains the resulting preview and artifact reference + */ +export type TerminalOutputPreviewSize = "small" | "medium" | "large" + +/** + * Byte limits for each terminal output preview size. + * + * Maps preview size names to their corresponding byte thresholds. + * When command output exceeds these thresholds, the excess is persisted + * to disk and made available via the `read_command_output` tool. + */ +export const TERMINAL_PREVIEW_BYTES: Record = { + small: 2048, // 2KB + medium: 4096, // 4KB + large: 8192, // 8KB +} + +/** + * Default terminal output preview size. + * The "medium" (4KB) setting provides a good balance between immediate + * visibility and context window conservation for most use cases. + */ +export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium" + /** * Minimum checkpoint timeout in seconds. */ @@ -149,6 +185,7 @@ export const globalSettingsSchema = z.object({ terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), + terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(), terminalShellIntegrationTimeout: z.number().optional(), terminalShellIntegrationDisabled: z.boolean().optional(), terminalCommandDelay: z.number().optional(), diff --git a/packages/types/src/terminal.ts b/packages/types/src/terminal.ts index ffa1ffe7811..34f7a74e244 100644 --- a/packages/types/src/terminal.ts +++ b/packages/types/src/terminal.ts @@ -32,3 +32,69 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [ ]) export type CommandExecutionStatus = z.infer + +/** + * PersistedCommandOutput + * + * Represents the result of a terminal command execution that may have been + * truncated and persisted to disk. + * + * When command output exceeds the configured preview threshold, the full + * output is saved to a disk artifact file. The LLM receives this structure + * which contains: + * - A preview of the output (for immediate display in context) + * - Metadata about the full output (size, truncation status) + * - A path to the artifact file for later retrieval via `read_command_output` + * + * ## Usage in execute_command Response + * + * The response format depends on whether truncation occurred: + * + * **Not truncated** (output fits in preview): + * ```json + * { + * "preview": "full output here...", + * "totalBytes": 1234, + * "artifactPath": null, + * "truncated": false + * } + * ``` + * + * **Truncated** (output exceeded threshold): + * ```json + * { + * "preview": "first 4KB of output...", + * "totalBytes": 1048576, + * "artifactPath": "/path/to/tasks/123/command-output/cmd-1706119234567.txt", + * "truncated": true + * } + * ``` + * + * @see OutputInterceptor - Creates these results during command execution + * @see ReadCommandOutputTool - Retrieves full content from artifact files + */ +export interface PersistedCommandOutput { + /** + * Preview of the command output, truncated to the preview threshold. + * Always contains the beginning of the output, even if truncated. + */ + preview: string + + /** + * Total size of the command output in bytes. + * Useful for determining if additional reads are needed. + */ + totalBytes: number + + /** + * Absolute path to the artifact file containing full output. + * `null` if output wasn't truncated (no artifact was created). + */ + artifactPath: string | null + + /** + * Whether the output was truncated (exceeded preview threshold). + * When `true`, use `read_command_output` to retrieve full content. + */ + truncated: boolean +} diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 147eb24b6cc..f90ef42ede4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -17,6 +17,7 @@ export type ToolGroup = z.infer export const toolNames = [ "execute_command", "read_file", + "read_command_output", "write_to_file", "apply_diff", "search_and_replace", diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index d3f1d9ab0b6..fa3d66fa744 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -302,6 +302,7 @@ export type ExtensionState = Pick< | "maxConcurrentFileReads" | "terminalOutputLineLimit" | "terminalOutputCharacterLimit" + | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" | "terminalCommandDelay" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944c4918897..60bff02c0b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,6 +1013,9 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/json-stream-stringify': + specifier: ^2.0.4 + version: 2.0.4 '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 @@ -4265,6 +4268,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json-stream-stringify@2.0.4': + resolution: {integrity: sha512-xSFsVnoQ8Y/7BiVF3/fEIwRx9RoGzssDKVwhy1g23wkA4GAmA3v8lsl6CxsmUD6vf4EiRd+J0ULLkMbAWRSsgQ==} + deprecated: This is a stub types definition. json-stream-stringify provides its own type definitions, so you do not need this installed. + '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} @@ -14241,6 +14248,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json-stream-stringify@2.0.4': + dependencies: + json-stream-stringify: 3.1.6 + '@types/katex@0.16.7': {} '@types/lodash.debounce@4.0.9': diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index e3f652d352a..019b84aa15d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,6 +17,7 @@ import { Task } from "../task/Task" import { fetchInstructionsTool } from "../tools/FetchInstructionsTool" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" +import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" import { writeToFileTool } from "../tools/WriteToFileTool" import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" import { searchReplaceTool } from "../tools/SearchReplaceTool" @@ -395,8 +396,10 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name}]` case "switch_mode": return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` - case "codebase_search": // Add case for the new tool + case "codebase_search": return `[${block.name} for '${block.params.query}']` + case "read_command_output": + return `[${block.name} for '${block.params.artifact_id}']` case "update_todo_list": return `[${block.name}]` case "new_task": { @@ -833,6 +836,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "read_command_output": + await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { + askApproval, + handleError, + pushToolResult, + }) + break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { askApproval, @@ -1074,6 +1084,7 @@ function containsXmlToolMarkup(text: string): boolean { "generate_image", "list_files", "new_task", + "read_command_output", "read_file", "search_and_replace", "search_files", diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index e35f290c398..4b68be0825c 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -1,7 +1,10 @@ +import * as path from "path" import { Task } from "../task/Task" import { ClineMessage } from "@roo-code/types" import { ApiMessage } from "../task-persistence/apiMessages" import { cleanupAfterTruncation } from "../condense" +import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" +import { getTaskDirectoryPath } from "../../utils/storage" export interface RewindOptions { /** Whether to include the target message in deletion (edit=true, delete=false) */ @@ -207,6 +210,32 @@ export class MessageManager { apiHistory = cleanupAfterTruncation(apiHistory) } + // Step 6: Cleanup orphaned command output artifacts + // Collect timestamps from remaining messages to identify valid artifact IDs + // Artifacts whose IDs don't match any remaining message timestamp will be removed + if (!skipCleanup) { + const validIds = new Set() + + // Collect timestamps from remaining clineMessages + for (const msg of this.task.clineMessages) { + if (msg.ts) { + validIds.add(String(msg.ts)) + } + } + + // Collect timestamps from remaining apiHistory + for (const msg of apiHistory) { + if (msg.ts) { + validIds.add(String(msg.ts)) + } + } + + // Cleanup artifacts asynchronously (fire-and-forget with error handling) + this.cleanupOrphanedArtifacts(validIds).catch((error) => { + console.error("[MessageManager] Error cleaning up orphaned command output artifacts:", error) + }) + } + // Only write if the history actually changed const historyChanged = apiHistory.length !== originalHistory.length || apiHistory.some((msg, i) => msg !== originalHistory[i]) @@ -215,4 +244,28 @@ export class MessageManager { await this.task.overwriteApiConversationHistory(apiHistory) } } + + /** + * Cleanup orphaned command output artifacts. + * Removes artifact files whose execution IDs don't match any remaining message timestamps. + */ + private async cleanupOrphanedArtifacts(validIds: Set): Promise { + try { + // Access globalStoragePath and taskId through the task reference + const task = this.task as any // Access private member + const globalStoragePath = task.globalStoragePath + const taskId = task.taskId + + if (!globalStoragePath || !taskId) { + return + } + + const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const outputDir = path.join(taskDir, "command-output") + await OutputInterceptor.cleanupByIds(outputDir, validIds) + } catch (error) { + // Silently fail - cleanup is best-effort + console.debug("[MessageManager] Artifact cleanup skipped:", error) + } + } } diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 4f78729cdc8..b6af18fa154 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -11,6 +11,7 @@ import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" +import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" @@ -65,6 +66,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch generateImage, listFiles, newTask, + readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, searchAndReplace, diff --git a/src/core/prompts/tools/native-tools/read_command_output.ts b/src/core/prompts/tools/native-tools/read_command_output.ts new file mode 100644 index 00000000000..0bab31be9e1 --- /dev/null +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -0,0 +1,77 @@ +import type OpenAI from "openai" + +/** + * Native tool definition for read_command_output. + * + * This tool allows the LLM to retrieve full command output that was truncated + * during execute_command. When command output exceeds the preview threshold, + * the full output is persisted to disk and an artifact_id is provided. The + * LLM can then use this tool to read the full content or search within it. + */ + +const READ_COMMAND_OUTPUT_DESCRIPTION = `Retrieve the full output from a command that was truncated in execute_command. Use this tool when: +1. The execute_command result shows "[OUTPUT TRUNCATED - Full output saved to artifact: cmd-XXXX.txt]" +2. You need to see more of the command output beyond the preview +3. You want to search for specific content in large command output + +The tool supports two modes: +- **Read mode**: Read output starting from a byte offset with optional limit +- **Search mode**: Filter lines matching a regex or literal pattern (like grep) + +Parameters: +- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt") +- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive. +- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination. +- limit: (optional) Maximum bytes to return. Default: 32KB. + +Example: Reading truncated command output +{ "artifact_id": "cmd-1706119234567.txt" } + +Example: Reading with pagination (after first 32KB) +{ "artifact_id": "cmd-1706119234567.txt", "offset": 32768 } + +Example: Searching for errors in build output +{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" } + +Example: Finding specific test failures +{ "artifact_id": "cmd-1706119234567.txt", "search": "FAIL" }` + +const ARTIFACT_ID_DESCRIPTION = `The artifact filename from the truncated command output (e.g., "cmd-1706119234567.txt")` + +const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep)` + +const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)` + +const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)` + +export default { + type: "function", + function: { + name: "read_command_output", + description: READ_COMMAND_OUTPUT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + artifact_id: { + type: "string", + description: ARTIFACT_ID_DESCRIPTION, + }, + search: { + type: ["string", "null"], + description: SEARCH_DESCRIPTION, + }, + offset: { + type: ["number", "null"], + description: OFFSET_DESCRIPTION, + }, + limit: { + type: ["number", "null"], + description: LIMIT_DESCRIPTION, + }, + }, + required: ["artifact_id", "search", "offset", "limit"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d2be23714e2..db79eb96ae9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -84,11 +84,13 @@ import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" import { findToolName } from "../../integrations/misc/export-markdown" import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" +import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" // utils import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" import { getWorkspacePath } from "../../utils/path" import { sanitizeToolUseId } from "../../utils/tool-id" +import { getTaskDirectoryPath } from "../../utils/storage" // prompts import { formatResponse } from "../prompts/responses" @@ -2252,6 +2254,16 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error releasing terminals:", error) } + // Cleanup command output artifacts + getTaskDirectoryPath(this.globalStoragePath, this.taskId) + .then((taskDir) => { + const outputDir = path.join(taskDir, "command-output") + return OutputInterceptor.cleanup(outputDir) + }) + .catch((error) => { + console.error("Error cleaning up command output artifacts:", error) + }) + try { this.urlContentFetcher.closeBrowser() } catch (error) { diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index d3e2bbce8df..2bbc066badc 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -4,7 +4,12 @@ import * as vscode from "vscode" import delay from "delay" -import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types" +import { + CommandExecutionStatus, + DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, + DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, + PersistedCommandOutput, +} from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" @@ -15,8 +20,10 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" +import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" import { Package } from "../../shared/package" import { t } from "../../i18n" +import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" class ShellIntegrationError extends Error {} @@ -185,6 +192,7 @@ export async function executeCommandInTerminal( let runInBackground = false let completed = false let result: string = "" + let persistedResult: PersistedCommandOutput | undefined let exitDetails: ExitCodeDetails | undefined let shellIntegrationError: string | undefined let hasAskedForCommandOutput = false @@ -192,10 +200,38 @@ export async function executeCommandInTerminal( const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode" const provider = await task.providerRef.deref() + // Get global storage path for persisted output artifacts + const globalStoragePath = provider?.context?.globalStorageUri?.fsPath + let interceptor: OutputInterceptor | undefined + + // Create OutputInterceptor if we have storage available + if (globalStoragePath) { + const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId) + const storageDir = path.join(taskDir, "command-output") + // Use default preview size for now (terminalOutputPreviewSize setting will be added in Phase 5) + const terminalOutputPreviewSize = DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE + const providerState = await provider?.getState() + const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true + + interceptor = new OutputInterceptor({ + executionId, + taskId: task.taskId, + command, + storageDir, + previewSize: terminalOutputPreviewSize, + compressProgressBar: terminalCompressProgressBar, + }) + } + let accumulatedOutput = "" const callbacks: RooTerminalCallbacks = { onLine: async (lines: string, process: RooTerminalProcess) => { accumulatedOutput += lines + + // Write to interceptor for persisted output + interceptor?.write(lines) + + // Continue sending compressed output to webview for UI display (unchanged behavior) const compressedOutput = Terminal.compressTerminalOutput( accumulatedOutput, terminalOutputLineLimit, @@ -224,6 +260,12 @@ export async function executeCommandInTerminal( } }, onCompleted: (output: string | undefined) => { + // Finalize interceptor and get persisted result + if (interceptor) { + persistedResult = interceptor.finalize() + } + + // Continue using compressed output for UI display result = Terminal.compressTerminalOutput( output ?? "", terminalOutputLineLimit, @@ -337,6 +379,14 @@ export async function executeCommandInTerminal( ), ] } else if (completed || exitDetails) { + const currentWorkingDir = terminal.getCurrentWorkingDirectory().toPosix() + + // Use persisted output format when output was truncated and spilled to disk + if (persistedResult?.truncated) { + return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)] + } + + // Use inline format for small outputs (original behavior with exit status) let exitStatus: string = "" if (exitDetails !== undefined) { @@ -361,9 +411,10 @@ export async function executeCommandInTerminal( exitStatus = `Exit code: ` } - let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'` - - return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`] + return [ + false, + `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, + ] } else { return [ false, @@ -376,4 +427,69 @@ export async function executeCommandInTerminal( } } +/** + * Format exit status from ExitCodeDetails + */ +function formatExitStatus(exitDetails: ExitCodeDetails | undefined): string { + if (exitDetails === undefined) { + return "Exit code: " + } + + if (exitDetails.signalName) { + let status = `Process terminated by signal ${exitDetails.signalName}` + if (exitDetails.coreDumpPossible) { + status += " - core dump possible" + } + return status + } + + if (exitDetails.exitCode === undefined) { + return "Exit code: " + } + + let status = "" + if (exitDetails.exitCode !== 0) { + status += "Command execution was not successful, inspect the cause and adjust as needed.\n" + } + status += `Exit code: ${exitDetails.exitCode}` + return status +} + +/** + * Format persisted output result for tool response when output was truncated + */ +function formatPersistedOutput( + result: PersistedCommandOutput, + exitDetails: ExitCodeDetails | undefined, + workingDir: string, +): string { + const exitStatus = formatExitStatus(exitDetails) + const sizeStr = formatBytes(result.totalBytes) + const artifactId = result.artifactPath ? path.basename(result.artifactPath) : "" + + return [ + `Command executed in '${workingDir}'. ${exitStatus}`, + "", + `Output (${sizeStr}) persisted. Artifact ID: ${artifactId}`, + "", + "Preview:", + result.preview, + "", + "Use read_command_output tool to view full output if needed.", + ].join("\n") +} + +/** + * Format bytes to human-readable string + */ +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B` + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB` + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + export const executeCommandTool = new ExecuteCommandTool() diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts new file mode 100644 index 00000000000..d81352c30a8 --- /dev/null +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -0,0 +1,380 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { Task } from "../task/Task" +import { getTaskDirectoryPath } from "../../utils/storage" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** Default byte limit for read operations (32KB) */ +const DEFAULT_LIMIT = 32 * 1024 // 32KB default limit + +/** + * Parameters accepted by the read_command_output tool. + */ +interface ReadCommandOutputParams { + /** + * The artifact file identifier (e.g., "cmd-1706119234567.txt"). + * This is provided in the execute_command output when truncation occurs. + */ + artifact_id: string + /** + * Optional search pattern (regex or literal string) to filter lines. + * When provided, only lines matching the pattern are returned. + */ + search?: string + /** + * Byte offset to start reading from (default: 0). + * Used for paginating through large outputs. + */ + offset?: number + /** + * Maximum bytes to return (default: 32KB). + * Limits the amount of data returned in a single request. + */ + limit?: number +} + +/** + * ReadCommandOutputTool allows the LLM to retrieve full command output that was truncated. + * + * When `execute_command` produces output exceeding the preview threshold, the full output + * is persisted to disk by the `OutputInterceptor`. This tool enables the LLM to: + * + * 1. **Read full output**: Retrieve the complete command output beyond the preview + * 2. **Search output**: Filter lines matching a pattern (like grep) + * 3. **Paginate**: Read large outputs in chunks using offset/limit + * + * ## Storage Location + * + * Artifacts are stored outside the workspace in the task directory: + * `globalStoragePath/tasks/{taskId}/command-output/cmd-{executionId}.txt` + * + * ## Security + * + * The tool validates artifact_id format to prevent path traversal attacks. + * Only files matching `cmd-{digits}.txt` pattern are accessible. + * + * ## Usage Flow + * + * 1. LLM calls `execute_command` which runs a command + * 2. If output is large, response includes `artifact_id` and truncation notice + * 3. LLM calls `read_command_output` with the artifact_id to get more content + * + * @example + * ```typescript + * // Basic usage - read from beginning + * await readCommandOutputTool.execute({ + * artifact_id: "cmd-1706119234567.txt" + * }, task, callbacks); + * + * // Search for specific content + * await readCommandOutputTool.execute({ + * artifact_id: "cmd-1706119234567.txt", + * search: "error|failed" + * }, task, callbacks); + * + * // Paginate through large output + * await readCommandOutputTool.execute({ + * artifact_id: "cmd-1706119234567.txt", + * offset: 32768, // Start after first 32KB + * limit: 32768 // Read next 32KB + * }, task, callbacks); + * ``` + */ +export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { + readonly name = "read_command_output" as const + + /** + * Execute the read_command_output tool. + * + * Reads persisted command output from disk, supporting both full reads and + * search-based filtering. Results include line numbers for easy reference. + * + * @param params - The tool parameters including artifact_id and optional search/pagination + * @param task - The current task instance for error reporting and state management + * @param callbacks - Callbacks for pushing tool results + */ + async execute(params: ReadCommandOutputParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + const { artifact_id, search, offset = 0, limit = DEFAULT_LIMIT } = params + + // Validate required parameters + if (!artifact_id) { + task.consecutiveMistakeCount++ + task.recordToolError("read_command_output") + task.didToolFailInCurrentTurn = true + const errorMsg = await task.sayAndCreateMissingParamError("read_command_output", "artifact_id") + pushToolResult(`Error: ${errorMsg}`) + return + } + + // Validate artifact_id format to prevent path traversal + if (!this.isValidArtifactId(artifact_id)) { + task.consecutiveMistakeCount++ + task.recordToolError("read_command_output") + task.didToolFailInCurrentTurn = true + const errorMsg = `Invalid artifact_id format: "${artifact_id}". Expected format: cmd-{timestamp}.txt (e.g., "cmd-1706119234567.txt")` + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + + try { + // Get the task directory path + const provider = await task.providerRef.deref() + const globalStoragePath = provider?.context?.globalStorageUri?.fsPath + + if (!globalStoragePath) { + const errorMsg = "Unable to access command output storage. Global storage path is not available." + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + + const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId) + const artifactPath = path.join(taskDir, "command-output", artifact_id) + + // Check if artifact exists + try { + await fs.access(artifactPath) + } catch { + const errorMsg = `Artifact not found: "${artifact_id}". Please verify the artifact_id from the command output message. Available artifacts are created when command output exceeds the preview size.` + await task.say("error", errorMsg) + task.didToolFailInCurrentTurn = true + pushToolResult(`Error: ${errorMsg}`) + return + } + + // Get file stats for metadata + const stats = await fs.stat(artifactPath) + const totalSize = stats.size + + // Validate offset + if (offset < 0 || offset >= totalSize) { + const errorMsg = `Invalid offset: ${offset}. File size is ${totalSize} bytes. Offset must be between 0 and ${totalSize - 1}.` + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + + let result: string + + if (search) { + // Search mode: filter lines matching the pattern + result = await this.searchInArtifact(artifactPath, search, totalSize, limit) + } else { + // Normal read mode with offset/limit + result = await this.readArtifact(artifactPath, offset, limit, totalSize) + } + + task.consecutiveMistakeCount = 0 + pushToolResult(result) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + await task.say("error", `Error reading command output: ${errorMsg}`) + task.didToolFailInCurrentTurn = true + pushToolResult(`Error reading command output: ${errorMsg}`) + } + } + + /** + * Validate artifact_id format to prevent path traversal attacks. + * + * Only accepts IDs matching the pattern `cmd-{digits}.txt` which are + * generated by the OutputInterceptor. This prevents malicious paths + * like `../../../etc/passwd` from being used. + * + * @param artifactId - The artifact ID to validate + * @returns `true` if the format is valid, `false` otherwise + * @private + */ + private isValidArtifactId(artifactId: string): boolean { + // Only allow alphanumeric, hyphens, underscores, and dots + // Must match pattern cmd-{digits}.txt + const validPattern = /^cmd-\d+\.txt$/ + return validPattern.test(artifactId) + } + + /** + * Read artifact content with offset and limit, adding line numbers. + * + * Performs efficient partial file reads using file handles and positional + * reads. Line numbers are calculated by counting newlines in the portion + * of the file before the offset. + * + * @param artifactPath - Absolute path to the artifact file + * @param offset - Byte offset to start reading from + * @param limit - Maximum bytes to read + * @param totalSize - Total size of the file in bytes + * @returns Formatted output with header metadata and line-numbered content + * @private + */ + private async readArtifact( + artifactPath: string, + offset: number, + limit: number, + totalSize: number, + ): Promise { + const fileHandle = await fs.open(artifactPath, "r") + + try { + const buffer = Buffer.alloc(Math.min(limit, totalSize - offset)) + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset) + const content = buffer.slice(0, bytesRead).toString("utf8") + + // Calculate line numbers based on offset + let startLineNumber = 1 + if (offset > 0) { + // Count newlines before offset to determine starting line number + const prefixBuffer = Buffer.alloc(offset) + await fileHandle.read(prefixBuffer, 0, offset, 0) + const prefix = prefixBuffer.toString("utf8") + startLineNumber = (prefix.match(/\n/g) || []).length + 1 + } + + const endOffset = offset + bytesRead + const truncated = endOffset < totalSize + const artifactId = path.basename(artifactPath) + + // Add line numbers to content + const numberedContent = this.addLineNumbers(content, startLineNumber) + + const header = [ + `[Command Output: ${artifactId}]`, + `Total size: ${this.formatBytes(totalSize)} | Showing bytes ${offset}-${endOffset} | ${truncated ? "TRUNCATED" : "COMPLETE"}`, + "", + ].join("\n") + + return header + numberedContent + } finally { + await fileHandle.close() + } + } + + /** + * Search artifact content for lines matching a pattern. + * + * Performs grep-like searching through the artifact file. The pattern + * is treated as a case-insensitive regex. If the pattern is invalid + * regex syntax, it's escaped and treated as a literal string. + * + * Results are limited by the byte limit to prevent excessive output. + * + * @param artifactPath - Absolute path to the artifact file + * @param pattern - Search pattern (regex or literal string) + * @param totalSize - Total size of the file in bytes (for display) + * @param limit - Maximum bytes of matching content to return + * @returns Formatted output with matching lines and their line numbers + * @private + */ + private async searchInArtifact( + artifactPath: string, + pattern: string, + totalSize: number, + limit: number, + ): Promise { + // Read the entire file for search (we need all content to search) + const content = await fs.readFile(artifactPath, "utf8") + const lines = content.split("\n") + + // Create case-insensitive regex for search + let regex: RegExp + try { + regex = new RegExp(pattern, "i") + } catch { + // If invalid regex, treat as literal string + regex = new RegExp(this.escapeRegExp(pattern), "i") + } + + // Find matching lines with their line numbers + const matches: Array<{ lineNumber: number; content: string }> = [] + let totalMatchBytes = 0 + + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + const lineContent = lines[i] + const lineBytes = Buffer.byteLength(lineContent, "utf8") + + // Stop if we've exceeded the byte limit + if (totalMatchBytes + lineBytes > limit) { + break + } + + matches.push({ lineNumber: i + 1, content: lineContent }) + totalMatchBytes += lineBytes + } + } + + const artifactId = path.basename(artifactPath) + + if (matches.length === 0) { + return [ + `[Command Output: ${artifactId}] (search: "${pattern}")`, + `Total size: ${this.formatBytes(totalSize)}`, + "", + "No matches found for the search pattern.", + ].join("\n") + } + + // Format matches with line numbers + const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n") + + return [ + `[Command Output: ${artifactId}] (search: "${pattern}")`, + `Total matches: ${matches.length} | Showing first ${matches.length}`, + "", + matchedLines, + ].join("\n") + } + + /** + * Add line numbers to content for easier reference. + * + * Each line is prefixed with its line number, right-padded to align + * all line numbers in the output. + * + * @param content - The text content to add line numbers to + * @param startLine - The line number for the first line + * @returns Content with line numbers prefixed to each line + * @private + */ + private addLineNumbers(content: string, startLine: number): string { + const lines = content.split("\n") + const maxLineNum = startLine + lines.length - 1 + const padding = String(maxLineNum).length + + return lines.map((line, index) => `${String(startLine + index).padStart(padding)} | ${line}`).join("\n") + } + + /** + * Format a byte count to a human-readable string. + * + * @param bytes - The byte count to format + * @returns Human-readable string (e.g., "1.5KB", "2.3MB") + * @private + */ + private formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} bytes` + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB` + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` + } + + /** + * Escape special regex characters in a string for literal matching. + * + * @param string - The string to escape + * @returns The escaped string safe for use in a RegExp constructor + * @private + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } +} + +/** Singleton instance of the ReadCommandOutputTool */ +export const readCommandOutputTool = new ReadCommandOutputTool() diff --git a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts new file mode 100644 index 00000000000..a2e3147cc66 --- /dev/null +++ b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts @@ -0,0 +1,571 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" + +import { ReadCommandOutputTool } from "../ReadCommandOutputTool" +import { Task } from "../../task/Task" + +// Mock filesystem operations +vi.mock("fs/promises", () => ({ + default: { + access: vi.fn(), + stat: vi.fn(), + open: vi.fn(), + readFile: vi.fn(), + }, + access: vi.fn(), + stat: vi.fn(), + open: vi.fn(), + readFile: vi.fn(), +})) + +// Mock getTaskDirectoryPath +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi.fn((globalStoragePath: string, taskId: string) => { + return path.join(globalStoragePath, "tasks", taskId) + }), +})) + +describe("ReadCommandOutputTool", () => { + let tool: ReadCommandOutputTool + let mockTask: any + let mockCallbacks: any + let mockFileHandle: any + let globalStoragePath: string + let taskId: string + + beforeEach(() => { + vi.clearAllMocks() + + tool = new ReadCommandOutputTool() + globalStoragePath = "/mock/global/storage" + taskId = "task-123" + + // Mock task object + mockTask = { + taskId, + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + say: vi.fn().mockResolvedValue(undefined), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter"), + recordToolError: vi.fn(), + providerRef: { + deref: vi.fn().mockResolvedValue({ + context: { + globalStorageUri: { + fsPath: globalStoragePath, + }, + }, + }), + }, + } + + // Mock callbacks + mockCallbacks = { + pushToolResult: vi.fn(), + } + + // Mock file handle + mockFileHandle = { + read: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + } + + // Default mocks + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any) + vi.mocked(fs.open).mockResolvedValue(mockFileHandle as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("Basic read functionality", () => { + it("should read artifact file correctly", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line 1\nLine 2\nLine 3\n" + const buffer = Buffer.from(content) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(fs.access).toHaveBeenCalledWith( + path.join(globalStoragePath, "tasks", taskId, "command-output", artifactId), + ) + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("Line 1") + expect(result).toContain("Line 2") + expect(result).toContain("Line 3") + }) + + it("should return content with line numbers", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "First line\nSecond line\nThird line\n" + const buffer = Buffer.from(content) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toMatch(/1 \| First line/) + expect(result).toMatch(/2 \| Second line/) + expect(result).toMatch(/3 \| Third line/) + }) + + it("should include size metadata in output", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Test output" + const fileSize = 5000 + const buffer = Buffer.from(content) + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain(`[Command Output: ${artifactId}]`) + expect(result).toContain("Total size:") + expect(result).toMatch(/\d+(\.\d+)?(bytes|KB|MB)/) + }) + + it("should close file handle after reading", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Test" + const buffer = Buffer.from(content) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(mockFileHandle.close).toHaveBeenCalled() + }) + }) + + describe("Pagination (offset/limit)", () => { + it("should use default limit of 32KB", async () => { + const artifactId = "cmd-1706119234567.txt" + const largeContent = "x".repeat(50 * 1024) // 50KB + const fileSize = Buffer.byteLength(largeContent, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + // Mock read to return only up to default limit (32KB) + mockFileHandle.read.mockImplementation((buf: Buffer) => { + const defaultLimit = 32 * 1024 + const bytesToRead = Math.min(buf.length, defaultLimit) + buf.write(largeContent.slice(0, bytesToRead)) + return Promise.resolve({ bytesRead: bytesToRead }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("TRUNCATED") + }) + + it("should start reading from custom offset", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "0123456789ABCDEFGHIJ" + const offset = 10 + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + // Mock first read for offset calculation (returns content before offset) + // Mock second read for actual content + let readCallCount = 0 + mockFileHandle.read.mockImplementation( + (buf: Buffer, bufOffset: number, length: number, position: number | null) => { + readCallCount++ + if (position === 0) { + // First read: prefix for line number calculation + const prefixContent = content.slice(0, offset) + buf.write(prefixContent) + return Promise.resolve({ bytesRead: prefixContent.length }) + } else { + // Second read: actual content from offset + const actualContent = content.slice(offset) + buf.write(actualContent) + return Promise.resolve({ bytesRead: actualContent.length }) + } + }, + ) + + await tool.execute({ artifact_id: artifactId, offset }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain(`Showing bytes ${offset}-`) + expect(mockFileHandle.read).toHaveBeenCalled() + }) + + it("should restrict output size with custom limit", async () => { + const artifactId = "cmd-1706119234567.txt" + const largeContent = "x".repeat(10000) + const customLimit = 1000 + const fileSize = Buffer.byteLength(largeContent, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + const bytesToRead = Math.min(buf.length, customLimit) + buf.write(largeContent.slice(0, bytesToRead)) + return Promise.resolve({ bytesRead: bytesToRead }) + }) + + await tool.execute({ artifact_id: artifactId, limit: customLimit }, mockTask, mockCallbacks) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("TRUNCATED") + }) + + it("should show TRUNCATED when more content exists", async () => { + const artifactId = "cmd-1706119234567.txt" + const fileSize = 10000 + const limit = 5000 + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + const content = "x".repeat(limit) + buf.write(content) + return Promise.resolve({ bytesRead: limit }) + }) + + await tool.execute({ artifact_id: artifactId, limit }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("TRUNCATED") + }) + + it("should show COMPLETE when all content is returned", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Small content" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buf.write(content) + return Promise.resolve({ bytesRead: fileSize }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("COMPLETE") + expect(result).not.toContain("TRUNCATED") + }) + }) + + describe("Search filtering", () => { + it("should filter lines matching pattern", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line 1: error occurred\nLine 2: success\nLine 3: error found\nLine 4: complete\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("error occurred") + expect(result).toContain("error found") + expect(result).not.toContain("success") + expect(result).not.toContain("complete") + }) + + it("should use case-insensitive matching", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "ERROR: Something bad\nwarning: minor issue\nERROR: Another problem\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("ERROR: Something bad") + expect(result).toContain("ERROR: Another problem") + }) + + it("should show match count and line numbers", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line 1\nError on line 2\nLine 3\nError on line 4\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + await tool.execute({ artifact_id: artifactId, search: "Error" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("Total matches: 2") + expect(result).toMatch(/2 \|.*Error on line 2/) + expect(result).toMatch(/4 \|.*Error on line 4/) + }) + + it("should handle empty search results gracefully", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line 1\nLine 2\nLine 3\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + await tool.execute({ artifact_id: artifactId, search: "NOTFOUND" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("No matches found for the search pattern") + }) + + it("should handle regex patterns in search", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "test123\ntest456\nabc789\ntest000\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + await tool.execute({ artifact_id: artifactId, search: "test\\d+" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("test123") + expect(result).toContain("test456") + expect(result).toContain("test000") + expect(result).not.toContain("abc789") + }) + + it("should handle invalid regex patterns by treating as literal", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line with [brackets]\nLine without\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + vi.mocked(fs.readFile).mockResolvedValue(content) + + // Invalid regex but valid as literal string + await tool.execute({ artifact_id: artifactId, search: "[" }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain("[brackets]") + }) + }) + + describe("Error handling", () => { + it("should return error for non-existent artifact", async () => { + const artifactId = "cmd-9999999999.txt" + + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("not found")) + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Error: Artifact not found"), + ) + }) + + it("should reject invalid artifact_id with path traversal attempt", async () => { + const invalidIds = [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32\\config", + "cmd-123/../other.txt", + "cmd-.txt", + "cmd-.txt", + "invalid-format.txt", + ] + + for (const invalidId of invalidIds) { + vi.clearAllMocks() + mockTask.consecutiveMistakeCount = 0 + mockTask.didToolFailInCurrentTurn = false + + await tool.execute({ artifact_id: invalidId }, mockTask, mockCallbacks) + + expect(mockTask.consecutiveMistakeCount).toBeGreaterThan(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("Invalid artifact_id format"), + ) + } + }) + + it("should accept valid artifact_id format", async () => { + const validId = "cmd-1706119234567.txt" + const content = "Test" + const buffer = Buffer.from(content) + + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: validId }, mockTask, mockCallbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("should handle invalid offset gracefully", async () => { + const artifactId = "cmd-1706119234567.txt" + const fileSize = 1000 + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + await tool.execute( + { artifact_id: artifactId, offset: 2000 }, // Offset beyond file size + mockTask, + mockCallbacks, + ) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Invalid offset")) + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error: Invalid offset")) + }) + + it("should handle negative offset", async () => { + const artifactId = "cmd-1706119234567.txt" + const fileSize = 1000 + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + await tool.execute({ artifact_id: artifactId, offset: -10 }, mockTask, mockCallbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Invalid offset")) + }) + + it("should handle missing artifact_id parameter", async () => { + await tool.execute({ artifact_id: "" }, mockTask, mockCallbacks) + + expect(mockTask.consecutiveMistakeCount).toBeGreaterThan(0) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_command_output") + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_command_output", "artifact_id") + }) + + it("should handle missing global storage path", async () => { + const artifactId = "cmd-1706119234567.txt" + + mockTask.providerRef.deref.mockResolvedValue({ + context: { + globalStorageUri: null, + }, + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("Global storage path is not available"), + ) + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) + }) + + it("should handle file read errors", async () => { + const artifactId = "cmd-1706119234567.txt" + + mockFileHandle.read.mockRejectedValue(new Error("Read error")) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading command output")) + }) + + it("should ensure file handle is closed even on error", async () => { + const artifactId = "cmd-1706119234567.txt" + + mockFileHandle.read.mockRejectedValue(new Error("Read error")) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + expect(mockFileHandle.close).toHaveBeenCalled() + }) + }) + + describe("Byte formatting", () => { + it("should format bytes correctly", async () => { + const testCases = [ + { size: 500, expected: "bytes" }, + { size: 1024, expected: "1.0KB" }, + { size: 2048, expected: "2.0KB" }, + { size: 1024 * 1024, expected: "1.0MB" }, + { size: 2.5 * 1024 * 1024, expected: "2.5MB" }, + ] + + for (const { size, expected } of testCases) { + vi.clearAllMocks() + const artifactId = "cmd-1706119234567.txt" + const content = "x" + const buffer = Buffer.from(content) + + vi.mocked(fs.stat).mockResolvedValue({ size } as any) + mockFileHandle.read.mockImplementation((buf: Buffer) => { + buffer.copy(buf) + return Promise.resolve({ bytesRead: buffer.length }) + }) + + await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + expect(result).toContain(expected) + } + }) + }) + + describe("Line number calculation", () => { + it("should calculate correct starting line number for offset", async () => { + const artifactId = "cmd-1706119234567.txt" + const content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" + const offset = 14 // After "Line 1\nLine 2\n" + const fileSize = Buffer.byteLength(content, "utf8") + + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + let readCallCount = 0 + mockFileHandle.read.mockImplementation( + (buf: Buffer, bufOffset: number, length: number, position: number | null) => { + readCallCount++ + if (position === 0) { + // Read prefix for line counting + const prefix = content.slice(0, offset) + buf.write(prefix) + return Promise.resolve({ bytesRead: prefix.length }) + } else { + // Read actual content from offset + const actualContent = content.slice(offset) + buf.write(actualContent) + return Promise.resolve({ bytesRead: actualContent.length }) + } + }, + ) + + await tool.execute({ artifact_id: artifactId, offset }, mockTask, mockCallbacks) + + const result = mockCallbacks.pushToolResult.mock.calls[0][0] + // Should start at line 3 since we skipped 2 newlines + expect(result).toMatch(/3 \|/) + }) + }) +}) diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts new file mode 100644 index 00000000000..c9e984ff69d --- /dev/null +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -0,0 +1,272 @@ +import * as fs from "fs" +import * as path from "path" + +import { TerminalOutputPreviewSize, TERMINAL_PREVIEW_BYTES, PersistedCommandOutput } from "@roo-code/types" + +import { processCarriageReturns, processBackspaces } from "../misc/extract-text" + +/** + * Configuration options for creating an OutputInterceptor instance. + */ +export interface OutputInterceptorOptions { + /** Unique identifier for this command execution (typically a timestamp) */ + executionId: string + /** ID of the task that initiated this command */ + taskId: string + /** The command string being executed */ + command: string + /** Directory path where command output artifacts will be stored */ + storageDir: string + /** Size category for the preview buffer (small/medium/large) */ + previewSize: TerminalOutputPreviewSize + /** Whether to compress progress bar output using carriage return processing */ + compressProgressBar: boolean +} + +/** + * OutputInterceptor buffers terminal command output and spills to disk when threshold exceeded. + * + * This implements a "persisted output" pattern where large command outputs are saved to disk + * files, with only a preview shown to the LLM. The LLM can then use the `read_command_output` + * tool to retrieve full contents or search through the output. + * + * The interceptor operates in two modes: + * 1. **Buffer mode**: Output is accumulated in memory until it exceeds the preview threshold + * 2. **Spill mode**: Once threshold is exceeded, output is streamed directly to disk + * + * This approach prevents large command outputs (like build logs, test results, or verbose + * operations) from overwhelming the context window while still allowing the LLM to access + * the full output when needed. + * + * @example + * ```typescript + * const interceptor = new OutputInterceptor({ + * executionId: Date.now().toString(), + * taskId: 'task-123', + * command: 'npm test', + * storageDir: '/path/to/task/command-output', + * previewSize: 'medium', + * compressProgressBar: true + * }); + * + * // Write output chunks as they arrive + * interceptor.write('Running tests...\n'); + * interceptor.write('Test 1 passed\n'); + * + * // Finalize and get the result + * const result = interceptor.finalize(); + * // result.preview contains truncated output for display + * // result.artifactPath contains path to full output if truncated + * ``` + */ +export class OutputInterceptor { + private buffer: string = "" + private writeStream: fs.WriteStream | null = null + private artifactPath: string + private totalBytes: number = 0 + private spilledToDisk: boolean = false + private readonly previewBytes: number + private readonly compressProgressBar: boolean + + /** + * Creates a new OutputInterceptor instance. + * + * @param options - Configuration options for the interceptor + */ + constructor(private readonly options: OutputInterceptorOptions) { + this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize] + this.compressProgressBar = options.compressProgressBar + this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`) + } + + /** + * Write a chunk of output to the interceptor. + * + * If the accumulated output exceeds the preview threshold, the interceptor + * automatically spills to disk and switches to streaming mode. Subsequent + * chunks are written directly to the disk file. + * + * @param chunk - The output string to write + * + * @example + * ```typescript + * interceptor.write('Building project...\n'); + * interceptor.write('Compiling 42 files\n'); + * ``` + */ + write(chunk: string): void { + const chunkBytes = Buffer.byteLength(chunk, "utf8") + this.totalBytes += chunkBytes + + if (!this.spilledToDisk) { + this.buffer += chunk + + if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) { + this.spillToDisk() + } + } else { + // Already spilling - write directly to disk + this.writeStream?.write(chunk) + } + } + + /** + * Spill buffered content to disk and switch to streaming mode. + * + * This is called automatically when the buffer exceeds the preview threshold. + * Creates the storage directory if it doesn't exist, writes the current buffer + * to the artifact file, and prepares for streaming subsequent output. + * + * @private + */ + private spillToDisk(): void { + // Ensure directory exists + const dir = path.dirname(this.artifactPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + this.writeStream = fs.createWriteStream(this.artifactPath) + this.writeStream.write(this.buffer) + this.spilledToDisk = true + + // Keep only preview portion in memory + this.buffer = this.buffer.slice(0, this.previewBytes) + } + + /** + * Finalize the interceptor and return the persisted output result. + * + * Closes any open file streams and returns a summary object containing: + * - A preview of the output (truncated to preview size) + * - The total byte count of all output + * - The path to the full output file (if truncated) + * - A flag indicating whether the output was truncated + * + * If `compressProgressBar` was enabled, the preview will have carriage returns + * and backspaces processed to show only final line states. + * + * @returns The persisted command output summary + * + * @example + * ```typescript + * const result = interceptor.finalize(); + * console.log(`Preview: ${result.preview}`); + * console.log(`Total bytes: ${result.totalBytes}`); + * if (result.truncated) { + * console.log(`Full output at: ${result.artifactPath}`); + * } + * ``` + */ + finalize(): PersistedCommandOutput { + // Close write stream if open + if (this.writeStream) { + this.writeStream.end() + } + + // Prepare preview + let preview = this.buffer.slice(0, this.previewBytes) + + // Apply compression to preview only (for readability) + if (this.compressProgressBar) { + preview = processCarriageReturns(preview) + preview = processBackspaces(preview) + } + + return { + preview, + totalBytes: this.totalBytes, + artifactPath: this.spilledToDisk ? this.artifactPath : null, + truncated: this.spilledToDisk, + } + } + + /** + * Get the current buffer content for UI display. + * + * Returns the in-memory buffer which contains either all output (if not spilled) + * or just the preview portion (if spilled to disk). + * + * @returns The current buffer content as a string + */ + getBufferForUI(): string { + return this.buffer + } + + /** + * Get the artifact file path for this command execution. + * + * Returns the path where the full output would be/is stored on disk. + * The file may not exist if output hasn't exceeded the preview threshold. + * + * @returns The absolute path to the artifact file + */ + getArtifactPath(): string { + return this.artifactPath + } + + /** + * Check if the output has been spilled to disk. + * + * @returns `true` if output exceeded threshold and was written to disk + */ + hasSpilledToDisk(): boolean { + return this.spilledToDisk + } + + /** + * Remove all command output artifact files from a directory. + * + * Deletes all files matching the pattern `cmd-*.txt` in the specified directory. + * This is typically called when a task is cleaned up or reset. + * + * @param storageDir - The directory containing artifact files to clean + * + * @example + * ```typescript + * await OutputInterceptor.cleanup('/path/to/task/command-output'); + * ``` + */ + static async cleanup(storageDir: string): Promise { + try { + const files = await fs.promises.readdir(storageDir) + for (const file of files) { + if (file.startsWith("cmd-")) { + await fs.promises.unlink(path.join(storageDir, file)).catch(() => {}) + } + } + } catch { + // Directory doesn't exist, nothing to clean + } + } + + /** + * Remove artifact files that are NOT in the provided set of execution IDs. + * + * This is used for selective cleanup, preserving artifacts that are still + * referenced in the conversation history while removing orphaned files. + * + * @param storageDir - The directory containing artifact files + * @param executionIds - Set of execution IDs to preserve (files NOT in this set are deleted) + * + * @example + * ```typescript + * // Keep only artifacts for executions 123 and 456 + * const keepIds = new Set(['123', '456']); + * await OutputInterceptor.cleanupByIds('/path/to/command-output', keepIds); + * ``` + */ + static async cleanupByIds(storageDir: string, executionIds: Set): Promise { + try { + const files = await fs.promises.readdir(storageDir) + for (const file of files) { + const match = file.match(/^cmd-(\d+)\.txt$/) + if (match && !executionIds.has(match[1])) { + await fs.promises.unlink(path.join(storageDir, file)).catch(() => {}) + } + } + } catch { + // Directory doesn't exist, nothing to clean + } + } +} diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts new file mode 100644 index 00000000000..9268854208a --- /dev/null +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -0,0 +1,481 @@ +import * as fs from "fs" +import * as path from "path" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" + +import { OutputInterceptor } from "../OutputInterceptor" +import { TerminalOutputPreviewSize } from "@roo-code/types" + +// Mock filesystem operations +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + createWriteStream: vi.fn(), + promises: { + readdir: vi.fn(), + unlink: vi.fn(), + }, + }, + existsSync: vi.fn(), + mkdirSync: vi.fn(), + createWriteStream: vi.fn(), + promises: { + readdir: vi.fn(), + unlink: vi.fn(), + }, +})) + +describe("OutputInterceptor", () => { + let mockWriteStream: any + let storageDir: string + + beforeEach(() => { + vi.clearAllMocks() + + storageDir = "/tmp/test-storage" + + // Setup mock write stream + mockWriteStream = { + write: vi.fn(), + end: vi.fn(), + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("Buffering behavior", () => { + it("should keep small output in memory without spilling to disk", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "echo test", + storageDir, + previewSize: "small", // 2KB + compressProgressBar: false, + }) + + const smallOutput = "Hello World\n" + interceptor.write(smallOutput) + + expect(interceptor.hasSpilledToDisk()).toBe(false) + expect(fs.createWriteStream).not.toHaveBeenCalled() + + const result = interceptor.finalize() + expect(result.preview).toBe(smallOutput) + expect(result.truncated).toBe(false) + expect(result.artifactPath).toBe(null) + expect(result.totalBytes).toBe(Buffer.byteLength(smallOutput, "utf8")) + }) + + it("should spill to disk when output exceeds threshold", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "echo test", + storageDir, + previewSize: "small", // 2KB = 2048 bytes + compressProgressBar: false, + }) + + // Write enough data to exceed 2KB threshold + const chunk = "x".repeat(1024) // 1KB chunk + interceptor.write(chunk) // 1KB - should stay in memory + expect(interceptor.hasSpilledToDisk()).toBe(false) + + interceptor.write(chunk) // 2KB - should stay in memory + expect(interceptor.hasSpilledToDisk()).toBe(false) + + interceptor.write(chunk) // 3KB - should trigger spill + expect(interceptor.hasSpilledToDisk()).toBe(true) + expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt")) + expect(mockWriteStream.write).toHaveBeenCalled() + }) + + it("should truncate preview after spilling to disk", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "echo test", + storageDir, + previewSize: "small", // 2KB + compressProgressBar: false, + }) + + // Write data that exceeds threshold + const chunk = "x".repeat(3000) + interceptor.write(chunk) + + expect(interceptor.hasSpilledToDisk()).toBe(true) + + const result = interceptor.finalize() + expect(result.truncated).toBe(true) + expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt")) + expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048) + }) + + it("should write subsequent chunks directly to disk after spilling", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "echo test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Trigger spill + const largeChunk = "x".repeat(3000) + interceptor.write(largeChunk) + expect(interceptor.hasSpilledToDisk()).toBe(true) + + // Clear mock to track next write + mockWriteStream.write.mockClear() + + // Write another chunk - should go directly to disk + const nextChunk = "y".repeat(1000) + interceptor.write(nextChunk) + + expect(mockWriteStream.write).toHaveBeenCalledWith(nextChunk) + }) + }) + + describe("Threshold settings", () => { + it("should handle small (2KB) threshold correctly", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Write exactly 2KB + interceptor.write("x".repeat(2048)) + expect(interceptor.hasSpilledToDisk()).toBe(false) + + // Write more to exceed 2KB + interceptor.write("x") + expect(interceptor.hasSpilledToDisk()).toBe(true) + }) + + it("should handle medium (4KB) threshold correctly", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "medium", + compressProgressBar: false, + }) + + // Write exactly 4KB + interceptor.write("x".repeat(4096)) + expect(interceptor.hasSpilledToDisk()).toBe(false) + + // Write more to exceed 4KB + interceptor.write("x") + expect(interceptor.hasSpilledToDisk()).toBe(true) + }) + + it("should handle large (8KB) threshold correctly", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "large", + compressProgressBar: false, + }) + + // Write exactly 8KB + interceptor.write("x".repeat(8192)) + expect(interceptor.hasSpilledToDisk()).toBe(false) + + // Write more to exceed 8KB + interceptor.write("x") + expect(interceptor.hasSpilledToDisk()).toBe(true) + }) + }) + + describe("Artifact creation", () => { + it("should create directory if it doesn't exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Trigger spill + interceptor.write("x".repeat(3000)) + + expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true }) + }) + + it("should create artifact file with correct naming pattern", () => { + const executionId = "1706119234567" + const interceptor = new OutputInterceptor({ + executionId, + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Trigger spill + interceptor.write("x".repeat(3000)) + + expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`)) + }) + + it("should write full output to artifact, not truncated", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const fullOutput = "x".repeat(5000) + interceptor.write(fullOutput) + + // The write stream should receive the full buffer content + expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput) + }) + + it("should get artifact path from getArtifactPath() method", () => { + const executionId = "12345" + const interceptor = new OutputInterceptor({ + executionId, + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`) + expect(interceptor.getArtifactPath()).toBe(expectedPath) + }) + }) + + describe("finalize() method", () => { + it("should return preview output for small commands", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "echo hello", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const output = "Hello World\n" + interceptor.write(output) + + const result = interceptor.finalize() + + expect(result.preview).toBe(output) + expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8")) + expect(result.artifactPath).toBe(null) + expect(result.truncated).toBe(false) + }) + + it("should return PersistedCommandOutput for large commands", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const largeOutput = "x".repeat(5000) + interceptor.write(largeOutput) + + const result = interceptor.finalize() + + expect(result.truncated).toBe(true) + expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt")) + expect(result.totalBytes).toBe(Buffer.byteLength(largeOutput, "utf8")) + expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048) + }) + + it("should close write stream when finalizing", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Trigger spill + interceptor.write("x".repeat(3000)) + interceptor.finalize() + + expect(mockWriteStream.end).toHaveBeenCalled() + }) + + it("should include correct metadata (artifactId, size, truncated flag)", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const output = "x".repeat(5000) + interceptor.write(output) + + const result = interceptor.finalize() + + expect(result).toHaveProperty("preview") + expect(result).toHaveProperty("totalBytes", 5000) + expect(result).toHaveProperty("artifactPath") + expect(result).toHaveProperty("truncated", true) + expect(result.artifactPath).toMatch(/cmd-12345\.txt$/) + }) + }) + + describe("Cleanup methods", () => { + it("should clean up all artifacts in directory", async () => { + const mockFiles = ["cmd-12345.txt", "cmd-67890.txt", "other-file.txt", "cmd-11111.txt"] + vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any) + vi.mocked(fs.promises.unlink).mockResolvedValue(undefined) + + await OutputInterceptor.cleanup(storageDir) + + expect(fs.promises.readdir).toHaveBeenCalledWith(storageDir) + expect(fs.promises.unlink).toHaveBeenCalledTimes(3) + expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt")) + expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-67890.txt")) + expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-11111.txt")) + expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "other-file.txt")) + }) + + it("should handle cleanup when directory doesn't exist", async () => { + vi.mocked(fs.promises.readdir).mockRejectedValue(new Error("ENOENT")) + + // Should not throw + await expect(OutputInterceptor.cleanup(storageDir)).resolves.toBeUndefined() + }) + + it("should clean up specific artifacts by executionIds", async () => { + const mockFiles = ["cmd-12345.txt", "cmd-67890.txt", "cmd-11111.txt"] + vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any) + vi.mocked(fs.promises.unlink).mockResolvedValue(undefined) + + // Keep 12345 and 67890, delete 11111 + const keepIds = new Set(["12345", "67890"]) + await OutputInterceptor.cleanupByIds(storageDir, keepIds) + + expect(fs.promises.unlink).toHaveBeenCalledTimes(1) + expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-11111.txt")) + expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt")) + expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "cmd-67890.txt")) + }) + + it("should handle unlink errors gracefully", async () => { + const mockFiles = ["cmd-12345.txt", "cmd-67890.txt"] + vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any) + vi.mocked(fs.promises.unlink).mockRejectedValue(new Error("Permission denied")) + + // Should not throw even if unlink fails + await expect(OutputInterceptor.cleanup(storageDir)).resolves.toBeUndefined() + }) + }) + + describe("Progress bar compression", () => { + it("should apply compression when compressProgressBar is true", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: true, + }) + + // Output with carriage returns (simulating progress bar) + const output = "Progress: 10%\rProgress: 50%\rProgress: 100%\n" + interceptor.write(output) + + const result = interceptor.finalize() + + // Preview should be compressed (carriage returns processed) + // The processCarriageReturns function should keep only the last line before \r + expect(result.preview).not.toBe(output) + }) + + it("should not apply compression when compressProgressBar is false", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const output = "Line 1\nLine 2\n" + interceptor.write(output) + + const result = interceptor.finalize() + expect(result.preview).toBe(output) + }) + }) + + describe("getBufferForUI() method", () => { + it("should return current buffer for UI updates", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + const output = "Hello World" + interceptor.write(output) + + expect(interceptor.getBufferForUI()).toBe(output) + }) + + it("should return truncated buffer after spilling to disk", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", + compressProgressBar: false, + }) + + // Trigger spill + const largeOutput = "x".repeat(5000) + interceptor.write(largeOutput) + + const buffer = interceptor.getBufferForUI() + expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048) + }) + }) +}) diff --git a/src/integrations/terminal/index.ts b/src/integrations/terminal/index.ts new file mode 100644 index 00000000000..afd05bb1e5f --- /dev/null +++ b/src/integrations/terminal/index.ts @@ -0,0 +1,57 @@ +/** + * Terminal Output Handling Module + * + * This module provides utilities for capturing, persisting, and retrieving + * command output from terminal executions. + * + * ## Overview + * + * When the LLM executes commands via `execute_command`, the output can be + * very large (build logs, test output, etc.). To prevent context window + * overflow while still allowing access to full output, this module + * implements a "persisted output" pattern: + * + * 1. **OutputInterceptor**: Buffers command output during execution. If + * output exceeds a configurable threshold, it "spills" to disk and + * keeps only a preview in memory. + * + * 2. **Artifact Storage**: Full outputs are stored as text files in the + * task's `command-output/` directory with names like `cmd-{timestamp}.txt`. + * + * 3. **ReadCommandOutputTool**: Allows the LLM to retrieve the full output + * later via the `read_command_output` tool, with support for search + * and pagination. + * + * ## Data Flow + * + * ``` + * execute_command + * │ + * ▼ + * OutputInterceptor.write() ──► Buffer accumulates + * │ + * ▼ (threshold exceeded) + * OutputInterceptor.spillToDisk() ──► Artifact file created + * │ + * ▼ + * OutputInterceptor.finalize() ──► Returns PersistedCommandOutput + * │ + * ▼ + * LLM receives preview + artifact_id + * │ + * ▼ (if needs full output) + * read_command_output(artifact_id) ──► Full content/search results + * ``` + * + * ## Configuration + * + * Preview size is controlled by `terminalOutputPreviewSize` setting: + * - `small`: 2KB preview + * - `medium`: 4KB preview (default) + * - `large`: 8KB preview + * + * @module terminal + */ + +export { OutputInterceptor } from "./OutputInterceptor" +export type { OutputInterceptorOptions } from "./OutputInterceptor" diff --git a/src/package.json b/src/package.json index 03825ff3aaf..fa63a506e9f 100644 --- a/src/package.json +++ b/src/package.json @@ -545,6 +545,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", + "@types/json-stream-stringify": "^2.0.4", "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "20.x", diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 66b058fceb5..dc1615c0654 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -72,6 +72,10 @@ export const toolParamNames = [ "old_string", // search_replace and edit_file parameter "new_string", // search_replace and edit_file parameter "expected_replacements", // edit_file parameter for multiple occurrences + "artifact_id", // read_command_output parameter + "search", // read_command_output parameter for grep-like search + "offset", // read_command_output parameter for pagination + "limit", // read_command_output parameter for max bytes to return ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -83,6 +87,7 @@ export type ToolParamName = (typeof toolParamNames)[number] export type NativeToolArgs = { access_mcp_resource: { server_name: string; uri: string } read_file: { files: FileEntry[] } + read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } execute_command: { command: string; cwd?: string } apply_diff: { path: string; diff: string } @@ -242,6 +247,7 @@ export type ToolGroupConfig = { export const TOOL_DISPLAY_NAMES: Record = { execute_command: "run commands", read_file: "read files", + read_command_output: "read command output", fetch_instructions: "fetch instructions", write_to_file: "write files", apply_diff: "apply changes", @@ -278,7 +284,7 @@ export const TOOL_GROUPS: Record = { tools: ["browser_action"], }, command: { - tools: ["execute_command"], + tools: ["execute_command", "read_command_output"], }, mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 67ba48676c5..823ffc0b21f 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -178,6 +178,7 @@ const SettingsView = forwardRef(({ onDone, t telemetrySetting, terminalOutputLineLimit, terminalOutputCharacterLimit, + terminalOutputPreviewSize, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream terminalCommandDelay, @@ -399,6 +400,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshP10k, terminalZdotdir, terminalCompressProgressBar, + terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500), @@ -868,8 +870,7 @@ const SettingsView = forwardRef(({ onDone, t {/* Terminal Section */} {renderTab === "terminal" && ( & { - terminalOutputLineLimit?: number - terminalOutputCharacterLimit?: number + terminalOutputPreviewSize?: TerminalOutputPreviewSize terminalShellIntegrationTimeout?: number terminalShellIntegrationDisabled?: boolean terminalCommandDelay?: number @@ -29,8 +28,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZdotdir?: boolean terminalCompressProgressBar?: boolean setCachedStateField: SetCachedStateField< - | "terminalOutputLineLimit" - | "terminalOutputCharacterLimit" + | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" | "terminalCommandDelay" @@ -44,8 +42,7 @@ type TerminalSettingsProps = HTMLAttributes & { } export const TerminalSettings = ({ - terminalOutputLineLimit, - terminalOutputCharacterLimit, + terminalOutputPreviewSize, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -100,67 +97,34 @@ export const TerminalSettings = ({
+ label={t("settings:terminal.outputPreviewSize.label")}> -
- setCachedStateField("terminalOutputLineLimit", value)} - data-testid="terminal-output-limit-slider" - /> - {terminalOutputLineLimit ?? 500} -
-
- - - {" "} - - -
-
- - -
- - setCachedStateField("terminalOutputCharacterLimit", value) - } - data-testid="terminal-output-character-limit-slider" - /> - {terminalOutputCharacterLimit ?? 50000} -
+
- - - {" "} - - + {t("settings:terminal.outputPreviewSize.description")}
void terminalOutputCharacterLimit?: number setTerminalOutputCharacterLimit: (value: number) => void + terminalOutputPreviewSize?: "small" | "medium" | "large" + setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void enableMcpServerCreation: boolean @@ -546,6 +548,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setTerminalOutputCharacterLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })), + setTerminalOutputPreviewSize: (value) => + setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })), setTerminalShellIntegrationTimeout: (value) => setState((prevState) => ({ ...prevState, terminalShellIntegrationTimeout: value })), setTerminalShellIntegrationDisabled: (value) => diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index af1e7cc604d..b9c870ec634 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -732,6 +732,15 @@ "label": "Terminal character limit", "description": "Overrides the line limit to prevent memory issues by enforcing a hard cap on output size. If exceeded, keeps the beginning and end and shows a placeholder to Roo where content is skipped. <0>Learn more" }, + "outputPreviewSize": { + "label": "Command output preview size", + "description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.", + "options": { + "small": "Small (2KB)", + "medium": "Medium (4KB)", + "large": "Large (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Terminal shell integration timeout", "description": "How long to wait for VS Code shell integration before running commands. Raise if your shell starts slowly or you see 'Shell Integration Unavailable' errors. <0>Learn more" From 06cb4303f32c59b381ce7d03a19b9af10142ec56 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 14:43:58 -0700 Subject: [PATCH 2/4] fix: address PR review comments - Read terminalOutputPreviewSize from providerState instead of hardcoded default - Fix native tool schema to only require artifact_id (optional params no longer required) - Fix Buffer allocation for line numbers using chunked 64KB reads to avoid memory blowup --- Roo-EXTRACTION-terminal-shell-integration.md | 409 ++++++ claude-code.md | 51 + codex-extract-terminal-spawning-tool.md | 1206 +++++++++++++++++ .../tools/native-tools/read_command_output.ts | 2 +- src/core/tools/ExecuteCommandTool.ts | 4 +- src/core/tools/ReadCommandOutputTool.ts | 47 +- 6 files changed, 1710 insertions(+), 9 deletions(-) create mode 100644 Roo-EXTRACTION-terminal-shell-integration.md create mode 100644 claude-code.md create mode 100644 codex-extract-terminal-spawning-tool.md diff --git a/Roo-EXTRACTION-terminal-shell-integration.md b/Roo-EXTRACTION-terminal-shell-integration.md new file mode 100644 index 00000000000..4b44c2e2306 --- /dev/null +++ b/Roo-EXTRACTION-terminal-shell-integration.md @@ -0,0 +1,409 @@ +# Terminal/Shell Integration - Agent Context Document + +--- + +Feature: Terminal/Shell Integration +Last Updated: 2025-01-24 +Status: Stable +Audience: Agents/Developers + +--- + +## Overview + +Roo Code's terminal integration enables the `execute_command` tool to run shell commands and capture their output. The system supports two execution providers: + +1. **VSCode Terminal Provider** (`vscode`) - Uses VSCode's native shell integration APIs for command execution with real-time output streaming and exit code detection +2. **Execa Provider** (`execa`) - A fallback that runs commands via Node.js's `execa` library without VSCode terminal UI integration + +## File Structure + +### Core Terminal Integration Files + +``` +src/integrations/terminal/ +├── BaseTerminal.ts # Abstract base class for terminal implementations +├── BaseTerminalProcess.ts # Abstract base class for process implementations +├── Terminal.ts # VSCode terminal provider implementation +├── TerminalProcess.ts # VSCode terminal process implementation +├── ExecaTerminal.ts # Execa provider implementation +├── ExecaTerminalProcess.ts # Execa process implementation +├── TerminalRegistry.ts # Singleton registry managing terminal instances +├── ShellIntegrationManager.ts # Manages zsh shell integration workarounds +├── mergePromise.ts # Utility for merging process with promise +└── types.ts # Type definitions for terminal interfaces +``` + +### Related Files + +| File | Purpose | +| -------------------------------------------------------------------------------- | ---------------------------------- | +| [`src/core/tools/ExecuteCommandTool.ts`](src/core/tools/ExecuteCommandTool.ts) | The `execute_command` tool handler | +| [`src/integrations/misc/extract-text.ts`](src/integrations/misc/extract-text.ts) | Output compression utilities | +| [`packages/types/src/terminal.ts`](packages/types/src/terminal.ts) | CommandExecutionStatus schema | +| [`packages/types/src/global-settings.ts`](packages/types/src/global-settings.ts) | Terminal configuration defaults | + +--- + +## Architecture + +### Class Hierarchy + +``` +BaseTerminal (abstract) +├── Terminal (vscode provider) +└── ExecaTerminal (execa provider) + +BaseTerminalProcess (abstract) +├── TerminalProcess (vscode provider) +└── ExecaTerminalProcess (execa provider) +``` + +### Key Interfaces + +**[`RooTerminal`](src/integrations/terminal/types.ts:5)** - Main terminal interface: + +```typescript +interface RooTerminal { + provider: "vscode" | "execa" + id: number + busy: boolean + running: boolean + taskId?: string + process?: RooTerminalProcess + getCurrentWorkingDirectory(): string + isClosed: () => boolean + runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise + setActiveStream(stream: AsyncIterable | undefined, pid?: number): void + shellExecutionComplete(exitDetails: ExitCodeDetails): void + getProcessesWithOutput(): RooTerminalProcess[] + getUnretrievedOutput(): string + getLastCommand(): string + cleanCompletedProcessQueue(): void +} +``` + +**[`RooTerminalCallbacks`](src/integrations/terminal/types.ts:23)** - Callbacks for command execution: + +```typescript +interface RooTerminalCallbacks { + onLine: (line: string, process: RooTerminalProcess) => void + onCompleted: (output: string | undefined, process: RooTerminalProcess) => void + onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void + onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void + onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void +} +``` + +**[`ExitCodeDetails`](src/integrations/terminal/types.ts:55)** - Exit information: + +```typescript +interface ExitCodeDetails { + exitCode: number | undefined + signal?: number | undefined + signalName?: string + coreDumpPossible?: boolean +} +``` + +--- + +## Command Execution Flow + +### 1. Tool Invocation + +When the LLM uses `execute_command`, [`ExecuteCommandTool.execute()`](src/core/tools/ExecuteCommandTool.ts:32) is called: + +1. Validates the `command` parameter exists +2. Checks `.rooignore` rules via `task.rooIgnoreController?.validateCommand(command)` +3. Requests user approval via `askApproval("command", unescapedCommand)` +4. Determines provider based on `terminalShellIntegrationDisabled` setting +5. Calls [`executeCommandInTerminal()`](src/core/tools/ExecuteCommandTool.ts:154) + +### 2. Terminal Selection + +[`TerminalRegistry.getOrCreateTerminal()`](src/integrations/terminal/TerminalRegistry.ts:152) selects a terminal: + +1. First priority: Terminal already assigned to this task with matching CWD +2. Second priority: Any available terminal with matching CWD +3. Fallback: Creates new terminal via [`TerminalRegistry.createTerminal()`](src/integrations/terminal/TerminalRegistry.ts:130) + +### 3. Command Execution + +**VSCode Provider Flow** ([`Terminal.runCommand()`](src/integrations/terminal/Terminal.ts:43)): + +1. Sets terminal as busy +2. Creates [`TerminalProcess`](src/integrations/terminal/TerminalProcess.ts:9) instance +3. Waits for shell integration with timeout (default 5s, configurable) +4. If shell integration available: executes via `terminal.shellIntegration.executeCommand()` +5. If shell integration unavailable: emits `no_shell_integration` event + +**Execa Provider Flow** ([`ExecaTerminal.runCommand()`](src/integrations/terminal/ExecaTerminal.ts:18)): + +1. Sets terminal as busy +2. Creates [`ExecaTerminalProcess`](src/integrations/terminal/ExecaTerminalProcess.ts:8) instance +3. Executes command via `execa` with `shell: true` +4. Streams output via async iterable + +### 4. Output Processing + +Output is processed through callbacks: + +- [`onLine`](src/core/tools/ExecuteCommandTool.ts:197) - Called as output streams in +- [`onCompleted`](src/core/tools/ExecuteCommandTool.ts:226) - Called when command completes +- [`onShellExecutionStarted`](src/core/tools/ExecuteCommandTool.ts:236) - Called when shell execution begins (with PID) +- [`onShellExecutionComplete`](src/core/tools/ExecuteCommandTool.ts:240) - Called when shell execution ends (with exit code) + +Output is compressed via [`Terminal.compressTerminalOutput()`](src/integrations/terminal/BaseTerminal.ts:275): + +1. Process carriage returns (progress bars) +2. Process backspaces +3. Apply run-length encoding for repeated lines +4. Truncate to line/character limits + +--- + +## VSCode Shell Integration Details + +### OSC 633 Protocol + +VSCode uses OSC 633 escape sequences for shell integration. Key markers: + +| Sequence | Meaning | +| ------------------------------------ | ----------------------------------------- | +| `\x1b]633;A` | Mark prompt start | +| `\x1b]633;B` | Mark prompt end | +| `\x1b]633;C` | Mark pre-execution (command output start) | +| `\x1b]633;D[;]` | Mark execution finished | +| `\x1b]633;E;[;]` | Explicitly set command line | + +The [`TerminalProcess`](src/integrations/terminal/TerminalProcess.ts) class parses these markers: + +- [`matchAfterVsceStartMarkers()`](src/integrations/terminal/TerminalProcess.ts:396) - Finds content after C marker +- [`matchBeforeVsceEndMarkers()`](src/integrations/terminal/TerminalProcess.ts:405) - Finds content before D marker + +### Shell Integration Event Handlers + +Registered in [`TerminalRegistry.initialize()`](src/integrations/terminal/TerminalRegistry.ts:26): + +- [`onDidStartTerminalShellExecution`](src/integrations/terminal/TerminalRegistry.ts:49) - Captures stream and marks terminal busy +- [`onDidEndTerminalShellExecution`](src/integrations/terminal/TerminalRegistry.ts:76) - Processes exit code and signals completion + +--- + +## Configuration Options + +All settings are stored in extension state and managed via [`ClineProvider`](src/core/webview/ClineProvider.ts:752). + +### Terminal Settings + +| Setting | Type | Default | Description | +| ---------------------------------- | --------- | -------- | ----------------------------------------------------------------------- | +| `terminalShellIntegrationDisabled` | `boolean` | `true` | When true, uses execa provider instead of VSCode terminal | +| `terminalShellIntegrationTimeout` | `number` | `30000` | Milliseconds to wait for shell integration init (VSCode provider only) | +| `terminalOutputLineLimit` | `number` | `500` | Maximum lines to keep in compressed output | +| `terminalOutputCharacterLimit` | `number` | `100000` | Maximum characters to keep in compressed output | +| `terminalCommandDelay` | `number` | `0` | Milliseconds to delay after command (workaround for VSCode bug #237208) | + +### Shell-Specific Settings + +| Setting | Type | Default | Description | +| ----------------------------- | --------- | ------- | ----------------------------------------------------------------------------- | +| `terminalZshClearEolMark` | `boolean` | `true` | Clear ZSH EOL mark (`PROMPT_EOL_MARK=""`) | +| `terminalZshOhMy` | `boolean` | `true` | Enable Oh My Zsh integration (`ITERM_SHELL_INTEGRATION_INSTALLED=Yes`) | +| `terminalZshP10k` | `boolean` | `false` | Enable Powerlevel10k integration (`POWERLEVEL9K_TERM_SHELL_INTEGRATION=true`) | +| `terminalZdotdir` | `boolean` | `true` | Use ZDOTDIR workaround for zsh shell integration | +| `terminalPowershellCounter` | `boolean` | `false` | Add counter workaround for PowerShell | +| `terminalCompressProgressBar` | `boolean` | `true` | Process carriage returns to compress progress bar output | + +### VSCode Configuration + +The tool also reads from VSCode configuration: + +- `roo-cline.commandExecutionTimeout` - Seconds to auto-abort commands (0 = disabled) +- `roo-cline.commandTimeoutAllowlist` - Command prefixes exempt from timeout + +--- + +## Environment Variables + +The [`Terminal.getEnv()`](src/integrations/terminal/Terminal.ts:153) method sets environment variables for shell integration: + +| Variable | Value | Purpose | +| ------------------------------------- | --------------------------- | ----------------------------------------- | +| `PAGER` | `cat` (non-Windows) | Prevent pager interruption | +| `VTE_VERSION` | `0` | Disable VTE prompt command interference | +| `ITERM_SHELL_INTEGRATION_INSTALLED` | `Yes` (if enabled) | Oh My Zsh compatibility | +| `POWERLEVEL9K_TERM_SHELL_INTEGRATION` | `true` (if enabled) | Powerlevel10k compatibility | +| `PROMPT_COMMAND` | `sleep X` (if delay > 0) | Workaround for VSCode output race | +| `PROMPT_EOL_MARK` | `""` (if enabled) | Prevent ZSH EOL mark issues | +| `ZDOTDIR` | Temp directory (if enabled) | Load shell integration before user config | + +--- + +## Fallback Mechanism + +When VSCode shell integration fails: + +1. [`ShellIntegrationError`](src/core/tools/ExecuteCommandTool.ts:22) is thrown +2. User sees `shell_integration_warning` message +3. Command is re-executed with `terminalShellIntegrationDisabled: true` +4. Execa provider runs command without terminal UI + +Fallback triggers: + +- Shell integration timeout exceeded +- OSC 633;C marker not received +- Stream did not start within timeout + +--- + +## Process State Management + +### Terminal States + +| Property | Type | Description | +| -------------- | --------- | ------------------------------------------------------ | +| `busy` | `boolean` | Terminal is executing or waiting for shell integration | +| `running` | `boolean` | Command is actively executing | +| `streamClosed` | `boolean` | Output stream has ended | + +### Process States + +| Property | Type | Description | +| -------------------- | --------- | ---------------------------------------------------------- | +| `isHot` | `boolean` | Process recently produced output (affects request timing) | +| `isListening` | `boolean` | Process is still accepting output events | +| `fullOutput` | `string` | Complete accumulated output | +| `lastRetrievedIndex` | `number` | Index of last retrieved output (for incremental retrieval) | + +### Hot Timer + +The [`startHotTimer()`](src/integrations/terminal/BaseTerminalProcess.ts:157) method marks a process as "hot" after receiving output: + +- Normal output: 2 second hot period +- Compilation output (detected via markers): 15 second hot period + +Compilation markers: `compiling`, `building`, `bundling`, `transpiling`, `generating`, `starting` + +--- + +## Command Execution Status Updates + +The webview receives status updates via [`CommandExecutionStatus`](packages/types/src/terminal.ts:7): + +| Status | When | Data | +| ---------- | ---------------------- | --------------------- | +| `started` | Shell execution begins | `pid`, `command` | +| `output` | Output received | `output` (compressed) | +| `exited` | Command completes | `exitCode` | +| `fallback` | Switching to execa | - | +| `timeout` | Command timed out | - | + +--- + +## Key Implementation Details + +### PowerShell Workarounds + +In [`TerminalProcess.run()`](src/integrations/terminal/TerminalProcess.ts:109): + +- Counter workaround: Appends `; "(Roo/PS Workaround: N)" > $null` to ensure unique commands +- Delay workaround: Appends `; start-sleep -milliseconds X` for output timing + +### ZDOTDIR Workaround + +[`ShellIntegrationManager.zshInitTmpDir()`](src/integrations/terminal/ShellIntegrationManager.ts:13): + +1. Creates temporary directory +2. Creates `.zshrc` that sources VSCode's shell integration script +3. Sources user's original zsh config files +4. Cleans up after shell integration succeeds or times out + +### Signal Handling + +[`BaseTerminalProcess.interpretExitCode()`](src/integrations/terminal/BaseTerminalProcess.ts:16) translates exit codes: + +- Exit codes > 128 indicate signal termination +- Signal number = exit code - 128 +- Maps to signal names (SIGINT, SIGTERM, etc.) +- Identifies signals that may produce core dumps + +--- + +## Testing + +Test files are located in `src/integrations/terminal/__tests__/`: + +| File | Coverage | +| ------------------------------------------ | ----------------------------------- | +| `TerminalProcess.spec.ts` | VSCode terminal process logic | +| `TerminalRegistry.spec.ts` | Terminal registration and selection | +| `ExecaTerminal.spec.ts` | Execa terminal provider | +| `ExecaTerminalProcess.spec.ts` | Execa process execution | +| `TerminalProcessExec.*.spec.ts` | Shell-specific execution tests | +| `TerminalProcessInterpretExitCode.spec.ts` | Exit code interpretation | + +Execute_command tool tests: `src/core/tools/__tests__/executeCommand*.spec.ts` + +--- + +## Common Issues and Debugging + +### Shell Integration Not Available + +**Symptoms**: `no_shell_integration` event emitted, fallback to execa + +**Causes**: + +- Shell doesn't support OSC 633 sequences +- User's shell config overrides VSCode's integration +- Timeout too short for slow shell startup + +**Resolution**: + +- Increase `terminalShellIntegrationTimeout` +- Enable `terminalZdotdir` for zsh +- Check for conflicting shell plugins + +### Output Missing or Truncated + +**Symptoms**: Incomplete command output + +**Causes**: + +- VSCode bug #237208 (race between completion and output) +- Output exceeds line/character limits + +**Resolution**: + +- Enable `terminalCommandDelay` setting +- Increase `terminalOutputLineLimit` or `terminalOutputCharacterLimit` + +### Progress Bars Garbled + +**Symptoms**: Multiple lines of progress instead of single updating line + +**Causes**: + +- `terminalCompressProgressBar` disabled +- Multi-byte characters in progress output + +**Resolution**: + +- Enable `terminalCompressProgressBar` +- Check [`processCarriageReturns()`](src/integrations/misc/extract-text.ts:355) handling + +--- + +## Related Features + +- **Terminal Actions** ([`packages/types/src/vscode.ts:17`](packages/types/src/vscode.ts:17)): Context menu actions for terminal output + + - `terminalAddToContext` + - `terminalFixCommand` + - `terminalExplainCommand` + +- **Background Terminals**: Terminals can continue running after task completion, tracked via [`TerminalRegistry.getBackgroundTerminals()`](src/integrations/terminal/TerminalRegistry.ts:255) + +- **Output Retrieval**: Unretrieved output can be retrieved incrementally via [`getUnretrievedOutput()`](src/integrations/terminal/BaseTerminal.ts:133) for background process monitoring diff --git a/claude-code.md b/claude-code.md new file mode 100644 index 00000000000..614210ebf1c --- /dev/null +++ b/claude-code.md @@ -0,0 +1,51 @@ + { + "name": "Bash", + "description": "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\n\nBefore executing the command, please follow these steps:\n\n1. Directory Verification:\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\n - For example, before running \"mkdir foo/bar\", first use `ls foo` to check that \"foo\" exists and is the intended parent directory\n\n2. Command Execution:\n - Always quote file paths that contain spaces with double quotes (e.g., cd \"path with spaces/file.txt\")\n - Examples of proper quoting:\n - cd \"/Users/name/My Documents\" (correct)\n - cd /Users/name/My Documents (incorrect - will fail)\n - python \"/path/with spaces/script.py\" (correct)\n - python /path/with spaces/script.py (incorrect - will fail)\n - After ensuring proper quoting, execute the command.\n - Capture the output of the command.\n\nUsage notes:\n - The command argument is required.\n - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).\n - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does.\n - If the output exceeds 30000 characters, output will be truncated before being returned to you.\n \n - You can use the `run_in_background` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes. You do not need to use '&' at the end of the command when using this parameter.\n \n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\n - File search: Use Glob (NOT find or ls)\n - Content search: Use Grep (NOT grep or rg)\n - Read files: Use Read (NOT cat/head/tail)\n - Edit files: Use Edit (NOT sed/awk)\n - Write files: Use Write (NOT echo >/cat <\n pytest /foo/bar/tests\n \n \n cd /foo/bar && pytest tests\n \n\n# Committing changes with git\n\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\n\nGit Safety Protocol:\n- NEVER update the git config\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\n- NEVER run force push to main/master, warn the user if they request it\n- CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend, unless the user explicitly requests it\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\n\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\n - Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.\n - Run a git diff command to see both staged and unstaged changes that will be committed.\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.).\n - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files\n - Draft a concise (1-2 sentences) commit message that focuses on the \"why\" rather than the \"what\"\n - Ensure it accurately reflects the changes and their purpose\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\n - Add relevant untracked files to the staging area.\n - Create the commit with a message ending with:\n Co-Authored-By: Claude Opus 4.5 \n - Run git status after the commit completes to verify success.\n Note: git status depends on the commit completing, so run it sequentially after the commit.\n4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit\n\nImportant notes:\n- NEVER run additional commands to read or explore code, besides git bash commands\n- NEVER use the TodoWrite or Task tools\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\n- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:\n\ngit commit -m \"$(cat <<'EOF'\n Commit message here.\n\n Co-Authored-By: Claude Opus 4.5 \n EOF\n )\"\n\n\n# Creating pull requests\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.\n\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\n\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\n - Run a git status command to see all untracked files (never use -uall flag)\n - Run a git diff command to see both staged and unstaged changes that will be committed\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\n - Create new branch if needed\n - Push to remote with -u flag if needed\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\n\ngh pr create --title \"the pr title\" --body \"$(cat <<'EOF'\n## Summary\n<1-3 bullet points>\n\n## Test plan\n[Bulleted markdown checklist of TODOs for testing the pull request...]\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\nEOF\n)\"\n\n\nImportant:\n- DO NOT use the TodoWrite or Task tools\n- Return the PR URL when you're done, so the user can see it\n\n# Other common operations\n- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "command": { + "description": "The command to execute", + "type": "string" + }, + "timeout": { + "description": "Optional timeout in milliseconds (max 600000)", + "type": "number" + }, + "description": { + "description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → \"List files in current directory\"\n- git status → \"Show working tree status\"\n- npm install → \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; → \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main → \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"", + "type": "string" + }, + "run_in_background": { + "description": "Set to true to run this command in the background. Use TaskOutput to read the output later.", + "type": "boolean" + }, + "dangerouslyDisableSandbox": { + "description": "Set this to true to dangerously override sandbox mode and run commands without sandboxing.", + "type": "boolean" + }, + "_simulatedSedEdit": { + "description": "Internal: pre-computed sed edit result from preview", + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "newContent": { + "type": "string" + } + }, + "required": [ + "filePath", + "newContent" + ], + "additionalProperties": false + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + }, diff --git a/codex-extract-terminal-spawning-tool.md b/codex-extract-terminal-spawning-tool.md new file mode 100644 index 00000000000..aef70d584c6 --- /dev/null +++ b/codex-extract-terminal-spawning-tool.md @@ -0,0 +1,1206 @@ +# Executive Summary + +This document specifies the **Terminal Spawning Tool** feature—a system that enables an AI agent to execute shell commands on a host machine with comprehensive support for: + +- **Multiple spawn modes**: PTY-based interactive sessions or pipe-based non-interactive processes +- **Shell abstraction**: Cross-platform shell detection and command translation (Bash, Zsh, PowerShell, sh, cmd) +- **Sandbox enforcement**: Platform-native sandboxing (macOS Seatbelt, Linux seccomp/Landlock, Windows restricted tokens) +- **Approval workflows**: Configurable human-in-the-loop approval for dangerous operations +- **Process lifecycle management**: Output buffering, timeout handling, cancellation, and cleanup +- **Interactive sessions**: Persistent PTY processes that maintain state across multiple tool calls + +The feature is designed for AI coding assistants that need to execute commands while balancing autonomy with safety through layered sandboxing and approval mechanisms. + +--- + +# Glossary + +| Term | Definition | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | +| **ToolHandler** | Registry entry that matches incoming tool calls by name and dispatches execution | +| **ToolRuntime** | Execution backend that runs a specific request type under sandbox orchestration | +| **ToolOrchestrator** | Central coordinator managing approval → sandbox selection → execution → retry | +| **ExecParams** | Portable command specification: command vector, working directory, environment, timeout | +| **ExecEnv** | Transformed execution environment ready for spawning (includes sandbox wrapper commands) | +| **SandboxPolicy** | Session-level filesystem/network access policy (ReadOnly, WorkspaceWrite, DangerFullAccess) | +| **SandboxPermissions** | Per-call override (UseDefault, RequireEscalated) | +| **SandboxType** | Platform-specific sandbox implementation (None, MacosSeatbelt, LinuxSeccomp, WindowsRestrictedToken) | +| **ProcessHandle** | Abstraction over a spawned process providing stdin writer, output receiver, and termination | +| **SpawnedProcess** | Return value from PTY/pipe spawn containing ProcessHandle, output channel, and exit receiver | +| **UnifiedExecProcess** | Managed process wrapper with output buffering, sandbox awareness, and lifecycle hooks | +| **ApprovalRequirement** | Classification of a command: Skip, NeedsApproval, or Forbidden | +| **Shell** | Detected user shell with type (Bash/Zsh/PowerShell/sh/cmd), path, and optional environment snapshot | + +--- + +# Feature Overview & Boundaries + +## What the Feature Does + +The Terminal Spawning Tool enables an AI agent to: + +1. **Execute shell commands** by translating high-level requests into platform-appropriate shell invocations +2. **Manage interactive sessions** where a PTY process persists across multiple tool calls, maintaining shell state +3. **Enforce security policies** through configurable sandboxing and human approval workflows +4. **Stream output** with intelligent truncation and buffering for token-efficient responses +5. **Handle timeouts and cancellation** gracefully, cleaning up process trees + +## Boundaries + +**In Scope:** + +- Shell command execution (one-shot and interactive) +- Cross-platform shell detection and argument translation +- Sandbox policy enforcement with platform-native mechanisms +- Approval caching and retry-without-sandbox flows +- Output buffering with head/tail preservation +- Process group management for clean termination + +**Out of Scope:** + +- GUI application launching +- Network service management +- Container orchestration +- Remote execution + +--- + +# System Architecture (High Level) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Agent / LLM Interface │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Tool Invocation Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ShellHandler │ │ShellCommandHandler│ │ UnifiedExec │ │ +│ │ (shell tool) │ │ (shell_command) │ │ (exec_command) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┴────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────▼─────────────────────────────────────┐ │ +│ │ ToolOrchestrator │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │ +│ │ │ Approval │ │ Sandbox │ │ Retry on Sandbox Denial │ │ │ +│ │ │ Workflow │ │ Selection │ │ (with re-approval) │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └────────────┬────────────┘ │ │ +│ └─────────┴────────────────┴──────────────────────┴─────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────▼─────────────────────────────────────┐ │ +│ │ SandboxManager │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Seatbelt │ │ Landlock/ │ │ Windows │ │ │ +│ │ │ (macOS) │ │ seccomp (Linux)│ │ Restricted │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +├────────────────────────────────▼────────────────────────────────────────────┤ +│ Process Spawning Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ PTY Spawn │ │ Pipe Spawn │ │ spawn_child_async│ │ +│ │ (interactive) │ │ (non-interactive)│ │ (direct) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +# Core Data Model & Schemas + +## ShellToolCallParams + +Parameters for the `shell` tool (command as array): + +```typescript +interface ShellToolCallParams { + command: string[] // e.g., ["ls", "-la"] + workdir?: string // Working directory (relative to session cwd) + timeout_ms?: number // Maximum execution time (default: 10000) + sandbox_permissions?: "use_default" | "require_escalated" + justification?: string // Reason for escalated permissions +} +``` + +## ShellCommandToolCallParams + +Parameters for the `shell_command` tool (command as freeform string): + +```typescript +interface ShellCommandToolCallParams { + command: string // e.g., "ls -la | grep foo" + workdir?: string + login?: boolean // Use login shell semantics (default: true) + timeout_ms?: number + sandbox_permissions?: "use_default" | "require_escalated" + justification?: string +} +``` + +## ExecParams (Internal) + +Portable execution parameters after initial processing: + +```typescript +interface ExecParams { + command: string[] // Full command vector including shell + cwd: PathBuf // Resolved absolute working directory + expiration: ExecExpiration // Timeout | DefaultTimeout | Cancellation + env: Map // Environment variables + sandbox_permissions: SandboxPermissions + justification?: string + arg0?: string // Optional argv[0] override +} +``` + +## ExecEnv (Sandbox-Transformed) + +Ready-to-spawn environment after sandbox transformation: + +```typescript +interface ExecEnv { + command: string[] // May include sandbox wrapper (e.g., sandbox-exec) + cwd: PathBuf + env: Map // Includes CODEX_SANDBOX_* variables + expiration: ExecExpiration + sandbox: SandboxType // None | MacosSeatbelt | LinuxSeccomp | WindowsRestrictedToken + sandbox_permissions: SandboxPermissions + justification?: string + arg0?: string +} +``` + +## Shell + +Detected user shell configuration: + +```typescript +interface Shell { + shell_type: "Zsh" | "Bash" | "PowerShell" | "Sh" | "Cmd" + shell_path: PathBuf // e.g., "/bin/zsh" + shell_snapshot?: ShellSnapshot // Optional environment snapshot for login shell emulation +} +``` + +## ProcessHandle + +Abstraction over a running process: + +```typescript +interface ProcessHandle { + writer_sender(): Sender // stdin channel + output_receiver(): BroadcastReceiver // stdout+stderr + has_exited(): boolean + exit_code(): number | null + terminate(): void +} +``` + +## SpawnedProcess + +Return value from spawn functions: + +```typescript +interface SpawnedProcess { + session: ProcessHandle + output_rx: BroadcastReceiver // Initial output subscription + exit_rx: OneshotReceiver // Exit code notification +} +``` + +## ExecToolCallOutput + +Result of command execution: + +```typescript +interface ExecToolCallOutput { + exit_code: number + stdout: StreamOutput + stderr: StreamOutput + aggregated_output: StreamOutput // Combined stdout + stderr + duration: Duration + timed_out: boolean +} + +interface StreamOutput { + text: T + truncated_after_lines?: number +} +``` + +--- + +# Public Interfaces + +## Tool Registration + +Tools are registered with a handler that implements: + +```typescript +interface ToolHandler { + kind(): ToolKind // Function | Custom | MCP + matches_kind(payload: ToolPayload): boolean // Can handle this payload type? + is_mutating(invocation: ToolInvocation): Promise // Affects filesystem? + handle(invocation: ToolInvocation): Promise +} +``` + +## ToolInvocation + +Context passed to handlers: + +```typescript +interface ToolInvocation { + session: Session // Global session state + turn: TurnContext // Current conversation turn + tracker: TurnDiffTracker // File change tracking + call_id: string // Unique identifier for this call + tool_name: string + payload: ToolPayload // Function | Custom | LocalShell | MCP +} +``` + +## ToolPayload Variants + +```typescript +type ToolPayload = + | { type: "Function"; arguments: string } // JSON arguments + | { type: "Custom"; input: string } // Raw input + | { type: "LocalShell"; params: ShellToolCallParams } + | { type: "Mcp"; server: string; tool: string; raw_arguments: string } +``` + +## ToolOutput + +Return value from handlers: + +```typescript +type ToolOutput = + | { type: "Function"; content: string; content_items?: ContentItem[]; success?: boolean } + | { type: "Mcp"; result: Result } +``` + +--- + +# Runtime Flow (End-to-End) + +```mermaid +sequenceDiagram + participant Agent + participant Handler as ShellHandler + participant Orchestrator as ToolOrchestrator + participant Runtime as ShellRuntime + participant Sandbox as SandboxManager + participant Spawner as spawn_child_async + + Agent->>Handler: handle(invocation) + Handler->>Handler: parse arguments to ExecParams + Handler->>Orchestrator: run(runtime, request, ctx) + + Orchestrator->>Orchestrator: check ExecApprovalRequirement + alt NeedsApproval + Orchestrator->>Agent: request_command_approval() + Agent-->>Orchestrator: ReviewDecision + end + + Orchestrator->>Sandbox: select_initial(policy, preference) + Sandbox-->>Orchestrator: SandboxType + + Orchestrator->>Runtime: run(request, attempt, ctx) + Runtime->>Runtime: build CommandSpec + Runtime->>Sandbox: transform(spec, policy, sandbox_type) + Sandbox-->>Runtime: ExecEnv + Runtime->>Spawner: spawn_child_async(program, args, cwd, env) + Spawner-->>Runtime: Child process + Runtime->>Runtime: consume_truncated_output(child, timeout) + Runtime-->>Orchestrator: ExecToolCallOutput + + alt Sandbox Denied & escalate_on_failure + Orchestrator->>Agent: request approval for no-sandbox retry + Agent-->>Orchestrator: Approved + Orchestrator->>Runtime: run(request, attempt{sandbox: None}) + Runtime-->>Orchestrator: ExecToolCallOutput + end + + Orchestrator-->>Handler: ExecToolCallOutput + Handler->>Handler: format output as ToolOutput + Handler-->>Agent: ToolOutput +``` + +--- + +# Initialization, Discovery, and Registration (If Applicable) + +## Shell Detection + +At session startup, the system detects the user's default shell: + +```mermaid +sequenceDiagram + participant Session + participant ShellDetector + participant System + + Session->>ShellDetector: default_user_shell() + ShellDetector->>System: getpwuid(getuid()).pw_shell [Unix] + System-->>ShellDetector: "/bin/zsh" + ShellDetector->>ShellDetector: detect_shell_type("/bin/zsh") + ShellDetector-->>Session: Shell { type: Zsh, path: "/bin/zsh" } +``` + +**Detection Algorithm:** + +1. On Unix: Read `pw_shell` from `getpwuid(getuid())` +2. Map shell path to type by matching basename (zsh → Zsh, bash → Bash, etc.) +3. Validate shell exists via `which` or fallback paths +4. On Windows: Default to PowerShell, fallback to cmd.exe + +## Tool Handler Registration + +Handlers are registered in a static registry: + +```typescript +// Pseudocode for handler registration +const TOOL_REGISTRY = { + shell: new ShellHandler(), + "container.exec": new ShellHandler(), // Alias + shell_command: new ShellCommandHandler(), + exec_command: new UnifiedExecHandler(), + write_stdin: new WriteStdinHandler(), +} +``` + +--- + +# Invocation, Routing, and Orchestration + +## Invocation Entry Points + +### 1. `shell` Tool (Vector Command) + +The agent provides a command as an array: + +```json +{ + "name": "shell", + "arguments": "{\"command\": [\"ls\", \"-la\"], \"workdir\": \"src\"}" +} +``` + +**Flow:** + +1. `ShellHandler.handle()` parses `ShellToolCallParams` +2. Converts to `ExecParams` (command vector used as-is) +3. Delegates to `run_exec_like()` + +### 2. `shell_command` Tool (Freeform String) + +The agent provides a shell command string: + +```json +{ + "name": "shell_command", + "arguments": "{\"command\": \"grep -r 'TODO' src/\"}" +} +``` + +**Flow:** + +1. `ShellCommandHandler.handle()` parses `ShellCommandToolCallParams` +2. Calls `derive_exec_args()` on the session's detected shell +3. For Bash/Zsh: `["/bin/zsh", "-lc", "grep -r 'TODO' src/"]` +4. For PowerShell: `["pwsh", "-Command", "grep -r 'TODO' src/"]` + +### 3. `exec_command` Tool (Interactive/Unified Exec) + +For interactive sessions that persist: + +```json +{ + "name": "exec_command", + "arguments": "{\"command\": [\"bash\", \"-i\"], \"process_id\": \"1234\", \"yield_time_ms\": 2500}" +} +``` + +**Flow:** + +1. `UnifiedExecHandler` allocates or retrieves process by ID +2. Opens PTY session if new +3. Collects output until yield time or process exit +4. Returns output with optional `process_id` for continuation + +### 4. `write_stdin` Tool (Send Input to Existing Process) + +```json +{ + "name": "write_stdin", + "arguments": "{\"process_id\": \"1234\", \"input\": \"export FOO=bar\\n\"}" +} +``` + +## Orchestration Flow + +The `ToolOrchestrator` coordinates the execution: + +``` +1. APPROVAL PHASE + ├─ Check ExecApprovalRequirement from exec_policy + ├─ If Skip: proceed immediately + ├─ If Forbidden: reject with error + └─ If NeedsApproval: + ├─ Check approval cache + ├─ If cached ApprovedForSession: proceed + └─ Else: prompt user, cache decision + +2. SANDBOX SELECTION PHASE + ├─ Check sandbox_mode_for_first_attempt(request) + ├─ If BypassSandboxFirstAttempt: use SandboxType::None + └─ Else: select_initial(policy, preference) + ├─ DangerFullAccess → None + ├─ ExternalSandbox → None + └─ ReadOnly/WorkspaceWrite → platform sandbox + +3. EXECUTION PHASE + ├─ Transform CommandSpec → ExecEnv via SandboxManager + ├─ Spawn process with spawn_child_async or PTY + └─ Collect output with timeout + +4. RETRY PHASE (on sandbox denial) + ├─ Detect denial via is_likely_sandbox_denied() + ├─ If escalate_on_failure && approval_policy allows: + │ ├─ Prompt for no-sandbox approval + │ └─ Re-execute with SandboxType::None + └─ Else: return error +``` + +--- + +# Permissions, Guardrails, and Validation + +## Approval Policies + +| Policy | Behavior | +| --------------- | ------------------------------------------------------ | +| `Never` | Never prompt; agent has full autonomy | +| `UnlessTrusted` | Always prompt unless command matches trusted patterns | +| `OnFailure` | Prompt only if command fails in sandbox | +| `OnRequest` | Prompt for all commands unless DangerFullAccess policy | + +## Sandbox Policies + +| Policy | Read | Write | Network | +| ------------------ | ---------------------------- | -------------------- | ------------ | +| `ReadOnly` | Anywhere | Nowhere | Blocked | +| `WorkspaceWrite` | Anywhere | cwd + writable_roots | Configurable | +| `DangerFullAccess` | Anywhere | Anywhere | Full | +| `ExternalSandbox` | Delegated to external system | | | + +## Safe Command Detection + +Commands are classified as "safe" (non-mutating) via `is_known_safe_command()`: + +```typescript +// Safe command patterns (no approval needed even in strict modes) +const SAFE_PATTERNS = [ + /^ls\b/, + /^cat\b/, + /^head\b/, + /^tail\b/, + /^grep\b/, + /^find\b/, + /^pwd$/, + /^echo\b/, + /^env$/, + // ... etc +] +``` + +## Sandbox Denial Detection + +After execution, output is scanned for sandbox denial indicators: + +```typescript +const SANDBOX_DENIED_KEYWORDS = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", +] +``` + +--- + +# Error Model, Retries, Timeouts, and Cancellation + +## Error Types + +```typescript +type ExecError = + | { type: "Timeout"; output: ExecToolCallOutput } // Command exceeded timeout + | { type: "Denied"; output: ExecToolCallOutput } // Sandbox blocked operation + | { type: "Signal"; signal: number } // Killed by signal + | { type: "IoError"; message: string } // Spawn/read failure + | { type: "Rejected"; reason: string } // User denied approval +``` + +## Timeout Handling + +```typescript +const DEFAULT_EXEC_COMMAND_TIMEOUT_MS = 10_000; +const EXEC_TIMEOUT_EXIT_CODE = 124; // Conventional timeout exit code + +async function consume_truncated_output(child, expiration) { + select! { + status = child.wait() => (status, timed_out: false), + _ = expiration.wait() => { + kill_child_process_group(child); + child.start_kill(); + (EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE, timed_out: true) + }, + _ = ctrl_c() => { + kill_child_process_group(child); + child.start_kill(); + (EXIT_CODE_SIGNAL_BASE + SIGKILL_CODE, timed_out: false) + } + } +} +``` + +## Cancellation + +Commands support cancellation via `CancellationToken`: + +```typescript +interface ExecExpiration { + type: "Timeout" | "DefaultTimeout" | "Cancellation" + duration?: Duration // For Timeout + token?: CancellationToken // For Cancellation +} +``` + +## Retry Logic + +On sandbox denial (detected via exit code + keywords): + +1. Check `escalate_on_failure()` on runtime → true for shell +2. Check approval policy allows retry → not Never/OnRequest +3. Prompt user with denial reason +4. If approved, re-execute with `SandboxType::None` + +--- + +# Async, Streaming, and Concurrency + +## Output Streaming + +Output is streamed via events during execution: + +```typescript +interface ExecCommandOutputDeltaEvent { + call_id: string + stream: "Stdout" | "Stderr" + chunk: bytes +} +``` + +Streaming is capped to prevent event flooding: + +```typescript +const MAX_EXEC_OUTPUT_DELTAS_PER_CALL = 10_000 +``` + +## Output Buffering + +For interactive sessions, a `HeadTailBuffer` preserves both beginning and end of output: + +```typescript +const UNIFIED_EXEC_OUTPUT_MAX_BYTES = 1024 * 1024 // 1 MiB + +class HeadTailBuffer { + head: bytes[] // First chunks + tail: bytes[] // Last chunks + total_bytes: number + + push_chunk(chunk: bytes) { + if (total_bytes >= MAX_BYTES) { + // Evict from middle, keep head + tail + } + } + + snapshot_chunks(): bytes[] { + return [...head, ...tail] + } +} +``` + +## Concurrent Process Management + +The `UnifiedExecProcessManager` tracks up to 64 concurrent interactive processes: + +```typescript +const MAX_UNIFIED_EXEC_PROCESSES = 64 +const WARNING_UNIFIED_EXEC_PROCESSES = 60 + +class ProcessStore { + processes: Map + reserved_process_ids: Set +} + +// Pruning policy when at capacity: +// 1. Prefer exited processes outside "recently used" set (last 8) +// 2. Fallback to LRU process outside protected set +``` + +## Process Group Management + +Child processes are placed in their own process group for clean termination: + +```typescript +// In pre_exec (Unix): +function detach_from_tty() { + setsid() // Start new session +} + +function set_parent_death_signal(parent_pid) { + // Linux only + prctl(PR_SET_PDEATHSIG, SIGTERM) + if (getppid() != parent_pid) raise(SIGTERM) // Race check +} + +// Termination: +function kill_process_group(pgid) { + killpg(pgid, SIGKILL) +} +``` + +--- + +# Logging, Metrics, and Telemetry + +## Event Emission + +Tool execution emits lifecycle events: + +```typescript +// Begin event +ToolEmitter.shell(command, cwd, source, freeform).begin(ctx) + +// End event (on completion) +emitter.finish(ctx, result) +``` + +## Telemetry Preview + +Output is truncated for telemetry: + +```typescript +const TELEMETRY_PREVIEW_MAX_BYTES = 2048 +const TELEMETRY_PREVIEW_MAX_LINES = 50 +const TELEMETRY_PREVIEW_TRUNCATION_NOTICE = "[output truncated]" +``` + +## Approval Metrics + +```typescript +otel.counter("codex.approval.requested", 1, { + tool: "shell", + approved: decision.to_opaque_string(), +}) +``` + +## Sandbox Environment Variables + +Set on spawned processes for observability: + +```typescript +// When network access is restricted: +CODEX_SANDBOX_NETWORK_DISABLED = 1 + +// When running under platform sandbox: +CODEX_SANDBOX = seatbelt // macOS +``` + +--- + +# Configuration + +## Session-Level Configuration + +```typescript +interface SessionConfig { + sandbox_policy: SandboxPolicy + approval_policy: AskForApproval + shell_environment_policy: ShellEnvironmentPolicy // env vars to inherit + codex_linux_sandbox_exe?: PathBuf // Path to Landlock sandbox binary +} +``` + +## Per-Turn Context + +```typescript +interface TurnContext { + cwd: PathBuf + sandbox_policy: SandboxPolicy + approval_policy: AskForApproval + shell_environment_policy: ShellEnvironmentPolicy + codex_linux_sandbox_exe?: PathBuf +} +``` + +## Environment Variables for Spawned Processes + +Interactive sessions (`exec_command`) inject: + +```typescript +const UNIFIED_EXEC_ENV = { + NO_COLOR: "1", + TERM: "dumb", + LANG: "C.UTF-8", + LC_CTYPE: "C.UTF-8", + LC_ALL: "C.UTF-8", + COLORTERM: "", + PAGER: "cat", + GIT_PAGER: "cat", + GH_PAGER: "cat", + CODEX_CI: "1", +} +``` + +--- + +# Extension Points + +## Adding a New Shell Type + +1. Add variant to `ShellType` enum +2. Implement `derive_exec_args()` for the new shell +3. Add detection in `detect_shell_type()` +4. Add discovery in `get_shell()` + +## Adding a New Sandbox Backend + +1. Add variant to `SandboxType` enum +2. Implement transformation in `SandboxManager.transform()` +3. Add platform detection in `get_platform_sandbox()` +4. Implement denial detection patterns + +## Adding a New Approval Policy + +1. Add variant to `AskForApproval` enum +2. Update `default_exec_approval_requirement()` +3. Update `wants_no_sandbox_approval()` logic +4. Create corresponding prompt template + +## Custom Tool Runtime + +Implement these traits: + +```typescript +interface ToolRuntime { + // From Sandboxable + sandbox_preference(): SandboxablePreference + escalate_on_failure(): boolean + + // From Approvable + approval_keys(req: Request): ApprovalKey[] + start_approval_async(req: Request, ctx: ApprovalCtx): Promise + + // Execution + run(req: Request, attempt: SandboxAttempt, ctx: ToolCtx): Promise +} +``` + +--- + +# Reference Implementation Sketch (Pseudocode) + +``` +// === TYPES === + +enum SandboxType { None, MacosSeatbelt, LinuxSeccomp, WindowsRestricted } +enum ApprovalPolicy { Never, UnlessTrusted, OnFailure, OnRequest } +enum ReviewDecision { Approved, ApprovedForSession, Denied, Abort } + +struct ExecParams { + command: Vec + cwd: Path + timeout: Duration + env: Map + sandbox_permissions: SandboxPermissions +} + +struct ExecEnv { + command: Vec + cwd: Path + env: Map + timeout: Duration + sandbox: SandboxType +} + +struct ExecOutput { + exit_code: i32 + stdout: String + stderr: String + timed_out: bool +} + +// === SHELL DETECTION === + +function detect_user_shell() -> Shell: + path = get_passwd_shell() OR "/bin/sh" + type = match basename(path): + "zsh" -> Zsh + "bash" -> Bash + "pwsh" | "powershell" -> PowerShell + "sh" -> Sh + "cmd" -> Cmd + return Shell { type, path } + +function derive_exec_args(shell: Shell, command: String, login: bool) -> Vec: + match shell.type: + Zsh | Bash | Sh: + flag = login ? "-lc" : "-c" + return [shell.path, flag, command] + PowerShell: + args = [shell.path] + if !login: args.push("-NoProfile") + args.push("-Command", command) + return args + Cmd: + return [shell.path, "/c", command] + +// === SANDBOX TRANSFORMATION === + +function select_sandbox(policy: SandboxPolicy) -> SandboxType: + if policy == DangerFullAccess OR policy == ExternalSandbox: + return None + return get_platform_sandbox() OR None + +function transform_for_sandbox(spec: CommandSpec, sandbox: SandboxType) -> ExecEnv: + env = spec.env.clone() + if !policy.has_network_access(): + env["CODEX_SANDBOX_NETWORK_DISABLED"] = "1" + + command = [spec.program] + spec.args + + match sandbox: + None: + return ExecEnv { command, cwd: spec.cwd, env, sandbox: None } + MacosSeatbelt: + env["CODEX_SANDBOX"] = "seatbelt" + wrapper = ["/usr/bin/sandbox-exec", "-f", profile_path()] + command + return ExecEnv { command: wrapper, cwd: spec.cwd, env, sandbox } + LinuxSeccomp: + wrapper = [sandbox_exe, "--policy", policy_json()] + command + return ExecEnv { command: wrapper, cwd: spec.cwd, env, sandbox } + +// === APPROVAL WORKFLOW === + +async function check_approval( + request: Request, + policy: ApprovalPolicy, + cache: ApprovalCache +) -> ReviewDecision: + + requirement = compute_approval_requirement(request, policy) + + match requirement: + Skip: + return Approved + Forbidden(reason): + throw Rejected(reason) + NeedsApproval: + key = approval_key(request) + if cache.get(key) == ApprovedForSession: + return ApprovedForSession + + decision = await prompt_user(request) + if decision == ApprovedForSession: + cache.put(key, decision) + return decision + +// === PROCESS SPAWNING === + +async function spawn_child(env: ExecEnv) -> Child: + command = Command::new(env.command[0]) + command.args(env.command[1..]) + command.current_dir(env.cwd) + command.env_clear() + command.envs(env.env) + + // Unix: detach from TTY, set parent death signal + command.pre_exec(|| { + setsid() + prctl(PR_SET_PDEATHSIG, SIGTERM) // Linux + }) + + command.stdin(Stdio::null()) // Prevent hanging on stdin + command.stdout(Stdio::piped()) + command.stderr(Stdio::piped()) + command.kill_on_drop(true) + + return command.spawn() + +async function spawn_pty(program: String, args: Vec, env: Map) -> SpawnedProcess: + pty = native_pty_system().openpty(24, 80) + child = pty.slave.spawn_command(CommandBuilder::new(program).args(args).env(env)) + + // Start reader task for PTY output + reader_task = spawn(async || { + loop: + chunk = pty.master.read() + if chunk.empty(): break + output_tx.send(chunk) + }) + + // Start writer task for PTY input + writer_task = spawn(async || { + while input = writer_rx.recv(): + pty.master.write(input) + }) + + return SpawnedProcess { handle, output_rx, exit_rx } + +// === EXECUTION WITH TIMEOUT === + +async function execute_with_timeout(child: Child, timeout: Duration) -> ExecOutput: + stdout_task = spawn(read_capped(child.stdout)) + stderr_task = spawn(read_capped(child.stderr)) + + select: + status = child.wait(): + stdout = await stdout_task + stderr = await stderr_task + return ExecOutput { exit_code: status.code(), stdout, stderr, timed_out: false } + + _ = sleep(timeout): + kill_process_group(child.pid()) + child.kill() + return ExecOutput { exit_code: 124, stdout: "", stderr: "", timed_out: true } + +// === SANDBOX DENIAL DETECTION === + +function is_sandbox_denied(sandbox: SandboxType, output: ExecOutput) -> bool: + if sandbox == None OR output.exit_code == 0: + return false + + keywords = ["operation not permitted", "permission denied", "read-only file system"] + text = (output.stdout + output.stderr).lowercase() + return any(k in text for k in keywords) + +// === MAIN ORCHESTRATION === + +async function run_shell_tool(invocation: ToolInvocation) -> ToolOutput: + params = parse_arguments(invocation.payload) + exec_params = to_exec_params(params, invocation.turn) + + // 1. Approval + decision = await check_approval(exec_params, invocation.turn.approval_policy, cache) + if decision in [Denied, Abort]: + throw Rejected("user denied") + + // 2. First sandbox attempt + sandbox = select_sandbox(invocation.turn.sandbox_policy) + exec_env = transform_for_sandbox(exec_params, sandbox) + child = await spawn_child(exec_env) + output = await execute_with_timeout(child, exec_params.timeout) + + // 3. Retry without sandbox if denied + if is_sandbox_denied(sandbox, output): + if approval_policy != Never: + retry_decision = await prompt_user_for_retry(exec_params) + if retry_decision == Approved: + exec_env = transform_for_sandbox(exec_params, None) + child = await spawn_child(exec_env) + output = await execute_with_timeout(child, exec_params.timeout) + + // 4. Format output + return ToolOutput::Function { + content: format_output(output), + success: output.exit_code == 0 + } +``` + +--- + +# Worked Example + +## Scenario: Execute `grep` Command with Sandbox + +**Agent Request:** + +```json +{ + "type": "function_call", + "name": "shell_command", + "call_id": "call_abc123", + "arguments": "{\"command\": \"grep -r 'TODO' src/\", \"timeout_ms\": 5000}" +} +``` + +**Step 1: Handler Dispatch** + +``` +ShellCommandHandler.handle(invocation) + params = ShellCommandToolCallParams { + command: "grep -r 'TODO' src/", + timeout_ms: 5000, + ...defaults + } +``` + +**Step 2: Shell Command Translation** + +``` +session.user_shell() = Shell { type: Zsh, path: "/bin/zsh" } +derive_exec_args(shell, "grep -r 'TODO' src/", login=true) + → ["/bin/zsh", "-lc", "grep -r 'TODO' src/"] +``` + +**Step 3: Build ExecParams** + +``` +ExecParams { + command: ["/bin/zsh", "-lc", "grep -r 'TODO' src/"], + cwd: "/home/user/project", + expiration: Timeout(5000ms), + env: { PATH: "...", HOME: "...", ... }, + sandbox_permissions: UseDefault +} +``` + +**Step 4: Orchestrator - Approval Check** + +``` +approval_policy = OnRequest +sandbox_policy = WorkspaceWrite +is_known_safe_command(["/bin/zsh", "-lc", "grep ..."]) = true // grep is safe +→ ExecApprovalRequirement::Skip { bypass_sandbox: false } +``` + +**Step 5: Orchestrator - Sandbox Selection** + +``` +sandbox_mode_for_first_attempt(request) = NoOverride +select_initial(WorkspaceWrite, Auto) = MacosSeatbelt // on macOS +``` + +**Step 6: SandboxManager Transform** + +``` +ExecEnv { + command: [ + "/usr/bin/sandbox-exec", + "-f", "/tmp/codex-sandbox-profile.sb", + "-D", "CWD=/home/user/project", + "/bin/zsh", "-lc", "grep -r 'TODO' src/" + ], + cwd: "/home/user/project", + env: { ..., CODEX_SANDBOX: "seatbelt", CODEX_SANDBOX_NETWORK_DISABLED: "1" }, + sandbox: MacosSeatbelt +} +``` + +**Step 7: Process Spawn** + +``` +child = spawn_child_async( + program: "/usr/bin/sandbox-exec", + args: ["-f", "...", "/bin/zsh", "-lc", "grep ..."], + cwd: "/home/user/project", + env: { ... }, + stdio_policy: RedirectForShellTool // stdin=null, stdout/stderr=piped +) +``` + +**Step 8: Output Collection** + +``` +consume_truncated_output(child, Timeout(5000ms)) + → stdout: "src/main.rs:42: // TODO: refactor this\n" + → stderr: "" + → exit_code: 0 + → timed_out: false +``` + +**Step 9: Result Formatting** + +``` +ExecToolCallOutput { + exit_code: 0, + stdout: StreamOutput { text: "src/main.rs:42: // TODO: refactor this\n" }, + stderr: StreamOutput { text: "" }, + aggregated_output: StreamOutput { text: "src/main.rs:42: // TODO: refactor this\n" }, + duration: 127ms, + timed_out: false +} +``` + +**Step 10: Tool Output** + +```json +{ + "type": "function_call_output", + "call_id": "call_abc123", + "output": "src/main.rs:42: // TODO: refactor this\n" +} +``` + +## Scenario: Interactive Session + +**Request 1: Start bash session** + +```json +{ + "name": "exec_command", + "arguments": "{\"command\": [\"bash\", \"-i\"], \"process_id\": \"1001\", \"yield_time_ms\": 2500, \"tty\": true}" +} +``` + +**Processing:** + +1. PTY spawned with bash +2. Output collected for 2500ms +3. Process persisted with ID "1001" +4. Response includes `process_id: "1001"` indicating session is alive + +**Request 2: Send command to session** + +```json +{ + "name": "write_stdin", + "arguments": "{\"process_id\": \"1001\", \"input\": \"export FOO=bar\\n\", \"yield_time_ms\": 1000}" +} +``` + +**Processing:** + +1. Retrieve process "1001" from store +2. Write `export FOO=bar\n` to PTY stdin +3. Wait 100ms for process to react +4. Collect output for remaining yield time +5. Response includes any shell prompt/echo + +**Request 3: Verify variable** + +```json +{ + "name": "write_stdin", + "arguments": "{\"process_id\": \"1001\", \"input\": \"echo $FOO\\n\", \"yield_time_ms\": 1000}" +} +``` + +**Response:** + +```json +{ + "output": "bar\n", + "process_id": "1001", + "exit_code": null +} +``` + +The session maintains state across calls, proving environment variable persistence. diff --git a/src/core/prompts/tools/native-tools/read_command_output.ts b/src/core/prompts/tools/native-tools/read_command_output.ts index 0bab31be9e1..b163b46c568 100644 --- a/src/core/prompts/tools/native-tools/read_command_output.ts +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -70,7 +70,7 @@ export default { description: LIMIT_DESCRIPTION, }, }, - required: ["artifact_id", "search", "offset", "limit"], + required: ["artifact_id"], additionalProperties: false, }, }, diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 2bbc066badc..2d442209f5f 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -208,9 +208,9 @@ export async function executeCommandInTerminal( if (globalStoragePath) { const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId) const storageDir = path.join(taskDir, "command-output") - // Use default preview size for now (terminalOutputPreviewSize setting will be added in Phase 5) - const terminalOutputPreviewSize = DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE const providerState = await provider?.getState() + const terminalOutputPreviewSize = + providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true interceptor = new OutputInterceptor({ diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index d81352c30a8..7d83c16fba1 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -223,14 +223,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset) const content = buffer.slice(0, bytesRead).toString("utf8") - // Calculate line numbers based on offset + // Calculate line numbers based on offset using chunked reading to avoid large allocations let startLineNumber = 1 if (offset > 0) { - // Count newlines before offset to determine starting line number - const prefixBuffer = Buffer.alloc(offset) - await fileHandle.read(prefixBuffer, 0, offset, 0) - const prefix = prefixBuffer.toString("utf8") - startLineNumber = (prefix.match(/\n/g) || []).length + 1 + startLineNumber = await this.countNewlinesBeforeOffset(fileHandle, offset) } const endOffset = offset + bytesRead @@ -374,6 +370,45 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { private escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } + + /** + * Count newlines before a given byte offset using fixed-size chunks. + * + * This avoids allocating a buffer of size `offset` which could be huge + * for large files. Instead, we read in 64KB chunks and count newlines. + * + * @param fileHandle - Open file handle for reading + * @param offset - The byte offset to count newlines up to + * @returns The line number at the given offset (1-indexed) + * @private + */ + private async countNewlinesBeforeOffset(fileHandle: fs.FileHandle, offset: number): Promise { + const CHUNK_SIZE = 64 * 1024 // 64KB chunks + let newlineCount = 0 + let bytesRead = 0 + + while (bytesRead < offset) { + const chunkSize = Math.min(CHUNK_SIZE, offset - bytesRead) + const buffer = Buffer.alloc(chunkSize) + const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead) + + if (result.bytesRead === 0) { + break + } + + // Count newlines in this chunk + for (let i = 0; i < result.bytesRead; i++) { + if (buffer[i] === 0x0a) { + // '\n' + newlineCount++ + } + } + + bytesRead += result.bytesRead + } + + return newlineCount + 1 // Line numbers are 1-indexed + } } /** Singleton instance of the ReadCommandOutputTool */ From 6c1bfe61f732d36cd7555543de6f154b7143b228 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 15:02:15 -0700 Subject: [PATCH 3/4] fix: bound accumulatedOutput growth in execute_command Prevent unbounded memory growth during long-running commands by trimming the accumulated output buffer. The full output is preserved by the OutputInterceptor; this buffer is only used for UI display. --- src/core/tools/ExecuteCommandTool.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 2d442209f5f..04e88559a00 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -224,10 +224,18 @@ export async function executeCommandInTerminal( } let accumulatedOutput = "" + // Bound accumulated output buffer size to prevent unbounded memory growth for long-running commands. + // The interceptor preserves full output; this buffer is only for UI display. + const maxAccumulatedOutputSize = terminalOutputCharacterLimit * 2 const callbacks: RooTerminalCallbacks = { onLine: async (lines: string, process: RooTerminalProcess) => { accumulatedOutput += lines + // Trim accumulated output to prevent unbounded memory growth + if (accumulatedOutput.length > maxAccumulatedOutputSize) { + accumulatedOutput = accumulatedOutput.slice(-maxAccumulatedOutputSize) + } + // Write to interceptor for persisted output interceptor?.write(lines) From 53939b669b85e5baf36e79f3710a24064d703a5d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 17:35:47 -0700 Subject: [PATCH 4/4] fix: resolve CI failures for PR #10944 - Remove unused barrel file (src/integrations/terminal/index.ts) to fix knip check - Fix Windows path test in OutputInterceptor.test.ts by using path.normalize() - Add missing translations for terminal.outputPreviewSize settings to all 17 locales --- .../__tests__/OutputInterceptor.test.ts | 2 +- src/integrations/terminal/index.ts | 57 ------------------- webview-ui/src/i18n/locales/ca/settings.json | 9 +++ webview-ui/src/i18n/locales/de/settings.json | 9 +++ webview-ui/src/i18n/locales/es/settings.json | 9 +++ webview-ui/src/i18n/locales/fr/settings.json | 9 +++ webview-ui/src/i18n/locales/hi/settings.json | 9 +++ webview-ui/src/i18n/locales/id/settings.json | 9 +++ webview-ui/src/i18n/locales/it/settings.json | 9 +++ webview-ui/src/i18n/locales/ja/settings.json | 9 +++ webview-ui/src/i18n/locales/ko/settings.json | 9 +++ webview-ui/src/i18n/locales/nl/settings.json | 9 +++ webview-ui/src/i18n/locales/pl/settings.json | 9 +++ .../src/i18n/locales/pt-BR/settings.json | 9 +++ webview-ui/src/i18n/locales/ru/settings.json | 9 +++ webview-ui/src/i18n/locales/tr/settings.json | 9 +++ webview-ui/src/i18n/locales/vi/settings.json | 9 +++ .../src/i18n/locales/zh-CN/settings.json | 9 +++ .../src/i18n/locales/zh-TW/settings.json | 9 +++ 19 files changed, 154 insertions(+), 58 deletions(-) delete mode 100644 src/integrations/terminal/index.ts diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index 9268854208a..3f829d8acf9 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -32,7 +32,7 @@ describe("OutputInterceptor", () => { beforeEach(() => { vi.clearAllMocks() - storageDir = "/tmp/test-storage" + storageDir = path.normalize("/tmp/test-storage") // Setup mock write stream mockWriteStream = { diff --git a/src/integrations/terminal/index.ts b/src/integrations/terminal/index.ts deleted file mode 100644 index afd05bb1e5f..00000000000 --- a/src/integrations/terminal/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Terminal Output Handling Module - * - * This module provides utilities for capturing, persisting, and retrieving - * command output from terminal executions. - * - * ## Overview - * - * When the LLM executes commands via `execute_command`, the output can be - * very large (build logs, test output, etc.). To prevent context window - * overflow while still allowing access to full output, this module - * implements a "persisted output" pattern: - * - * 1. **OutputInterceptor**: Buffers command output during execution. If - * output exceeds a configurable threshold, it "spills" to disk and - * keeps only a preview in memory. - * - * 2. **Artifact Storage**: Full outputs are stored as text files in the - * task's `command-output/` directory with names like `cmd-{timestamp}.txt`. - * - * 3. **ReadCommandOutputTool**: Allows the LLM to retrieve the full output - * later via the `read_command_output` tool, with support for search - * and pagination. - * - * ## Data Flow - * - * ``` - * execute_command - * │ - * ▼ - * OutputInterceptor.write() ──► Buffer accumulates - * │ - * ▼ (threshold exceeded) - * OutputInterceptor.spillToDisk() ──► Artifact file created - * │ - * ▼ - * OutputInterceptor.finalize() ──► Returns PersistedCommandOutput - * │ - * ▼ - * LLM receives preview + artifact_id - * │ - * ▼ (if needs full output) - * read_command_output(artifact_id) ──► Full content/search results - * ``` - * - * ## Configuration - * - * Preview size is controlled by `terminalOutputPreviewSize` setting: - * - `small`: 2KB preview - * - `medium`: 4KB preview (default) - * - `large`: 8KB preview - * - * @module terminal - */ - -export { OutputInterceptor } from "./OutputInterceptor" -export type { OutputInterceptorOptions } from "./OutputInterceptor" diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 8709334e5ae..9e8a367b1ed 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -723,6 +723,15 @@ "label": "Límit de caràcters del terminal", "description": "Anul·la el límit de línies per evitar problemes de memòria imposant un límit dur a la mida de sortida. Si se supera, manté l'inici i el final i mostra un marcador a Roo on s'ha omès el contingut. <0>Aprèn-ne més" }, + "outputPreviewSize": { + "label": "Mida de la previsualització de la sortida d'ordres", + "description": "Controla quanta sortida d'ordres veu Roo directament. La sortida completa sempre es desa i és accessible quan calgui.", + "options": { + "small": "Petita (2KB)", + "medium": "Mitjana (4KB)", + "large": "Gran (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Temps d'espera d'integració del shell del terminal", "description": "Quant de temps esperar la integració del shell de VS Code abans d'executar comandes. Augmenta si el teu shell s'inicia lentament o veus errors 'Integració del Shell No Disponible'. <0>Aprèn-ne més" diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index cb09cba088b..cb27076979a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -723,6 +723,15 @@ "label": "Terminal-Zeichenlimit", "description": "Überschreibt das Zeilenlimit, um Speicherprobleme durch eine harte Obergrenze für die Ausgabegröße zu vermeiden. Bei Überschreitung behält es Anfang und Ende und zeigt Roo einen Platzhalter, wo Inhalt übersprungen wird. <0>Mehr erfahren" }, + "outputPreviewSize": { + "label": "Befehlsausgabe-Vorschaugröße", + "description": "Steuert, wie viel Befehlsausgabe Roo direkt sieht. Die vollständige Ausgabe wird immer gespeichert und ist bei Bedarf zugänglich.", + "options": { + "small": "Klein (2KB)", + "medium": "Mittel (4KB)", + "large": "Groß (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Terminal-Shell-Integrations-Timeout", "description": "Wie lange auf VS Code Shell-Integration gewartet wird, bevor Befehle ausgeführt werden. Erhöhe den Wert, wenn deine Shell langsam startet oder du 'Shell-Integration nicht verfügbar'-Fehler siehst. <0>Mehr erfahren" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index b8c124614a7..ffd3e056709 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -723,6 +723,15 @@ "label": "Límite de caracteres del terminal", "description": "Anula el límite de líneas para evitar problemas de memoria imponiendo un límite estricto al tamaño de salida. Si se excede, mantiene el inicio y el final y muestra un marcador a Roo donde se omite el contenido. <0>Más información" }, + "outputPreviewSize": { + "label": "Tamaño de vista previa de salida de comandos", + "description": "Controla cuánta salida de comandos ve Roo directamente. La salida completa siempre se guarda y es accesible cuando sea necesario.", + "options": { + "small": "Pequeño (2KB)", + "medium": "Mediano (4KB)", + "large": "Grande (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Tiempo de espera de integración del shell del terminal", "description": "Cuánto tiempo esperar la integración del shell de VS Code antes de ejecutar comandos. Aumenta si tu shell inicia lentamente o ves errores 'Integración del Shell No Disponible'. <0>Más información" diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 8dbd308aa83..9c85912202c 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -723,6 +723,15 @@ "label": "Limite de caractères du terminal", "description": "Remplace la limite de lignes pour éviter les problèmes de mémoire en imposant un plafond strict sur la taille de sortie. Si dépassé, conserve le début et la fin et affiche un espace réservé à Roo là où le contenu est ignoré. <0>En savoir plus" }, + "outputPreviewSize": { + "label": "Taille de l'aperçu de sortie des commandes", + "description": "Contrôle la quantité de sortie de commande que Roo voit directement. La sortie complète est toujours sauvegardée et accessible en cas de besoin.", + "options": { + "small": "Petite (2KB)", + "medium": "Moyenne (4KB)", + "large": "Grande (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Délai d'attente d'intégration du shell du terminal", "description": "Temps d'attente de l'intégration du shell de VS Code avant d'exécuter des commandes. Augmentez si votre shell démarre lentement ou si vous voyez des erreurs 'Intégration du Shell Indisponible'. <0>En savoir plus" diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 1f74fd4aae7..e23c86fdba3 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -724,6 +724,15 @@ "label": "टर्मिनल वर्ण सीमा", "description": "मेमोरी समस्याओं को रोकने के लिए आउटपुट आकार पर कठोर सीमा लगाकर लाइन सीमा को ओवरराइड करता है। यदि पार हो जाती है, तो शुरुआत और अंत रखता है और Roo को प्लेसहोल्डर दिखाता है जहां सामग्री छोड़ी गई है। <0>अधिक जानें" }, + "outputPreviewSize": { + "label": "कमांड आउटपुट पूर्वावलोकन आकार", + "description": "नियंत्रित करता है कि Roo कितना कमांड आउटपुट सीधे देखता है। पूर्ण आउटपुट हमेशा सहेजा जाता है और आवश्यकता पड़ने पर सुलभ होता है।", + "options": { + "small": "छोटा (2KB)", + "medium": "मध्यम (4KB)", + "large": "बड़ा (8KB)" + } + }, "shellIntegrationTimeout": { "label": "टर्मिनल शेल एकीकरण टाइमआउट", "description": "कमांड चलाने से पहले VS Code शेल एकीकरण की प्रतीक्षा करने का समय। यदि आपका शेल धीरे शुरू होता है या आप 'Shell Integration Unavailable' त्रुटियां देखते हैं तो बढ़ाएं। <0>अधिक जानें" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 26b1ca0e8ac..1925e17992d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -728,6 +728,15 @@ "label": "Batas karakter terminal", "description": "Override batas baris untuk mencegah masalah memori dengan memberlakukan cap keras pada ukuran output. Jika terlampaui, simpan awal dan akhir lalu tampilkan placeholder ke Roo di mana konten dilewati. <0>Pelajari lebih lanjut" }, + "outputPreviewSize": { + "label": "Ukuran pratinjau keluaran perintah", + "description": "Mengontrol seberapa banyak keluaran perintah yang dilihat Roo secara langsung. Keluaran lengkap selalu disimpan dan dapat diakses saat diperlukan.", + "options": { + "small": "Kecil (2KB)", + "medium": "Sedang (4KB)", + "large": "Besar (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Timeout integrasi shell terminal", "description": "Waktu tunggu integrasi shell VS Code sebelum menjalankan perintah. Naikkan jika shell lambat start atau muncul error 'Shell Integration Unavailable'. <0>Pelajari lebih lanjut" diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 8a6da00b847..2599aad3c46 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -724,6 +724,15 @@ "label": "Limite caratteri terminale", "description": "Sovrascrive il limite di righe per prevenire problemi di memoria imponendo un limite rigido alla dimensione di output. Se superato, mantiene l'inizio e la fine e mostra un segnaposto a Roo dove il contenuto viene saltato. <0>Scopri di più" }, + "outputPreviewSize": { + "label": "Dimensione anteprima output comandi", + "description": "Controlla quanto output dei comandi Roo vede direttamente. L'output completo viene sempre salvato ed è accessibile quando necessario.", + "options": { + "small": "Piccola (2KB)", + "medium": "Media (4KB)", + "large": "Grande (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Timeout integrazione shell terminale", "description": "Quanto tempo attendere l'integrazione della shell di VS Code prima di eseguire i comandi. Aumenta se la tua shell si avvia lentamente o vedi errori 'Integrazione Shell Non Disponibile'. <0>Scopri di più" diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 1ebea573b9b..f452612e583 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -724,6 +724,15 @@ "label": "ターミナル文字制限", "description": "出力サイズにハードキャップを適用してメモリ問題を防ぐため、行制限を上書きします。超過した場合、最初と最後を保持し、コンテンツがスキップされた箇所にRooにプレースホルダーを表示します。<0>詳細情報" }, + "outputPreviewSize": { + "label": "コマンド出力プレビューサイズ", + "description": "Rooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。", + "options": { + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" + } + }, "shellIntegrationTimeout": { "label": "ターミナルシェル統合タイムアウト", "description": "コマンドを実行する前�����VS Codeシェル統合を待機する時間。シェルが遅く起動する場合や「シェル統合が利用できません」というエラーが表示される場合は、この値を増やしてください。<0>詳細" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index a0f1b7a710b..70ea46e205b 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -724,6 +724,15 @@ "label": "터미널 문자 제한", "description": "출력 크기에 대한 엄격한 상한을 적용하여 메모리 문제를 방지하기 위해 줄 제한을 재정의합니다. 초과하면 시작과 끝을 유지하고 내용이 생략된 곳에 Roo에게 자리 표시자를 표시합니다. <0>자세히 알아보기" }, + "outputPreviewSize": { + "label": "명령 출력 미리보기 크기", + "description": "Roo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.", + "options": { + "small": "작게 (2KB)", + "medium": "보통 (4KB)", + "large": "크게 (8KB)" + } + }, "shellIntegrationTimeout": { "label": "터미널 셸 통합 시간 초과", "description": "명령을 실행하기 전에 VS Code 셸 통합을 기다리는 시간입니다. 셸이 느리게 시작되거나 '셸 통합을 사용할 수 없음' 오류가 표시되면 이 값을 늘리십시오. <0>자세히 알아보기" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 5ff57bf331e..d56623c95cd 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -724,6 +724,15 @@ "label": "Terminal-tekenlimiet", "description": "Overschrijft de regellimiet om geheugenproblemen te voorkomen door een harde limiet op uitvoergrootte af te dwingen. Bij overschrijding behoudt het begin en einde en toont een placeholder aan Roo waar inhoud wordt overgeslagen. <0>Meer informatie" }, + "outputPreviewSize": { + "label": "Grootte opdrachtuitvoer voorvertoning", + "description": "Bepaalt hoeveel opdrachtuitvoer Roo direct ziet. Volledige uitvoer wordt altijd opgeslagen en is toegankelijk wanneer nodig.", + "options": { + "small": "Klein (2KB)", + "medium": "Gemiddeld (4KB)", + "large": "Groot (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Terminal-shell-integratie timeout", "description": "Hoe lang te wachten op VS Code-shell-integratie voordat commando's worden uitgevoerd. Verhoog als je shell traag opstart of je 'Shell-Integratie Niet Beschikbaar'-fouten ziet. <0>Meer informatie" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 1986522f267..f9280c04240 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -724,6 +724,15 @@ "label": "Limit znaków terminala", "description": "Zastępuje limit linii, aby zapobiec problemom z pamięcią, narzucając twardy limit rozmiaru wyjścia. W przypadku przekroczenia zachowuje początek i koniec i pokazuje symbol zastępczy Roo tam, gdzie treść jest pomijana. <0>Dowiedz się więcej" }, + "outputPreviewSize": { + "label": "Rozmiar podglądu wyjścia polecenia", + "description": "Kontroluje, ile wyjścia polecenia Roo widzi bezpośrednio. Pełne wyjście jest zawsze zapisywane i dostępne w razie potrzeby.", + "options": { + "small": "Mały (2KB)", + "medium": "Średni (4KB)", + "large": "Duży (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Limit czasu integracji powłoki terminala", "description": "Jak długo czekać na integrację powłoki VS Code przed wykonaniem poleceń. Zwiększ, jeśli twoja powłoka wolno się uruchamia lub widzisz błędy 'Integracja Powłoki Niedostępna'. <0>Dowiedz się więcej" diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e45efdf6665..41d30efa213 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -724,6 +724,15 @@ "label": "Limite de caracteres do terminal", "description": "Substitui o limite de linhas para evitar problemas de memória, impondo um limite rígido no tamanho da saída. Se excedido, mantém o início e o fim e mostra um placeholder para o Roo onde o conteúdo é pulado. <0>Saiba mais" }, + "outputPreviewSize": { + "label": "Tamanho da visualização da saída de comandos", + "description": "Controla quanto da saída de comandos Roo vê diretamente. A saída completa é sempre salva e acessível quando necessário.", + "options": { + "small": "Pequeno (2KB)", + "medium": "Médio (4KB)", + "large": "Grande (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Tempo limite de integração do shell do terminal", "description": "Quanto tempo esperar pela integração do shell do VS Code antes de executar comandos. Aumente se o seu shell demorar para iniciar ou se você vir erros de 'Integração do Shell Indisponível'. <0>Saiba mais" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index ca1c790c87b..fec01a1b5ee 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -724,6 +724,15 @@ "label": "Лимит символов терминала", "description": "Переопределяет лимит строк для предотвращения проблем с памятью, устанавливая жёсткое ограничение на размер вывода. При превышении сохраняет начало и конец и показывает Roo заполнитель там, где контент пропущен. <0>Подробнее" }, + "outputPreviewSize": { + "label": "Размер предпросмотра вывода команд", + "description": "Контролирует, сколько вывода команды Roo видит напрямую. Полный вывод всегда сохраняется и доступен при необходимости.", + "options": { + "small": "Маленький (2KB)", + "medium": "Средний (4KB)", + "large": "Большой (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Таймаут интеграции shell терминала", "description": "Сколько ждать интеграции shell VS Code перед выполнением команд. Увеличьте, если ваш shell запускается медленно или вы видите ошибки 'Интеграция Shell Недоступна'. <0>Подробнее" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7d275b23e63..35da7a90c6a 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -724,6 +724,15 @@ "label": "Terminal karakter sınırı", "description": "Çıktı boyutuna katı bir üst sınır uygulayarak bellek sorunlarını önlemek için satır sınırını geçersiz kılar. Aşılırsa, başlangıcı ve sonu tutar ve içeriğin atlandığı yerde Roo'ya bir yer tutucu gösterir. <0>Daha fazla bilgi edinin" }, + "outputPreviewSize": { + "label": "Komut çıktısı önizleme boyutu", + "description": "Roo'nun doğrudan gördüğü komut çıktısı miktarını kontrol eder. Tam çıktı her zaman kaydedilir ve gerektiğinde erişilebilir.", + "options": { + "small": "Küçük (2KB)", + "medium": "Orta (4KB)", + "large": "Büyük (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Terminal shell entegrasyon timeout", "description": "Komut çalıştırmadan önce VS Code shell entegrasyonunu bekleme süresi. Shell yavaş başlıyorsa veya 'Shell Integration Unavailable' hatası görüyorsanız artırın. <0>Daha fazla bilgi edinin" diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 3e0f4594f5f..14e4785c4bb 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -724,6 +724,15 @@ "label": "Giới hạn ký tự terminal", "description": "Ghi đè giới hạn dòng để tránh vấn đề bộ nhớ bằng cách áp đặt giới hạn cứng cho kích thước đầu ra. Nếu vượt quá, giữ đầu và cuối, hiển thị placeholder cho Roo nơi nội dung bị bỏ qua. <0>Tìm hiểu thêm" }, + "outputPreviewSize": { + "label": "Kích thước xem trước đầu ra lệnh", + "description": "Kiểm soát lượng đầu ra lệnh mà Roo nhìn thấy trực tiếp. Đầu ra đầy đủ luôn được lưu và có thể truy cập khi cần thiết.", + "options": { + "small": "Nhỏ (2KB)", + "medium": "Trung bình (4KB)", + "large": "Lớn (8KB)" + } + }, "shellIntegrationTimeout": { "label": "Timeout tích hợp shell terminal", "description": "Thời gian đợi tích hợp shell VS Code trước khi chạy lệnh. Tăng nếu shell khởi động chậm hoặc thấy lỗi 'Shell Integration Unavailable'. <0>Tìm hiểu thêm" diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 701570903c1..0d779520291 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -724,6 +724,15 @@ "label": "终端字符限制", "description": "通过强制限制输出大小来覆盖行限制以防止内存问题。如果超出,保留开头和结尾并向 Roo 显示内容被跳过的占位符。<0>了解更多" }, + "outputPreviewSize": { + "label": "命令输出预览大小", + "description": "控制 Roo 直接看到的命令输出量。完整输出始终会被保存,需要时可以访问。", + "options": { + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" + } + }, "shellIntegrationTimeout": { "label": "终端 shell 集成超时", "description": "运行命令前等待 VS Code shell 集成的时间。如果 shell 启动缓慢或看到 'Shell Integration Unavailable' 错误,请提高此值。<0>了解更多" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index ec6dc04fdf9..6b21f8dfa19 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -724,6 +724,15 @@ "label": "終端機字元限制", "description": "透過強制限制輸出大小來覆寫行限制以防止記憶體問題。如果超出,保留開頭和結尾並向 Roo 顯示內容被跳過的佔位符。<0>了解更多" }, + "outputPreviewSize": { + "label": "命令輸出預覽大小", + "description": "控制 Roo 直接看到的命令輸出量。完整輸出始終會被儲存,需要時可以存取。", + "options": { + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" + } + }, "shellIntegrationTimeout": { "label": "終端機 shell 整合逾時", "description": "執行命令前等待 VS Code shell 整合的時間。如果 shell 啟動緩慢或看到 'Shell Integration Unavailable' 錯誤,請提高此值。<0>了解更多"