From 0a70e046e77b18f6e6f2dcdb24dd1f433981709a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 13:44:20 -0700 Subject: [PATCH 01/24] 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 | 16 +- packages/types/src/vscode-extension-host.ts | 2 + pnpm-lock.yaml | 11 + .../tools/native-tools/read_command_output.ts | 26 +- src/core/tools/ExecuteCommandTool.ts | 9 +- src/core/tools/ReadCommandOutputTool.ts | 158 ++--------- .../__tests__/ReadCommandOutputTool.test.ts | 50 ++-- .../terminal/OutputInterceptor.ts | 244 +++-------------- .../__tests__/OutputInterceptor.test.ts | 255 +++++++----------- src/integrations/terminal/index.ts | 57 ++++ src/package.json | 1 + .../src/components/settings/SettingsView.tsx | 3 + .../components/settings/TerminalSettings.tsx | 25 ++ .../src/context/ExtensionStateContext.tsx | 8 + webview-ui/src/i18n/locales/en/settings.json | 6 +- 15 files changed, 331 insertions(+), 540 deletions(-) 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 d57ec616ff..65adaefb9d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -29,9 +29,9 @@ export const DEFAULT_WRITE_DELAY_MS = 1000 * the LLM decides to retrieve more via `read_command_output`. Larger previews * mean more immediate context but consume more of the context window. * - * - `small`: 5KB preview - Best for long-running commands with verbose output - * - `medium`: 10KB preview - Balanced default for most use cases - * - `large`: 20KB preview - Best when commands produce critical info early + * - `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 @@ -46,14 +46,14 @@ export type TerminalOutputPreviewSize = "small" | "medium" | "large" * to disk and made available via the `read_command_output` tool. */ export const TERMINAL_PREVIEW_BYTES: Record = { - small: 5 * 1024, // 5KB - medium: 10 * 1024, // 10KB - large: 20 * 1024, // 20KB + small: 2048, // 2KB + medium: 4096, // 4KB + large: 8192, // 8KB } /** * Default terminal output preview size. - * The "medium" (10KB) setting provides a good balance between immediate + * 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" @@ -176,6 +176,8 @@ export const globalSettingsSchema = z.object({ maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), + terminalOutputLineLimit: z.number().optional(), + terminalOutputCharacterLimit: z.number().optional(), terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(), terminalShellIntegrationTimeout: z.number().optional(), terminalShellIntegrationDisabled: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 7ae89e8777..ce0d337d91 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -302,6 +302,8 @@ export type ExtensionState = Pick< | "soundEnabled" | "soundVolume" | "maxConcurrentFileReads" + | "terminalOutputLineLimit" + | "terminalOutputCharacterLimit" | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ca01240b..b0a904f457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,6 +1017,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 @@ -4302,6 +4305,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==} @@ -14316,6 +14323,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/prompts/tools/native-tools/read_command_output.ts b/src/core/prompts/tools/native-tools/read_command_output.ts index 44c069be1e..0bab31be9e 100644 --- a/src/core/prompts/tools/native-tools/read_command_output.ts +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -20,15 +20,15 @@ The tool supports two modes: 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. **Omit this parameter entirely if you don't need to filter - do not pass null or empty string.** +- 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: 40KB. +- limit: (optional) Maximum bytes to return. Default: 32KB. Example: Reading truncated command output { "artifact_id": "cmd-1706119234567.txt" } -Example: Reading with pagination (after first 40KB) -{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 } +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" } @@ -38,22 +38,18 @@ Example: Finding specific test failures 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). Omit this parameter if not searching - do not pass null or empty string.` +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: 40KB)` +const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)` export default { type: "function", function: { name: "read_command_output", description: READ_COMMAND_OUTPUT_DESCRIPTION, - // Note: strict mode is intentionally disabled for this tool. - // With strict: true, OpenAI requires ALL properties to be in the 'required' array, - // which forces the LLM to always provide explicit values (even null) for optional params. - // This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can - // omit optional parameters entirely, making the tool easier to use. + strict: true, parameters: { type: "object", properties: { @@ -62,19 +58,19 @@ export default { description: ARTIFACT_ID_DESCRIPTION, }, search: { - type: "string", + type: ["string", "null"], description: SEARCH_DESCRIPTION, }, offset: { - type: "number", + type: ["number", "null"], description: OFFSET_DESCRIPTION, }, limit: { - type: "number", + type: ["number", "null"], description: LIMIT_DESCRIPTION, }, }, - required: ["artifact_id"], + required: ["artifact_id", "search", "offset", "limit"], additionalProperties: false, }, }, diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index fca3cf7a31..28957fc868 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_PREVIEW_SIZE, PersistedCommandOutput } 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" @@ -196,6 +201,7 @@ export async function executeCommandInTerminal( const providerState = await provider?.getState() const terminalOutputPreviewSize = providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE + const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true interceptor = new OutputInterceptor({ executionId, @@ -203,6 +209,7 @@ export async function executeCommandInTerminal( command, storageDir, previewSize: terminalOutputPreviewSize, + compressProgressBar: terminalCompressProgressBar, }) } diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index 9d3bbd35dd..d81352c30a 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -6,8 +6,8 @@ import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" -/** Default byte limit for read operations (40KB) */ -const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit +/** Default byte limit for read operations (32KB) */ +const DEFAULT_LIMIT = 32 * 1024 // 32KB default limit /** * Parameters accepted by the read_command_output tool. @@ -159,38 +159,15 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { } let result: string - let readStart = 0 - let readEnd = 0 - let matchCount: number | undefined if (search) { // Search mode: filter lines matching the pattern - const searchResult = await this.searchInArtifact(artifactPath, search, totalSize, limit) - result = searchResult.content - matchCount = searchResult.matchCount - // For search, we're scanning the whole file - readStart = 0 - readEnd = totalSize + result = await this.searchInArtifact(artifactPath, search, totalSize, limit) } else { // Normal read mode with offset/limit result = await this.readArtifact(artifactPath, offset, limit, totalSize) - // Calculate actual read range - readStart = offset - readEnd = Math.min(offset + limit, totalSize) } - // Report to UI that we read command output - await task.say( - "tool", - JSON.stringify({ - tool: "readCommandOutput", - readStart, - readEnd, - totalBytes: totalSize, - ...(search && { searchPattern: search, matchCount }), - }), - ) - task.consecutiveMistakeCount = 0 pushToolResult(result) } catch (error) { @@ -246,10 +223,14 @@ 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 using chunked reading to avoid large allocations + // Calculate line numbers based on offset let startLineNumber = 1 if (offset > 0) { - startLineNumber = await this.countNewlinesBeforeOffset(fileHandle, offset) + // 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 @@ -272,14 +253,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { } /** - * Search artifact content for lines matching a pattern using chunked streaming. - * - * Performs grep-like searching through the artifact file using bounded memory. - * Instead of loading the entire file into memory, this reads in fixed-size chunks - * and processes lines as they are encountered. This keeps memory usage predictable - * even for very large command outputs (e.g., 100MB+ build logs). + * Search artifact content for lines matching a pattern. * - * The pattern is treated as a case-insensitive regex. If the pattern is invalid + * 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. @@ -296,8 +273,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { pattern: string, totalSize: number, limit: number, - ): Promise<{ content: string; matchCount: number }> { - const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory + ): 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 @@ -308,89 +287,45 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { regex = new RegExp(this.escapeRegExp(pattern), "i") } - const fileHandle = await fs.open(artifactPath, "r") + // Find matching lines with their line numbers const matches: Array<{ lineNumber: number; content: string }> = [] let totalMatchBytes = 0 - let lineNumber = 0 - let partialLine = "" // Holds incomplete line from previous chunk - let bytesRead = 0 - let hitLimit = false - try { - while (bytesRead < totalSize && !hitLimit) { - const chunkSize = Math.min(CHUNK_SIZE, totalSize - bytesRead) - const buffer = Buffer.alloc(chunkSize) - const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead) + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + const lineContent = lines[i] + const lineBytes = Buffer.byteLength(lineContent, "utf8") - if (result.bytesRead === 0) { + // Stop if we've exceeded the byte limit + if (totalMatchBytes + lineBytes > limit) { break } - const chunk = buffer.slice(0, result.bytesRead).toString("utf8") - bytesRead += result.bytesRead - - // Combine with partial line from previous chunk - const combined = partialLine + chunk - const lines = combined.split("\n") - - // Last element may be incomplete (no trailing newline), save for next iteration - partialLine = lines.pop() ?? "" - - // Process complete lines - for (const line of lines) { - lineNumber++ - - if (regex.test(line)) { - const lineBytes = Buffer.byteLength(line, "utf8") - - // Stop if we've exceeded the byte limit - if (totalMatchBytes + lineBytes > limit) { - hitLimit = true - break - } - - matches.push({ lineNumber, content: line }) - totalMatchBytes += lineBytes - } - } - } - - // Process any remaining partial line at end of file - if (!hitLimit && partialLine.length > 0) { - lineNumber++ - if (regex.test(partialLine)) { - const lineBytes = Buffer.byteLength(partialLine, "utf8") - if (totalMatchBytes + lineBytes <= limit) { - matches.push({ lineNumber, content: partialLine }) - } - } + matches.push({ lineNumber: i + 1, content: lineContent }) + totalMatchBytes += lineBytes } - } finally { - await fileHandle.close() } const artifactId = path.basename(artifactPath) if (matches.length === 0) { - const content = [ + return [ `[Command Output: ${artifactId}] (search: "${pattern}")`, `Total size: ${this.formatBytes(totalSize)}`, "", "No matches found for the search pattern.", ].join("\n") - return { content, matchCount: 0 } } // Format matches with line numbers const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n") - const content = [ + return [ `[Command Output: ${artifactId}] (search: "${pattern}")`, `Total matches: ${matches.length} | Showing first ${matches.length}`, "", matchedLines, ].join("\n") - return { content, matchCount: matches.length } } /** @@ -439,45 +374,6 @@ 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 */ diff --git a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts index 11f85e67c0..a2e3147cc6 100644 --- a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts +++ b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts @@ -159,16 +159,16 @@ describe("ReadCommandOutputTool", () => { }) describe("Pagination (offset/limit)", () => { - it("should use default limit of 40KB", async () => { + 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 (40KB) + // Mock read to return only up to default limit (32KB) mockFileHandle.read.mockImplementation((buf: Buffer) => { - const defaultLimit = 40 * 1024 + const defaultLimit = 32 * 1024 const bytesToRead = Math.min(buf.length, defaultLimit) buf.write(largeContent.slice(0, bytesToRead)) return Promise.resolve({ bytesRead: bytesToRead }) @@ -276,31 +276,13 @@ describe("ReadCommandOutputTool", () => { }) describe("Search filtering", () => { - // Helper to setup file handle mock for search (which now uses streaming) - const setupSearchMock = (content: string) => { - const buffer = Buffer.from(content) - const fileSize = buffer.length - vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) - - // Mock streaming read - return entire content in one chunk (simulates small file) - mockFileHandle.read.mockImplementation( - (buf: Buffer, bufOffset: number, length: number, position: number | null) => { - const pos = position ?? 0 - if (pos >= fileSize) { - return Promise.resolve({ bytesRead: 0 }) - } - const bytesToRead = Math.min(length, fileSize - pos) - buffer.copy(buf, 0, pos, pos + bytesToRead) - return Promise.resolve({ bytesRead: bytesToRead }) - }, - ) - } - 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") - setupSearchMock(content) + 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) @@ -314,8 +296,10 @@ describe("ReadCommandOutputTool", () => { 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") - setupSearchMock(content) + 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) @@ -327,8 +311,10 @@ describe("ReadCommandOutputTool", () => { 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") - setupSearchMock(content) + 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) @@ -341,8 +327,10 @@ describe("ReadCommandOutputTool", () => { 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") - setupSearchMock(content) + 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) @@ -353,8 +341,10 @@ describe("ReadCommandOutputTool", () => { 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") - setupSearchMock(content) + 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) @@ -368,8 +358,10 @@ describe("ReadCommandOutputTool", () => { 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") - setupSearchMock(content) + 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) diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts index d1725c6426..c9e984ff69 100644 --- a/src/integrations/terminal/OutputInterceptor.ts +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -3,6 +3,8 @@ 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. */ @@ -17,6 +19,8 @@ export interface OutputInterceptorOptions { 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 } /** @@ -26,14 +30,13 @@ export interface OutputInterceptorOptions { * 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 uses a **head/tail buffer** strategy (inspired by Codex): - * - 50% of the preview budget is allocated to the "head" (beginning of output) - * - 50% of the preview budget is allocated to the "tail" (end of output) - * - Middle content is dropped when output exceeds the preview threshold + * 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 ensures the LLM sees both: - * - The beginning (command startup, environment info, early errors) - * - The end (final results, exit codes, error summaries) + * 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 @@ -43,6 +46,7 @@ export interface OutputInterceptorOptions { * command: 'npm test', * storageDir: '/path/to/task/command-output', * previewSize: 'medium', + * compressProgressBar: true * }); * * // Write output chunks as they arrive @@ -51,38 +55,18 @@ export interface OutputInterceptorOptions { * * // Finalize and get the result * const result = interceptor.finalize(); - * // result.preview contains head + [omitted] + tail for display + * // result.preview contains truncated output for display * // result.artifactPath contains path to full output if truncated * ``` */ export class OutputInterceptor { - /** Buffer for the head (beginning) of output */ - private headBuffer: string = "" - /** Buffer for the tail (end) of output - rolling buffer that drops front when full */ - private tailBuffer: string = "" - /** Number of bytes currently in the head buffer */ - private headBytes: number = 0 - /** Number of bytes currently in the tail buffer */ - private tailBytes: number = 0 - /** Number of bytes omitted from the middle */ - private omittedBytes: number = 0 - - /** - * Pending chunks accumulated before spilling to disk. - * These contain ALL content (lossless) until we decide to spill. - * Once spilled, this array is cleared and subsequent writes go directly to disk. - */ - private pendingChunks: string[] = [] - + private buffer: string = "" private writeStream: fs.WriteStream | null = null private artifactPath: string private totalBytes: number = 0 private spilledToDisk: boolean = false private readonly previewBytes: number - /** Budget for the head buffer (50% of total preview) */ - private readonly headBudget: number - /** Budget for the tail buffer (50% of total preview) */ - private readonly tailBudget: number + private readonly compressProgressBar: boolean /** * Creates a new OutputInterceptor instance. @@ -91,19 +75,16 @@ export class OutputInterceptor { */ constructor(private readonly options: OutputInterceptorOptions) { this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize] - this.headBudget = Math.floor(this.previewBytes / 2) - this.tailBudget = this.previewBytes - this.headBudget + this.compressProgressBar = options.compressProgressBar this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`) } /** * Write a chunk of output to the interceptor. * - * Output is first added to the head buffer until it's full (50% of preview budget). - * Subsequent output goes to a rolling tail buffer that keeps the most recent content. - * - * If the total output exceeds the preview threshold, the interceptor spills to disk - * for full output storage while maintaining head/tail buffers for the preview. + * 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 * @@ -117,15 +98,10 @@ export class OutputInterceptor { const chunkBytes = Buffer.byteLength(chunk, "utf8") this.totalBytes += chunkBytes - // Always update the head/tail preview buffers - this.addToPreviewBuffers(chunk) - - // Handle disk spilling for full output preservation if (!this.spilledToDisk) { - // Accumulate ALL chunks for lossless disk storage - this.pendingChunks.push(chunk) + this.buffer += chunk - if (this.totalBytes > this.previewBytes) { + if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) { this.spillToDisk() } } else { @@ -134,127 +110,6 @@ export class OutputInterceptor { } } - /** - * Add a chunk to the head/tail preview buffers using 50/50 split strategy. - * - * Fill head first until budget exhausted, then maintain a rolling tail buffer. - * - * @private - */ - private addToPreviewBuffers(chunk: string): void { - let remaining = chunk - let remainingBytes = Buffer.byteLength(chunk, "utf8") - - // First, fill the head buffer if there's room - if (this.headBytes < this.headBudget) { - const headRoom = this.headBudget - this.headBytes - if (remainingBytes <= headRoom) { - // Entire chunk fits in head - this.headBuffer += remaining - this.headBytes += remainingBytes - return - } - // Split: part goes to head, rest goes to tail - const headPortion = this.sliceByBytes(remaining, headRoom) - this.headBuffer += headPortion - this.headBytes += headRoom - remaining = remaining.slice(headPortion.length) - remainingBytes = Buffer.byteLength(remaining, "utf8") - } - - // Add remainder to tail buffer - this.addToTailBuffer(remaining, remainingBytes) - } - - /** - * Add content to the rolling tail buffer, dropping old content as needed. - * - * @private - */ - private addToTailBuffer(chunk: string, chunkBytes: number): void { - if (this.tailBudget === 0) { - this.omittedBytes += chunkBytes - return - } - - // If this single chunk is larger than the tail budget, keep only the last tailBudget bytes - if (chunkBytes >= this.tailBudget) { - const dropped = this.tailBytes + (chunkBytes - this.tailBudget) - this.omittedBytes += dropped - this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget) - this.tailBytes = this.tailBudget - return - } - - // Append to tail - this.tailBuffer += chunk - this.tailBytes += chunkBytes - - // Trim from front if over budget - this.trimTailToFit() - } - - /** - * Trim the tail buffer from the front to fit within the tail budget. - * - * @private - */ - private trimTailToFit(): void { - while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) { - const excess = this.tailBytes - this.tailBudget - // Remove characters from the front until we're under budget - // We need to be careful with multi-byte characters - let removed = 0 - let removeChars = 0 - while (removed < excess && removeChars < this.tailBuffer.length) { - const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8") - removed += charBytes - removeChars++ - } - this.omittedBytes += removed - this.tailBytes -= removed - this.tailBuffer = this.tailBuffer.slice(removeChars) - } - } - - /** - * Slice a string to get approximately the first N bytes (UTF-8). - * - * @private - */ - private sliceByBytes(str: string, maxBytes: number): string { - let bytes = 0 - let i = 0 - while (i < str.length && bytes < maxBytes) { - const charBytes = Buffer.byteLength(str[i], "utf8") - if (bytes + charBytes > maxBytes) { - break - } - bytes += charBytes - i++ - } - return str.slice(0, i) - } - - /** - * Slice a string to get approximately the last N bytes (UTF-8). - * - * @private - */ - private sliceByBytesFromEnd(str: string, maxBytes: number): string { - let bytes = 0 - let i = str.length - 1 - while (i >= 0 && bytes < maxBytes) { - const charBytes = Buffer.byteLength(str[i], "utf8") - if (bytes + charBytes > maxBytes) { - break - } - bytes += charBytes - i-- - } - return str.slice(i + 1) - } - /** * Spill buffered content to disk and switch to streaming mode. * @@ -272,36 +127,30 @@ export class OutputInterceptor { } this.writeStream = fs.createWriteStream(this.artifactPath) - - // Write ALL pending chunks to disk for lossless storage. - // This ensures no content is lost, even if the preview buffers have dropped middle content. - for (const chunk of this.pendingChunks) { - this.writeStream.write(chunk) - } - - // Clear pending chunks to free memory - subsequent writes go directly to disk - this.pendingChunks = [] - + 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 waits for them to fully flush before returning. - * This ensures the artifact file is completely written and ready for reading. - * - * Returns a summary object containing: - * - A preview of the output (head + [omitted indicator] + tail) + * 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 = await interceptor.finalize(); + * const result = interceptor.finalize(); * console.log(`Preview: ${result.preview}`); * console.log(`Total bytes: ${result.totalBytes}`); * if (result.truncated) { @@ -309,24 +158,19 @@ export class OutputInterceptor { * } * ``` */ - async finalize(): Promise { - // Close write stream if open and wait for it to fully flush. - // This ensures the artifact is completely written before we advertise the artifact_id. + finalize(): PersistedCommandOutput { + // Close write stream if open if (this.writeStream) { - await new Promise((resolve, reject) => { - this.writeStream!.end(() => resolve()) - this.writeStream!.on("error", reject) - }) + this.writeStream.end() } - // Prepare preview: head + [omission indicator] + tail - let preview: string - if (this.omittedBytes > 0) { - const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n` - preview = this.headBuffer + omissionIndicator + this.tailBuffer - } else { - // No truncation, just combine head and tail (or head alone if tail is empty) - preview = this.headBuffer + this.tailBuffer + // 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 { @@ -340,15 +184,13 @@ export class OutputInterceptor { /** * Get the current buffer content for UI display. * - * Returns the combined head + tail content for real-time UI updates. - * Note: Does not include the omission indicator to avoid flickering during streaming. + * 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 { - // For UI, return combined head + tail without omission indicator - // This provides a smoother streaming experience - return this.headBuffer + this.tailBuffer + return this.buffer } /** diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index ed308cff13..9268854208 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -32,16 +32,12 @@ describe("OutputInterceptor", () => { beforeEach(() => { vi.clearAllMocks() - storageDir = path.normalize("/tmp/test-storage") + storageDir = "/tmp/test-storage" - // Setup mock write stream with callback support for end() + // Setup mock write stream mockWriteStream = { write: vi.fn(), - end: vi.fn((callback?: () => void) => { - // Immediately call the callback to simulate stream flush completing - if (callback) callback() - }), - on: vi.fn(), + end: vi.fn(), } vi.mocked(fs.existsSync).mockReturnValue(true) @@ -53,13 +49,14 @@ describe("OutputInterceptor", () => { }) describe("Buffering behavior", () => { - it("should keep small output in memory without spilling to disk", async () => { + 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", // 5KB + previewSize: "small", // 2KB + compressProgressBar: false, }) const smallOutput = "Hello World\n" @@ -68,7 +65,7 @@ describe("OutputInterceptor", () => { expect(interceptor.hasSpilledToDisk()).toBe(false) expect(fs.createWriteStream).not.toHaveBeenCalled() - const result = await interceptor.finalize() + const result = interceptor.finalize() expect(result.preview).toBe(smallOutput) expect(result.truncated).toBe(false) expect(result.artifactPath).toBe(null) @@ -81,45 +78,44 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "echo test", storageDir, - previewSize: "small", // 5KB = 5120 bytes + previewSize: "small", // 2KB = 2048 bytes + compressProgressBar: false, }) - // Write enough data to exceed 5KB threshold - const chunk = "x".repeat(2 * 1024) // 2KB chunk - interceptor.write(chunk) // 2KB - should stay in memory + // 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) // 4KB - should stay in memory + interceptor.write(chunk) // 2KB - should stay in memory expect(interceptor.hasSpilledToDisk()).toBe(false) - interceptor.write(chunk) // 6KB - should trigger spill + 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 using head/tail split", async () => { + it("should truncate preview after spilling to disk", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "echo test", storageDir, - previewSize: "small", // 5KB + previewSize: "small", // 2KB + compressProgressBar: false, }) // Write data that exceeds threshold - const chunk = "x".repeat(6000) + const chunk = "x".repeat(3000) interceptor.write(chunk) expect(interceptor.hasSpilledToDisk()).toBe(true) - const result = await interceptor.finalize() + const result = interceptor.finalize() expect(result.truncated).toBe(true) expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt")) - // Preview is head (1024) + omission indicator + tail (1024) - // The omission indicator adds some extra bytes - expect(result.preview).toContain("[...") - expect(result.preview).toContain("bytes omitted...]") + expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048) }) it("should write subsequent chunks directly to disk after spilling", () => { @@ -129,10 +125,11 @@ describe("OutputInterceptor", () => { command: "echo test", storageDir, previewSize: "small", + compressProgressBar: false, }) - // Trigger spill (must exceed 5KB = 5120 bytes) - const largeChunk = "x".repeat(6000) + // Trigger spill + const largeChunk = "x".repeat(3000) interceptor.write(largeChunk) expect(interceptor.hasSpilledToDisk()).toBe(true) @@ -148,56 +145,59 @@ describe("OutputInterceptor", () => { }) describe("Threshold settings", () => { - it("should handle small (5KB) threshold correctly", () => { + 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 5KB - interceptor.write("x".repeat(5 * 1024)) + // Write exactly 2KB + interceptor.write("x".repeat(2048)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 5KB + // Write more to exceed 2KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) - it("should handle medium (10KB) threshold correctly", () => { + 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 10KB - interceptor.write("x".repeat(10 * 1024)) + // Write exactly 4KB + interceptor.write("x".repeat(4096)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 10KB + // Write more to exceed 4KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) - it("should handle large (20KB) threshold correctly", () => { + 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 20KB - interceptor.write("x".repeat(20 * 1024)) + // Write exactly 8KB + interceptor.write("x".repeat(8192)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 20KB + // Write more to exceed 8KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) @@ -213,10 +213,11 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", + compressProgressBar: false, }) - // Trigger spill (must exceed 5KB = 5120 bytes) - interceptor.write("x".repeat(6000)) + // Trigger spill + interceptor.write("x".repeat(3000)) expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true }) }) @@ -229,31 +230,30 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", + compressProgressBar: false, }) - // Trigger spill (must exceed 5KB = 5120 bytes) - interceptor.write("x".repeat(6000)) + // Trigger spill + interceptor.write("x".repeat(3000)) expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`)) }) - it("should write head and tail buffers to artifact when spilling", () => { + it("should write full output to artifact, not truncated", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560 + previewSize: "small", + compressProgressBar: false, }) - const fullOutput = "x".repeat(10000) + const fullOutput = "x".repeat(5000) interceptor.write(fullOutput) - // The write stream should receive the head buffer content first - // (spillToDisk writes head + tail that existed at spill time) - expect(mockWriteStream.write).toHaveBeenCalled() - // Verify that we're writing to disk - expect(interceptor.hasSpilledToDisk()).toBe(true) + // The write stream should receive the full buffer content + expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput) }) it("should get artifact path from getArtifactPath() method", () => { @@ -264,6 +264,7 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", + compressProgressBar: false, }) const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`) @@ -272,19 +273,20 @@ describe("OutputInterceptor", () => { }) describe("finalize() method", () => { - it("should return preview output for small commands", async () => { + 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 = await interceptor.finalize() + const result = interceptor.finalize() expect(result.preview).toBe(output) expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8")) @@ -292,61 +294,61 @@ describe("OutputInterceptor", () => { expect(result.truncated).toBe(false) }) - it("should return PersistedCommandOutput for large commands with head/tail preview", async () => { + it("should return PersistedCommandOutput for large commands", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 5KB = 5120, head=2560, tail=2560 + previewSize: "small", + compressProgressBar: false, }) - const largeOutput = "x".repeat(10000) + const largeOutput = "x".repeat(5000) interceptor.write(largeOutput) - const result = await interceptor.finalize() + 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")) - // Preview should contain head + omission indicator + tail - expect(result.preview).toContain("[...") - expect(result.preview).toContain("bytes omitted...]") + expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048) }) - it("should close write stream when finalizing", async () => { + 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 (must exceed 5KB = 5120 bytes) - interceptor.write("x".repeat(6000)) - await interceptor.finalize() + // Trigger spill + interceptor.write("x".repeat(3000)) + interceptor.finalize() expect(mockWriteStream.end).toHaveBeenCalled() }) - it("should include correct metadata (artifactId, size, truncated flag)", async () => { + 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, }) - // Must exceed 5KB = 5120 bytes to trigger truncation - const output = "x".repeat(6000) + const output = "x".repeat(5000) interceptor.write(output) - const result = await interceptor.finalize() + const result = interceptor.finalize() expect(result).toHaveProperty("preview") - expect(result).toHaveProperty("totalBytes", 6000) + expect(result).toHaveProperty("totalBytes", 5000) expect(result).toHaveProperty("artifactPath") expect(result).toHaveProperty("truncated", true) expect(result.artifactPath).toMatch(/cmd-12345\.txt$/) @@ -401,132 +403,79 @@ describe("OutputInterceptor", () => { }) }) - describe("getBufferForUI() method", () => { - it("should return current buffer for UI updates", () => { + 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, }) - const output = "Hello World" + // Output with carriage returns (simulating progress bar) + const output = "Progress: 10%\rProgress: 50%\rProgress: 100%\n" interceptor.write(output) - expect(interceptor.getBufferForUI()).toBe(output) - }) - - it("should return head + tail buffer after spilling to disk", () => { - const interceptor = new OutputInterceptor({ - executionId: "12345", - taskId: "task-1", - command: "test", - storageDir, - previewSize: "small", // 5KB = 5120, head=2560, tail=2560 - }) - - // Trigger spill - const largeOutput = "x".repeat(10000) - interceptor.write(largeOutput) - - const buffer = interceptor.getBufferForUI() - // Buffer for UI is head + tail (no omission indicator for smooth streaming) - expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120) - }) - }) - - describe("Head/Tail split behavior", () => { - it("should preserve first 50% and last 50% of output", async () => { - const interceptor = new OutputInterceptor({ - executionId: "12345", - taskId: "task-1", - command: "test", - storageDir, - previewSize: "small", // 5KB = 5120, head=2560, tail=2560 - }) - - // Create identifiable head and tail content - const headContent = "HEAD".repeat(750) // 3000 bytes - const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted) - const tailContent = "TAIL".repeat(750) // 3000 bytes - - interceptor.write(headContent) - interceptor.write(middleContent) - interceptor.write(tailContent) + const result = interceptor.finalize() - const result = await interceptor.finalize() - - // Should start with HEAD content (first 2560 bytes of head budget) - expect(result.preview.startsWith("HEAD")).toBe(true) - // Should end with TAIL content (last 2560 bytes) - expect(result.preview.endsWith("TAIL")).toBe(true) - // Should have omission indicator - expect(result.preview).toContain("[...") - expect(result.preview).toContain("bytes omitted...]") + // 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 add omission indicator when output fits in budget", async () => { + it("should not apply compression when compressProgressBar is false", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 5KB + previewSize: "small", + compressProgressBar: false, }) - const smallOutput = "Hello World\n" - interceptor.write(smallOutput) - - const result = await interceptor.finalize() + const output = "Line 1\nLine 2\n" + interceptor.write(output) - // No omission indicator for small output - expect(result.preview).toBe(smallOutput) - expect(result.preview).not.toContain("[...") + const result = interceptor.finalize() + expect(result.preview).toBe(output) }) + }) - it("should handle output that exactly fills head budget", async () => { + 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", // 5KB = 5120, head=2560 + previewSize: "small", + compressProgressBar: false, }) - // Write exactly 2560 bytes (head budget) - const exactHeadContent = "x".repeat(2560) - interceptor.write(exactHeadContent) - - const result = await interceptor.finalize() + const output = "Hello World" + interceptor.write(output) - // Should fit entirely in head, no truncation - expect(result.preview).toBe(exactHeadContent) - expect(result.truncated).toBe(false) + expect(interceptor.getBufferForUI()).toBe(output) }) - it("should split single large chunk across head and tail", async () => { + it("should return truncated buffer after spilling to disk", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 5KB = 5120, head=2560, tail=2560 + previewSize: "small", + compressProgressBar: false, }) - // Write a single chunk larger than preview budget - // First 2560 chars go to head, last 2560 chars go to tail - const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560) - interceptor.write(content) - - const result = await interceptor.finalize() + // Trigger spill + const largeOutput = "x".repeat(5000) + interceptor.write(largeOutput) - // Head should have A's - expect(result.preview.startsWith("A")).toBe(true) - // Tail should have C's - expect(result.preview.endsWith("C")).toBe(true) - // Should have omission indicator - expect(result.preview).toContain("[...") + 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 0000000000..afd05bb1e5 --- /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 bf4a009a94..674ab06e18 100644 --- a/src/package.json +++ b/src/package.json @@ -541,6 +541,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/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index b84a9dd3a3..054047284c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -179,6 +179,8 @@ const SettingsView = forwardRef(({ onDone, t ttsSpeed, soundVolume, telemetrySetting, + terminalOutputLineLimit, + terminalOutputCharacterLimit, terminalOutputPreviewSize, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream @@ -397,6 +399,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalCompressProgressBar, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 07f062cc01..881058caf2 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -124,6 +124,31 @@ export const TerminalSettings = ({ {t("settings:terminal.outputPreviewSize.description")} + + + setCachedStateField("terminalCompressProgressBar", e.target.checked) + } + data-testid="terminal-compress-progress-bar-checkbox"> + {t("settings:terminal.compressProgressBar.label")} + +
+ + + {" "} + + +
+
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d37f09bbc5..01bc032cd3 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -96,6 +96,10 @@ export interface ExtensionStateContextType extends ExtensionState { setWriteDelayMs: (value: number) => void screenshotQuality?: number setScreenshotQuality: (value: number) => void + terminalOutputLineLimit?: number + setTerminalOutputLineLimit: (value: number) => void + terminalOutputCharacterLimit?: number + setTerminalOutputCharacterLimit: (value: number) => void terminalOutputPreviewSize?: "small" | "medium" | "large" setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean @@ -537,6 +541,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, browserViewportSize: value })), setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })), setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })), + setTerminalOutputLineLimit: (value) => + setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), + setTerminalOutputCharacterLimit: (value) => + setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })), setTerminalOutputPreviewSize: (value) => setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })), setTerminalShellIntegrationTimeout: (value) => diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 63f4056d66..f53aa48a1f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -737,9 +737,9 @@ "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 (5KB)", - "medium": "Medium (10KB)", - "large": "Large (20KB)" + "small": "Small (2KB)", + "medium": "Medium (4KB)", + "large": "Large (8KB)" } }, "shellIntegrationTimeout": { From 525bf9f3add4527659b3e22f3177bdf7234b5a8e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 14:43:58 -0700 Subject: [PATCH 02/24] 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/ReadCommandOutputTool.ts | 47 +- 5 files changed, 1708 insertions(+), 7 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 0000000000..4b44c2e230 --- /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 0000000000..614210ebf1 --- /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 0000000000..aef70d584c --- /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 0bab31be9e..b163b46c56 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/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index d81352c30a..7d83c16fba 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 980e616c548178341e53c87e8037c62167f4872c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 24 Jan 2026 17:35:47 -0700 Subject: [PATCH 03/24] 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 | 10 +++- webview-ui/src/i18n/locales/de/settings.json | 10 +++- webview-ui/src/i18n/locales/es/settings.json | 10 +++- webview-ui/src/i18n/locales/fr/settings.json | 10 +++- webview-ui/src/i18n/locales/hi/settings.json | 10 +++- webview-ui/src/i18n/locales/id/settings.json | 10 +++- webview-ui/src/i18n/locales/it/settings.json | 10 +++- webview-ui/src/i18n/locales/ja/settings.json | 10 +++- webview-ui/src/i18n/locales/ko/settings.json | 10 +++- webview-ui/src/i18n/locales/nl/settings.json | 10 +++- webview-ui/src/i18n/locales/pl/settings.json | 10 +++- .../src/i18n/locales/pt-BR/settings.json | 10 +++- webview-ui/src/i18n/locales/ru/settings.json | 10 +++- webview-ui/src/i18n/locales/tr/settings.json | 10 +++- webview-ui/src/i18n/locales/vi/settings.json | 10 +++- .../src/i18n/locales/zh-CN/settings.json | 10 +++- .../src/i18n/locales/zh-TW/settings.json | 10 +++- 19 files changed, 120 insertions(+), 109 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 9268854208..3f829d8acf 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 afd05bb1e5..0000000000 --- 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 dfe769e6ea..7f133e7a5d 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -728,9 +728,9 @@ "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 (5KB)", - "medium": "Mitjana (10KB)", - "large": "Gran (20KB)" + "small": "Petita (2KB)", + "medium": "Mitjana (4KB)", + "large": "Gran (8KB)" } }, "shellIntegrationTimeout": { @@ -745,6 +745,10 @@ "label": "Retard de comanda del terminal", "description": "Afegeix una pausa breu després de cada comanda perquè el terminal de VS Code pugui buidar tota la sortida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa només si veus que falta sortida final; altrament deixa a 0. <0>Aprèn-ne més" }, + "compressProgressBar": { + "label": "Comprimeix sortida de barra de progrés", + "description": "Col·lapsa barres de progrés/spinners perquè només es mantingui l'estat final (estalvia tokens). <0>Aprèn-ne més" + }, "powershellCounter": { "label": "Activa solució de comptador de PowerShell", "description": "Activa quan falta o es duplica la sortida de PowerShell; afegeix un petit comptador a cada comanda per estabilitzar la sortida. Mantén desactivat si la sortida ja es veu correcta. <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 c49fac0f3b..4fb7a2bf96 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -728,9 +728,9 @@ "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 (5KB)", - "medium": "Mittel (10KB)", - "large": "Groß (20KB)" + "small": "Klein (2KB)", + "medium": "Mittel (4KB)", + "large": "Groß (8KB)" } }, "shellIntegrationTimeout": { @@ -745,6 +745,10 @@ "label": "Terminal-Befehlsverzögerung", "description": "Fügt nach jedem Befehl eine kurze Pause hinzu, damit das VS Code-Terminal alle Ausgaben leeren kann (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Verwende dies nur, wenn du fehlende Tail-Ausgabe siehst; sonst lass es bei 0. <0>Mehr erfahren" }, + "compressProgressBar": { + "label": "Fortschrittsbalken-Ausgabe komprimieren", + "description": "Klappt Fortschrittsbalken/Spinner zusammen, sodass nur der Endzustand erhalten bleibt (spart Token). <0>Mehr erfahren" + }, "powershellCounter": { "label": "PowerShell-Zähler-Workaround aktivieren", "description": "Schalte dies ein, wenn PowerShell-Ausgabe fehlt oder dupliziert wird; es fügt jedem Befehl einen kleinen Zähler hinzu, um die Ausgabe zu stabilisieren. Lass es ausgeschaltet, wenn die Ausgabe bereits korrekt aussieht. <0>Mehr erfahren" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 7115da6795..4b93a18e06 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -728,9 +728,9 @@ "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 (5KB)", - "medium": "Mediano (10KB)", - "large": "Grande (20KB)" + "small": "Pequeño (2KB)", + "medium": "Mediano (4KB)", + "large": "Grande (8KB)" } }, "shellIntegrationTimeout": { @@ -745,6 +745,10 @@ "label": "Retraso de comando del terminal", "description": "Añade una pausa breve después de cada comando para que el terminal de VS Code pueda vaciar toda la salida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo si ves salida final faltante; si no, deja en 0. <0>Más información" }, + "compressProgressBar": { + "label": "Comprimir salida de barra de progreso", + "description": "Colapsa barras de progreso/spinners para que solo se mantenga el estado final (ahorra tokens). <0>Más información" + }, "powershellCounter": { "label": "Activar solución del contador de PowerShell", "description": "Activa cuando falta o se duplica la salida de PowerShell; añade un pequeño contador a cada comando para estabilizar la salida. Mantén desactivado si la salida ya se ve correcta. <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 8cdbc1edb4..e76404d258 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -728,9 +728,9 @@ "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 (5KB)", - "medium": "Moyenne (10KB)", - "large": "Grande (20KB)" + "small": "Petite (2KB)", + "medium": "Moyenne (4KB)", + "large": "Grande (8KB)" } }, "shellIntegrationTimeout": { @@ -745,6 +745,10 @@ "label": "Délai de commande du terminal", "description": "Ajoute une courte pause après chaque commande pour que le terminal VS Code puisse vider toute la sortie (bash/zsh : PROMPT_COMMAND sleep ; PowerShell : start-sleep). Utilisez uniquement si vous voyez une sortie de fin manquante ; sinon laissez à 0. <0>En savoir plus" }, + "compressProgressBar": { + "label": "Compresser la sortie de barre de progression", + "description": "Réduit les barres de progression/spinners pour ne conserver que l'état final (économise des jetons). <0>En savoir plus" + }, "powershellCounter": { "label": "Activer la solution de contournement du compteur PowerShell", "description": "Activez lorsque la sortie PowerShell est manquante ou dupliquée ; ajoute un petit compteur à chaque commande pour stabiliser la sortie. Laissez désactivé si la sortie semble déjà correcte. <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 3f7e0dfa9a..c68babce76 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -729,9 +729,9 @@ "label": "कमांड आउटपुट पूर्वावलोकन आकार", "description": "नियंत्रित करता है कि Roo कितना कमांड आउटपुट सीधे देखता है। पूर्ण आउटपुट हमेशा सहेजा जाता है और आवश्यकता पड़ने पर सुलभ होता है।", "options": { - "small": "छोटा (5KB)", - "medium": "मध्यम (10KB)", - "large": "बड़ा (20KB)" + "small": "छोटा (2KB)", + "medium": "मध्यम (4KB)", + "large": "बड़ा (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "टर्मिनल कमांड विलंब", "description": "प्रत्येक कमांड के बाद छोटा विराम जोड़ता है ताकि VS Code टर्मिनल सभी आउटपुट फ्लश कर सके (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)। केवल तभी उपयोग करें जब टेल आउटपुट गायब हो; अन्यथा 0 पर छोड़ दें। <0>अधिक जानें" }, + "compressProgressBar": { + "label": "प्रगति बार आउटपुट संपीड़ित करें", + "description": "प्रगति बार/स्पिनर को संक्षिप्त करता है ताकि केवल अंतिम स्थिति रखी जाए (token बचाता है)। <0>अधिक जानें" + }, "powershellCounter": { "label": "PowerShell काउंटर समाधान सक्षम करें", "description": "जब PowerShell आउटपुट गायब हो या डुप्लिकेट हो तो इसे चालू करें; यह आउटपुट को स्थिर करने के लिए प्रत्येक कमांड में एक छोटा काउंटर जोड़ता है। यदि आउटपुट पहले से सही दिखता है तो इसे बंद रखें। <0>अधिक जानें" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index c1505c4ba9..da5d070b87 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -733,9 +733,9 @@ "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 (5KB)", - "medium": "Sedang (10KB)", - "large": "Besar (20KB)" + "small": "Kecil (2KB)", + "medium": "Sedang (4KB)", + "large": "Besar (8KB)" } }, "shellIntegrationTimeout": { @@ -750,6 +750,10 @@ "label": "Delay perintah terminal", "description": "Tambahkan jeda singkat setelah setiap perintah agar VS Code terminal bisa flush semua output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gunakan hanya jika output ekor hilang; jika tidak biarkan di 0. <0>Pelajari lebih lanjut" }, + "compressProgressBar": { + "label": "Kompres keluaran bilah kemajuan", + "description": "Menciutkan bilah kemajuan/spinner sehingga hanya status akhir yang disimpan (menghemat token). <0>Pelajari lebih lanjut" + }, "powershellCounter": { "label": "Aktifkan solusi penghitung PowerShell", "description": "Aktifkan saat keluaran PowerShell hilang atau digandakan; menambahkan penghitung kecil ke setiap perintah untuk menstabilkan keluaran. Biarkan nonaktif jika keluaran sudah terlihat benar. <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 139de3f16d..452edaa702 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -729,9 +729,9 @@ "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 (5KB)", - "medium": "Media (10KB)", - "large": "Grande (20KB)" + "small": "Piccola (2KB)", + "medium": "Media (4KB)", + "large": "Grande (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Ritardo comando terminale", "description": "Aggiunge una breve pausa dopo ogni comando affinché il terminale VS Code possa svuotare tutto l'output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo se vedi output finale mancante; altrimenti lascia a 0. <0>Scopri di più" }, + "compressProgressBar": { + "label": "Comprimi output barra di avanzamento", + "description": "Comprime barre di avanzamento/spinner in modo che venga mantenuto solo lo stato finale (risparmia token). <0>Scopri di più" + }, "powershellCounter": { "label": "Abilita workaround contatore PowerShell", "description": "Attiva quando l'output PowerShell è mancante o duplicato; aggiunge un piccolo contatore a ogni comando per stabilizzare l'output. Mantieni disattivato se l'output sembra già corretto. <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 7ed6498351..386accabc5 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -729,9 +729,9 @@ "label": "コマンド出力プレビューサイズ", "description": "Rooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。", "options": { - "small": "小 (5KB)", - "medium": "中 (10KB)", - "large": "大 (20KB)" + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "ターミナルコマンド遅延", "description": "VS Codeターミナルがすべての出力をフラッシュできるよう、各コマンド後に短い一時停止を追加します(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。末尾出力が欠落している場合のみ使用;それ以外は0のままにします。<0>詳細情報" }, + "compressProgressBar": { + "label": "プログレスバー出力を圧���������", + "description": "プログレスバー/スピナーを折りたたんで、最終状態のみを保持します(トークンを節約します)。<0>詳細情報" + }, "powershellCounter": { "label": "PowerShellカウンターの回避策を有効にする", "description": "PowerShellの出力が欠落または重複している場合にこれをオンにします。出力を安定させるために各コマンドに小さなカウンターを追加します。出力がすでに正しい場合はオフのままにします。<0>詳細情報" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 20bf3858f7..c14f6d6f71 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -729,9 +729,9 @@ "label": "명령 출력 미리보기 크기", "description": "Roo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.", "options": { - "small": "작게 (5KB)", - "medium": "보통 (10KB)", - "large": "크게 (20KB)" + "small": "작게 (2KB)", + "medium": "보통 (4KB)", + "large": "크게 (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "터미널 명령 지연", "description": "VS Code 터미널이 모든 출력을 플러시할 수 있도록 각 명령 후에 짧은 일시 중지를 추가합니다(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). 누락된 꼬리 출력이 표시되는 경우에만 사용하고, 그렇지 않으면 0으로 둡니다. <0>자세히 알아보기" }, + "compressProgressBar": { + "label": "진행률 표시줄 출력 압축", + "description": "진행률 표시줄/스피너를 축소하여 최종 상태만 유지합니다(토큰 절약). <0>자세히 알아보기" + }, "powershellCounter": { "label": "PowerShell 카운터 해결 방법 활성화", "description": "PowerShell 출력이 누락되거나 중복될 때 이 기능을 켜십시오. 출력을 안정화하기 위해 각 명령에 작은 카운터를 추가합니다. 출력이 이미 올바르게 표시되면 이 기능을 끄십시오. <0>자세히 알아보기" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 25b2a48f64..2232d5825f 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -729,9 +729,9 @@ "label": "Grootte opdrachtuitvoer voorvertoning", "description": "Bepaalt hoeveel opdrachtuitvoer Roo direct ziet. Volledige uitvoer wordt altijd opgeslagen en is toegankelijk wanneer nodig.", "options": { - "small": "Klein (5KB)", - "medium": "Gemiddeld (10KB)", - "large": "Groot (20KB)" + "small": "Klein (2KB)", + "medium": "Gemiddeld (4KB)", + "large": "Groot (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Terminal-commandovertraging", "description": "Voegt korte pauze toe na elk commando zodat VS Code-terminal alle uitvoer kan flushen (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gebruik alleen als je ontbrekende tail-uitvoer ziet; anders op 0 laten. <0>Meer informatie" }, + "compressProgressBar": { + "label": "Voortgangsbalk-uitvoer comprimeren", + "description": "Klapt voortgangsbalken/spinners in zodat alleen eindstatus behouden blijft (bespaart tokens). <0>Meer informatie" + }, "powershellCounter": { "label": "PowerShell-teller workaround inschakelen", "description": "Schakel in wanneer PowerShell-uitvoer ontbreekt of gedupliceerd wordt; voegt kleine teller toe aan elk commando om uitvoer te stabiliseren. Laat uit als uitvoer al correct lijkt. <0>Meer informatie" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 1ed4e59159..d88f6f0583 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -729,9 +729,9 @@ "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 (5KB)", - "medium": "Średni (10KB)", - "large": "Duży (20KB)" + "small": "Mały (2KB)", + "medium": "Średni (4KB)", + "large": "Duży (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Opóźnienie polecenia terminala", "description": "Dodaje krótką pauzę po każdym poleceniu, aby terminal VS Code mógł opróżnić całe wyjście (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Używaj tylko gdy widzisz brakujące wyjście końcowe; w przeciwnym razie zostaw na 0. <0>Dowiedz się więcej" }, + "compressProgressBar": { + "label": "Kompresuj wyjście paska postępu", + "description": "Zwija paski postępu/spinnery, aby zachować tylko stan końcowy (oszczędza tokeny). <0>Dowiedz się więcej" + }, "powershellCounter": { "label": "Włącz obejście licznika PowerShell", "description": "Włącz gdy brakuje lub jest zduplikowane wyjście PowerShell; dodaje mały licznik do każdego polecenia, aby ustabilizować wyjście. Pozostaw wyłączone, jeśli wyjście już wygląda poprawnie. <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 1d989db379..e3abcbfac9 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -729,9 +729,9 @@ "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 (5KB)", - "medium": "Médio (10KB)", - "large": "Grande (20KB)" + "small": "Pequeno (2KB)", + "medium": "Médio (4KB)", + "large": "Grande (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Atraso de comando do terminal", "description": "Adiciona uma pequena pausa após cada comando para que o terminal do VS Code possa liberar toda a saída (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Use apenas se você vir a saída final faltando; caso contrário, deixe em 0. <0>Saiba mais" }, + "compressProgressBar": { + "label": "Comprimir saída da barra de progresso", + "description": "Recolhe barras de progresso/spinners para que apenas o estado final seja mantido (economiza tokens). <0>Saiba mais" + }, "powershellCounter": { "label": "Ativar solução alternativa do contador do PowerShell", "description": "Ative isso quando a saída do PowerShell estiver faltando ou duplicada; ele adiciona um pequeno contador a cada comando para estabilizar a saída. Mantenha desativado se a saída já parecer correta. <0>Saiba mais" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5a736ef1ec..5003befe35 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -729,9 +729,9 @@ "label": "Размер предпросмотра вывода команд", "description": "Контролирует, сколько вывода команды Roo видит напрямую. Полный вывод всегда сохраняется и доступен при необходимости.", "options": { - "small": "Маленький (5KB)", - "medium": "Средний (10KB)", - "large": "Большой (20KB)" + "small": "Маленький (2KB)", + "medium": "Средний (4KB)", + "large": "Большой (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Задержка команды терминала", "description": "Добавляет короткую паузу после каждой команды, чтобы терминал VS Code мог вывести весь output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Используйте только если видите отсутствующий tail output; иначе оставьте 0. <0>Подробнее" }, + "compressProgressBar": { + "label": "Сжимать вывод прогресс-бара", + "description": "Сворачивает прогресс-бары/спиннеры, чтобы сохранялось только финальное состояние (экономит токены). <0>Подробнее" + }, "powershellCounter": { "label": "Включить обходчик счётчика PowerShell", "description": "Включите, когда вывод PowerShell отсутствует или дублируется; добавляет маленький счётчик к каждой команде для стабилизации вывода. Оставьте выключенным, если вывод уже выглядит корректно. <0>Подробнее" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 9110f27d6e..698b086beb 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -729,9 +729,9 @@ "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 (5KB)", - "medium": "Orta (10KB)", - "large": "Büyük (20KB)" + "small": "Küçük (2KB)", + "medium": "Orta (4KB)", + "large": "Büyük (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Terminal komut delay", "description": "VS Code terminalin tüm outputu flush edebilmesi için her komuttan sonra kısa pause ekler (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Sadece tail output eksikse kullan; yoksa 0'da bırak. <0>Daha fazla bilgi edinin" }, + "compressProgressBar": { + "label": "İlerleme çubuğu çıktısını sıkıştır", + "description": "İlerleme çubukları/spinner'ları daraltır, sadece son durumu tutar (token tasarrufu). <0>Daha fazla bilgi edinin" + }, "powershellCounter": { "label": "PowerShell sayaç geçici çözümünü etkinleştir", "description": "PowerShell çıktısı eksik veya yineleniyorsa bunu açın; çıktıyı stabilize etmek için her komuta küçük bir sayaç ekler. Çıktı zaten doğru görünüyorsa bunu kapalı tutun. <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 14c8904e09..0736ccbac4 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -729,9 +729,9 @@ "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ỏ (5KB)", - "medium": "Trung bình (10KB)", - "large": "Lớn (20KB)" + "small": "Nhỏ (2KB)", + "medium": "Trung bình (4KB)", + "large": "Lớn (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "Delay lệnh terminal", "description": "Thêm khoảng dừng ngắn sau mỗi lệnh để VS Code terminal flush tất cả output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Chỉ dùng nếu thiếu tail output; nếu không để ở 0. <0>Tìm hiểu thêm" }, + "compressProgressBar": { + "label": "Nén đầu ra thanh tiến trình", + "description": "Thu gọn các thanh tiến trình/vòng quay để chỉ giữ lại trạng thái cuối cùng (tiết kiệm token). <0>Tìm hiểu thêm" + }, "powershellCounter": { "label": "Bật workaround bộ đếm PowerShell", "description": "Bật khi output PowerShell thiếu hoặc trùng lặp; thêm counter nhỏ vào mỗi lệnh để ổn định output. Tắt nếu output đã đúng. <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 1b58484ced..7003ba2ed7 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -729,9 +729,9 @@ "label": "命令输出预览大小", "description": "控制 Roo 直接看到的命令输出量。完整输出始终会被保存,需要时可以访问。", "options": { - "small": "小 (5KB)", - "medium": "中 (10KB)", - "large": "大 (20KB)" + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" } }, "shellIntegrationTimeout": { @@ -746,6 +746,10 @@ "label": "终端命令延迟", "description": "在每个命令后添加短暂暂停,以便 VS Code 终端刷新所有输出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。仅在看到缺少尾部输出时使用;否则保持为 0。<0>了解更多" }, + "compressProgressBar": { + "label": "压缩进度条输出", + "description": "折叠进度条/旋转器,仅保留最终状态(节省 token)。<0>了解更多" + }, "powershellCounter": { "label": "启用 PowerShell 计数器解决方案", "description": "当 PowerShell 输出丢失或重复时启用此选项;它会为每个命令附加一个小计数器以稳定输出。如果输出已正常,请保持关闭。<0>了解更多" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index d18a5c8443..7f718021f1 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -737,9 +737,9 @@ "label": "命令輸出預覽大小", "description": "控制 Roo 直接看到的命令輸出量。完整輸出始終會被儲存,需要時可以存取。", "options": { - "small": "小 (5KB)", - "medium": "中 (10KB)", - "large": "大 (20KB)" + "small": "小 (2KB)", + "medium": "中 (4KB)", + "large": "大 (8KB)" } }, "shellIntegrationTimeout": { @@ -754,6 +754,10 @@ "label": "終端機命令延遲", "description": "在每個命令後新增短暫暫停,以便 VS Code 終端機刷新所有輸出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。僅在看到缺少尾部輸出時使用;否則保持為 0。<0>了解更多" }, + "compressProgressBar": { + "label": "壓縮進度條輸出", + "description": "折疊進度條/旋轉器,僅保留最終狀態(節省 Token)。<0>了解更多" + }, "powershellCounter": { "label": "啟用 PowerShell 計數器解決方案", "description": "當 PowerShell 輸出遺失或重複時啟用此選項;它會為每個命令附加一個小計數器以穩定輸出。如果輸出已正常,請保持關閉。<0>了解更多" From cd1b9f43fde6aea3841e5f60426547968bf76f45 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 19:35:23 -0700 Subject: [PATCH 04/24] fix(parser): add read_command_output to NativeToolCallParser chore: remove terminalCompressProgressBar setting - Fix: Add missing read_command_output case to parser (was causing 'Invalid arguments' errors) - Remove: Delete compress progress bar setting from all components (redundant with preview size control) - Clean up: Remove from global-settings, OutputInterceptor, BaseTerminal, ExecuteCommandTool, SettingsView, TerminalSettings, ExtensionStateContext - Clean up: Remove from all i18n locale files --- packages/types/src/cloud.ts | 1 + src/core/tools/ExecuteCommandTool.ts | 2 - src/integrations/terminal/BaseTerminal.ts | 1 - .../terminal/OutputInterceptor.ts | 18 +----- .../__tests__/OutputInterceptor.test.ts | 57 ------------------- .../src/components/settings/SettingsView.tsx | 1 - .../components/settings/TerminalSettings.tsx | 25 -------- webview-ui/src/i18n/locales/ca/settings.json | 4 -- webview-ui/src/i18n/locales/de/settings.json | 4 -- webview-ui/src/i18n/locales/es/settings.json | 4 -- webview-ui/src/i18n/locales/fr/settings.json | 4 -- webview-ui/src/i18n/locales/hi/settings.json | 4 -- webview-ui/src/i18n/locales/id/settings.json | 4 -- webview-ui/src/i18n/locales/it/settings.json | 4 -- webview-ui/src/i18n/locales/ja/settings.json | 4 -- webview-ui/src/i18n/locales/ko/settings.json | 4 -- webview-ui/src/i18n/locales/nl/settings.json | 4 -- webview-ui/src/i18n/locales/pl/settings.json | 4 -- .../src/i18n/locales/pt-BR/settings.json | 4 -- webview-ui/src/i18n/locales/ru/settings.json | 4 -- webview-ui/src/i18n/locales/tr/settings.json | 4 -- webview-ui/src/i18n/locales/vi/settings.json | 4 -- .../src/i18n/locales/zh-CN/settings.json | 4 -- .../src/i18n/locales/zh-TW/settings.json | 4 -- 24 files changed, 2 insertions(+), 171 deletions(-) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index f14f14370b..6732a55908 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -99,6 +99,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema maxWorkspaceFiles: true, showRooIgnoredFiles: true, terminalCommandDelay: true, + terminalOutputLineLimit: true, terminalShellIntegrationDisabled: true, terminalShellIntegrationTimeout: true, terminalZshClearEolMark: true, diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 28957fc868..442ff7340f 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -201,7 +201,6 @@ export async function executeCommandInTerminal( const providerState = await provider?.getState() const terminalOutputPreviewSize = providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE - const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true interceptor = new OutputInterceptor({ executionId, @@ -209,7 +208,6 @@ export async function executeCommandInTerminal( command, storageDir, previewSize: terminalOutputPreviewSize, - compressProgressBar: terminalCompressProgressBar, }) } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index 121dc34313..599fa0b598 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -1,5 +1,4 @@ import { truncateOutput, applyRunLengthEncoding } from "../misc/extract-text" - import type { RooTerminalProvider, RooTerminal, diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts index c9e984ff69..3ef2fcb08c 100644 --- a/src/integrations/terminal/OutputInterceptor.ts +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -3,8 +3,6 @@ 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. */ @@ -19,8 +17,6 @@ export interface OutputInterceptorOptions { 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 } /** @@ -46,7 +42,6 @@ export interface OutputInterceptorOptions { * command: 'npm test', * storageDir: '/path/to/task/command-output', * previewSize: 'medium', - * compressProgressBar: true * }); * * // Write output chunks as they arrive @@ -66,7 +61,6 @@ export class OutputInterceptor { private totalBytes: number = 0 private spilledToDisk: boolean = false private readonly previewBytes: number - private readonly compressProgressBar: boolean /** * Creates a new OutputInterceptor instance. @@ -75,7 +69,6 @@ export class OutputInterceptor { */ 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`) } @@ -143,9 +136,6 @@ export class OutputInterceptor { * - 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 @@ -165,13 +155,7 @@ export class OutputInterceptor { } // 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) - } + const preview = this.buffer.slice(0, this.previewBytes) return { preview, diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index 3f829d8acf..ecdb708da0 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -56,7 +56,6 @@ describe("OutputInterceptor", () => { command: "echo test", storageDir, previewSize: "small", // 2KB - compressProgressBar: false, }) const smallOutput = "Hello World\n" @@ -79,7 +78,6 @@ describe("OutputInterceptor", () => { command: "echo test", storageDir, previewSize: "small", // 2KB = 2048 bytes - compressProgressBar: false, }) // Write enough data to exceed 2KB threshold @@ -103,7 +101,6 @@ describe("OutputInterceptor", () => { command: "echo test", storageDir, previewSize: "small", // 2KB - compressProgressBar: false, }) // Write data that exceeds threshold @@ -125,7 +122,6 @@ describe("OutputInterceptor", () => { command: "echo test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Trigger spill @@ -152,7 +148,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Write exactly 2KB @@ -171,7 +166,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "medium", - compressProgressBar: false, }) // Write exactly 4KB @@ -190,7 +184,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "large", - compressProgressBar: false, }) // Write exactly 8KB @@ -213,7 +206,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Trigger spill @@ -230,7 +222,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Trigger spill @@ -246,7 +237,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) const fullOutput = "x".repeat(5000) @@ -264,7 +254,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`) @@ -280,7 +269,6 @@ describe("OutputInterceptor", () => { command: "echo hello", storageDir, previewSize: "small", - compressProgressBar: false, }) const output = "Hello World\n" @@ -301,7 +289,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) const largeOutput = "x".repeat(5000) @@ -322,7 +309,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Trigger spill @@ -339,7 +325,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) const output = "x".repeat(5000) @@ -403,46 +388,6 @@ describe("OutputInterceptor", () => { }) }) - 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({ @@ -451,7 +396,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) const output = "Hello World" @@ -467,7 +411,6 @@ describe("OutputInterceptor", () => { command: "test", storageDir, previewSize: "small", - compressProgressBar: false, }) // Trigger spill diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 054047284c..f2fbb8a348 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -399,7 +399,6 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, - terminalCompressProgressBar, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 881058caf2..07f062cc01 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -124,31 +124,6 @@ export const TerminalSettings = ({ {t("settings:terminal.outputPreviewSize.description")} - - - setCachedStateField("terminalCompressProgressBar", e.target.checked) - } - data-testid="terminal-compress-progress-bar-checkbox"> - {t("settings:terminal.compressProgressBar.label")} - -
- - - {" "} - - -
-
diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 7f133e7a5d..051cae86df 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -745,10 +745,6 @@ "label": "Retard de comanda del terminal", "description": "Afegeix una pausa breu després de cada comanda perquè el terminal de VS Code pugui buidar tota la sortida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa només si veus que falta sortida final; altrament deixa a 0. <0>Aprèn-ne més" }, - "compressProgressBar": { - "label": "Comprimeix sortida de barra de progrés", - "description": "Col·lapsa barres de progrés/spinners perquè només es mantingui l'estat final (estalvia tokens). <0>Aprèn-ne més" - }, "powershellCounter": { "label": "Activa solució de comptador de PowerShell", "description": "Activa quan falta o es duplica la sortida de PowerShell; afegeix un petit comptador a cada comanda per estabilitzar la sortida. Mantén desactivat si la sortida ja es veu correcta. <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 4fb7a2bf96..5ab8155826 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -745,10 +745,6 @@ "label": "Terminal-Befehlsverzögerung", "description": "Fügt nach jedem Befehl eine kurze Pause hinzu, damit das VS Code-Terminal alle Ausgaben leeren kann (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Verwende dies nur, wenn du fehlende Tail-Ausgabe siehst; sonst lass es bei 0. <0>Mehr erfahren" }, - "compressProgressBar": { - "label": "Fortschrittsbalken-Ausgabe komprimieren", - "description": "Klappt Fortschrittsbalken/Spinner zusammen, sodass nur der Endzustand erhalten bleibt (spart Token). <0>Mehr erfahren" - }, "powershellCounter": { "label": "PowerShell-Zähler-Workaround aktivieren", "description": "Schalte dies ein, wenn PowerShell-Ausgabe fehlt oder dupliziert wird; es fügt jedem Befehl einen kleinen Zähler hinzu, um die Ausgabe zu stabilisieren. Lass es ausgeschaltet, wenn die Ausgabe bereits korrekt aussieht. <0>Mehr erfahren" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 4b93a18e06..6651d3465c 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -745,10 +745,6 @@ "label": "Retraso de comando del terminal", "description": "Añade una pausa breve después de cada comando para que el terminal de VS Code pueda vaciar toda la salida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo si ves salida final faltante; si no, deja en 0. <0>Más información" }, - "compressProgressBar": { - "label": "Comprimir salida de barra de progreso", - "description": "Colapsa barras de progreso/spinners para que solo se mantenga el estado final (ahorra tokens). <0>Más información" - }, "powershellCounter": { "label": "Activar solución del contador de PowerShell", "description": "Activa cuando falta o se duplica la salida de PowerShell; añade un pequeño contador a cada comando para estabilizar la salida. Mantén desactivado si la salida ya se ve correcta. <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 e76404d258..fbe269eeb3 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -745,10 +745,6 @@ "label": "Délai de commande du terminal", "description": "Ajoute une courte pause après chaque commande pour que le terminal VS Code puisse vider toute la sortie (bash/zsh : PROMPT_COMMAND sleep ; PowerShell : start-sleep). Utilisez uniquement si vous voyez une sortie de fin manquante ; sinon laissez à 0. <0>En savoir plus" }, - "compressProgressBar": { - "label": "Compresser la sortie de barre de progression", - "description": "Réduit les barres de progression/spinners pour ne conserver que l'état final (économise des jetons). <0>En savoir plus" - }, "powershellCounter": { "label": "Activer la solution de contournement du compteur PowerShell", "description": "Activez lorsque la sortie PowerShell est manquante ou dupliquée ; ajoute un petit compteur à chaque commande pour stabiliser la sortie. Laissez désactivé si la sortie semble déjà correcte. <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 c68babce76..cdee2252c7 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -746,10 +746,6 @@ "label": "टर्मिनल कमांड विलंब", "description": "प्रत्येक कमांड के बाद छोटा विराम जोड़ता है ताकि VS Code टर्मिनल सभी आउटपुट फ्लश कर सके (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)। केवल तभी उपयोग करें जब टेल आउटपुट गायब हो; अन्यथा 0 पर छोड़ दें। <0>अधिक जानें" }, - "compressProgressBar": { - "label": "प्रगति बार आउटपुट संपीड़ित करें", - "description": "प्रगति बार/स्पिनर को संक्षिप्त करता है ताकि केवल अंतिम स्थिति रखी जाए (token बचाता है)। <0>अधिक जानें" - }, "powershellCounter": { "label": "PowerShell काउंटर समाधान सक्षम करें", "description": "जब PowerShell आउटपुट गायब हो या डुप्लिकेट हो तो इसे चालू करें; यह आउटपुट को स्थिर करने के लिए प्रत्येक कमांड में एक छोटा काउंटर जोड़ता है। यदि आउटपुट पहले से सही दिखता है तो इसे बंद रखें। <0>अधिक जानें" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index da5d070b87..570afc1889 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -750,10 +750,6 @@ "label": "Delay perintah terminal", "description": "Tambahkan jeda singkat setelah setiap perintah agar VS Code terminal bisa flush semua output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gunakan hanya jika output ekor hilang; jika tidak biarkan di 0. <0>Pelajari lebih lanjut" }, - "compressProgressBar": { - "label": "Kompres keluaran bilah kemajuan", - "description": "Menciutkan bilah kemajuan/spinner sehingga hanya status akhir yang disimpan (menghemat token). <0>Pelajari lebih lanjut" - }, "powershellCounter": { "label": "Aktifkan solusi penghitung PowerShell", "description": "Aktifkan saat keluaran PowerShell hilang atau digandakan; menambahkan penghitung kecil ke setiap perintah untuk menstabilkan keluaran. Biarkan nonaktif jika keluaran sudah terlihat benar. <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 452edaa702..b295c8efe9 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -746,10 +746,6 @@ "label": "Ritardo comando terminale", "description": "Aggiunge una breve pausa dopo ogni comando affinché il terminale VS Code possa svuotare tutto l'output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo se vedi output finale mancante; altrimenti lascia a 0. <0>Scopri di più" }, - "compressProgressBar": { - "label": "Comprimi output barra di avanzamento", - "description": "Comprime barre di avanzamento/spinner in modo che venga mantenuto solo lo stato finale (risparmia token). <0>Scopri di più" - }, "powershellCounter": { "label": "Abilita workaround contatore PowerShell", "description": "Attiva quando l'output PowerShell è mancante o duplicato; aggiunge un piccolo contatore a ogni comando per stabilizzare l'output. Mantieni disattivato se l'output sembra già corretto. <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 386accabc5..e0aa410384 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -746,10 +746,6 @@ "label": "ターミナルコマンド遅延", "description": "VS Codeターミナルがすべての出力をフラッシュできるよう、各コマンド後に短い一時停止を追加します(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。末尾出力が欠落している場合のみ使用;それ以外は0のままにします。<0>詳細情報" }, - "compressProgressBar": { - "label": "プログレスバー出力を圧���������", - "description": "プログレスバー/スピナーを折りたたんで、最終状態のみを保持します(トークンを節約します)。<0>詳細情報" - }, "powershellCounter": { "label": "PowerShellカウンターの回避策を有効にする", "description": "PowerShellの出力が欠落または重複している場合にこれをオンにします。出力を安定させるために各コマンドに小さなカウンターを追加します。出力がすでに正しい場合はオフのままにします。<0>詳細情報" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c14f6d6f71..5717f8567f 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -746,10 +746,6 @@ "label": "터미널 명령 지연", "description": "VS Code 터미널이 모든 출력을 플러시할 수 있도록 각 명령 후에 짧은 일시 중지를 추가합니다(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). 누락된 꼬리 출력이 표시되는 경우에만 사용하고, 그렇지 않으면 0으로 둡니다. <0>자세히 알아보기" }, - "compressProgressBar": { - "label": "진행률 표시줄 출력 압축", - "description": "진행률 표시줄/스피너를 축소하여 최종 상태만 유지합니다(토큰 절약). <0>자세히 알아보기" - }, "powershellCounter": { "label": "PowerShell 카운터 해결 방법 활성화", "description": "PowerShell 출력이 누락되거나 중복될 때 이 기능을 켜십시오. 출력을 안정화하기 위해 각 명령에 작은 카운터를 추가합니다. 출력이 이미 올바르게 표시되면 이 기능을 끄십시오. <0>자세히 알아보기" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2232d5825f..b493155a01 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -746,10 +746,6 @@ "label": "Terminal-commandovertraging", "description": "Voegt korte pauze toe na elk commando zodat VS Code-terminal alle uitvoer kan flushen (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gebruik alleen als je ontbrekende tail-uitvoer ziet; anders op 0 laten. <0>Meer informatie" }, - "compressProgressBar": { - "label": "Voortgangsbalk-uitvoer comprimeren", - "description": "Klapt voortgangsbalken/spinners in zodat alleen eindstatus behouden blijft (bespaart tokens). <0>Meer informatie" - }, "powershellCounter": { "label": "PowerShell-teller workaround inschakelen", "description": "Schakel in wanneer PowerShell-uitvoer ontbreekt of gedupliceerd wordt; voegt kleine teller toe aan elk commando om uitvoer te stabiliseren. Laat uit als uitvoer al correct lijkt. <0>Meer informatie" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index d88f6f0583..92fcd8f8fc 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -746,10 +746,6 @@ "label": "Opóźnienie polecenia terminala", "description": "Dodaje krótką pauzę po każdym poleceniu, aby terminal VS Code mógł opróżnić całe wyjście (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Używaj tylko gdy widzisz brakujące wyjście końcowe; w przeciwnym razie zostaw na 0. <0>Dowiedz się więcej" }, - "compressProgressBar": { - "label": "Kompresuj wyjście paska postępu", - "description": "Zwija paski postępu/spinnery, aby zachować tylko stan końcowy (oszczędza tokeny). <0>Dowiedz się więcej" - }, "powershellCounter": { "label": "Włącz obejście licznika PowerShell", "description": "Włącz gdy brakuje lub jest zduplikowane wyjście PowerShell; dodaje mały licznik do każdego polecenia, aby ustabilizować wyjście. Pozostaw wyłączone, jeśli wyjście już wygląda poprawnie. <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 e3abcbfac9..a74086f29b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -746,10 +746,6 @@ "label": "Atraso de comando do terminal", "description": "Adiciona uma pequena pausa após cada comando para que o terminal do VS Code possa liberar toda a saída (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Use apenas se você vir a saída final faltando; caso contrário, deixe em 0. <0>Saiba mais" }, - "compressProgressBar": { - "label": "Comprimir saída da barra de progresso", - "description": "Recolhe barras de progresso/spinners para que apenas o estado final seja mantido (economiza tokens). <0>Saiba mais" - }, "powershellCounter": { "label": "Ativar solução alternativa do contador do PowerShell", "description": "Ative isso quando a saída do PowerShell estiver faltando ou duplicada; ele adiciona um pequeno contador a cada comando para estabilizar a saída. Mantenha desativado se a saída já parecer correta. <0>Saiba mais" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5003befe35..76bf711a73 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -746,10 +746,6 @@ "label": "Задержка команды терминала", "description": "Добавляет короткую паузу после каждой команды, чтобы терминал VS Code мог вывести весь output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Используйте только если видите отсутствующий tail output; иначе оставьте 0. <0>Подробнее" }, - "compressProgressBar": { - "label": "Сжимать вывод прогресс-бара", - "description": "Сворачивает прогресс-бары/спиннеры, чтобы сохранялось только финальное состояние (экономит токены). <0>Подробнее" - }, "powershellCounter": { "label": "Включить обходчик счётчика PowerShell", "description": "Включите, когда вывод PowerShell отсутствует или дублируется; добавляет маленький счётчик к каждой команде для стабилизации вывода. Оставьте выключенным, если вывод уже выглядит корректно. <0>Подробнее" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 698b086beb..dddadd2c35 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -746,10 +746,6 @@ "label": "Terminal komut delay", "description": "VS Code terminalin tüm outputu flush edebilmesi için her komuttan sonra kısa pause ekler (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Sadece tail output eksikse kullan; yoksa 0'da bırak. <0>Daha fazla bilgi edinin" }, - "compressProgressBar": { - "label": "İlerleme çubuğu çıktısını sıkıştır", - "description": "İlerleme çubukları/spinner'ları daraltır, sadece son durumu tutar (token tasarrufu). <0>Daha fazla bilgi edinin" - }, "powershellCounter": { "label": "PowerShell sayaç geçici çözümünü etkinleştir", "description": "PowerShell çıktısı eksik veya yineleniyorsa bunu açın; çıktıyı stabilize etmek için her komuta küçük bir sayaç ekler. Çıktı zaten doğru görünüyorsa bunu kapalı tutun. <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 0736ccbac4..c23cc1d6b3 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -746,10 +746,6 @@ "label": "Delay lệnh terminal", "description": "Thêm khoảng dừng ngắn sau mỗi lệnh để VS Code terminal flush tất cả output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Chỉ dùng nếu thiếu tail output; nếu không để ở 0. <0>Tìm hiểu thêm" }, - "compressProgressBar": { - "label": "Nén đầu ra thanh tiến trình", - "description": "Thu gọn các thanh tiến trình/vòng quay để chỉ giữ lại trạng thái cuối cùng (tiết kiệm token). <0>Tìm hiểu thêm" - }, "powershellCounter": { "label": "Bật workaround bộ đếm PowerShell", "description": "Bật khi output PowerShell thiếu hoặc trùng lặp; thêm counter nhỏ vào mỗi lệnh để ổn định output. Tắt nếu output đã đúng. <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 7003ba2ed7..5b4e2330a9 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -746,10 +746,6 @@ "label": "终端命令延迟", "description": "在每个命令后添加短暂暂停,以便 VS Code 终端刷新所有输出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。仅在看到缺少尾部输出时使用;否则保持为 0。<0>了解更多" }, - "compressProgressBar": { - "label": "压缩进度条输出", - "description": "折叠进度条/旋转器,仅保留最终状态(节省 token)。<0>了解更多" - }, "powershellCounter": { "label": "启用 PowerShell 计数器解决方案", "description": "当 PowerShell 输出丢失或重复时启用此选项;它会为每个命令附加一个小计数器以稳定输出。如果输出已正常,请保持关闭。<0>了解更多" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 7f718021f1..ea99a37f43 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -754,10 +754,6 @@ "label": "終端機命令延遲", "description": "在每個命令後新增短暫暫停,以便 VS Code 終端機刷新所有輸出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。僅在看到缺少尾部輸出時使用;否則保持為 0。<0>了解更多" }, - "compressProgressBar": { - "label": "壓縮進度條輸出", - "description": "折疊進度條/旋轉器,僅保留最終狀態(節省 Token)。<0>了解更多" - }, "powershellCounter": { "label": "啟用 PowerShell 計數器解決方案", "description": "當 PowerShell 輸出遺失或重複時啟用此選項;它會為每個命令附加一個小計數器以穩定輸出。如果輸出已正常,請保持關閉。<0>了解更多" From 47c3f496a0d9d6891c717be14972218790f3cc2d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 19:36:07 -0700 Subject: [PATCH 05/24] feat(ui): add read_command_output activity indicator Show read progress in chat when read_command_output tool is used: - Display 'Roo read command output (0 B - 100 KB of 263.3 KB)' - Shows the specific byte range being read each call - Uses same visual style as read_file tool - Add 'tool' to ClineSay types - Add readCommandOutput to ClineSayTool with readStart/readEnd/totalBytes --- src/core/tools/ReadCommandOutputTool.ts | 19 +++++++++++++++++++ webview-ui/src/components/chat/ChatRow.tsx | 1 - 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index 7d83c16fba..e7aaf91135 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -159,15 +159,34 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { } let result: string + let readStart = 0 + let readEnd = 0 if (search) { // Search mode: filter lines matching the pattern result = await this.searchInArtifact(artifactPath, search, totalSize, limit) + // For search, we're scanning the whole file + readStart = 0 + readEnd = totalSize } else { // Normal read mode with offset/limit result = await this.readArtifact(artifactPath, offset, limit, totalSize) + // Calculate actual read range + readStart = offset + readEnd = Math.min(offset + limit, totalSize) } + // Report to UI that we read command output + await task.say( + "tool", + JSON.stringify({ + tool: "readCommandOutput", + readStart, + readEnd, + totalBytes: totalSize, + }), + ) + task.consecutiveMistakeCount = 0 pushToolResult(result) } catch (error) { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 6a6d5c3f6d..c79dd11a86 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1470,7 +1470,6 @@ export const ChatRowContent = ({ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } - // Determine if this is a search operation const isSearch = sayTool.searchPattern !== undefined From e60d6898df1e15f2be06f11524b98ec99726aada Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 19:52:18 -0700 Subject: [PATCH 06/24] refactor: remove terminalOutputLineLimit and terminalOutputCharacterLimit settings These settings were redundant with terminalOutputPreviewSize which controls the preview shown to the LLM. The line/char limits were for UI truncation which is now handled with hardcoded defaults (500 lines, 50K chars) since they don't need to be user-configurable. - Remove settings from packages/types schemas - Remove DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT constant - Update compressTerminalOutput() to use hardcoded limits - Update ExecuteCommandTool to not pass limit parameters - Update ClineProvider state handling - Update webview context and settings - Update tests to not use removed settings --- packages/types/src/cloud.ts | 1 - packages/types/src/global-settings.ts | 2 -- packages/types/src/vscode-extension-host.ts | 2 -- src/core/tools/ExecuteCommandTool.ts | 8 +------- webview-ui/src/components/settings/SettingsView.tsx | 2 -- webview-ui/src/context/ExtensionStateContext.tsx | 8 -------- 6 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 6732a55908..f14f14370b 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -99,7 +99,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema maxWorkspaceFiles: true, showRooIgnoredFiles: true, terminalCommandDelay: true, - terminalOutputLineLimit: true, terminalShellIntegrationDisabled: true, terminalShellIntegrationTimeout: true, terminalZshClearEolMark: true, diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 65adaefb9d..f07134df3a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -176,8 +176,6 @@ export const globalSettingsSchema = z.object({ maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), - terminalOutputLineLimit: z.number().optional(), - terminalOutputCharacterLimit: z.number().optional(), terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(), terminalShellIntegrationTimeout: z.number().optional(), terminalShellIntegrationDisabled: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ce0d337d91..7ae89e8777 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -302,8 +302,6 @@ export type ExtensionState = Pick< | "soundEnabled" | "soundVolume" | "maxConcurrentFileReads" - | "terminalOutputLineLimit" - | "terminalOutputCharacterLimit" | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 442ff7340f..26f01fc736 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -4,12 +4,7 @@ import * as vscode from "vscode" import delay from "delay" -import { - CommandExecutionStatus, - DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, - DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, - PersistedCommandOutput, -} from "@roo-code/types" +import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, PersistedCommandOutput } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" @@ -215,7 +210,6 @@ export async function executeCommandInTerminal( // 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 (100KB limit). const maxAccumulatedOutputSize = 100_000 - // Track when onCompleted callback finishes to avoid race condition. // The callback is async but Terminal/ExecaTerminal don't await it, so we track completion // explicitly to ensure persistedResult is set before we use it. diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f2fbb8a348..b84a9dd3a3 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -179,8 +179,6 @@ const SettingsView = forwardRef(({ onDone, t ttsSpeed, soundVolume, telemetrySetting, - terminalOutputLineLimit, - terminalOutputCharacterLimit, terminalOutputPreviewSize, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 01bc032cd3..d37f09bbc5 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -96,10 +96,6 @@ export interface ExtensionStateContextType extends ExtensionState { setWriteDelayMs: (value: number) => void screenshotQuality?: number setScreenshotQuality: (value: number) => void - terminalOutputLineLimit?: number - setTerminalOutputLineLimit: (value: number) => void - terminalOutputCharacterLimit?: number - setTerminalOutputCharacterLimit: (value: number) => void terminalOutputPreviewSize?: "small" | "medium" | "large" setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean @@ -541,10 +537,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, browserViewportSize: value })), setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })), setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })), - setTerminalOutputLineLimit: (value) => - setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), - setTerminalOutputCharacterLimit: (value) => - setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })), setTerminalOutputPreviewSize: (value) => setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })), setTerminalShellIntegrationTimeout: (value) => From af0f70a3701109ff2b3e0ac2ccf97ff6b64fb958 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 22:28:51 -0700 Subject: [PATCH 07/24] Delete claude-code.md --- claude-code.md | 51 -------------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 claude-code.md diff --git a/claude-code.md b/claude-code.md deleted file mode 100644 index 614210ebf1..0000000000 --- a/claude-code.md +++ /dev/null @@ -1,51 +0,0 @@ - { - "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 - } - }, From d4e0305fcd657fc97a5edbd0fcb691eef373baaa Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 22:29:07 -0700 Subject: [PATCH 08/24] Delete codex-extract-terminal-spawning-tool.md --- codex-extract-terminal-spawning-tool.md | 1206 ----------------------- 1 file changed, 1206 deletions(-) delete mode 100644 codex-extract-terminal-spawning-tool.md diff --git a/codex-extract-terminal-spawning-tool.md b/codex-extract-terminal-spawning-tool.md deleted file mode 100644 index aef70d584c..0000000000 --- a/codex-extract-terminal-spawning-tool.md +++ /dev/null @@ -1,1206 +0,0 @@ -# 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. From 22346f0224dd3e13a16fb4bfbcad2c11f1148d0a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 22:29:25 -0700 Subject: [PATCH 09/24] Delete Roo-EXTRACTION-terminal-shell-integration.md --- Roo-EXTRACTION-terminal-shell-integration.md | 409 ------------------- 1 file changed, 409 deletions(-) delete mode 100644 Roo-EXTRACTION-terminal-shell-integration.md diff --git a/Roo-EXTRACTION-terminal-shell-integration.md b/Roo-EXTRACTION-terminal-shell-integration.md deleted file mode 100644 index 4b44c2e230..0000000000 --- a/Roo-EXTRACTION-terminal-shell-integration.md +++ /dev/null @@ -1,409 +0,0 @@ -# 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 From cf7472945dccdca9e1f884c4ea3bbcd78de59000 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 11:05:38 -0700 Subject: [PATCH 10/24] feat: update OutputInterceptor to use 50/50 head/tail split like Codex - Replace single buffer with separate headBuffer and tailBuffer - Each buffer gets 50% of the preview budget - Head captures beginning of output, tail keeps rolling end - Middle content is dropped when output exceeds threshold - Preview shows: head + [omission indicator] + tail - Add tests for head/tail split behavior This approach ensures the LLM sees both: - The beginning (command startup, environment info, early errors) - The end (final results, exit codes, error summaries) --- .../terminal/OutputInterceptor.ts | 216 +++++++++++++++--- .../__tests__/OutputInterceptor.test.ts | 125 +++++++++- 2 files changed, 304 insertions(+), 37 deletions(-) diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts index 3ef2fcb08c..3978cdd145 100644 --- a/src/integrations/terminal/OutputInterceptor.ts +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -26,13 +26,14 @@ export interface OutputInterceptorOptions { * 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 + * The interceptor uses a **head/tail buffer** strategy (inspired by Codex): + * - 50% of the preview budget is allocated to the "head" (beginning of output) + * - 50% of the preview budget is allocated to the "tail" (end of output) + * - Middle content is dropped when output exceeds the preview threshold * - * 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. + * This approach ensures the LLM sees both: + * - The beginning (command startup, environment info, early errors) + * - The end (final results, exit codes, error summaries) * * @example * ```typescript @@ -50,17 +51,31 @@ export interface OutputInterceptorOptions { * * // Finalize and get the result * const result = interceptor.finalize(); - * // result.preview contains truncated output for display + * // result.preview contains head + [omitted] + tail for display * // result.artifactPath contains path to full output if truncated * ``` */ export class OutputInterceptor { - private buffer: string = "" + /** Buffer for the head (beginning) of output */ + private headBuffer: string = "" + /** Buffer for the tail (end) of output - rolling buffer that drops front when full */ + private tailBuffer: string = "" + /** Number of bytes currently in the head buffer */ + private headBytes: number = 0 + /** Number of bytes currently in the tail buffer */ + private tailBytes: number = 0 + /** Number of bytes omitted from the middle */ + private omittedBytes: number = 0 + private writeStream: fs.WriteStream | null = null private artifactPath: string private totalBytes: number = 0 private spilledToDisk: boolean = false private readonly previewBytes: number + /** Budget for the head buffer (50% of total preview) */ + private readonly headBudget: number + /** Budget for the tail buffer (50% of total preview) */ + private readonly tailBudget: number /** * Creates a new OutputInterceptor instance. @@ -69,15 +84,19 @@ export class OutputInterceptor { */ constructor(private readonly options: OutputInterceptorOptions) { this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize] + this.headBudget = Math.floor(this.previewBytes / 2) + this.tailBudget = this.previewBytes - this.headBudget 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. + * Output is first added to the head buffer until it's full (50% of preview budget). + * Subsequent output goes to a rolling tail buffer that keeps the most recent content. + * + * If the total output exceeds the preview threshold, the interceptor spills to disk + * for full output storage while maintaining head/tail buffers for the preview. * * @param chunk - The output string to write * @@ -91,11 +110,13 @@ export class OutputInterceptor { const chunkBytes = Buffer.byteLength(chunk, "utf8") this.totalBytes += chunkBytes - if (!this.spilledToDisk) { - this.buffer += chunk + // Always update the head/tail preview buffers + this.addToPreviewBuffers(chunk) - if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) { - this.spillToDisk() + // Handle disk spilling for full output preservation + if (!this.spilledToDisk) { + if (this.totalBytes > this.previewBytes) { + this.spillToDisk(chunk) } } else { // Already spilling - write directly to disk @@ -103,6 +124,127 @@ export class OutputInterceptor { } } + /** + * Add a chunk to the head/tail preview buffers using 50/50 split strategy. + * + * Fill head first until budget exhausted, then maintain a rolling tail buffer. + * + * @private + */ + private addToPreviewBuffers(chunk: string): void { + let remaining = chunk + let remainingBytes = Buffer.byteLength(chunk, "utf8") + + // First, fill the head buffer if there's room + if (this.headBytes < this.headBudget) { + const headRoom = this.headBudget - this.headBytes + if (remainingBytes <= headRoom) { + // Entire chunk fits in head + this.headBuffer += remaining + this.headBytes += remainingBytes + return + } + // Split: part goes to head, rest goes to tail + const headPortion = this.sliceByBytes(remaining, headRoom) + this.headBuffer += headPortion + this.headBytes += headRoom + remaining = remaining.slice(headPortion.length) + remainingBytes = Buffer.byteLength(remaining, "utf8") + } + + // Add remainder to tail buffer + this.addToTailBuffer(remaining, remainingBytes) + } + + /** + * Add content to the rolling tail buffer, dropping old content as needed. + * + * @private + */ + private addToTailBuffer(chunk: string, chunkBytes: number): void { + if (this.tailBudget === 0) { + this.omittedBytes += chunkBytes + return + } + + // If this single chunk is larger than the tail budget, keep only the last tailBudget bytes + if (chunkBytes >= this.tailBudget) { + const dropped = this.tailBytes + (chunkBytes - this.tailBudget) + this.omittedBytes += dropped + this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget) + this.tailBytes = this.tailBudget + return + } + + // Append to tail + this.tailBuffer += chunk + this.tailBytes += chunkBytes + + // Trim from front if over budget + this.trimTailToFit() + } + + /** + * Trim the tail buffer from the front to fit within the tail budget. + * + * @private + */ + private trimTailToFit(): void { + while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) { + const excess = this.tailBytes - this.tailBudget + // Remove characters from the front until we're under budget + // We need to be careful with multi-byte characters + let removed = 0 + let removeChars = 0 + while (removed < excess && removeChars < this.tailBuffer.length) { + const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8") + removed += charBytes + removeChars++ + } + this.omittedBytes += removed + this.tailBytes -= removed + this.tailBuffer = this.tailBuffer.slice(removeChars) + } + } + + /** + * Slice a string to get approximately the first N bytes (UTF-8). + * + * @private + */ + private sliceByBytes(str: string, maxBytes: number): string { + let bytes = 0 + let i = 0 + while (i < str.length && bytes < maxBytes) { + const charBytes = Buffer.byteLength(str[i], "utf8") + if (bytes + charBytes > maxBytes) { + break + } + bytes += charBytes + i++ + } + return str.slice(0, i) + } + + /** + * Slice a string to get approximately the last N bytes (UTF-8). + * + * @private + */ + private sliceByBytesFromEnd(str: string, maxBytes: number): string { + let bytes = 0 + let i = str.length - 1 + while (i >= 0 && bytes < maxBytes) { + const charBytes = Buffer.byteLength(str[i], "utf8") + if (bytes + charBytes > maxBytes) { + break + } + bytes += charBytes + i-- + } + return str.slice(i + 1) + } + /** * Spill buffered content to disk and switch to streaming mode. * @@ -112,7 +254,7 @@ export class OutputInterceptor { * * @private */ - private spillToDisk(): void { + private spillToDisk(currentChunk: string): void { // Ensure directory exists const dir = path.dirname(this.artifactPath) if (!fs.existsSync(dir)) { @@ -120,18 +262,31 @@ export class OutputInterceptor { } this.writeStream = fs.createWriteStream(this.artifactPath) - this.writeStream.write(this.buffer) - this.spilledToDisk = true + // Write the full head buffer + any tail content accumulated so far + // Note: We need to reconstruct full output seen so far + // The full content before this chunk is: totalBytes - currentChunkBytes + // But we've already been tracking head/tail, so we write head + omitted + tail + current + // Actually, we need to write the complete original content + // Since we're spilling on the chunk that pushes us over, we need to write everything + // that came before plus this chunk + + // Reconstruct: we have headBuffer (complete head) + whatever was in tail before trimming + // For simplicity, write head + tail + current chunk (the tail already has some data) + this.writeStream.write(this.headBuffer) + if (this.tailBuffer.length > 0) { + this.writeStream.write(this.tailBuffer) + } + // Don't write currentChunk here - it was already processed into head/tail buffers + // and will be written via the streaming path - // Keep only preview portion in memory - this.buffer = this.buffer.slice(0, this.previewBytes) + this.spilledToDisk = true } /** * 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) + * - A preview of the output (head + [omitted indicator] + tail) * - The total byte count of all output * - The path to the full output file (if truncated) * - A flag indicating whether the output was truncated @@ -154,8 +309,15 @@ export class OutputInterceptor { this.writeStream.end() } - // Prepare preview - const preview = this.buffer.slice(0, this.previewBytes) + // Prepare preview: head + [omission indicator] + tail + let preview: string + if (this.omittedBytes > 0) { + const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n` + preview = this.headBuffer + omissionIndicator + this.tailBuffer + } else { + // No truncation, just combine head and tail (or head alone if tail is empty) + preview = this.headBuffer + this.tailBuffer + } return { preview, @@ -168,13 +330,15 @@ export class OutputInterceptor { /** * 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 combined head + tail content for real-time UI updates. + * Note: Does not include the omission indicator to avoid flickering during streaming. * * @returns The current buffer content as a string */ getBufferForUI(): string { - return this.buffer + // For UI, return combined head + tail without omission indicator + // This provides a smoother streaming experience + return this.headBuffer + this.tailBuffer } /** diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index ecdb708da0..b8ca5a251d 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -94,7 +94,7 @@ describe("OutputInterceptor", () => { expect(mockWriteStream.write).toHaveBeenCalled() }) - it("should truncate preview after spilling to disk", () => { + it("should truncate preview after spilling to disk using head/tail split", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -112,7 +112,10 @@ describe("OutputInterceptor", () => { 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) + // Preview is head (1024) + omission indicator + tail (1024) + // The omission indicator adds some extra bytes + expect(result.preview).toContain("[...") + expect(result.preview).toContain("bytes omitted...]") }) it("should write subsequent chunks directly to disk after spilling", () => { @@ -230,20 +233,23 @@ describe("OutputInterceptor", () => { expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`)) }) - it("should write full output to artifact, not truncated", () => { + it("should write head and tail buffers to artifact when spilling", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", + previewSize: "small", // 2KB = 2048 bytes, so head=1024, tail=1024 }) const fullOutput = "x".repeat(5000) interceptor.write(fullOutput) - // The write stream should receive the full buffer content - expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput) + // The write stream should receive the head buffer content first + // (spillToDisk writes head + tail that existed at spill time) + expect(mockWriteStream.write).toHaveBeenCalled() + // Verify that we're writing to disk + expect(interceptor.hasSpilledToDisk()).toBe(true) }) it("should get artifact path from getArtifactPath() method", () => { @@ -282,13 +288,13 @@ describe("OutputInterceptor", () => { expect(result.truncated).toBe(false) }) - it("should return PersistedCommandOutput for large commands", () => { + it("should return PersistedCommandOutput for large commands with head/tail preview", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", + previewSize: "small", // 2KB = 2048, head=1024, tail=1024 }) const largeOutput = "x".repeat(5000) @@ -299,7 +305,9 @@ describe("OutputInterceptor", () => { 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) + // Preview should contain head + omission indicator + tail + expect(result.preview).toContain("[...") + expect(result.preview).toContain("bytes omitted...]") }) it("should close write stream when finalizing", () => { @@ -404,13 +412,13 @@ describe("OutputInterceptor", () => { expect(interceptor.getBufferForUI()).toBe(output) }) - it("should return truncated buffer after spilling to disk", () => { + it("should return head + tail buffer after spilling to disk", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", command: "test", storageDir, - previewSize: "small", + previewSize: "small", // 2KB = 2048, head=1024, tail=1024 }) // Trigger spill @@ -418,7 +426,102 @@ describe("OutputInterceptor", () => { interceptor.write(largeOutput) const buffer = interceptor.getBufferForUI() + // Buffer for UI is head + tail (no omission indicator for smooth streaming) expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048) }) }) + + describe("Head/Tail split behavior", () => { + it("should preserve first 50% and last 50% of output", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + }) + + // Create identifiable head and tail content + const headContent = "HEAD".repeat(300) // 1200 bytes + const middleContent = "M".repeat(3000) // 3000 bytes (will be omitted) + const tailContent = "TAIL".repeat(300) // 1200 bytes + + interceptor.write(headContent) + interceptor.write(middleContent) + interceptor.write(tailContent) + + const result = interceptor.finalize() + + // Should start with HEAD content (first 1024 bytes of head budget) + expect(result.preview.startsWith("HEAD")).toBe(true) + // Should end with TAIL content (last 1024 bytes) + expect(result.preview.endsWith("TAIL")).toBe(true) + // Should have omission indicator + expect(result.preview).toContain("[...") + expect(result.preview).toContain("bytes omitted...]") + }) + + it("should not add omission indicator when output fits in budget", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", // 2KB + }) + + const smallOutput = "Hello World\n" + interceptor.write(smallOutput) + + const result = interceptor.finalize() + + // No omission indicator for small output + expect(result.preview).toBe(smallOutput) + expect(result.preview).not.toContain("[...") + }) + + it("should handle output that exactly fills head budget", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", // 2KB = 2048, head=1024 + }) + + // Write exactly 1024 bytes (head budget) + const exactHeadContent = "x".repeat(1024) + interceptor.write(exactHeadContent) + + const result = interceptor.finalize() + + // Should fit entirely in head, no truncation + expect(result.preview).toBe(exactHeadContent) + expect(result.truncated).toBe(false) + }) + + it("should split single large chunk across head and tail", () => { + const interceptor = new OutputInterceptor({ + executionId: "12345", + taskId: "task-1", + command: "test", + storageDir, + previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + }) + + // Write a single chunk larger than preview budget + // First 1024 chars go to head, last 1024 chars go to tail + const content = "A".repeat(1024) + "B".repeat(2000) + "C".repeat(1024) + interceptor.write(content) + + const result = interceptor.finalize() + + // Head should have A's + expect(result.preview.startsWith("A")).toBe(true) + // Tail should have C's + expect(result.preview.endsWith("C")).toBe(true) + // Should have omission indicator + expect(result.preview).toContain("[...") + }) + }) }) From 2dcd3a187faef301bd1d4a9e98c977b4912bdc97 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 11:56:06 -0700 Subject: [PATCH 11/24] fix: ensure lossless artifact storage and strict mode compatibility - OutputInterceptor: Buffer ALL chunks before spilling to disk to preserve full content losslessly. Previously, the rolling tail buffer could drop middle content before the spill decision was made. - read_command_output schema: Include all properties in 'required' array for OpenAI strict mode compliance. With strict: true, all properties must be listed in required (optional ones use null union types). --- .../tools/native-tools/read_command_output.ts | 4 +- .../terminal/OutputInterceptor.ts | 37 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) 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 b163b46c56..5bd8bc4575 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,9 @@ export default { description: LIMIT_DESCRIPTION, }, }, - required: ["artifact_id"], + // With strict: true, ALL properties must be listed in required. + // Optional params use union type with null (e.g., ["string", "null"]). + required: ["artifact_id", "search", "offset", "limit"], additionalProperties: false, }, }, diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts index 3978cdd145..0910697f83 100644 --- a/src/integrations/terminal/OutputInterceptor.ts +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -67,6 +67,13 @@ export class OutputInterceptor { /** Number of bytes omitted from the middle */ private omittedBytes: number = 0 + /** + * Pending chunks accumulated before spilling to disk. + * These contain ALL content (lossless) until we decide to spill. + * Once spilled, this array is cleared and subsequent writes go directly to disk. + */ + private pendingChunks: string[] = [] + private writeStream: fs.WriteStream | null = null private artifactPath: string private totalBytes: number = 0 @@ -115,8 +122,11 @@ export class OutputInterceptor { // Handle disk spilling for full output preservation if (!this.spilledToDisk) { + // Accumulate ALL chunks for lossless disk storage + this.pendingChunks.push(chunk) + if (this.totalBytes > this.previewBytes) { - this.spillToDisk(chunk) + this.spillToDisk() } } else { // Already spilling - write directly to disk @@ -254,7 +264,7 @@ export class OutputInterceptor { * * @private */ - private spillToDisk(currentChunk: string): void { + private spillToDisk(): void { // Ensure directory exists const dir = path.dirname(this.artifactPath) if (!fs.existsSync(dir)) { @@ -262,22 +272,15 @@ export class OutputInterceptor { } this.writeStream = fs.createWriteStream(this.artifactPath) - // Write the full head buffer + any tail content accumulated so far - // Note: We need to reconstruct full output seen so far - // The full content before this chunk is: totalBytes - currentChunkBytes - // But we've already been tracking head/tail, so we write head + omitted + tail + current - // Actually, we need to write the complete original content - // Since we're spilling on the chunk that pushes us over, we need to write everything - // that came before plus this chunk - - // Reconstruct: we have headBuffer (complete head) + whatever was in tail before trimming - // For simplicity, write head + tail + current chunk (the tail already has some data) - this.writeStream.write(this.headBuffer) - if (this.tailBuffer.length > 0) { - this.writeStream.write(this.tailBuffer) + + // Write ALL pending chunks to disk for lossless storage. + // This ensures no content is lost, even if the preview buffers have dropped middle content. + for (const chunk of this.pendingChunks) { + this.writeStream.write(chunk) } - // Don't write currentChunk here - it was already processed into head/tail buffers - // and will be written via the streaming path + + // Clear pending chunks to free memory - subsequent writes go directly to disk + this.pendingChunks = [] this.spilledToDisk = true } From f5faf0ff93cffbb5fb2bbef143c1f7b9a31e9f94 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 12:05:36 -0700 Subject: [PATCH 12/24] fix: use chunked streaming for search to avoid memory blowup Replace fs.readFile with chunked streaming in searchInArtifact() to keep memory usage bounded for large command outputs. Instead of loading the entire file into memory, reads in 64KB chunks and processes lines as they are encountered. This addresses the concern that loading 100MB+ build logs into memory defeats the purpose of the persisted output feature. --- src/core/tools/ReadCommandOutputTool.ts | 74 +++++++++++++++---- .../__tests__/ReadCommandOutputTool.test.ts | 44 ++++++----- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index e7aaf91135..d1893c380b 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -268,10 +268,14 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { } /** - * Search artifact content for lines matching a pattern. + * Search artifact content for lines matching a pattern using chunked streaming. * - * Performs grep-like searching through the artifact file. The pattern - * is treated as a case-insensitive regex. If the pattern is invalid + * Performs grep-like searching through the artifact file using bounded memory. + * Instead of loading the entire file into memory, this reads in fixed-size chunks + * and processes lines as they are encountered. This keeps memory usage predictable + * even for very large command outputs (e.g., 100MB+ build logs). + * + * 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. @@ -289,9 +293,7 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { 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") + const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory // Create case-insensitive regex for search let regex: RegExp @@ -302,23 +304,65 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { regex = new RegExp(this.escapeRegExp(pattern), "i") } - // Find matching lines with their line numbers + const fileHandle = await fs.open(artifactPath, "r") const matches: Array<{ lineNumber: number; content: string }> = [] let totalMatchBytes = 0 + let lineNumber = 0 + let partialLine = "" // Holds incomplete line from previous chunk + let bytesRead = 0 + let hitLimit = false - for (let i = 0; i < lines.length; i++) { - if (regex.test(lines[i])) { - const lineContent = lines[i] - const lineBytes = Buffer.byteLength(lineContent, "utf8") + try { + while (bytesRead < totalSize && !hitLimit) { + const chunkSize = Math.min(CHUNK_SIZE, totalSize - bytesRead) + const buffer = Buffer.alloc(chunkSize) + const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead) - // Stop if we've exceeded the byte limit - if (totalMatchBytes + lineBytes > limit) { + if (result.bytesRead === 0) { break } - matches.push({ lineNumber: i + 1, content: lineContent }) - totalMatchBytes += lineBytes + const chunk = buffer.slice(0, result.bytesRead).toString("utf8") + bytesRead += result.bytesRead + + // Combine with partial line from previous chunk + const combined = partialLine + chunk + const lines = combined.split("\n") + + // Last element may be incomplete (no trailing newline), save for next iteration + partialLine = lines.pop() ?? "" + + // Process complete lines + for (const line of lines) { + lineNumber++ + + if (regex.test(line)) { + const lineBytes = Buffer.byteLength(line, "utf8") + + // Stop if we've exceeded the byte limit + if (totalMatchBytes + lineBytes > limit) { + hitLimit = true + break + } + + matches.push({ lineNumber, content: line }) + totalMatchBytes += lineBytes + } + } + } + + // Process any remaining partial line at end of file + if (!hitLimit && partialLine.length > 0) { + lineNumber++ + if (regex.test(partialLine)) { + const lineBytes = Buffer.byteLength(partialLine, "utf8") + if (totalMatchBytes + lineBytes <= limit) { + matches.push({ lineNumber, content: partialLine }) + } + } } + } finally { + await fileHandle.close() } const artifactId = path.basename(artifactPath) diff --git a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts index a2e3147cc6..beec4094a1 100644 --- a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts +++ b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts @@ -276,13 +276,31 @@ describe("ReadCommandOutputTool", () => { }) describe("Search filtering", () => { + // Helper to setup file handle mock for search (which now uses streaming) + const setupSearchMock = (content: string) => { + const buffer = Buffer.from(content) + const fileSize = buffer.length + vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any) + + // Mock streaming read - return entire content in one chunk (simulates small file) + mockFileHandle.read.mockImplementation( + (buf: Buffer, bufOffset: number, length: number, position: number | null) => { + const pos = position ?? 0 + if (pos >= fileSize) { + return Promise.resolve({ bytesRead: 0 }) + } + const bytesToRead = Math.min(length, fileSize - pos) + buffer.copy(buf, 0, pos, pos + bytesToRead) + return Promise.resolve({ bytesRead: bytesToRead }) + }, + ) + } + 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) + setupSearchMock(content) await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks) @@ -296,10 +314,8 @@ describe("ReadCommandOutputTool", () => { 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) + setupSearchMock(content) await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks) @@ -311,10 +327,8 @@ describe("ReadCommandOutputTool", () => { 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) + setupSearchMock(content) await tool.execute({ artifact_id: artifactId, search: "Error" }, mockTask, mockCallbacks) @@ -327,10 +341,8 @@ describe("ReadCommandOutputTool", () => { 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) + setupSearchMock(content) await tool.execute({ artifact_id: artifactId, search: "NOTFOUND" }, mockTask, mockCallbacks) @@ -341,10 +353,8 @@ describe("ReadCommandOutputTool", () => { 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) + setupSearchMock(content) await tool.execute({ artifact_id: artifactId, search: "test\\d+" }, mockTask, mockCallbacks) @@ -358,10 +368,8 @@ describe("ReadCommandOutputTool", () => { 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) + setupSearchMock(content) // Invalid regex but valid as literal string await tool.execute({ artifact_id: artifactId, search: "[" }, mockTask, mockCallbacks) From 6bc2a14d6156d16eaf3a003ec27c47d485e83feb Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 12:51:40 -0700 Subject: [PATCH 13/24] fix: address review feedback for lossless terminal output - OutputInterceptor.finalize() now awaits stream flush before returning This ensures artifact files are fully written before the artifact_id is advertised to the LLM, preventing partial reads. - Remove strict mode from read_command_output native tool schema With strict: true, OpenAI requires all params in 'required', forcing the LLM to provide explicit null values for optional params. This created verbose tool calls. Now optional params can be omitted entirely. - Update tests to handle async finalize() method --- .../tools/native-tools/read_command_output.ts | 16 ++++--- src/core/tools/ExecuteCommandTool.ts | 4 +- .../terminal/OutputInterceptor.ts | 17 +++++-- .../__tests__/OutputInterceptor.test.ts | 48 ++++++++++--------- 4 files changed, 49 insertions(+), 36 deletions(-) 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 5bd8bc4575..af5148cd10 100644 --- a/src/core/prompts/tools/native-tools/read_command_output.ts +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -49,7 +49,11 @@ export default { function: { name: "read_command_output", description: READ_COMMAND_OUTPUT_DESCRIPTION, - strict: true, + // Note: strict mode is intentionally disabled for this tool. + // With strict: true, OpenAI requires ALL properties to be in the 'required' array, + // which forces the LLM to always provide explicit values (even null) for optional params. + // This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can + // omit optional parameters entirely, making the tool easier to use. parameters: { type: "object", properties: { @@ -58,21 +62,19 @@ export default { description: ARTIFACT_ID_DESCRIPTION, }, search: { - type: ["string", "null"], + type: "string", description: SEARCH_DESCRIPTION, }, offset: { - type: ["number", "null"], + type: "number", description: OFFSET_DESCRIPTION, }, limit: { - type: ["number", "null"], + type: "number", description: LIMIT_DESCRIPTION, }, }, - // With strict: true, ALL properties must be listed in required. - // Optional params use union type with null (e.g., ["string", "null"]). - 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 26f01fc736..bc16886ded 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -263,10 +263,10 @@ export async function executeCommandInTerminal( if (interceptor) { persistedResult = await interceptor.finalize() } - + // Continue using compressed output for UI display result = Terminal.compressTerminalOutput(output ?? "") - + task.say("command_output", result) completed = true } finally { diff --git a/src/integrations/terminal/OutputInterceptor.ts b/src/integrations/terminal/OutputInterceptor.ts index 0910697f83..d1725c6426 100644 --- a/src/integrations/terminal/OutputInterceptor.ts +++ b/src/integrations/terminal/OutputInterceptor.ts @@ -288,7 +288,10 @@ export class OutputInterceptor { /** * Finalize the interceptor and return the persisted output result. * - * Closes any open file streams and returns a summary object containing: + * Closes any open file streams and waits for them to fully flush before returning. + * This ensures the artifact file is completely written and ready for reading. + * + * Returns a summary object containing: * - A preview of the output (head + [omitted indicator] + tail) * - The total byte count of all output * - The path to the full output file (if truncated) @@ -298,7 +301,7 @@ export class OutputInterceptor { * * @example * ```typescript - * const result = interceptor.finalize(); + * const result = await interceptor.finalize(); * console.log(`Preview: ${result.preview}`); * console.log(`Total bytes: ${result.totalBytes}`); * if (result.truncated) { @@ -306,10 +309,14 @@ export class OutputInterceptor { * } * ``` */ - finalize(): PersistedCommandOutput { - // Close write stream if open + async finalize(): Promise { + // Close write stream if open and wait for it to fully flush. + // This ensures the artifact is completely written before we advertise the artifact_id. if (this.writeStream) { - this.writeStream.end() + await new Promise((resolve, reject) => { + this.writeStream!.end(() => resolve()) + this.writeStream!.on("error", reject) + }) } // Prepare preview: head + [omission indicator] + tail diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index b8ca5a251d..c91253d6ed 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -34,10 +34,14 @@ describe("OutputInterceptor", () => { storageDir = path.normalize("/tmp/test-storage") - // Setup mock write stream + // Setup mock write stream with callback support for end() mockWriteStream = { write: vi.fn(), - end: vi.fn(), + end: vi.fn((callback?: () => void) => { + // Immediately call the callback to simulate stream flush completing + if (callback) callback() + }), + on: vi.fn(), } vi.mocked(fs.existsSync).mockReturnValue(true) @@ -49,7 +53,7 @@ describe("OutputInterceptor", () => { }) describe("Buffering behavior", () => { - it("should keep small output in memory without spilling to disk", () => { + it("should keep small output in memory without spilling to disk", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -64,7 +68,7 @@ describe("OutputInterceptor", () => { expect(interceptor.hasSpilledToDisk()).toBe(false) expect(fs.createWriteStream).not.toHaveBeenCalled() - const result = interceptor.finalize() + const result = await interceptor.finalize() expect(result.preview).toBe(smallOutput) expect(result.truncated).toBe(false) expect(result.artifactPath).toBe(null) @@ -94,7 +98,7 @@ describe("OutputInterceptor", () => { expect(mockWriteStream.write).toHaveBeenCalled() }) - it("should truncate preview after spilling to disk using head/tail split", () => { + it("should truncate preview after spilling to disk using head/tail split", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -109,7 +113,7 @@ describe("OutputInterceptor", () => { expect(interceptor.hasSpilledToDisk()).toBe(true) - const result = interceptor.finalize() + const result = await interceptor.finalize() expect(result.truncated).toBe(true) expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt")) // Preview is head (1024) + omission indicator + tail (1024) @@ -268,7 +272,7 @@ describe("OutputInterceptor", () => { }) describe("finalize() method", () => { - it("should return preview output for small commands", () => { + it("should return preview output for small commands", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -280,7 +284,7 @@ describe("OutputInterceptor", () => { const output = "Hello World\n" interceptor.write(output) - const result = interceptor.finalize() + const result = await interceptor.finalize() expect(result.preview).toBe(output) expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8")) @@ -288,7 +292,7 @@ describe("OutputInterceptor", () => { expect(result.truncated).toBe(false) }) - it("should return PersistedCommandOutput for large commands with head/tail preview", () => { + it("should return PersistedCommandOutput for large commands with head/tail preview", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -300,7 +304,7 @@ describe("OutputInterceptor", () => { const largeOutput = "x".repeat(5000) interceptor.write(largeOutput) - const result = interceptor.finalize() + const result = await interceptor.finalize() expect(result.truncated).toBe(true) expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt")) @@ -310,7 +314,7 @@ describe("OutputInterceptor", () => { expect(result.preview).toContain("bytes omitted...]") }) - it("should close write stream when finalizing", () => { + it("should close write stream when finalizing", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -321,12 +325,12 @@ describe("OutputInterceptor", () => { // Trigger spill interceptor.write("x".repeat(3000)) - interceptor.finalize() + await interceptor.finalize() expect(mockWriteStream.end).toHaveBeenCalled() }) - it("should include correct metadata (artifactId, size, truncated flag)", () => { + it("should include correct metadata (artifactId, size, truncated flag)", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -338,7 +342,7 @@ describe("OutputInterceptor", () => { const output = "x".repeat(5000) interceptor.write(output) - const result = interceptor.finalize() + const result = await interceptor.finalize() expect(result).toHaveProperty("preview") expect(result).toHaveProperty("totalBytes", 5000) @@ -432,7 +436,7 @@ describe("OutputInterceptor", () => { }) describe("Head/Tail split behavior", () => { - it("should preserve first 50% and last 50% of output", () => { + it("should preserve first 50% and last 50% of output", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -450,7 +454,7 @@ describe("OutputInterceptor", () => { interceptor.write(middleContent) interceptor.write(tailContent) - const result = interceptor.finalize() + const result = await interceptor.finalize() // Should start with HEAD content (first 1024 bytes of head budget) expect(result.preview.startsWith("HEAD")).toBe(true) @@ -461,7 +465,7 @@ describe("OutputInterceptor", () => { expect(result.preview).toContain("bytes omitted...]") }) - it("should not add omission indicator when output fits in budget", () => { + it("should not add omission indicator when output fits in budget", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -473,14 +477,14 @@ describe("OutputInterceptor", () => { const smallOutput = "Hello World\n" interceptor.write(smallOutput) - const result = interceptor.finalize() + const result = await interceptor.finalize() // No omission indicator for small output expect(result.preview).toBe(smallOutput) expect(result.preview).not.toContain("[...") }) - it("should handle output that exactly fills head budget", () => { + it("should handle output that exactly fills head budget", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -493,14 +497,14 @@ describe("OutputInterceptor", () => { const exactHeadContent = "x".repeat(1024) interceptor.write(exactHeadContent) - const result = interceptor.finalize() + const result = await interceptor.finalize() // Should fit entirely in head, no truncation expect(result.preview).toBe(exactHeadContent) expect(result.truncated).toBe(false) }) - it("should split single large chunk across head and tail", () => { + it("should split single large chunk across head and tail", async () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -514,7 +518,7 @@ describe("OutputInterceptor", () => { const content = "A".repeat(1024) + "B".repeat(2000) + "C".repeat(1024) interceptor.write(content) - const result = interceptor.finalize() + const result = await interceptor.finalize() // Head should have A's expect(result.preview.startsWith("A")).toBe(true) From 5711f1f32bddb2653547aa6dbbd30e9012e21d89 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 14:14:14 -0700 Subject: [PATCH 14/24] fix: ensure onCompleted callback finishes before using persistedResult - Update RooTerminalCallbacks.onCompleted type to allow async callbacks (void | Promise) - Track onCompleted completion with a promise and await it before using persistedResult - This fixes a race condition where exitDetails could be set before the async finalize() completes - Fix test callback to not return assignment value --- src/core/tools/ExecuteCommandTool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index bc16886ded..f94f70ebeb 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -210,6 +210,7 @@ export async function executeCommandInTerminal( // 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 (100KB limit). const maxAccumulatedOutputSize = 100_000 + // Track when onCompleted callback finishes to avoid race condition. // The callback is async but Terminal/ExecaTerminal don't await it, so we track completion // explicitly to ensure persistedResult is set before we use it. From 70787f7a4a7b9e7933d997cbafc40e8e31950bad Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 17:28:00 -0700 Subject: [PATCH 15/24] feat: align output limits with terminal integration spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update preview sizes: 2KB/4KB/8KB → 5KB/10KB/20KB (default 10KB) - Update read_command_output default limit: 32KB → 40KB - Match spec's MODEL_TRUNCATION_BYTES (10KB) for preview - Match spec's DEFAULT_MAX_OUTPUT_TOKENS (10000 tokens × 4 bytes = 40KB) for retrieval - Update all related tests and documentation --- packages/types/src/global-settings.ts | 14 +-- .../tools/native-tools/read_command_output.ts | 8 +- src/core/tools/ReadCommandOutputTool.ts | 4 +- .../__tests__/ReadCommandOutputTool.test.ts | 6 +- .../__tests__/OutputInterceptor.test.ts | 103 +++++++++--------- 5 files changed, 68 insertions(+), 67 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f07134df3a..d57ec616ff 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -29,9 +29,9 @@ export const DEFAULT_WRITE_DELAY_MS = 1000 * 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 + * - `small`: 5KB preview - Best for long-running commands with verbose output + * - `medium`: 10KB preview - Balanced default for most use cases + * - `large`: 20KB 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 @@ -46,14 +46,14 @@ export type TerminalOutputPreviewSize = "small" | "medium" | "large" * 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 + small: 5 * 1024, // 5KB + medium: 10 * 1024, // 10KB + large: 20 * 1024, // 20KB } /** * Default terminal output preview size. - * The "medium" (4KB) setting provides a good balance between immediate + * The "medium" (10KB) 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" 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 af5148cd10..007915b005 100644 --- a/src/core/prompts/tools/native-tools/read_command_output.ts +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -22,13 +22,13 @@ 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. +- limit: (optional) Maximum bytes to return. Default: 40KB. 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: Reading with pagination (after first 40KB) +{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 } Example: Searching for errors in build output { "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" } @@ -42,7 +42,7 @@ const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (c const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)` -const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)` +const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 40KB)` export default { type: "function", diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index d1893c380b..9ae4a377ef 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -6,8 +6,8 @@ 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 +/** Default byte limit for read operations (40KB) */ +const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit /** * Parameters accepted by the read_command_output tool. diff --git a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts index beec4094a1..11f85e67c0 100644 --- a/src/core/tools/__tests__/ReadCommandOutputTool.test.ts +++ b/src/core/tools/__tests__/ReadCommandOutputTool.test.ts @@ -159,16 +159,16 @@ describe("ReadCommandOutputTool", () => { }) describe("Pagination (offset/limit)", () => { - it("should use default limit of 32KB", async () => { + it("should use default limit of 40KB", 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) + // Mock read to return only up to default limit (40KB) mockFileHandle.read.mockImplementation((buf: Buffer) => { - const defaultLimit = 32 * 1024 + const defaultLimit = 40 * 1024 const bytesToRead = Math.min(buf.length, defaultLimit) buf.write(largeContent.slice(0, bytesToRead)) return Promise.resolve({ bytesRead: bytesToRead }) diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index c91253d6ed..ed308cff13 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -59,7 +59,7 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "echo test", storageDir, - previewSize: "small", // 2KB + previewSize: "small", // 5KB }) const smallOutput = "Hello World\n" @@ -81,18 +81,18 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "echo test", storageDir, - previewSize: "small", // 2KB = 2048 bytes + previewSize: "small", // 5KB = 5120 bytes }) - // Write enough data to exceed 2KB threshold - const chunk = "x".repeat(1024) // 1KB chunk - interceptor.write(chunk) // 1KB - should stay in memory + // Write enough data to exceed 5KB threshold + const chunk = "x".repeat(2 * 1024) // 2KB chunk + interceptor.write(chunk) // 2KB - should stay in memory expect(interceptor.hasSpilledToDisk()).toBe(false) - interceptor.write(chunk) // 2KB - should stay in memory + interceptor.write(chunk) // 4KB - should stay in memory expect(interceptor.hasSpilledToDisk()).toBe(false) - interceptor.write(chunk) // 3KB - should trigger spill + interceptor.write(chunk) // 6KB - should trigger spill expect(interceptor.hasSpilledToDisk()).toBe(true) expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt")) expect(mockWriteStream.write).toHaveBeenCalled() @@ -104,11 +104,11 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "echo test", storageDir, - previewSize: "small", // 2KB + previewSize: "small", // 5KB }) // Write data that exceeds threshold - const chunk = "x".repeat(3000) + const chunk = "x".repeat(6000) interceptor.write(chunk) expect(interceptor.hasSpilledToDisk()).toBe(true) @@ -131,8 +131,8 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - // Trigger spill - const largeChunk = "x".repeat(3000) + // Trigger spill (must exceed 5KB = 5120 bytes) + const largeChunk = "x".repeat(6000) interceptor.write(largeChunk) expect(interceptor.hasSpilledToDisk()).toBe(true) @@ -148,7 +148,7 @@ describe("OutputInterceptor", () => { }) describe("Threshold settings", () => { - it("should handle small (2KB) threshold correctly", () => { + it("should handle small (5KB) threshold correctly", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -157,16 +157,16 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - // Write exactly 2KB - interceptor.write("x".repeat(2048)) + // Write exactly 5KB + interceptor.write("x".repeat(5 * 1024)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 2KB + // Write more to exceed 5KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) - it("should handle medium (4KB) threshold correctly", () => { + it("should handle medium (10KB) threshold correctly", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -175,16 +175,16 @@ describe("OutputInterceptor", () => { previewSize: "medium", }) - // Write exactly 4KB - interceptor.write("x".repeat(4096)) + // Write exactly 10KB + interceptor.write("x".repeat(10 * 1024)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 4KB + // Write more to exceed 10KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) - it("should handle large (8KB) threshold correctly", () => { + it("should handle large (20KB) threshold correctly", () => { const interceptor = new OutputInterceptor({ executionId: "12345", taskId: "task-1", @@ -193,11 +193,11 @@ describe("OutputInterceptor", () => { previewSize: "large", }) - // Write exactly 8KB - interceptor.write("x".repeat(8192)) + // Write exactly 20KB + interceptor.write("x".repeat(20 * 1024)) expect(interceptor.hasSpilledToDisk()).toBe(false) - // Write more to exceed 8KB + // Write more to exceed 20KB interceptor.write("x") expect(interceptor.hasSpilledToDisk()).toBe(true) }) @@ -215,8 +215,8 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - // Trigger spill - interceptor.write("x".repeat(3000)) + // Trigger spill (must exceed 5KB = 5120 bytes) + interceptor.write("x".repeat(6000)) expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true }) }) @@ -231,8 +231,8 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - // Trigger spill - interceptor.write("x".repeat(3000)) + // Trigger spill (must exceed 5KB = 5120 bytes) + interceptor.write("x".repeat(6000)) expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`)) }) @@ -243,10 +243,10 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048 bytes, so head=1024, tail=1024 + previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560 }) - const fullOutput = "x".repeat(5000) + const fullOutput = "x".repeat(10000) interceptor.write(fullOutput) // The write stream should receive the head buffer content first @@ -298,10 +298,10 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + previewSize: "small", // 5KB = 5120, head=2560, tail=2560 }) - const largeOutput = "x".repeat(5000) + const largeOutput = "x".repeat(10000) interceptor.write(largeOutput) const result = await interceptor.finalize() @@ -323,8 +323,8 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - // Trigger spill - interceptor.write("x".repeat(3000)) + // Trigger spill (must exceed 5KB = 5120 bytes) + interceptor.write("x".repeat(6000)) await interceptor.finalize() expect(mockWriteStream.end).toHaveBeenCalled() @@ -339,13 +339,14 @@ describe("OutputInterceptor", () => { previewSize: "small", }) - const output = "x".repeat(5000) + // Must exceed 5KB = 5120 bytes to trigger truncation + const output = "x".repeat(6000) interceptor.write(output) const result = await interceptor.finalize() expect(result).toHaveProperty("preview") - expect(result).toHaveProperty("totalBytes", 5000) + expect(result).toHaveProperty("totalBytes", 6000) expect(result).toHaveProperty("artifactPath") expect(result).toHaveProperty("truncated", true) expect(result.artifactPath).toMatch(/cmd-12345\.txt$/) @@ -422,16 +423,16 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + previewSize: "small", // 5KB = 5120, head=2560, tail=2560 }) // Trigger spill - const largeOutput = "x".repeat(5000) + const largeOutput = "x".repeat(10000) interceptor.write(largeOutput) const buffer = interceptor.getBufferForUI() // Buffer for UI is head + tail (no omission indicator for smooth streaming) - expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048) + expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120) }) }) @@ -442,13 +443,13 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + previewSize: "small", // 5KB = 5120, head=2560, tail=2560 }) // Create identifiable head and tail content - const headContent = "HEAD".repeat(300) // 1200 bytes - const middleContent = "M".repeat(3000) // 3000 bytes (will be omitted) - const tailContent = "TAIL".repeat(300) // 1200 bytes + const headContent = "HEAD".repeat(750) // 3000 bytes + const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted) + const tailContent = "TAIL".repeat(750) // 3000 bytes interceptor.write(headContent) interceptor.write(middleContent) @@ -456,9 +457,9 @@ describe("OutputInterceptor", () => { const result = await interceptor.finalize() - // Should start with HEAD content (first 1024 bytes of head budget) + // Should start with HEAD content (first 2560 bytes of head budget) expect(result.preview.startsWith("HEAD")).toBe(true) - // Should end with TAIL content (last 1024 bytes) + // Should end with TAIL content (last 2560 bytes) expect(result.preview.endsWith("TAIL")).toBe(true) // Should have omission indicator expect(result.preview).toContain("[...") @@ -471,7 +472,7 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB + previewSize: "small", // 5KB }) const smallOutput = "Hello World\n" @@ -490,11 +491,11 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048, head=1024 + previewSize: "small", // 5KB = 5120, head=2560 }) - // Write exactly 1024 bytes (head budget) - const exactHeadContent = "x".repeat(1024) + // Write exactly 2560 bytes (head budget) + const exactHeadContent = "x".repeat(2560) interceptor.write(exactHeadContent) const result = await interceptor.finalize() @@ -510,12 +511,12 @@ describe("OutputInterceptor", () => { taskId: "task-1", command: "test", storageDir, - previewSize: "small", // 2KB = 2048, head=1024, tail=1024 + previewSize: "small", // 5KB = 5120, head=2560, tail=2560 }) // Write a single chunk larger than preview budget - // First 1024 chars go to head, last 1024 chars go to tail - const content = "A".repeat(1024) + "B".repeat(2000) + "C".repeat(1024) + // First 2560 chars go to head, last 2560 chars go to tail + const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560) interceptor.write(content) const result = await interceptor.finalize() From f15bea2c59d9cffd3807d12899da5693d272b012 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 17:42:57 -0700 Subject: [PATCH 16/24] feat: update i18n labels for new preview sizes (5KB/10KB/20KB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all 18 locale files with new preview size labels: - small: 2KB → 5KB - medium: 4KB → 10KB - large: 8KB → 20KB --- webview-ui/src/i18n/locales/ca/settings.json | 6 +++--- webview-ui/src/i18n/locales/de/settings.json | 6 +++--- webview-ui/src/i18n/locales/en/settings.json | 6 +++--- webview-ui/src/i18n/locales/es/settings.json | 6 +++--- webview-ui/src/i18n/locales/fr/settings.json | 6 +++--- webview-ui/src/i18n/locales/hi/settings.json | 6 +++--- webview-ui/src/i18n/locales/id/settings.json | 6 +++--- webview-ui/src/i18n/locales/it/settings.json | 6 +++--- webview-ui/src/i18n/locales/ja/settings.json | 6 +++--- webview-ui/src/i18n/locales/ko/settings.json | 6 +++--- webview-ui/src/i18n/locales/nl/settings.json | 6 +++--- webview-ui/src/i18n/locales/pl/settings.json | 6 +++--- webview-ui/src/i18n/locales/pt-BR/settings.json | 6 +++--- webview-ui/src/i18n/locales/ru/settings.json | 6 +++--- webview-ui/src/i18n/locales/tr/settings.json | 6 +++--- webview-ui/src/i18n/locales/vi/settings.json | 6 +++--- webview-ui/src/i18n/locales/zh-CN/settings.json | 6 +++--- webview-ui/src/i18n/locales/zh-TW/settings.json | 6 +++--- 18 files changed, 54 insertions(+), 54 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 051cae86df..dfe769e6ea 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -728,9 +728,9 @@ "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)" + "small": "Petita (5KB)", + "medium": "Mitjana (10KB)", + "large": "Gran (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 5ab8155826..c49fac0f3b 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -728,9 +728,9 @@ "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)" + "small": "Klein (5KB)", + "medium": "Mittel (10KB)", + "large": "Groß (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index f53aa48a1f..63f4056d66 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -737,9 +737,9 @@ "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)" + "small": "Small (5KB)", + "medium": "Medium (10KB)", + "large": "Large (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 6651d3465c..7115da6795 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -728,9 +728,9 @@ "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)" + "small": "Pequeño (5KB)", + "medium": "Mediano (10KB)", + "large": "Grande (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index fbe269eeb3..8cdbc1edb4 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -728,9 +728,9 @@ "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)" + "small": "Petite (5KB)", + "medium": "Moyenne (10KB)", + "large": "Grande (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index cdee2252c7..3f7e0dfa9a 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -729,9 +729,9 @@ "label": "कमांड आउटपुट पूर्वावलोकन आकार", "description": "नियंत्रित करता है कि Roo कितना कमांड आउटपुट सीधे देखता है। पूर्ण आउटपुट हमेशा सहेजा जाता है और आवश्यकता पड़ने पर सुलभ होता है।", "options": { - "small": "छोटा (2KB)", - "medium": "मध्यम (4KB)", - "large": "बड़ा (8KB)" + "small": "छोटा (5KB)", + "medium": "मध्यम (10KB)", + "large": "बड़ा (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 570afc1889..c1505c4ba9 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -733,9 +733,9 @@ "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)" + "small": "Kecil (5KB)", + "medium": "Sedang (10KB)", + "large": "Besar (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b295c8efe9..139de3f16d 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Piccola (5KB)", + "medium": "Media (10KB)", + "large": "Grande (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e0aa410384..7ed6498351 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -729,9 +729,9 @@ "label": "コマンド出力プレビューサイズ", "description": "Rooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。", "options": { - "small": "小 (2KB)", - "medium": "中 (4KB)", - "large": "大 (8KB)" + "small": "小 (5KB)", + "medium": "中 (10KB)", + "large": "大 (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 5717f8567f..20bf3858f7 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -729,9 +729,9 @@ "label": "명령 출력 미리보기 크기", "description": "Roo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.", "options": { - "small": "작게 (2KB)", - "medium": "보통 (4KB)", - "large": "크게 (8KB)" + "small": "작게 (5KB)", + "medium": "보통 (10KB)", + "large": "크게 (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index b493155a01..25b2a48f64 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Klein (5KB)", + "medium": "Gemiddeld (10KB)", + "large": "Groot (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 92fcd8f8fc..1ed4e59159 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Mały (5KB)", + "medium": "Średni (10KB)", + "large": "Duży (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index a74086f29b..1d989db379 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Pequeno (5KB)", + "medium": "Médio (10KB)", + "large": "Grande (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 76bf711a73..5a736ef1ec 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -729,9 +729,9 @@ "label": "Размер предпросмотра вывода команд", "description": "Контролирует, сколько вывода команды Roo видит напрямую. Полный вывод всегда сохраняется и доступен при необходимости.", "options": { - "small": "Маленький (2KB)", - "medium": "Средний (4KB)", - "large": "Большой (8KB)" + "small": "Маленький (5KB)", + "medium": "Средний (10KB)", + "large": "Большой (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index dddadd2c35..9110f27d6e 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Küçük (5KB)", + "medium": "Orta (10KB)", + "large": "Büyük (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index c23cc1d6b3..14c8904e09 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -729,9 +729,9 @@ "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)" + "small": "Nhỏ (5KB)", + "medium": "Trung bình (10KB)", + "large": "Lớn (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 5b4e2330a9..1b58484ced 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -729,9 +729,9 @@ "label": "命令输出预览大小", "description": "控制 Roo 直接看到的命令输出量。完整输出始终会被保存,需要时可以访问。", "options": { - "small": "小 (2KB)", - "medium": "中 (4KB)", - "large": "大 (8KB)" + "small": "小 (5KB)", + "medium": "中 (10KB)", + "large": "大 (20KB)" } }, "shellIntegrationTimeout": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index ea99a37f43..d18a5c8443 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -737,9 +737,9 @@ "label": "命令輸出預覽大小", "description": "控制 Roo 直接看到的命令輸出量。完整輸出始終會被儲存,需要時可以存取。", "options": { - "small": "小 (2KB)", - "medium": "中 (4KB)", - "large": "大 (8KB)" + "small": "小 (5KB)", + "medium": "中 (10KB)", + "large": "大 (20KB)" } }, "shellIntegrationTimeout": { From d6aab9fcaf7592dc148b942e4edbc0b2c14a3267 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 18:17:40 -0700 Subject: [PATCH 17/24] fix: display search pattern and match count in read_command_output UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using search mode, the UI now shows the search pattern and match count instead of the misleading byte range (0 B - totalSize). - Added searchPattern and matchCount fields to ClineSayTool type - Updated ReadCommandOutputTool to return match count from search operations - Updated ChatRow to display 'search: "pattern" • N matches' for search mode --- src/core/tools/ReadCommandOutputTool.ts | 14 ++++++++++---- webview-ui/src/components/chat/ChatRow.tsx | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/core/tools/ReadCommandOutputTool.ts b/src/core/tools/ReadCommandOutputTool.ts index 9ae4a377ef..9d3bbd35dd 100644 --- a/src/core/tools/ReadCommandOutputTool.ts +++ b/src/core/tools/ReadCommandOutputTool.ts @@ -161,10 +161,13 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { let result: string let readStart = 0 let readEnd = 0 + let matchCount: number | undefined if (search) { // Search mode: filter lines matching the pattern - result = await this.searchInArtifact(artifactPath, search, totalSize, limit) + const searchResult = await this.searchInArtifact(artifactPath, search, totalSize, limit) + result = searchResult.content + matchCount = searchResult.matchCount // For search, we're scanning the whole file readStart = 0 readEnd = totalSize @@ -184,6 +187,7 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { readStart, readEnd, totalBytes: totalSize, + ...(search && { searchPattern: search, matchCount }), }), ) @@ -292,7 +296,7 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { pattern: string, totalSize: number, limit: number, - ): Promise { + ): Promise<{ content: string; matchCount: number }> { const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory // Create case-insensitive regex for search @@ -368,23 +372,25 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> { const artifactId = path.basename(artifactPath) if (matches.length === 0) { - return [ + const content = [ `[Command Output: ${artifactId}] (search: "${pattern}")`, `Total size: ${this.formatBytes(totalSize)}`, "", "No matches found for the search pattern.", ].join("\n") + return { content, matchCount: 0 } } // Format matches with line numbers const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n") - return [ + const content = [ `[Command Output: ${artifactId}] (search: "${pattern}")`, `Total matches: ${matches.length} | Showing first ${matches.length}`, "", matchedLines, ].join("\n") + return { content, matchCount: matches.length } } /** diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index c79dd11a86..4c205a4a1b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1470,6 +1470,7 @@ export const ChatRowContent = ({ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } + // Determine if this is a search operation const isSearch = sayTool.searchPattern !== undefined From 3b99b936f827c5414a5d2401263c592ecc15d3f8 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 16:49:57 -0700 Subject: [PATCH 18/24] feat: add write_stdin tool for interactive terminal support This commit implements the interactive terminal feature from the terminal integration specification (plans/extract-terminal-integration.md). Changes: - Add write_stdin to toolNames in packages/types/src/tool.ts - Create write_stdin native tool schema in src/core/prompts/tools/native-tools/ - Create WriteStdinTool handler in src/core/tools/ - Add ProcessManager to track running processes by session_id - Modify ExecuteCommandTool to register processes when still running - Add write_stdin to tool routing in presentAssistantMessage.ts - Add write_stdin to NativeToolCallParser for streaming support - Add tests for ProcessManager The write_stdin tool enables the LLM to: - Send input to running terminal processes (y/n prompts, passwords) - Send control characters like Ctrl+C (\x03) - Poll for new output from long-running processes When execute_command starts a process that's still running after the yield time, it registers the process with ProcessManager and returns a session_id. The LLM can then use write_stdin with that session_id to interact with the process. --- packages/types/src/tool.ts | 1 + .../assistant-message/NativeToolCallParser.ts | 22 ++ .../presentAssistantMessage.ts | 8 + src/core/prompts/tools/native-tools/index.ts | 2 + .../prompts/tools/native-tools/write_stdin.ts | 87 +++++ src/core/tools/ExecuteCommandTool.ts | 20 ++ src/core/tools/WriteStdinTool.ts | 337 ++++++++++++++++++ src/integrations/terminal/ProcessManager.ts | 267 ++++++++++++++ .../terminal/__tests__/ProcessManager.spec.ts | 225 ++++++++++++ src/shared/tools.ts | 8 +- 10 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/tools/native-tools/write_stdin.ts create mode 100644 src/core/tools/WriteStdinTool.ts create mode 100644 src/integrations/terminal/ProcessManager.ts create mode 100644 src/integrations/terminal/__tests__/ProcessManager.spec.ts diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index f90ef42ede..7ac5d5b68f 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -16,6 +16,7 @@ export type ToolGroup = z.infer export const toolNames = [ "execute_command", + "write_stdin", "read_file", "read_command_output", "write_to_file", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 8aa369f74d..5bead6abdd 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -400,6 +400,17 @@ export class NativeToolCallParser { } break + case "write_stdin": + if (partialArgs.session_id !== undefined) { + nativeArgs = { + session_id: partialArgs.session_id, + chars: partialArgs.chars, + yield_time_ms: partialArgs.yield_time_ms, + max_output_tokens: partialArgs.max_output_tokens, + } + } + break + case "write_to_file": if (partialArgs.path || partialArgs.content) { nativeArgs = { @@ -687,6 +698,17 @@ export class NativeToolCallParser { } break + case "write_stdin": + if (args.session_id !== undefined) { + nativeArgs = { + session_id: args.session_id, + chars: args.chars, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + } as NativeArgsFor + } + break + case "apply_diff": if (args.path !== undefined && args.diff !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index db17bb9704..7304f5a9af 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -26,6 +26,7 @@ import { applyPatchTool } from "../tools/ApplyPatchTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" import { executeCommandTool } from "../tools/ExecuteCommandTool" +import { writeStdinTool } from "../tools/WriteStdinTool" import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" @@ -856,6 +857,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "write_stdin": + await writeStdinTool.handle(cline, block as ToolUse<"write_stdin">, { + askApproval, + handleError, + pushToolResult, + }) + break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { askApproval, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index b6af18fa15..70f7865753 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -20,6 +20,7 @@ import edit_file from "./edit_file" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" +import writeStdin from "./write_stdin" import writeToFile from "./write_to_file" export { getMcpServerTools } from "./mcp_server" @@ -75,6 +76,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch searchFiles, switchMode, updateTodoList, + writeStdin, writeToFile, ] satisfies OpenAI.Chat.ChatCompletionTool[] } diff --git a/src/core/prompts/tools/native-tools/write_stdin.ts b/src/core/prompts/tools/native-tools/write_stdin.ts new file mode 100644 index 0000000000..10170489f7 --- /dev/null +++ b/src/core/prompts/tools/native-tools/write_stdin.ts @@ -0,0 +1,87 @@ +import type OpenAI from "openai" + +/** + * Native tool definition for write_stdin. + * + * This tool allows the LLM to write characters to an existing terminal session + * and receive the resulting output. It enables interactive terminal workflows + * where the LLM can respond to prompts, provide input to running processes, + * and monitor long-running commands. + */ + +const WRITE_STDIN_DESCRIPTION = `Writes characters to an existing exec session and returns recent output. + +Use this tool when: +1. A command started with execute_command is still running and waiting for input +2. You need to respond to an interactive prompt (e.g., "Press y to continue", password prompts) +3. You want to poll a long-running process for new output without sending input + +The session_id is returned by execute_command when a process is still running. + +Parameters: +- session_id: (required) Identifier of the running exec session (returned by execute_command) +- chars: (optional) Characters to write to stdin. Use empty string or omit to just poll for output. +- yield_time_ms: (optional) Milliseconds to wait for output after writing (default: 250, min: 250, max: 30000) +- max_output_tokens: (optional) Maximum tokens to return in the response + +Common use cases: +- Sending 'y' or 'n' to confirmation prompts +- Providing input to interactive CLI tools +- Sending Ctrl+C (\\x03) to terminate a process +- Polling for output from a long-running process + +Example: Responding to a confirmation prompt +{ "session_id": 1234, "chars": "y\\n" } + +Example: Sending Ctrl+C to stop a process +{ "session_id": 1234, "chars": "\\x03" } + +Example: Polling for new output (no input) +{ "session_id": 1234, "chars": "", "yield_time_ms": 2000 } + +Example: Providing password (note: prefer non-interactive approaches when possible) +{ "session_id": 1234, "chars": "password\\n" }` + +const SESSION_ID_DESCRIPTION = `Identifier of the running exec session (returned by execute_command when a process is still running)` + +const CHARS_DESCRIPTION = `Characters to write to stdin. May be empty to just poll for output. Supports escape sequences like \\n (newline) and \\x03 (Ctrl+C).` + +const YIELD_TIME_MS_DESCRIPTION = `Milliseconds to wait for output after writing (default: 250, range: 250-30000). Use higher values when expecting delayed output.` + +const MAX_OUTPUT_TOKENS_DESCRIPTION = `Maximum tokens to return in the response. Excess output will be truncated with head/tail preservation.` + +export default { + type: "function", + function: { + name: "write_stdin", + description: WRITE_STDIN_DESCRIPTION, + // Note: strict mode is intentionally disabled for this tool. + // With strict: true, OpenAI requires ALL properties to be in the 'required' array, + // which forces the LLM to always provide explicit values (even null) for optional params. + // This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can + // omit optional parameters entirely, making the tool easier to use. + parameters: { + type: "object", + properties: { + session_id: { + type: "number", + description: SESSION_ID_DESCRIPTION, + }, + chars: { + type: "string", + description: CHARS_DESCRIPTION, + }, + yield_time_ms: { + type: "number", + description: YIELD_TIME_MS_DESCRIPTION, + }, + max_output_tokens: { + type: "number", + description: MAX_OUTPUT_TOKENS_DESCRIPTION, + }, + }, + required: ["session_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index f94f70ebeb..f6d350f1ec 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -16,6 +16,7 @@ import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../.. import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" +import { ProcessManager } from "../../integrations/terminal/ProcessManager" import { Package } from "../../shared/package" import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" @@ -423,12 +424,31 @@ export async function executeCommandInTerminal( `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, ] } else { + // Process is still running - register it with ProcessManager for write_stdin interaction + let sessionId: number | undefined + const currentProcess = terminal.process + + if (currentProcess) { + try { + const processManager = ProcessManager.getInstance() + sessionId = processManager.registerProcess(terminal, currentProcess, task.taskId, command) + } catch (error) { + console.warn(`[ExecuteCommandTool] Failed to register process: ${error}`) + } + } + + const sessionInfo = + sessionId !== undefined + ? `\nSession ID: ${sessionId} - Use write_stdin tool with this session_id to send input to the process.` + : "" + return [ false, [ `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`, result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", "You will be updated on the terminal status and new output in the future.", + sessionInfo, ].join("\n"), ] } diff --git a/src/core/tools/WriteStdinTool.ts b/src/core/tools/WriteStdinTool.ts new file mode 100644 index 0000000000..3b7d17bc7a --- /dev/null +++ b/src/core/tools/WriteStdinTool.ts @@ -0,0 +1,337 @@ +import delay from "delay" + +import { Task } from "../task/Task" +import { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { ProcessManager } from "../../integrations/terminal/ProcessManager" +import { Terminal } from "../../integrations/terminal/Terminal" +import { t } from "../../i18n" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * Minimum yield time in milliseconds. + */ +const MIN_YIELD_TIME_MS = 250 + +/** + * Maximum yield time in milliseconds. + */ +const MAX_YIELD_TIME_MS = 30_000 + +/** + * Default yield time when no input is provided (polling). + */ +const DEFAULT_POLL_YIELD_MS = 5_000 + +/** + * Default yield time when input is provided. + */ +const DEFAULT_INPUT_YIELD_MS = 250 + +/** + * Default maximum output tokens. + */ +const DEFAULT_MAX_OUTPUT_TOKENS = 10_000 + +/** + * Parameters for the write_stdin tool. + */ +interface WriteStdinParams { + /** Session ID of the running process */ + session_id: number + /** Characters to write to stdin (may be empty to poll) */ + chars?: string + /** Milliseconds to wait for output */ + yield_time_ms?: number + /** Maximum tokens to return */ + max_output_tokens?: number +} + +/** + * WriteStdinTool enables the LLM to write to stdin of running processes. + * + * This tool works in conjunction with execute_command: + * 1. execute_command starts a process and returns a session_id if still running + * 2. write_stdin uses that session_id to send input to the process + * 3. The tool returns any new output after sending the input + * + * ## Use Cases + * + * - Responding to interactive prompts (y/n confirmations, passwords) + * - Providing input to CLI tools that request it + * - Sending control characters (Ctrl+C = \x03) + * - Polling for output from long-running processes + * + * ## Terminal Types + * + * - VSCode Terminal: Uses terminal.sendText() for stdin + * - Execa Terminal: Uses subprocess.stdin.write() (requires stdin: "pipe") + */ +export class WriteStdinTool extends BaseTool<"write_stdin"> { + readonly name = "write_stdin" as const + + async execute(params: WriteStdinParams, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult } = callbacks + + try { + const { session_id, chars = "", yield_time_ms, max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS } = params + + // Validate session_id + if (session_id === undefined || session_id === null) { + task.consecutiveMistakeCount++ + task.recordToolError("write_stdin") + pushToolResult(await task.sayAndCreateMissingParamError("write_stdin", "session_id")) + return + } + + // Get process from ProcessManager + const processManager = ProcessManager.getInstance() + const entry = processManager.getProcess(session_id) + + if (!entry) { + task.consecutiveMistakeCount++ + task.recordToolError("write_stdin") + task.didToolFailInCurrentTurn = true + const errorMsg = `Session ${session_id} not found. The process may have exited or the session ID is invalid. Use execute_command to start a new process.` + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + + // Check if process is still running + if (!entry.running) { + task.consecutiveMistakeCount++ + task.recordToolError("write_stdin") + task.didToolFailInCurrentTurn = true + const errorMsg = `Session ${session_id} has completed. The process is no longer running. Use execute_command to start a new process if needed.` + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + + // Reset mistake count on valid input + task.consecutiveMistakeCount = 0 + + // Calculate yield time + const hasInput = chars.length > 0 + const defaultYield = hasInput ? DEFAULT_INPUT_YIELD_MS : DEFAULT_POLL_YIELD_MS + const requestedYield = yield_time_ms ?? defaultYield + const clampedYield = Math.max(MIN_YIELD_TIME_MS, Math.min(MAX_YIELD_TIME_MS, requestedYield)) + + // Process escape sequences in input + const processedChars = this.processEscapeSequences(chars) + + // Write to stdin + const { terminal, process } = entry + let writeSuccess = false + + try { + if (terminal instanceof Terminal) { + // VSCode terminal - use sendText + // Note: sendText automatically adds a newline by default, so we pass false + // to prevent double newlines when the input already ends with \n + terminal.terminal.sendText(processedChars, false) + writeSuccess = true + } else { + // Execa terminal - would need stdin pipe support + // For now, we'll indicate this isn't supported for execa + // TODO: Implement stdin support for ExecaTerminalProcess + const errorMsg = `Session ${session_id} is using a non-interactive terminal. Interactive stdin is only supported for VSCode terminals.` + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + return + } + } catch (writeError) { + const errorMsg = `Failed to write to session ${session_id}: ${writeError instanceof Error ? writeError.message : String(writeError)}` + await task.say("error", errorMsg) + task.didToolFailInCurrentTurn = true + pushToolResult(`Error: ${errorMsg}`) + return + } + + // Wait for output + await delay(clampedYield) + + // Get any new output + let output = "" + if (process.hasUnretrievedOutput()) { + output = process.getUnretrievedOutput() + } + + // Check if process has exited + const isStillRunning = !terminal.isClosed() && terminal.running + if (!isStillRunning) { + processManager.markCompleted(session_id) + } + + // Truncate output if needed + const truncatedOutput = this.truncateOutput(output, max_output_tokens) + + // Build response + const result = this.formatResponse({ + sessionId: session_id, + command: entry.command, + input: chars, + output: truncatedOutput.text, + truncated: truncatedOutput.truncated, + originalTokens: truncatedOutput.originalTokens, + running: isStillRunning, + yieldTime: clampedYield, + }) + + pushToolResult(result) + } catch (error) { + await handleError("writing to stdin", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"write_stdin">): Promise { + const sessionId = block.params.session_id ?? block.nativeArgs?.session_id + const chars = block.params.chars ?? block.nativeArgs?.chars ?? "" + await task + .ask( + "command", + `write_stdin session=${sessionId} chars="${chars.slice(0, 20)}${chars.length > 20 ? "..." : ""}"`, + block.partial, + ) + .catch(() => {}) + } + + /** + * Process escape sequences in the input string. + * + * Handles: + * - \n -> newline + * - \r -> carriage return + * - \t -> tab + * - \xNN -> hex byte + * - \\ -> backslash + */ + private processEscapeSequences(input: string): string { + return input.replace(/\\(n|r|t|\\|x[0-9a-fA-F]{2})/g, (match, escape) => { + switch (escape) { + case "n": + return "\n" + case "r": + return "\r" + case "t": + return "\t" + case "\\": + return "\\" + default: + // Handle \xNN hex escapes + if (escape.startsWith("x")) { + const hexValue = parseInt(escape.slice(1), 16) + return String.fromCharCode(hexValue) + } + return match + } + }) + } + + /** + * Truncate output to fit within token limit. + * + * Uses head/tail preservation to keep the beginning and end + * while truncating the middle. + */ + private truncateOutput( + output: string, + maxTokens: number, + ): { text: string; truncated: boolean; originalTokens: number } { + // Rough estimate: 4 characters per token + const BYTES_PER_TOKEN = 4 + const maxBytes = maxTokens * BYTES_PER_TOKEN + const originalTokens = Math.ceil(output.length / BYTES_PER_TOKEN) + + if (output.length <= maxBytes) { + return { text: output, truncated: false, originalTokens } + } + + // Split budget 50/50 between head and tail + const halfBudget = Math.floor(maxBytes / 2) + const head = output.slice(0, halfBudget) + const tail = output.slice(-halfBudget) + const truncatedTokens = originalTokens - maxTokens + + const marker = `\n\n...[${truncatedTokens} tokens truncated]...\n\n` + return { + text: head + marker + tail, + truncated: true, + originalTokens, + } + } + + /** + * Format the tool response. + */ + private formatResponse(params: { + sessionId: number + command: string + input: string + output: string + truncated: boolean + originalTokens: number + running: boolean + yieldTime: number + }): string { + const { sessionId, command, input, output, truncated, originalTokens, running, yieldTime } = params + + const lines: string[] = [] + + // Header + if (running) { + lines.push(`Session ${sessionId} is still running.`) + } else { + lines.push(`Session ${sessionId} has exited.`) + } + + // Input echo (if any) + if (input) { + const displayInput = input.length > 50 ? input.slice(0, 50) + "..." : input + lines.push(`Sent: "${this.escapeForDisplay(displayInput)}"`) + } else { + lines.push(`Polled for output (waited ${yieldTime}ms)`) + } + + // Output + if (output) { + if (truncated) { + lines.push(`Output (truncated from ~${originalTokens} tokens):`) + } else { + lines.push(`Output:`) + } + lines.push(output) + } else { + lines.push(`No new output received.`) + } + + // Guidance + if (running) { + lines.push(`\nUse write_stdin with session_id=${sessionId} to continue interacting with this process.`) + } else { + lines.push(`\nUse execute_command to start a new process if needed.`) + } + + return lines.join("\n") + } + + /** + * Escape control characters for display. + */ + private escapeForDisplay(str: string): string { + return ( + str + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + // eslint-disable-next-line no-control-regex -- Intentionally matching control characters for escaping + .replace(/[\x00-\x1F]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + ) + } +} + +// Export singleton instance +export const writeStdinTool = new WriteStdinTool() diff --git a/src/integrations/terminal/ProcessManager.ts b/src/integrations/terminal/ProcessManager.ts new file mode 100644 index 0000000000..ba28347b84 --- /dev/null +++ b/src/integrations/terminal/ProcessManager.ts @@ -0,0 +1,267 @@ +import { RooTerminal, RooTerminalProcess } from "./types" + +/** + * ProcessEntry represents a running process that can receive stdin input. + */ +export interface ProcessEntry { + /** The terminal containing the process */ + terminal: RooTerminal + /** The running process handle */ + process: RooTerminalProcess + /** Task ID that owns this process */ + taskId: string + /** Original command that started the process */ + command: string + /** Timestamp of last interaction */ + lastUsed: number + /** Whether the process is still running */ + running: boolean +} + +/** + * ProcessManager tracks running terminal processes by session ID. + * + * This enables the write_stdin tool to send input to processes that were + * started by execute_command and are still running. + * + * Session IDs are assigned when a command is started and returns a process + * that hasn't exited. The LLM can then use write_stdin with the session_id + * to interact with that process. + * + * ## Lifecycle + * + * 1. execute_command starts a process + * 2. If process is still running after yield_time, ProcessManager registers it + * 3. LLM calls write_stdin with session_id to send input + * 4. When process exits, entry is cleaned up + * + * ## Limits + * + * - Maximum 64 concurrent tracked processes + * - Warning issued at 60 processes + * - Oldest unused processes evicted when limit reached + */ +export class ProcessManager { + private static instance: ProcessManager | null = null + private processes: Map = new Map() + private nextSessionId = 1 + + static readonly MAX_PROCESSES = 64 + static readonly WARNING_THRESHOLD = 60 + + /** + * Get the singleton ProcessManager instance. + */ + static getInstance(): ProcessManager { + if (!ProcessManager.instance) { + ProcessManager.instance = new ProcessManager() + } + return ProcessManager.instance + } + + /** + * Reset the singleton instance (for testing). + */ + static resetInstance(): void { + ProcessManager.instance = null + } + + /** + * Register a running process and return its session ID. + * + * @param terminal - The terminal containing the process + * @param process - The running process + * @param taskId - The task that owns this process + * @param command - The original command + * @returns The session ID for this process + * @throws Error if maximum process limit is reached + */ + registerProcess(terminal: RooTerminal, process: RooTerminalProcess, taskId: string, command: string): number { + // Clean up completed processes first + this.cleanup() + + // Check limits + if (this.processes.size >= ProcessManager.MAX_PROCESSES) { + // Try to evict oldest unused process + const evicted = this.evictOldest() + if (!evicted) { + throw new Error( + `Maximum concurrent processes (${ProcessManager.MAX_PROCESSES}) reached. ` + + `Please wait for existing processes to complete or terminate them.`, + ) + } + } + + if (this.processes.size >= ProcessManager.WARNING_THRESHOLD) { + console.warn( + `[ProcessManager] ${this.processes.size} concurrent processes tracked. ` + + `Consider cleaning up long-running processes.`, + ) + } + + const sessionId = this.nextSessionId++ + const entry: ProcessEntry = { + terminal, + process, + taskId, + command, + lastUsed: Date.now(), + running: true, + } + + this.processes.set(sessionId, entry) + console.log(`[ProcessManager] Registered session ${sessionId} for command: ${command.slice(0, 50)}...`) + + return sessionId + } + + /** + * Get a process entry by session ID. + * + * @param sessionId - The session ID + * @returns The process entry, or undefined if not found + */ + getProcess(sessionId: number): ProcessEntry | undefined { + const entry = this.processes.get(sessionId) + if (entry) { + entry.lastUsed = Date.now() + } + return entry + } + + /** + * Check if a session exists and is still running. + * + * @param sessionId - The session ID + * @returns True if session exists and process is running + */ + isRunning(sessionId: number): boolean { + const entry = this.processes.get(sessionId) + return entry !== undefined && entry.running + } + + /** + * Mark a process as no longer running. + * + * @param sessionId - The session ID + */ + markCompleted(sessionId: number): void { + const entry = this.processes.get(sessionId) + if (entry) { + entry.running = false + console.log(`[ProcessManager] Session ${sessionId} marked as completed`) + } + } + + /** + * Unregister a process by session ID. + * + * @param sessionId - The session ID to unregister + * @returns True if the session was found and removed + */ + unregisterProcess(sessionId: number): boolean { + const removed = this.processes.delete(sessionId) + if (removed) { + console.log(`[ProcessManager] Unregistered session ${sessionId}`) + } + return removed + } + + /** + * Unregister all processes for a specific task. + * + * @param taskId - The task ID + * @returns Number of processes unregistered + */ + unregisterTaskProcesses(taskId: string): number { + let count = 0 + for (const [sessionId, entry] of this.processes.entries()) { + if (entry.taskId === taskId) { + this.processes.delete(sessionId) + count++ + } + } + if (count > 0) { + console.log(`[ProcessManager] Unregistered ${count} processes for task ${taskId}`) + } + return count + } + + /** + * Get all session IDs for a task. + * + * @param taskId - The task ID + * @returns Array of session IDs + */ + getTaskSessions(taskId: string): number[] { + const sessions: number[] = [] + for (const [sessionId, entry] of this.processes.entries()) { + if (entry.taskId === taskId) { + sessions.push(sessionId) + } + } + return sessions + } + + /** + * Get the number of tracked processes. + */ + get size(): number { + return this.processes.size + } + + /** + * Clean up completed processes. + */ + private cleanup(): void { + const toRemove: number[] = [] + for (const [sessionId, entry] of this.processes.entries()) { + // Check if terminal is closed or process is no longer running + if (entry.terminal.isClosed() || !entry.running) { + toRemove.push(sessionId) + } + } + for (const sessionId of toRemove) { + this.processes.delete(sessionId) + } + if (toRemove.length > 0) { + console.log(`[ProcessManager] Cleaned up ${toRemove.length} completed processes`) + } + } + + /** + * Evict the oldest unused process to make room. + * + * @returns True if a process was evicted + */ + private evictOldest(): boolean { + let oldestId: number | null = null + let oldestTime = Infinity + + for (const [sessionId, entry] of this.processes.entries()) { + // Only evict non-running processes first + if (!entry.running && entry.lastUsed < oldestTime) { + oldestId = sessionId + oldestTime = entry.lastUsed + } + } + + // If no completed processes, evict oldest running one + if (oldestId === null) { + for (const [sessionId, entry] of this.processes.entries()) { + if (entry.lastUsed < oldestTime) { + oldestId = sessionId + oldestTime = entry.lastUsed + } + } + } + + if (oldestId !== null) { + console.warn(`[ProcessManager] Evicting session ${oldestId} to make room`) + this.processes.delete(oldestId) + return true + } + + return false + } +} diff --git a/src/integrations/terminal/__tests__/ProcessManager.spec.ts b/src/integrations/terminal/__tests__/ProcessManager.spec.ts new file mode 100644 index 0000000000..69e21dbdc4 --- /dev/null +++ b/src/integrations/terminal/__tests__/ProcessManager.spec.ts @@ -0,0 +1,225 @@ +import { ProcessManager, ProcessEntry } from "../ProcessManager" +import { RooTerminal, RooTerminalProcess } from "../types" + +// Mock terminal +const createMockTerminal = (id: number, closed = false): RooTerminal => + ({ + id, + busy: false, + running: true, + isClosed: () => closed, + getCurrentWorkingDirectory: () => "/test/dir", + }) as unknown as RooTerminal + +// Mock process +const createMockProcess = (): RooTerminalProcess => + ({ + command: "test command", + isHot: false, + hasUnretrievedOutput: () => false, + getUnretrievedOutput: () => "", + }) as unknown as RooTerminalProcess + +describe("ProcessManager", () => { + beforeEach(() => { + // Reset singleton between tests + ProcessManager.resetInstance() + }) + + describe("getInstance", () => { + it("should return the same instance", () => { + const instance1 = ProcessManager.getInstance() + const instance2 = ProcessManager.getInstance() + expect(instance1).toBe(instance2) + }) + }) + + describe("registerProcess", () => { + it("should register a process and return a session ID", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + + expect(sessionId).toBeGreaterThan(0) + expect(manager.size).toBe(1) + }) + + it("should return unique session IDs for each registration", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + const sessionId1 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + const sessionId2 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 2") + + expect(sessionId1).not.toBe(sessionId2) + expect(manager.size).toBe(2) + }) + + it("should evict oldest non-running process when maximum reached", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + // Register MAX_PROCESSES processes and mark first one as completed + const sessionIds: number[] = [] + for (let i = 0; i < ProcessManager.MAX_PROCESSES; i++) { + const process = createMockProcess() + const sessionId = manager.registerProcess(terminal, process, "task-1", `echo ${i}`) + sessionIds.push(sessionId) + } + + // Mark the first process as completed (eligible for eviction) + manager.markCompleted(sessionIds[0]) + + // This should succeed by evicting the completed process + const newSessionId = manager.registerProcess(terminal, createMockProcess(), "task-1", "new process") + expect(newSessionId).toBeGreaterThan(0) + expect(manager.size).toBe(ProcessManager.MAX_PROCESSES) + + // The first session should be evicted + expect(manager.getProcess(sessionIds[0])).toBeUndefined() + }) + }) + + describe("getProcess", () => { + it("should return the process entry for valid session ID", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + const entry = manager.getProcess(sessionId) + + expect(entry).toBeDefined() + expect(entry!.terminal).toBe(terminal) + expect(entry!.process).toBe(process) + expect(entry!.taskId).toBe("task-1") + expect(entry!.command).toBe("echo test") + expect(entry!.running).toBe(true) + }) + + it("should return undefined for invalid session ID", () => { + const manager = ProcessManager.getInstance() + + const entry = manager.getProcess(999) + + expect(entry).toBeUndefined() + }) + + it("should update lastUsed timestamp on access", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + const entry1 = manager.getProcess(sessionId) + const lastUsed1 = entry1!.lastUsed + + // Wait a bit then access again + const entry2 = manager.getProcess(sessionId) + const lastUsed2 = entry2!.lastUsed + + expect(lastUsed2).toBeGreaterThanOrEqual(lastUsed1) + }) + }) + + describe("isRunning", () => { + it("should return true for running process", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + + expect(manager.isRunning(sessionId)).toBe(true) + }) + + it("should return false for non-existent session", () => { + const manager = ProcessManager.getInstance() + + expect(manager.isRunning(999)).toBe(false) + }) + + it("should return false for completed process", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + manager.markCompleted(sessionId) + + expect(manager.isRunning(sessionId)).toBe(false) + }) + }) + + describe("markCompleted", () => { + it("should mark process as not running", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + manager.markCompleted(sessionId) + + const entry = manager.getProcess(sessionId) + expect(entry!.running).toBe(false) + }) + }) + + describe("unregisterProcess", () => { + it("should remove the process entry", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + const removed = manager.unregisterProcess(sessionId) + + expect(removed).toBe(true) + expect(manager.getProcess(sessionId)).toBeUndefined() + expect(manager.size).toBe(0) + }) + + it("should return false for non-existent session", () => { + const manager = ProcessManager.getInstance() + + const removed = manager.unregisterProcess(999) + + expect(removed).toBe(false) + }) + }) + + describe("unregisterTaskProcesses", () => { + it("should remove all processes for a task", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 2") + manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 3") + + const count = manager.unregisterTaskProcesses("task-1") + + expect(count).toBe(2) + expect(manager.size).toBe(1) + }) + }) + + describe("getTaskSessions", () => { + it("should return all session IDs for a task", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + const id1 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + const id2 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 2") + manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 3") + + const sessions = manager.getTaskSessions("task-1") + + expect(sessions).toHaveLength(2) + expect(sessions).toContain(id1) + expect(sessions).toContain(id2) + }) + }) +}) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index dc1615c065..1ee38e14e8 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -76,6 +76,10 @@ export const toolParamNames = [ "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 + "session_id", // write_stdin parameter for terminal session + "chars", // write_stdin parameter for stdin input + "yield_time_ms", // write_stdin parameter for output wait time + "max_output_tokens", // write_stdin parameter for output token limit ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -110,6 +114,7 @@ export type NativeToolArgs = { switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } + write_stdin: { session_id: number; chars?: string; yield_time_ms?: number; max_output_tokens?: number } write_to_file: { path: string; content: string } // Add more tools as they are migrated to native protocol } @@ -246,6 +251,7 @@ export type ToolGroupConfig = { export const TOOL_DISPLAY_NAMES: Record = { execute_command: "run commands", + write_stdin: "write to terminal input", read_file: "read files", read_command_output: "read command output", fetch_instructions: "fetch instructions", @@ -284,7 +290,7 @@ export const TOOL_GROUPS: Record = { tools: ["browser_action"], }, command: { - tools: ["execute_command", "read_command_output"], + tools: ["execute_command", "write_stdin", "read_command_output"], }, mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], From c9af7621145d7b9c4a842c114e09e9f720eb1c18 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 17:02:00 -0700 Subject: [PATCH 19/24] feat: add terminate_session and list_sessions tools - Add terminate_session tool to kill running terminal sessions - Add list_sessions tool to view active terminal sessions - Add listSessions and terminateSession methods to ProcessManager - Create native tool schemas for both tools - Create tool handlers (TerminateSessionTool, ListSessionsTool) - Add routing in presentAssistantMessage.ts - Add parsing support in NativeToolCallParser.ts - Add 8 new tests for ProcessManager methods (23 total tests) These tools complement write_stdin to provide complete terminal session management: - execute_command starts a process (returns session_id if still running) - write_stdin sends input to running processes - list_sessions shows all active sessions - terminate_session kills a running session --- packages/types/src/tool.ts | 2 + .../assistant-message/NativeToolCallParser.ts | 26 ++++ .../presentAssistantMessage.ts | 16 ++ src/core/prompts/tools/native-tools/index.ts | 4 + .../tools/native-tools/list_sessions.ts | 47 ++++++ .../tools/native-tools/terminate_session.ts | 47 ++++++ src/core/tools/ListSessionsTool.ts | 143 ++++++++++++++++++ src/core/tools/TerminateSessionTool.ts | 113 ++++++++++++++ src/integrations/terminal/ProcessManager.ts | 88 +++++++++++ .../terminal/__tests__/ProcessManager.spec.ts | 121 +++++++++++++++ src/shared/tools.ts | 6 +- 11 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/tools/native-tools/list_sessions.ts create mode 100644 src/core/prompts/tools/native-tools/terminate_session.ts create mode 100644 src/core/tools/ListSessionsTool.ts create mode 100644 src/core/tools/TerminateSessionTool.ts diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 7ac5d5b68f..a8351c261e 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -17,6 +17,8 @@ export type ToolGroup = z.infer export const toolNames = [ "execute_command", "write_stdin", + "terminate_session", + "list_sessions", "read_file", "read_command_output", "write_to_file", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 5bead6abdd..ca9b27c243 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -411,6 +411,19 @@ export class NativeToolCallParser { } break + case "terminate_session": + if (partialArgs.session_id !== undefined) { + nativeArgs = { + session_id: partialArgs.session_id, + } + } + break + + case "list_sessions": + // No parameters needed + nativeArgs = {} + break + case "write_to_file": if (partialArgs.path || partialArgs.content) { nativeArgs = { @@ -709,6 +722,19 @@ export class NativeToolCallParser { } break + case "terminate_session": + if (args.session_id !== undefined) { + nativeArgs = { + session_id: args.session_id, + } as NativeArgsFor + } + break + + case "list_sessions": + // No parameters needed + nativeArgs = {} as NativeArgsFor + break + case "apply_diff": if (args.path !== undefined && args.diff !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7304f5a9af..9aa17f1bff 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -27,6 +27,8 @@ import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" import { executeCommandTool } from "../tools/ExecuteCommandTool" import { writeStdinTool } from "../tools/WriteStdinTool" +import { terminateSessionTool } from "../tools/TerminateSessionTool" +import { listSessionsTool } from "../tools/ListSessionsTool" import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" @@ -864,6 +866,20 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "terminate_session": + await terminateSessionTool.handle(cline, block as ToolUse<"terminate_session">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "list_sessions": + await listSessionsTool.handle(cline, block as ToolUse<"list_sessions">, { + askApproval, + handleError, + pushToolResult, + }) + break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { askApproval, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 70f7865753..21347d2fa5 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -10,6 +10,7 @@ import executeCommand from "./execute_command" import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" +import listSessions from "./list_sessions" import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" @@ -19,6 +20,7 @@ import searchReplace from "./search_replace" import edit_file from "./edit_file" import searchFiles from "./search_files" import switchMode from "./switch_mode" +import terminateSession from "./terminate_session" import updateTodoList from "./update_todo_list" import writeStdin from "./write_stdin" import writeToFile from "./write_to_file" @@ -66,6 +68,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch fetchInstructions, generateImage, listFiles, + listSessions, newTask, readCommandOutput, createReadFileTool(readFileOptions), @@ -75,6 +78,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch edit_file, searchFiles, switchMode, + terminateSession, updateTodoList, writeStdin, writeToFile, diff --git a/src/core/prompts/tools/native-tools/list_sessions.ts b/src/core/prompts/tools/native-tools/list_sessions.ts new file mode 100644 index 0000000000..1b57d9a365 --- /dev/null +++ b/src/core/prompts/tools/native-tools/list_sessions.ts @@ -0,0 +1,47 @@ +import type OpenAI from "openai" + +/** + * Native tool definition for list_sessions. + * + * This tool allows the LLM to see all active terminal sessions + * that can be interacted with using write_stdin or terminated. + */ + +const LIST_SESSIONS_DESCRIPTION = `Lists all active terminal sessions that were started by execute_command. + +Use this tool when: +1. You need to know which sessions are still running +2. You forgot or lost track of a session_id +3. You want to see the status of multiple background processes +4. Before using write_stdin or terminate_session when unsure of the session_id + +Returns a list of sessions with: +- session_id: The identifier to use with write_stdin or terminate_session +- command: The original command that was executed +- running: Whether the process is still actively running +- last_used: Relative time since last interaction + +Example response: +┌──────────┬─────────────────────────────────┬─────────┬──────────────┐ +│ Session │ Command │ Status │ Last Used │ +├──────────┼─────────────────────────────────┼─────────┼──────────────┤ +│ 1 │ npm run dev │ Running │ 30 seconds │ +│ 2 │ python manage.py runserver │ Running │ 2 minutes │ +│ 3 │ tail -f /var/log/syslog │ Stopped │ 5 minutes │ +└──────────┴─────────────────────────────────┴─────────┴──────────────┘ + +This tool takes no parameters.` + +export default { + type: "function", + function: { + name: "list_sessions", + description: LIST_SESSIONS_DESCRIPTION, + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/terminate_session.ts b/src/core/prompts/tools/native-tools/terminate_session.ts new file mode 100644 index 0000000000..e77019aa23 --- /dev/null +++ b/src/core/prompts/tools/native-tools/terminate_session.ts @@ -0,0 +1,47 @@ +import type OpenAI from "openai" + +/** + * Native tool definition for terminate_session. + * + * This tool allows the LLM to terminate a running terminal session + * that was started by execute_command and is still active. + */ + +const TERMINATE_SESSION_DESCRIPTION = `Terminates a running terminal session by sending an abort signal to the process. + +Use this tool when: +1. A long-running command needs to be stopped (e.g., a server, watch process) +2. A command is stuck or unresponsive +3. You no longer need a background process that was started earlier +4. You want to free up resources from idle sessions + +The session_id is returned by execute_command when a process is still running. + +Parameters: +- session_id: (required) Identifier of the running exec session to terminate + +Example: Terminating a development server +{ "session_id": 1234 } + +Note: After termination, the session_id is no longer valid. Use list_sessions to see remaining active sessions.` + +const SESSION_ID_DESCRIPTION = `Identifier of the running exec session to terminate (returned by execute_command)` + +export default { + type: "function", + function: { + name: "terminate_session", + description: TERMINATE_SESSION_DESCRIPTION, + parameters: { + type: "object", + properties: { + session_id: { + type: "number", + description: SESSION_ID_DESCRIPTION, + }, + }, + required: ["session_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/ListSessionsTool.ts b/src/core/tools/ListSessionsTool.ts new file mode 100644 index 0000000000..da8bd70d26 --- /dev/null +++ b/src/core/tools/ListSessionsTool.ts @@ -0,0 +1,143 @@ +import { Task } from "../task/Task" +import { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { ProcessManager } from "../../integrations/terminal/ProcessManager" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * ListSessionsTool enables the LLM to see all active terminal sessions. + * + * This tool lists all terminal sessions that were started by execute_command + * and can be interacted with using write_stdin or terminated. + * + * ## Use Cases + * + * - Checking which background processes are still running + * - Finding a session_id that was forgotten + * - Auditing resource usage before task completion + * - Verifying that a server/process is still active + */ +export class ListSessionsTool extends BaseTool<"list_sessions"> { + readonly name = "list_sessions" as const + + async execute(_params: Record, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult } = callbacks + + try { + // Get all sessions from ProcessManager + const processManager = ProcessManager.getInstance() + const sessions = processManager.listSessions(task.taskId) + + task.consecutiveMistakeCount = 0 + + // Format response + const response = this.formatResponse(sessions) + + await task.say("tool", response, undefined, false) + pushToolResult(formatResponse.toolResult(response)) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + await handleError("listing sessions", error instanceof Error ? error : new Error(errorMessage)) + task.recordToolError("list_sessions") + } + } + + override async handlePartial(task: Task, _block: ToolUse<"list_sessions">): Promise { + await task.say( + "tool", + JSON.stringify({ + tool: "list_sessions", + content: "Listing active sessions...", + }), + undefined, + true, + ) + } + + /** + * Format the sessions list into a readable table. + */ + private formatResponse( + sessions: Array<{ + sessionId: number + taskId: string + command: string + running: boolean + lastUsed: number + }>, + ): string { + if (sessions.length === 0) { + return `## Active Terminal Sessions + +No active sessions found. + +Sessions are created when execute_command starts a process that doesn't complete within the yield time. +Use execute_command to start a new process that can be interacted with.` + } + + const lines: string[] = [] + lines.push("## Active Terminal Sessions") + lines.push("") + lines.push(`Found ${sessions.length} active session${sessions.length !== 1 ? "s" : ""}:`) + lines.push("") + lines.push("| Session | Command | Status | Last Used |") + lines.push("|---------|---------|--------|-----------|") + + for (const session of sessions) { + const status = session.running ? "🟢 Running" : "⚪ Stopped" + const lastUsed = this.formatTimeSince(session.lastUsed) + const command = this.truncateCommand(session.command, 40) + + lines.push(`| ${session.sessionId} | \`${command}\` | ${status} | ${lastUsed} |`) + } + + lines.push("") + lines.push("**Actions:**") + lines.push("- Use `write_stdin` with a session_id to send input to a running process") + lines.push("- Use `terminate_session` with a session_id to stop a process") + + return lines.join("\n") + } + + /** + * Format time since a timestamp as a human-readable string. + */ + private formatTimeSince(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + + if (seconds < 60) { + return `${seconds}s ago` + } + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return `${minutes}m ago` + } + + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h ago` + } + + const days = Math.floor(hours / 24) + return `${days}d ago` + } + + /** + * Truncate a command string for display. + */ + private truncateCommand(command: string, maxLength: number): string { + // Remove newlines and extra whitespace + const cleaned = command.replace(/\s+/g, " ").trim() + + if (cleaned.length <= maxLength) { + return cleaned + } + + return cleaned.slice(0, maxLength - 3) + "..." + } +} + +// Export singleton instance +export const listSessionsTool = new ListSessionsTool() diff --git a/src/core/tools/TerminateSessionTool.ts b/src/core/tools/TerminateSessionTool.ts new file mode 100644 index 0000000000..1371f89025 --- /dev/null +++ b/src/core/tools/TerminateSessionTool.ts @@ -0,0 +1,113 @@ +import { Task } from "../task/Task" +import { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { ProcessManager } from "../../integrations/terminal/ProcessManager" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * Parameters for the terminate_session tool. + */ +interface TerminateSessionParams { + /** Session ID of the running process to terminate */ + session_id: number +} + +/** + * TerminateSessionTool enables the LLM to terminate running terminal sessions. + * + * This tool works in conjunction with execute_command: + * 1. execute_command starts a process and returns a session_id if still running + * 2. terminate_session uses that session_id to abort the process + * + * ## Use Cases + * + * - Stopping a development server that's no longer needed + * - Terminating stuck or unresponsive processes + * - Cleaning up background processes before completing a task + * - Freeing resources from long-running processes + */ +export class TerminateSessionTool extends BaseTool<"terminate_session"> { + readonly name = "terminate_session" as const + + async execute(params: TerminateSessionParams, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult } = callbacks + + try { + const { session_id } = params + + // Validate session_id + if (session_id === undefined || session_id === null) { + task.consecutiveMistakeCount++ + task.recordToolError("terminate_session") + pushToolResult(await task.sayAndCreateMissingParamError("terminate_session", "session_id")) + return + } + + // Get ProcessManager and terminate the session + const processManager = ProcessManager.getInstance() + const result = processManager.terminateSession(session_id) + + if (result.success) { + task.consecutiveMistakeCount = 0 + + // Format success response + const response = this.formatResponse({ + sessionId: session_id, + success: true, + message: result.message, + }) + + await task.say("tool", response, undefined, false) + pushToolResult(formatResponse.toolResult(response)) + } else { + task.consecutiveMistakeCount++ + task.recordToolError("terminate_session") + task.didToolFailInCurrentTurn = true + + const errorMsg = result.message + await task.say("error", errorMsg) + pushToolResult(`Error: ${errorMsg}`) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + await handleError("terminating session", error instanceof Error ? error : new Error(errorMessage)) + task.recordToolError("terminate_session") + } + } + + override async handlePartial(task: Task, block: ToolUse<"terminate_session">): Promise { + const sessionId = block.params.session_id || block.nativeArgs?.session_id + + if (sessionId) { + await task.say( + "tool", + JSON.stringify({ + tool: "terminate_session", + session_id: sessionId, + content: `Terminating session ${sessionId}...`, + }), + undefined, + true, + ) + } + } + + /** + * Format the response message for the tool result. + */ + private formatResponse(params: { sessionId: number; success: boolean; message: string }): string { + const { sessionId, success, message } = params + + const lines: string[] = [] + lines.push(`## Session ${sessionId} Termination`) + lines.push("") + lines.push(`**Status:** ${success ? "✅ Success" : "❌ Failed"}`) + lines.push(`**Message:** ${message}`) + + return lines.join("\n") + } +} + +// Export singleton instance +export const terminateSessionTool = new TerminateSessionTool() diff --git a/src/integrations/terminal/ProcessManager.ts b/src/integrations/terminal/ProcessManager.ts index ba28347b84..203f993e6c 100644 --- a/src/integrations/terminal/ProcessManager.ts +++ b/src/integrations/terminal/ProcessManager.ts @@ -210,6 +210,94 @@ export class ProcessManager { return this.processes.size } + /** + * List all active sessions with their info. + * + * @param taskId - Optional task ID to filter by + * @returns Array of session info objects + */ + listSessions(taskId?: string): Array<{ + sessionId: number + taskId: string + command: string + running: boolean + lastUsed: number + }> { + // Clean up first to get accurate state + this.cleanup() + + const sessions: Array<{ + sessionId: number + taskId: string + command: string + running: boolean + lastUsed: number + }> = [] + + for (const [sessionId, entry] of this.processes.entries()) { + if (!taskId || entry.taskId === taskId) { + sessions.push({ + sessionId, + taskId: entry.taskId, + command: entry.command, + running: entry.running, + lastUsed: entry.lastUsed, + }) + } + } + + // Sort by session ID for consistent ordering + return sessions.sort((a, b) => a.sessionId - b.sessionId) + } + + /** + * Terminate a session by sending abort signal to the process. + * + * @param sessionId - The session ID to terminate + * @returns Object with success status and optional message + */ + terminateSession(sessionId: number): { success: boolean; message: string } { + const entry = this.processes.get(sessionId) + + if (!entry) { + return { + success: false, + message: `Session ${sessionId} not found. Use list_sessions to see active sessions.`, + } + } + + if (!entry.running) { + // Session exists but already completed + this.processes.delete(sessionId) + return { + success: true, + message: `Session ${sessionId} was already completed. Entry removed.`, + } + } + + try { + // Abort the process + entry.process.abort() + entry.running = false + + // Remove from tracking + this.processes.delete(sessionId) + + console.log(`[ProcessManager] Terminated session ${sessionId}`) + return { + success: true, + message: `Session ${sessionId} terminated successfully.`, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[ProcessManager] Error terminating session ${sessionId}:`, errorMessage) + return { + success: false, + message: `Failed to terminate session ${sessionId}: ${errorMessage}`, + } + } + } + /** * Clean up completed processes. */ diff --git a/src/integrations/terminal/__tests__/ProcessManager.spec.ts b/src/integrations/terminal/__tests__/ProcessManager.spec.ts index 69e21dbdc4..9a971f954e 100644 --- a/src/integrations/terminal/__tests__/ProcessManager.spec.ts +++ b/src/integrations/terminal/__tests__/ProcessManager.spec.ts @@ -222,4 +222,125 @@ describe("ProcessManager", () => { expect(sessions).toContain(id2) }) }) + + describe("listSessions", () => { + it("should return all sessions when no taskId filter", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 2") + + const sessions = manager.listSessions() + + expect(sessions).toHaveLength(2) + expect(sessions[0]).toMatchObject({ + taskId: "task-1", + command: "echo 1", + running: true, + }) + expect(sessions[1]).toMatchObject({ + taskId: "task-2", + command: "echo 2", + running: true, + }) + }) + + it("should filter sessions by taskId", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 2") + + const sessions = manager.listSessions("task-1") + + expect(sessions).toHaveLength(1) + expect(sessions[0].taskId).toBe("task-1") + }) + + it("should return empty array when no sessions exist", () => { + const manager = ProcessManager.getInstance() + + const sessions = manager.listSessions() + + expect(sessions).toHaveLength(0) + }) + + it("should sort sessions by session ID", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + + const id1 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1") + const id2 = manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 2") + + const sessions = manager.listSessions() + + expect(sessions[0].sessionId).toBe(id1) + expect(sessions[1].sessionId).toBe(id2) + }) + }) + + describe("terminateSession", () => { + it("should return error for non-existent session", () => { + const manager = ProcessManager.getInstance() + + const result = manager.terminateSession(999) + + expect(result.success).toBe(false) + expect(result.message).toContain("not found") + }) + + it("should remove completed session and return success", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = createMockProcess() + + const sessionId = manager.registerProcess(terminal, process, "task-1", "echo test") + manager.markCompleted(sessionId) + + const result = manager.terminateSession(sessionId) + + expect(result.success).toBe(true) + expect(result.message).toContain("already completed") + expect(manager.getProcess(sessionId)).toBeUndefined() + }) + + it("should abort running process and return success", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const mockAbort = vi.fn() + const process = { + ...createMockProcess(), + abort: mockAbort, + } as unknown as RooTerminalProcess + + const sessionId = manager.registerProcess(terminal, process, "task-1", "sleep 100") + + const result = manager.terminateSession(sessionId) + + expect(result.success).toBe(true) + expect(result.message).toContain("terminated successfully") + expect(mockAbort).toHaveBeenCalled() + expect(manager.getProcess(sessionId)).toBeUndefined() + }) + + it("should handle abort errors gracefully", () => { + const manager = ProcessManager.getInstance() + const terminal = createMockTerminal(1) + const process = { + ...createMockProcess(), + abort: () => { + throw new Error("Failed to abort") + }, + } as unknown as RooTerminalProcess + + const sessionId = manager.registerProcess(terminal, process, "task-1", "sleep 100") + + const result = manager.terminateSession(sessionId) + + expect(result.success).toBe(false) + expect(result.message).toContain("Failed to abort") + }) + }) }) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1ee38e14e8..3a99aa1874 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -115,6 +115,8 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_stdin: { session_id: number; chars?: string; yield_time_ms?: number; max_output_tokens?: number } + terminate_session: { session_id: number } + list_sessions: Record // No parameters write_to_file: { path: string; content: string } // Add more tools as they are migrated to native protocol } @@ -252,6 +254,8 @@ export type ToolGroupConfig = { export const TOOL_DISPLAY_NAMES: Record = { execute_command: "run commands", write_stdin: "write to terminal input", + terminate_session: "terminate terminal sessions", + list_sessions: "list active terminal sessions", read_file: "read files", read_command_output: "read command output", fetch_instructions: "fetch instructions", @@ -290,7 +294,7 @@ export const TOOL_GROUPS: Record = { tools: ["browser_action"], }, command: { - tools: ["execute_command", "write_stdin", "read_command_output"], + tools: ["execute_command", "write_stdin", "terminate_session", "list_sessions", "read_command_output"], }, mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], From cb28f75fff3747214c8f53f25eeb23f6abc4ddfc Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 18:30:56 -0700 Subject: [PATCH 20/24] feat: add stdin support for ExecaTerminalProcess - Add writeStdin method to RooTerminalProcess interface - Add abstract writeStdin to BaseTerminalProcess - Implement writeStdin in ExecaTerminalProcess using stdin pipe - Implement writeStdin in TerminalProcess using terminal.sendText - Change ExecaTerminalProcess from stdin: 'ignore' to stdin: 'pipe' - Update WriteStdinTool to use unified process.writeStdin() method This enables write_stdin tool to work with both VSCode terminals and Execa terminals, providing consistent interactive terminal support. --- src/core/tools/WriteStdinTool.ts | 19 +++++---------- .../terminal/BaseTerminalProcess.ts | 7 ++++++ .../terminal/ExecaTerminalProcess.ts | 24 +++++++++++++++++-- src/integrations/terminal/TerminalProcess.ts | 22 +++++++++++++++++ src/integrations/terminal/types.ts | 5 ++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/core/tools/WriteStdinTool.ts b/src/core/tools/WriteStdinTool.ts index 3b7d17bc7a..6c870ae1b8 100644 --- a/src/core/tools/WriteStdinTool.ts +++ b/src/core/tools/WriteStdinTool.ts @@ -4,7 +4,6 @@ import { Task } from "../task/Task" import { ToolUse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { ProcessManager } from "../../integrations/terminal/ProcessManager" -import { Terminal } from "../../integrations/terminal/Terminal" import { t } from "../../i18n" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -122,22 +121,16 @@ export class WriteStdinTool extends BaseTool<"write_stdin"> { // Process escape sequences in input const processedChars = this.processEscapeSequences(chars) - // Write to stdin + // Write to stdin using the unified writeStdin interface const { terminal, process } = entry let writeSuccess = false try { - if (terminal instanceof Terminal) { - // VSCode terminal - use sendText - // Note: sendText automatically adds a newline by default, so we pass false - // to prevent double newlines when the input already ends with \n - terminal.terminal.sendText(processedChars, false) - writeSuccess = true - } else { - // Execa terminal - would need stdin pipe support - // For now, we'll indicate this isn't supported for execa - // TODO: Implement stdin support for ExecaTerminalProcess - const errorMsg = `Session ${session_id} is using a non-interactive terminal. Interactive stdin is only supported for VSCode terminals.` + // Use the process writeStdin method which works for both VSCode and Execa terminals + writeSuccess = process.writeStdin(processedChars) + + if (!writeSuccess) { + const errorMsg = `Failed to write to session ${session_id}: stdin is not available` await task.say("error", errorMsg) pushToolResult(`Error: ${errorMsg}`) return diff --git a/src/integrations/terminal/BaseTerminalProcess.ts b/src/integrations/terminal/BaseTerminalProcess.ts index c1e26d51ee..d9b55a024a 100644 --- a/src/integrations/terminal/BaseTerminalProcess.ts +++ b/src/integrations/terminal/BaseTerminalProcess.ts @@ -137,6 +137,13 @@ export abstract class BaseTerminalProcess extends EventEmitter boolean getUnretrievedOutput: () => string trimRetrievedOutput: () => void + /** + * Write characters to stdin. Returns true if write was successful. + * May return false if stdin is not available (e.g., stdin: "ignore"). + */ + writeStdin: (chars: string) => boolean } export type RooTerminalProcessResultPromise = RooTerminalProcess & Promise From 56da141bd96e37425c7246f11e4e904edebb3040 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 27 Jan 2026 18:37:41 -0700 Subject: [PATCH 21/24] feat(ui): improve write_stdin, terminate_session, list_sessions rendering in ChatRow - Add proper UI cases for write_stdin showing session ID and input chars - Add terminate_session rendering with termination status - Add list_sessions rendering with sessions table - Add i18n translations for all stdin operations --- webview-ui/src/components/chat/ChatRow.tsx | 75 ++++++++++++++++++++++ webview-ui/src/i18n/locales/en/chat.json | 8 +++ 2 files changed, 83 insertions(+) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 4c205a4a1b..3099232609 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -71,6 +71,7 @@ import { Split, ArrowRight, Check, + OctagonX, } from "lucide-react" import { cn } from "@/lib/utils" import { PathTooltip } from "../ui/PathTooltip" @@ -992,6 +993,80 @@ export const ChatRowContent = ({ )} ) + case "write_stdin": { + const stdinTool = tool as any + const sessionId = stdinTool.session_id + const chars = stdinTool.chars || "" + const displayChars = chars.length > 30 ? chars.slice(0, 30) + "..." : chars + const escapedChars = displayChars.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + return ( + <> +
+ + + {chars + ? t("chat:stdinOperations.sentToSession", { + sessionId, + chars: escapedChars, + }) + : t("chat:stdinOperations.polledSession", { sessionId })} + +
+ {stdinTool.content && ( +
+ +
+ )} + + ) + } + case "terminate_session": { + const terminateTool = tool as any + const sessionId = terminateTool.session_id + return ( + <> +
+ + + {message.type === "ask" + ? t("chat:stdinOperations.wantsToTerminate", { sessionId }) + : t("chat:stdinOperations.didTerminate", { sessionId })} + +
+ {terminateTool.content && ( +
{terminateTool.content}
+ )} + + ) + } + case "list_sessions": + return ( + <> +
+ + + {message.type === "ask" + ? t("chat:stdinOperations.wantsToListSessions") + : t("chat:stdinOperations.didListSessions")} + +
+ {tool.content && ( +
+ +
+ )} + + ) default: return null } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index d167a19ff3..336605e5ce 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -498,5 +498,13 @@ }, "readCommandOutput": { "title": "Roo read command output" + }, + "stdinOperations": { + "sentToSession": "Sent \"{{chars}}\" to session {{sessionId}}", + "polledSession": "Polled session {{sessionId}}", + "wantsToTerminate": "Wants to terminate session {{sessionId}}", + "didTerminate": "Terminated session {{sessionId}}", + "wantsToListSessions": "Wants to list active sessions", + "didListSessions": "Listed active sessions" } } From 1071717537758b43058d82c2c1d3983c7e1cb47e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 28 Jan 2026 14:18:22 -0700 Subject: [PATCH 22/24] feat(ui): add terminal session indicator in TaskHeader - Add activeTerminalSessions count to ExtensionState type - Add ProcessManager query in ClineProvider.getState() to count running sessions - Add TerminalSquare icon indicator in TaskHeader showing session count - Show indicator only when there are active sessions (green icon + count) - Add i18n translations for terminal session tooltip - Update ExtensionStateContext test with new property --- packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 12 ++++++++ webview-ui/src/components/chat/TaskHeader.tsx | 28 ++++++++++++++++++- .../__tests__/ExtensionStateContext.spec.tsx | 1 + webview-ui/src/i18n/locales/en/chat.json | 5 ++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 7ae89e8777..d1435500da 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -385,6 +385,7 @@ export type ExtensionState = Pick< organizationSettingsVersion?: number isBrowserSessionActive: boolean // Actual browser session state + activeTerminalSessions: number // Count of active terminal sessions for current task autoCondenseContext: boolean autoCondenseContextPercent: number diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b101bee7d2..00a29930c3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -62,6 +62,7 @@ import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" import { ProfileValidator } from "../../shared/ProfileValidator" import { Terminal } from "../../integrations/terminal/Terminal" +import { ProcessManager } from "../../integrations/terminal/ProcessManager" import { downloadTask, getTaskFileName } from "../../integrations/misc/export-markdown" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getTheme } from "../../integrations/theme/getTheme" @@ -2074,6 +2075,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, isBrowserSessionActive, + activeTerminalSessions, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2123,6 +2125,7 @@ export class ClineProvider alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, isBrowserSessionActive, + activeTerminalSessions, allowedMaxRequests, allowedMaxCost, autoCondenseContext: autoCondenseContext ?? true, @@ -2357,6 +2360,14 @@ export class ClineProvider // Get actual browser session state const isBrowserSessionActive = this.getCurrentTask()?.browserSession?.isSessionActive() ?? false + // Get active terminal sessions count for current task + const currentTaskId = this.getCurrentTask()?.taskId + const activeTerminalSessions = currentTaskId + ? ProcessManager.getInstance() + .listSessions(currentTaskId) + .filter((s) => s.running).length + : 0 + // Return the same structure as before. return { apiConfiguration: providerSettings, @@ -2375,6 +2386,7 @@ export class ClineProvider alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, isBrowserSessionActive, + activeTerminalSessions, followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, allowedMaxRequests: stateValues.allowedMaxRequests, diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index d5424b7422..09e7e070c3 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -11,6 +11,7 @@ import { FoldVertical, Globe, ArrowLeft, + TerminalSquare, } from "lucide-react" import prettyBytes from "pretty-bytes" @@ -68,7 +69,8 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, activeTerminalSessions } = + useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -369,6 +371,30 @@ const TaskHeader = ({ )} )} + {(activeTerminalSessions ?? 0) > 0 && ( +
e.stopPropagation()}> + +
+ +
+
+ + {activeTerminalSessions} + +
+ )} )} {/* Expanded state: Show task text and images */} diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 0ee69a4ad6..9c5d11b30d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -219,6 +219,7 @@ describe("mergeExtensionState", () => { taskSyncEnabled: false, featureRoomoteControlEnabled: false, isBrowserSessionActive: false, + activeTerminalSessions: 0, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 336605e5ce..a69c068afe 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -407,6 +407,11 @@ "close": "Close browser" } }, + "terminal": { + "sessionsRunning_one": "{{count}} terminal session running", + "sessionsRunning_other": "{{count}} terminal sessions running", + "sessionsRunning": "{{count}} terminal session(s) running" + }, "codeblock": { "tooltips": { "expand": "Expand code block", From bb5ee6c0f260b901751d641b4143c728221a3e73 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 28 Jan 2026 14:55:27 -0700 Subject: [PATCH 23/24] fix: add activeTerminalSessions to initial state in ExtensionStateContext and test mock --- src/core/webview/__tests__/ClineProvider.spec.ts | 1 + webview-ui/src/context/ExtensionStateContext.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index cacaf26004..3f9501ee49 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -587,6 +587,7 @@ describe("ClineProvider", () => { taskSyncEnabled: false, featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + activeTerminalSessions: 0, } const message: ExtensionMessage = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d37f09bbc5..7660911e3a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -270,6 +270,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterImageGenerationSelectedModel: "", includeCurrentTime: true, includeCurrentCost: true, + activeTerminalSessions: 0, }) const [didHydrateState, setDidHydrateState] = useState(false) From 65a6ddefe70d3aab5148518405b736c5e289ee43 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 28 Jan 2026 15:22:54 -0700 Subject: [PATCH 24/24] i18n: add missing translations for interactive terminal feature Add translations for terminal session and stdin operation strings to all 17 supported locales in chat.json: - terminal.sessionsRunning (singular/plural forms) - stdinOperations.sentToSession - stdinOperations.polledSession - stdinOperations.wantsToTerminate - stdinOperations.didTerminate - stdinOperations.wantsToListSessions - stdinOperations.didListSessions --- webview-ui/src/i18n/locales/ca/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/de/chat.json | 13 +++++++++++++ webview-ui/src/i18n/locales/es/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/fr/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/hi/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/id/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/it/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/ja/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/ko/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/nl/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/pl/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/pt-BR/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/ru/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/tr/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/vi/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/zh-CN/chat.json | 15 ++++++++++++++- webview-ui/src/i18n/locales/zh-TW/chat.json | 15 ++++++++++++++- 17 files changed, 237 insertions(+), 16 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index c44a872433..ff674d468b 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -355,6 +355,11 @@ "total": "Cost total: ${{cost}}", "includesSubtasks": "Inclou els costos de les subtasques" }, + "terminal": { + "sessionsRunning_one": "{{count}} sessió de terminal en execució", + "sessionsRunning_other": "{{count}} sessions de terminal en execució", + "sessionsRunning": "{{count}} sessió(ns) de terminal en execució" + }, "browser": { "session": "Sessió del navegador", "active": "Actiu", @@ -506,6 +511,14 @@ "openMcpSettings": "Obrir configuració de MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo ha llegit la sortida de la comanda" + }, + "stdinOperations": { + "sentToSession": "S'ha enviat \"{{chars}}\" a la sessió {{sessionId}}", + "polledSession": "S'ha consultat la sessió {{sessionId}}", + "wantsToTerminate": "Vol finalitzar la sessió {{sessionId}}", + "didTerminate": "S'ha finalitzat la sessió {{sessionId}}", + "wantsToListSessions": "Vol llistar les sessions actives", + "didListSessions": "S'han llistat les sessions actives" } } diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 1f3f11bc81..9db17fc628 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -355,6 +355,11 @@ "total": "Gesamtkosten: ${{cost}}", "includesSubtasks": "Enthält Kosten für Unteraufgaben" }, + "terminal": { + "sessionsRunning_one": "{{count}} Terminal-Sitzung läuft", + "sessionsRunning_other": "{{count}} Terminal-Sitzungen laufen", + "sessionsRunning": "{{count}} Terminal-Sitzung(en) läuft/laufen" + }, "browser": { "session": "Browser-Sitzung", "active": "Aktiv", @@ -507,5 +512,13 @@ }, "readCommandOutput": { "title": "Roo las Befehlsausgabe" + }, + "stdinOperations": { + "sentToSession": "\"{{chars}}\" an Sitzung {{sessionId}} gesendet", + "polledSession": "Sitzung {{sessionId}} abgefragt", + "wantsToTerminate": "Möchte Sitzung {{sessionId}} beenden", + "didTerminate": "Sitzung {{sessionId}} beendet", + "wantsToListSessions": "Möchte aktive Sitzungen auflisten", + "didListSessions": "Aktive Sitzungen aufgelistet" } } diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 2c9418cfa7..52093804b7 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -355,6 +355,11 @@ "total": "Costo total: ${{cost}}", "includesSubtasks": "Incluye costos de subtareas" }, + "terminal": { + "sessionsRunning_one": "{{count}} sesión de terminal en ejecución", + "sessionsRunning_other": "{{count}} sesiones de terminal en ejecución", + "sessionsRunning": "{{count}} sesión(es) de terminal en ejecución" + }, "browser": { "session": "Sesión del navegador", "active": "Activo", @@ -506,6 +511,14 @@ "openMcpSettings": "Abrir configuración de MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo leyó la salida del comando" + }, + "stdinOperations": { + "sentToSession": "Se envió \"{{chars}}\" a la sesión {{sessionId}}", + "polledSession": "Se consultó la sesión {{sessionId}}", + "wantsToTerminate": "Quiere terminar la sesión {{sessionId}}", + "didTerminate": "Se terminó la sesión {{sessionId}}", + "wantsToListSessions": "Quiere listar las sesiones activas", + "didListSessions": "Se listaron las sesiones activas" } } diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 8aa09075dc..1699c8dfc0 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -355,6 +355,11 @@ "total": "Coût total : ${{cost}}", "includesSubtasks": "Inclut les coûts des sous-tâches" }, + "terminal": { + "sessionsRunning_one": "{{count}} session de terminal en cours d'exécution", + "sessionsRunning_other": "{{count}} sessions de terminal en cours d'exécution", + "sessionsRunning": "{{count}} session(s) de terminal en cours d'exécution" + }, "browser": { "session": "Session du navigateur", "active": "Actif", @@ -506,6 +511,14 @@ "openMcpSettings": "Ouvrir les paramètres MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo a lu la sortie de la commande" + }, + "stdinOperations": { + "sentToSession": "\"{{chars}}\" envoyé à la session {{sessionId}}", + "polledSession": "Session {{sessionId}} interrogée", + "wantsToTerminate": "Veut terminer la session {{sessionId}}", + "didTerminate": "Session {{sessionId}} terminée", + "wantsToListSessions": "Veut lister les sessions actives", + "didListSessions": "Sessions actives listées" } } diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 9c155e62ec..3776207907 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -355,6 +355,11 @@ "total": "कुल लागत: ${{cost}}", "includesSubtasks": "उप-कार्यों की लागत शामिल है" }, + "terminal": { + "sessionsRunning_one": "{{count}} टर्मिनल सत्र चल रहा है", + "sessionsRunning_other": "{{count}} टर्मिनल सत्र चल रहे हैं", + "sessionsRunning": "{{count}} टर्मिनल सत्र चल रहा/रहे है/हैं" + }, "browser": { "session": "ब्राउज़र सत्र", "active": "सक्रिय", @@ -506,6 +511,14 @@ "openMcpSettings": "MCP सेटिंग्स खोलें" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo ने कमांड आउटपुट पढ़ा" + }, + "stdinOperations": { + "sentToSession": "\"{{chars}}\" सत्र {{sessionId}} को भेजा गया", + "polledSession": "सत्र {{sessionId}} की जांच की गई", + "wantsToTerminate": "सत्र {{sessionId}} को समाप्त करना चाहता है", + "didTerminate": "सत्र {{sessionId}} समाप्त किया गया", + "wantsToListSessions": "सक्रिय सत्रों की सूची बनाना चाहता है", + "didListSessions": "सक्रिय सत्रों की सूची बनाई गई" } } diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index c8569f3646..f72fed5a16 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -376,6 +376,11 @@ "total": "Total Biaya: ${{cost}}", "includesSubtasks": "Termasuk biaya subtugas" }, + "terminal": { + "sessionsRunning_one": "{{count}} sesi terminal berjalan", + "sessionsRunning_other": "{{count}} sesi terminal berjalan", + "sessionsRunning": "{{count}} sesi terminal berjalan" + }, "browser": { "session": "Sesi Browser", "active": "Aktif", @@ -512,6 +517,14 @@ "openMcpSettings": "Buka Pengaturan MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo membaca output perintah" + }, + "stdinOperations": { + "sentToSession": "Mengirim \"{{chars}}\" ke sesi {{sessionId}}", + "polledSession": "Memeriksa sesi {{sessionId}}", + "wantsToTerminate": "Ingin menghentikan sesi {{sessionId}}", + "didTerminate": "Menghentikan sesi {{sessionId}}", + "wantsToListSessions": "Ingin mendaftar sesi aktif", + "didListSessions": "Mendaftar sesi aktif" } } diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index ac00a6dea0..2f3840aa7f 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -355,6 +355,11 @@ "total": "Costo totale: ${{cost}}", "includesSubtasks": "Include i costi delle sottoattività" }, + "terminal": { + "sessionsRunning_one": "{{count}} sessione di terminale in esecuzione", + "sessionsRunning_other": "{{count}} sessioni di terminale in esecuzione", + "sessionsRunning": "{{count}} sessione/i di terminale in esecuzione" + }, "browser": { "session": "Sessione del browser", "active": "Attivo", @@ -506,6 +511,14 @@ "openMcpSettings": "Apri impostazioni MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo ha letto l'output del comando" + }, + "stdinOperations": { + "sentToSession": "Inviato \"{{chars}}\" alla sessione {{sessionId}}", + "polledSession": "Controllata sessione {{sessionId}}", + "wantsToTerminate": "Vuole terminare la sessione {{sessionId}}", + "didTerminate": "Terminata sessione {{sessionId}}", + "wantsToListSessions": "Vuole elencare le sessioni attive", + "didListSessions": "Elencate le sessioni attive" } } diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 34a494ba23..fe721ff45a 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -355,6 +355,11 @@ "total": "合計コスト: ${{cost}}", "includesSubtasks": "サブタスクのコストを含む" }, + "terminal": { + "sessionsRunning_one": "{{count}} 個のターミナルセッションが実行中", + "sessionsRunning_other": "{{count}} 個のターミナルセッションが実行中", + "sessionsRunning": "{{count}} 個のターミナルセッションが実行中" + }, "browser": { "session": "ブラウザセッション", "active": "アクティブ", @@ -506,6 +511,14 @@ "openMcpSettings": "MCP 設定を開く" }, "readCommandOutput": { - "title": "Rooがコマンド出力を読み込みました" + "title": "Rooがコマンド出力を読み取りました" + }, + "stdinOperations": { + "sentToSession": "セッション {{sessionId}} に \"{{chars}}\" を送信しました", + "polledSession": "セッション {{sessionId}} をポーリングしました", + "wantsToTerminate": "セッション {{sessionId}} を終了したい", + "didTerminate": "セッション {{sessionId}} を終了しました", + "wantsToListSessions": "アクティブなセッションを一覧表示したい", + "didListSessions": "アクティブなセッションを一覧表示しました" } } diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 18d0089e34..f39896cb34 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -355,6 +355,11 @@ "total": "총 비용: ${{cost}}", "includesSubtasks": "하위 작업 비용 포함" }, + "terminal": { + "sessionsRunning_one": "{{count}}개의 터미널 세션이 실행 중", + "sessionsRunning_other": "{{count}}개의 터미널 세션이 실행 중", + "sessionsRunning": "{{count}}개의 터미널 세션이 실행 중" + }, "browser": { "session": "브라우저 세션", "active": "활성", @@ -506,6 +511,14 @@ "openMcpSettings": "MCP 설정 열기" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo가 명령 출력을 읽었습니다" + }, + "stdinOperations": { + "sentToSession": "세션 {{sessionId}}에 \"{{chars}}\" 전송", + "polledSession": "세션 {{sessionId}} 폴링", + "wantsToTerminate": "세션 {{sessionId}}를 종료하려고 함", + "didTerminate": "세션 {{sessionId}} 종료", + "wantsToListSessions": "활성 세션 목록을 보려고 함", + "didListSessions": "활성 세션 목록 표시" } } diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 5f0f693619..6afbe5e592 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -355,6 +355,11 @@ "total": "Totale kosten: ${{cost}}", "includesSubtasks": "Inclusief kosten van subtaken" }, + "terminal": { + "sessionsRunning_one": "{{count}} terminalsessie actief", + "sessionsRunning_other": "{{count}} terminalsessies actief", + "sessionsRunning": "{{count}} terminalsessie(s) actief" + }, "browser": { "session": "Browsersessie", "active": "Actief", @@ -506,6 +511,14 @@ "openMcpSettings": "MCP-instellingen openen" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo heeft opdrachtuitvoer gelezen" + }, + "stdinOperations": { + "sentToSession": "\"{{chars}}\" verzonden naar sessie {{sessionId}}", + "polledSession": "Sessie {{sessionId}} opgevraagd", + "wantsToTerminate": "Wil sessie {{sessionId}} beëindigen", + "didTerminate": "Sessie {{sessionId}} beëindigd", + "wantsToListSessions": "Wil actieve sessies weergeven", + "didListSessions": "Actieve sessies weergegeven" } } diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index fd90a26003..8432645284 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -355,6 +355,11 @@ "total": "Całkowity koszt: ${{cost}}", "includesSubtasks": "Zawiera koszty podzadań" }, + "terminal": { + "sessionsRunning_one": "{{count}} sesja terminala uruchomiona", + "sessionsRunning_other": "{{count}} sesji terminala uruchomionych", + "sessionsRunning": "{{count}} sesja/sesji terminala uruchomiona/uruchomionych" + }, "browser": { "session": "Sesja przeglądarki", "active": "Aktywna", @@ -506,6 +511,14 @@ "openMcpSettings": "Otwórz ustawienia MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo odczytał dane wyjściowe polecenia" + }, + "stdinOperations": { + "sentToSession": "Wysłano \"{{chars}}\" do sesji {{sessionId}}", + "polledSession": "Sprawdzono sesję {{sessionId}}", + "wantsToTerminate": "Chce zakończyć sesję {{sessionId}}", + "didTerminate": "Zakończono sesję {{sessionId}}", + "wantsToListSessions": "Chce wyświetlić aktywne sesje", + "didListSessions": "Wyświetlono aktywne sesje" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index c6fdc35e82..acc12cbf8b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -355,6 +355,11 @@ "total": "Custo Total: ${{cost}}", "includesSubtasks": "Inclui custos de subtarefas" }, + "terminal": { + "sessionsRunning_one": "{{count}} sessão de terminal em execução", + "sessionsRunning_other": "{{count}} sessões de terminal em execução", + "sessionsRunning": "{{count}} sessão(ões) de terminal em execução" + }, "browser": { "session": "Sessão do Navegador", "active": "Ativo", @@ -506,6 +511,14 @@ "openMcpSettings": "Abrir Configurações MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo leu a saída do comando" + }, + "stdinOperations": { + "sentToSession": "Enviado \"{{chars}}\" para a sessão {{sessionId}}", + "polledSession": "Sessão {{sessionId}} consultada", + "wantsToTerminate": "Deseja terminar a sessão {{sessionId}}", + "didTerminate": "Sessão {{sessionId}} terminada", + "wantsToListSessions": "Deseja listar sessões ativas", + "didListSessions": "Sessões ativas listadas" } } diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index dffbc64e8d..68ad020db4 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -356,6 +356,11 @@ "total": "Общая стоимость: ${{cost}}", "includesSubtasks": "Включает стоимость подзадач" }, + "terminal": { + "sessionsRunning_one": "{{count}} сеанс терминала запущен", + "sessionsRunning_other": "{{count}} сеансов терминала запущено", + "sessionsRunning": "{{count}} сеанс(ов) терминала запущено" + }, "browser": { "session": "Сеанс браузера", "active": "Активен", @@ -507,6 +512,14 @@ "openMcpSettings": "Открыть настройки MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo прочитал вывод команды" + }, + "stdinOperations": { + "sentToSession": "Отправлено \"{{chars}}\" в сеанс {{sessionId}}", + "polledSession": "Опрошен сеанс {{sessionId}}", + "wantsToTerminate": "Хочет завершить сеанс {{sessionId}}", + "didTerminate": "Завершен сеанс {{sessionId}}", + "wantsToListSessions": "Хочет получить список активных сеансов", + "didListSessions": "Получен список активных сеансов" } } diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 5d5d93893e..c26c124504 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -356,6 +356,11 @@ "total": "Toplam Maliyet: ${{cost}}", "includesSubtasks": "Alt görev maliyetlerini içerir" }, + "terminal": { + "sessionsRunning_one": "{{count}} terminal oturumu çalışıyor", + "sessionsRunning_other": "{{count}} terminal oturumu çalışıyor", + "sessionsRunning": "{{count}} terminal oturumu çalışıyor" + }, "browser": { "session": "Tarayıcı Oturumu", "active": "Aktif", @@ -507,6 +512,14 @@ "openMcpSettings": "MCP Ayarlarını Aç" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo komut çıktısını okudu" + }, + "stdinOperations": { + "sentToSession": "Oturum {{sessionId}}'ye \"{{chars}}\" gönderildi", + "polledSession": "Oturum {{sessionId}} sorgulandı", + "wantsToTerminate": "Oturum {{sessionId}}'yi sonlandırmak istiyor", + "didTerminate": "Oturum {{sessionId}} sonlandırıldı", + "wantsToListSessions": "Aktif oturumları listelemek istiyor", + "didListSessions": "Aktif oturumlar listelendi" } } diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 76191a03cf..78f38b846d 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -356,6 +356,11 @@ "total": "Tổng chi phí: ${{cost}}", "includesSubtasks": "Bao gồm chi phí của các tác vụ phụ" }, + "terminal": { + "sessionsRunning_one": "{{count}} phiên terminal đang chạy", + "sessionsRunning_other": "{{count}} phiên terminal đang chạy", + "sessionsRunning": "{{count}} phiên terminal đang chạy" + }, "browser": { "session": "Phiên trình duyệt", "active": "Đang hoạt động", @@ -507,6 +512,14 @@ "openMcpSettings": "Mở cài đặt MCP" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo đã đọc đầu ra lệnh" + }, + "stdinOperations": { + "sentToSession": "Đã gửi \"{{chars}}\" tới phiên {{sessionId}}", + "polledSession": "Đã thăm dò phiên {{sessionId}}", + "wantsToTerminate": "Muốn kết thúc phiên {{sessionId}}", + "didTerminate": "Đã kết thúc phiên {{sessionId}}", + "wantsToListSessions": "Muốn liệt kê các phiên đang hoạt động", + "didListSessions": "Đã liệt kê các phiên đang hoạt động" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index e63cc5dd08..26e9888740 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -356,6 +356,11 @@ "total": "总成本: ${{cost}}", "includesSubtasks": "包括子任务成本" }, + "terminal": { + "sessionsRunning_one": "{{count}} 个终端会话运行中", + "sessionsRunning_other": "{{count}} 个终端会话运行中", + "sessionsRunning": "{{count}} 个终端会话运行中" + }, "browser": { "session": "浏览器会话", "active": "活动中", @@ -507,6 +512,14 @@ "openMcpSettings": "打开 MCP 设置" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo 读取了命令输出" + }, + "stdinOperations": { + "sentToSession": "已向会话 {{sessionId}} 发送 \"{{chars}}\"", + "polledSession": "已轮询会话 {{sessionId}}", + "wantsToTerminate": "想要终止会话 {{sessionId}}", + "didTerminate": "已终止会话 {{sessionId}}", + "wantsToListSessions": "想要列出活动会话", + "didListSessions": "已列出活动会话" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 95a96503ba..667d442dca 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -369,6 +369,11 @@ "total": "總成本:${{cost}}", "includesSubtasks": "包含子任務成本" }, + "terminal": { + "sessionsRunning_one": "{{count}} 個終端機工作階段執行中", + "sessionsRunning_other": "{{count}} 個終端機工作階段執行中", + "sessionsRunning": "{{count}} 個終端機工作階段執行中" + }, "browser": { "session": "瀏覽器工作階段", "active": "使用中", @@ -497,6 +502,14 @@ "openMcpSettings": "開啟 MCP 設定" }, "readCommandOutput": { - "title": "Roo read command output" + "title": "Roo 讀取了命令輸出" + }, + "stdinOperations": { + "sentToSession": "已向工作階段 {{sessionId}} 傳送 \"{{chars}}\"", + "polledSession": "已輪詢工作階段 {{sessionId}}", + "wantsToTerminate": "想要終止工作階段 {{sessionId}}", + "didTerminate": "已終止工作階段 {{sessionId}}", + "wantsToListSessions": "想要列出使用中的工作階段", + "didListSessions": "已列出使用中的工作階段" } }