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"],