mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-09 03:17:58 +00:00
feat: add write_stdin tool for interactive terminal support
This commit implements the interactive terminal feature from the terminal integration specification (plans/extract-terminal-integration.md). Changes: - Add write_stdin to toolNames in packages/types/src/tool.ts - Create write_stdin native tool schema in src/core/prompts/tools/native-tools/ - Create WriteStdinTool handler in src/core/tools/ - Add ProcessManager to track running processes by session_id - Modify ExecuteCommandTool to register processes when still running - Add write_stdin to tool routing in presentAssistantMessage.ts - Add write_stdin to NativeToolCallParser for streaming support - Add tests for ProcessManager The write_stdin tool enables the LLM to: - Send input to running terminal processes (y/n prompts, passwords) - Send control characters like Ctrl+C (\x03) - Poll for new output from long-running processes When execute_command starts a process that's still running after the yield time, it registers the process with ProcessManager and returns a session_id. The LLM can then use write_stdin with that session_id to interact with the process.
This commit is contained in:
parent
d6aab9fcaf
commit
3b99b936f8
10 changed files with 976 additions and 1 deletions
|
|
@ -16,6 +16,7 @@ export type ToolGroup = z.infer<typeof toolGroupsSchema>
|
|||
|
||||
export const toolNames = [
|
||||
"execute_command",
|
||||
"write_stdin",
|
||||
"read_file",
|
||||
"read_command_output",
|
||||
"write_to_file",
|
||||
|
|
|
|||
|
|
@ -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<TName>
|
||||
}
|
||||
break
|
||||
|
||||
case "apply_diff":
|
||||
if (args.path !== undefined && args.diff !== undefined) {
|
||||
nativeArgs = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
87
src/core/prompts/tools/native-tools/write_stdin.ts
Normal file
87
src/core/prompts/tools/native-tools/write_stdin.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
337
src/core/tools/WriteStdinTool.ts
Normal file
337
src/core/tools/WriteStdinTool.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
267
src/integrations/terminal/ProcessManager.ts
Normal file
267
src/integrations/terminal/ProcessManager.ts
Normal file
|
|
@ -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<number, ProcessEntry> = 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
|
||||
}
|
||||
}
|
||||
225
src/integrations/terminal/__tests__/ProcessManager.spec.ts
Normal file
225
src/integrations/terminal/__tests__/ProcessManager.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown> }
|
||||
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<ToolName, string> = {
|
||||
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<ToolGroup, ToolGroupConfig> = {
|
|||
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"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue