diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index f90ef42ede..a8351c261e 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -16,6 +16,9 @@ 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/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/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/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 8aa369f74d..ca9b27c243 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -400,6 +400,30 @@ 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 "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 = { @@ -687,6 +711,30 @@ 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 "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 db17bb9704..9aa17f1bff 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -26,6 +26,9 @@ 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 { terminateSessionTool } from "../tools/TerminateSessionTool" +import { listSessionsTool } from "../tools/ListSessionsTool" import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" @@ -856,6 +859,27 @@ 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 "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 b6af18fa15..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,7 +20,9 @@ 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" export { getMcpServerTools } from "./mcp_server" @@ -65,6 +68,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch fetchInstructions, generateImage, listFiles, + listSessions, newTask, readCommandOutput, createReadFileTool(readFileOptions), @@ -74,7 +78,9 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch edit_file, searchFiles, switchMode, + terminateSession, updateTodoList, + writeStdin, writeToFile, ] satisfies OpenAI.Chat.ChatCompletionTool[] } 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/read_command_output.ts b/src/core/prompts/tools/native-tools/read_command_output.ts index 44c069be1e..007915b005 100644 --- a/src/core/prompts/tools/native-tools/read_command_output.ts +++ b/src/core/prompts/tools/native-tools/read_command_output.ts @@ -20,7 +20,7 @@ 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. @@ -38,7 +38,7 @@ 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)` 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/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 fca3cf7a31..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" @@ -264,10 +265,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 { @@ -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/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/core/tools/WriteStdinTool.ts b/src/core/tools/WriteStdinTool.ts new file mode 100644 index 0000000000..6c870ae1b8 --- /dev/null +++ b/src/core/tools/WriteStdinTool.ts @@ -0,0 +1,330 @@ +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 { 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 using the unified writeStdin interface + const { terminal, process } = entry + let writeSuccess = false + + try { + // 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 + } + } 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/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/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/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/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 = 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 + } + + /** + * 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. + */ + 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/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 7aba55173f..66e2e79db4 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -314,6 +314,28 @@ export class TerminalProcess extends BaseTerminalProcess { return this.removeEscapeSequences(outputToProcess) } + /** + * Write characters to stdin of the running process. + * For VSCode terminals, this uses sendText which sends to the terminal. + * @param chars The characters to write + * @returns true if write was successful + */ + public override writeStdin(chars: string): boolean { + try { + const vsceTerminal = this.terminal.terminal + if (!vsceTerminal) { + console.warn("[TerminalProcess#writeStdin] No VSCode terminal available") + return false + } + // sendText with addNewline=false to avoid double newlines + vsceTerminal.sendText(chars, false) + return true + } catch (error) { + console.error("[TerminalProcess#writeStdin] Failed to write:", error) + return false + } + } + private emitRemainingBufferIfListening() { if (this.isListening) { const remainingBuffer = this.getUnretrievedOutput() diff --git a/src/integrations/terminal/__tests__/ProcessManager.spec.ts b/src/integrations/terminal/__tests__/ProcessManager.spec.ts new file mode 100644 index 0000000000..9a971f954e --- /dev/null +++ b/src/integrations/terminal/__tests__/ProcessManager.spec.ts @@ -0,0 +1,346 @@ +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) + }) + }) + + 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/integrations/terminal/types.ts b/src/integrations/terminal/types.ts index a0c5cde5d5..4d4240d1bd 100644 --- a/src/integrations/terminal/types.ts +++ b/src/integrations/terminal/types.ts @@ -37,6 +37,11 @@ export interface RooTerminalProcess 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 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/src/shared/tools.ts b/src/shared/tools.ts index dc1615c065..3a99aa1874 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,9 @@ 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 } + 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 } @@ -246,6 +253,9 @@ 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", @@ -284,7 +294,7 @@ export const TOOL_GROUPS: Record = { tools: ["browser_action"], }, command: { - tools: ["execute_command", "read_command_output"], + tools: ["execute_command", "write_stdin", "terminate_session", "list_sessions", "read_command_output"], }, mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 6a6d5c3f6d..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 } @@ -1470,7 +1545,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 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/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) 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/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/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index d167a19ff3..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", @@ -498,5 +503,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" } } 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": "已列出使用中的工作階段" } }