diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 2c7495e5ebef..2bad4ec5b385 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -33,6 +33,7 @@ export const toolNames = [ "new_task", "fetch_instructions", "codebase_search", + "terminal_kill", "update_todo_list", "run_slash_command", "generate_image", diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 689675999fd1..5cf02fe74c04 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,6 +37,7 @@ import { Task } from "../task/Task" import { codebaseSearchTool } from "../tools/codebaseSearchTool" import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { applyDiffToolLegacy } from "../tools/applyDiffTool" +import { terminalKillTool } from "../tools/terminalKillTool" /** * Processes and presents assistant message content to the user interface. @@ -223,10 +224,14 @@ export async function presentAssistantMessage(cline: Task) { const modeName = getModeBySlug(mode, customModes)?.name ?? mode return `[${block.name} in ${modeName} mode: '${message}']` } + case "terminal_kill": + return `[${block.name}]` case "run_slash_command": return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + default: + return `[${block.name}]` } } @@ -552,6 +557,9 @@ export async function presentAssistantMessage(cline: Task) { askFinishSubTaskApproval, ) break + case "terminal_kill": + await terminalKillTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + break case "run_slash_command": await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break diff --git a/src/core/prompts/tools/execute-command.ts b/src/core/prompts/tools/execute-command.ts index c1fc1ea3f19c..8c9220ad54c3 100644 --- a/src/core/prompts/tools/execute-command.ts +++ b/src/core/prompts/tools/execute-command.ts @@ -6,15 +6,18 @@ Description: Request to execute a CLI command on the system. Use this when you n Parameters: - command: (required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions. - cwd: (optional) The working directory to execute the command in (default: ${args.cwd}) +- run_in_background: (optional) If true, runs the command in the background without user interaction. Useful for long-running commands like dev servers or monitoring (default: false) + Usage: Your command here Working directory path (optional) -Example: Requesting to execute npm run dev +Example: Requesting to execute npm run dev in background npm run dev +true Example: Requesting to execute ls in a specific directory if directed diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index c212b18a3de4..dffb83535ff3 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -28,6 +28,7 @@ import { getUpdateTodoListDescription } from "./update-todo-list" import { getRunSlashCommandDescription } from "./run-slash-command" import { getGenerateImageDescription } from "./generate-image" import { CodeIndexManager } from "../../../services/code-index/manager" +import { getTerminalKillDescription } from "./terminal-ctrl" // Map of tool names to their description functions const toolDescriptionMap: Record string | undefined> = { @@ -58,6 +59,7 @@ const toolDescriptionMap: Record string | undefined> apply_diff: (args) => args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", update_todo_list: (args) => getUpdateTodoListDescription(args), + terminal_kill: (args) => getTerminalKillDescription(args), run_slash_command: () => getRunSlashCommandDescription(), generate_image: (args) => getGenerateImageDescription(args), } @@ -177,6 +179,7 @@ export { getSwitchModeDescription, getInsertContentDescription, getSearchAndReplaceDescription, + getTerminalKillDescription, getCodebaseSearchDescription, getRunSlashCommandDescription, getGenerateImageDescription, diff --git a/src/core/prompts/tools/terminal-ctrl.ts b/src/core/prompts/tools/terminal-ctrl.ts new file mode 100644 index 000000000000..4cdc57259a02 --- /dev/null +++ b/src/core/prompts/tools/terminal-ctrl.ts @@ -0,0 +1,14 @@ +import { ToolArgs } from "./types" + +export function getTerminalKillDescription(args: ToolArgs): string | undefined { + return `## terminal_kill +Description: Manage running processes in terminals. + +Parameters: +- terminal_id: (required) The terminal ID containing the process to kill + +Usage example: Kill a process running in terminal 1 + +1 +` +} diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index b9e0af3a8a38..e1c5f48c429b 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -21,6 +21,7 @@ vitest.mock("../../prompts/responses", () => ({ formatResponse: { toolError: vitest.fn((msg) => `Tool Error: ${msg}`), rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`), + toolResult: vitest.fn((content, images) => `Tool Result: ${content}`), }, })) vitest.mock("../../../utils/text-normalization", () => ({ @@ -52,6 +53,7 @@ describe("Command Execution Timeout Integration", () => { postMessageToWebview: vitest.fn(), }), }, + ask: vitest.fn(), say: vitest.fn().mockResolvedValue(undefined), } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index dbb1945177a3..a46690685a9f 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -67,7 +67,8 @@ beforeEach(() => { // Get the custom working directory if provided const customCwd = block.params.cwd - const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd) + const runInBackground = block.params.run_in_background === "true" + const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd, runInBackground) if (userRejected) { cline.didRejectTool = true @@ -309,4 +310,41 @@ describe("executeCommandTool", () => { expect(mockOptions.commandExecutionTimeout).toBeDefined() }) }) + + describe("run_in_background parameter", () => { + it("should extract run_in_background parameter when set to 'true'", async () => { + mockToolUse.params.command = "npm run dev" + mockToolUse.params.run_in_background = "true" + + await executeCommandTool( + mockCline as unknown as Task, + mockToolUse, + mockAskApproval as unknown as AskApproval, + mockHandleError as unknown as HandleError, + mockPushToolResult as unknown as PushToolResult, + mockRemoveClosingTag as unknown as RemoveClosingTag, + ) + + expect(mockExecuteCommand).toHaveBeenCalled() + const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1] + expect(lastCall[3]).toBe(true) // run_in_background parameter should be true + }) + + it("should default run_in_background to false when parameter is missing", async () => { + mockToolUse.params.command = "echo test" + + await executeCommandTool( + mockCline as unknown as Task, + mockToolUse, + mockAskApproval as unknown as AskApproval, + mockHandleError as unknown as HandleError, + mockPushToolResult as unknown as PushToolResult, + mockRemoveClosingTag as unknown as RemoveClosingTag, + ) + + expect(mockExecuteCommand).toHaveBeenCalled() + const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1] + expect(lastCall[3]).toBe(false) // run_in_background parameter should be false + }) + }) }) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 2c7ce0d023e2..25d8dcfbfb59 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -30,6 +30,7 @@ export async function executeCommandTool( ) { let command: string | undefined = block.params.command const customCwd: string | undefined = block.params.cwd + const runInBackground: boolean = block.params.run_in_background === "true" try { if (block.partial) { @@ -94,6 +95,7 @@ export async function executeCommandTool( terminalOutputLineLimit, terminalOutputCharacterLimit, commandExecutionTimeout, + runInBackground, } try { @@ -141,6 +143,7 @@ export type ExecuteCommandOptions = { terminalOutputLineLimit?: number terminalOutputCharacterLimit?: number commandExecutionTimeout?: number + runInBackground?: boolean } export async function executeCommand( @@ -150,6 +153,7 @@ export async function executeCommand( command, customCwd, terminalShellIntegrationDisabled = false, + runInBackground: runInBackgroundRequested = false, terminalOutputLineLimit = 500, terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, commandExecutionTimeout = 0, @@ -174,7 +178,7 @@ export async function executeCommand( } let message: { text?: string; images?: string[] } | undefined - let runInBackground = false + let runInBackground = runInBackgroundRequested let completed = false let result: string = "" let exitDetails: ExitCodeDetails | undefined @@ -219,10 +223,14 @@ export async function executeCommand( task.say("command_output", result) completed = true }, - onShellExecutionStarted: (pid: number | undefined) => { + onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => { console.log(`[executeCommand] onShellExecutionStarted: ${pid}`) const status: CommandExecutionStatus = { executionId, status: "started", pid, command } provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + + if (runInBackground) { + process.continue() + } }, onShellExecutionComplete: (details: ExitCodeDetails) => { const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode } diff --git a/src/core/tools/terminalKillTool.ts b/src/core/tools/terminalKillTool.ts new file mode 100644 index 000000000000..97c48ce00e4e --- /dev/null +++ b/src/core/tools/terminalKillTool.ts @@ -0,0 +1,111 @@ +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../integrations/terminal/Terminal" +import { formatResponse } from "../prompts/responses" +import { Task } from "../task/Task" +import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" + +export async function terminalKillTool( + task: Task, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const terminalId: string | undefined = block.params.terminal_id + + try { + if (block.partial) { + await task.ask("tool", removeClosingTag("terminal_id", terminalId), block.partial).catch(() => {}) + return + } + + if (!terminalId) { + task.consecutiveMistakeCount++ + task.recordToolError("terminal_kill") + pushToolResult(await task.sayAndCreateMissingParamError("terminal_kill", "terminal_id")) + return + } + + // Get approval for the action + const didApprove = await askApproval("tool", `Kill process in terminal ${terminalId}`) + if (!didApprove) { + return + } + + try { + const parsedId = parseInt(terminalId) + if (isNaN(parsedId)) { + task.consecutiveMistakeCount++ + task.recordToolError("terminal_kill") + pushToolResult(formatResponse.toolError(`Invalid terminal_id "${terminalId}". Must be a number.`)) + return + } + + const result = await killTerminalProcess(parsedId) + pushToolResult(formatResponse.toolResult(result)) + } catch (error) { + await handleError("killing terminal process", error) + } + } catch (error) { + await handleError("terminal control operation", error) + } +} + +/** + * Kills a process running in a specific terminal by sending Ctrl+C + * @param terminalId The terminal ID containing the process to kill + * @returns Promise Result message + */ +async function killTerminalProcess(terminalId: number): Promise { + const targetTerminal = findTerminal(terminalId) + if (!targetTerminal) { + return getTerminalNotFoundMessage(terminalId) + } + + if (!targetTerminal.busy && !targetTerminal.process) { + return `Terminal ${terminalId} is not running any process.` + } + + try { + if (targetTerminal instanceof Terminal) { + // For VSCode terminals, send Ctrl+C + targetTerminal.terminal.sendText("\x03") + return `Sent Ctrl+C to terminal ${terminalId}. Process should terminate shortly.` + } else { + // For ExecaTerminal, use the abort method + if (targetTerminal.process) { + targetTerminal.process.abort() + return `Terminated process in terminal ${terminalId}.` + } else { + return `No active process found in terminal ${terminalId}.` + } + } + } catch (error) { + throw new Error( + `Failed to kill process in terminal ${terminalId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +/** + * Helper function to find a terminal by ID + */ +function findTerminal(terminalId: number) { + const busyTerminals = TerminalRegistry.getTerminals(true) + const allTerminals = TerminalRegistry.getTerminals(false) + const allTerminalsList = [...busyTerminals, ...allTerminals] + + return allTerminalsList.find((t) => t.id === terminalId) +} + +/** + * Helper function to get terminal not found message + */ +function getTerminalNotFoundMessage(terminalId: number): string { + const busyTerminals = TerminalRegistry.getTerminals(true) + const allTerminals = TerminalRegistry.getTerminals(false) + const allTerminalsList = [...busyTerminals, ...allTerminals] + + return `Terminal ${terminalId} not found. Available terminals: ${allTerminalsList.map((t) => t.id).join(", ")}` +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 608b50752e7d..615c835f48f4 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -67,6 +67,8 @@ export const toolParamNames = [ "todos", "prompt", "image", + "run_in_background", + "terminal_id", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -82,7 +84,12 @@ export interface ToolUse { export interface ExecuteCommandToolUse extends ToolUse { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional - params: Partial, "command" | "cwd">> + params: Partial, "command" | "cwd" | "run_in_background">> +} + +export interface TerminalCtrlToolUse extends ToolUse { + name: "terminal_kill" + params: Partial, "terminal_id">> } export interface ReadFileToolUse extends ToolUse { @@ -200,6 +207,7 @@ export const TOOL_DISPLAY_NAMES: Record = { new_task: "create new task", insert_content: "insert content", search_and_replace: "search and replace", + terminal_kill: "terminal kill", codebase_search: "codebase search", update_todo_list: "update todo list", run_slash_command: "run slash command", @@ -225,7 +233,7 @@ export const TOOL_GROUPS: Record = { tools: ["browser_action"], }, command: { - tools: ["execute_command"], + tools: ["execute_command", "terminal_kill"], }, mcp: { tools: ["use_mcp_tool", "access_mcp_resource"],