Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const toolNames = [
"new_task",
"fetch_instructions",
"codebase_search",
"terminal_kill",
"update_todo_list",
"run_slash_command",
"generate_image",
Expand Down
8 changes: 8 additions & 0 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}]`
}
}

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/core/prompts/tools/execute-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<execute_command>
<command>Your command here</command>
<cwd>Working directory path (optional)</cwd>
</execute_command>

Example: Requesting to execute npm run dev
Example: Requesting to execute npm run dev in background
<execute_command>
<command>npm run dev</command>
<run_in_background>true</run_in_background>
</execute_command>

Example: Requesting to execute ls in a specific directory if directed
Expand Down
3 changes: 3 additions & 0 deletions src/core/prompts/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, (args: ToolArgs) => string | undefined> = {
Expand Down Expand Up @@ -58,6 +59,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => 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),
}
Expand Down Expand Up @@ -177,6 +179,7 @@ export {
getSwitchModeDescription,
getInsertContentDescription,
getSearchAndReplaceDescription,
getTerminalKillDescription,
getCodebaseSearchDescription,
getRunSlashCommandDescription,
getGenerateImageDescription,
Expand Down
14 changes: 14 additions & 0 deletions src/core/prompts/tools/terminal-ctrl.ts
Original file line number Diff line number Diff line change
@@ -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
<terminal_kill>
<terminal_id>1</terminal_id>
</terminal_kill>`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -52,6 +53,7 @@ describe("Command Execution Timeout Integration", () => {
postMessageToWebview: vitest.fn(),
}),
},
ask: vitest.fn(),
say: vitest.fn().mockResolvedValue(undefined),
}

Expand Down
40 changes: 39 additions & 1 deletion src/core/tools/__tests__/executeCommandTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})
})
})
10 changes: 9 additions & 1 deletion src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -94,6 +95,7 @@ export async function executeCommandTool(
terminalOutputLineLimit,
terminalOutputCharacterLimit,
commandExecutionTimeout,
runInBackground,
}

try {
Expand Down Expand Up @@ -141,6 +143,7 @@ export type ExecuteCommandOptions = {
terminalOutputLineLimit?: number
terminalOutputCharacterLimit?: number
commandExecutionTimeout?: number
runInBackground?: boolean
}

export async function executeCommand(
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -223,6 +227,10 @@ export async function executeCommand(
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 }
Expand Down
111 changes: 111 additions & 0 deletions src/core/tools/terminalKillTool.ts
Original file line number Diff line number Diff line change
@@ -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<string> Result message
*/
async function killTerminalProcess(terminalId: number): Promise<string> {
const targetTerminal = findTerminal(terminalId)
if (!targetTerminal) {
return getTerminalNotFoundMessage(terminalId)
}

if (!targetTerminal.busy && !targetTerminal.process) {
return `Terminal ${terminalId} is not running any process.`
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent null checking for process. The condition at line 74 checks !targetTerminal.busy && !targetTerminal.process, which returns early when process is null. However, at line 85 in the ExecaTerminal branch, the code calls targetTerminal.process.abort() without verifying process exists. This creates a potential null pointer exception if targetTerminal.busy is true but targetTerminal.process is null.

The early return condition should be if (!targetTerminal.process) to ensure process exists before attempting to use it in either branch.


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(", ")}`
}
12 changes: 10 additions & 2 deletions src/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const toolParamNames = [
"todos",
"prompt",
"image",
"run_in_background",
"terminal_id",
] as const

export type ToolParamName = (typeof toolParamNames)[number]
Expand All @@ -82,7 +84,12 @@ export interface ToolUse {
export interface ExecuteCommandToolUse extends ToolUse {
name: "execute_command"
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd">>
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd" | "run_in_background">>
}

export interface TerminalCtrlToolUse extends ToolUse {
name: "terminal_kill"
params: Partial<Pick<Record<ToolParamName, string>, "terminal_id">>
}

export interface ReadFileToolUse extends ToolUse {
Expand Down Expand Up @@ -200,6 +207,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
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",
Expand All @@ -225,7 +233,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
tools: ["browser_action"],
},
command: {
tools: ["execute_command"],
tools: ["execute_command", "terminal_kill"],
},
mcp: {
tools: ["use_mcp_tool", "access_mcp_resource"],
Expand Down