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:
Hannes Rudolph 2026-01-27 16:49:57 -07:00
parent d6aab9fcaf
commit 3b99b936f8
10 changed files with 976 additions and 1 deletions

View file

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

View 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 = {

View file

@ -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,

View file

@ -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[]
}

View 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

View file

@ -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"),
]
}

View 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()

View 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
}
}

View 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)
})
})
})

View file

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