Skip to content

Commit ad01db6

Browse files
Chris Hassonhassoncs
authored andcommitted
feat(terminal): add background command execution and terminal control
- Add `run_in_background` parameter to `execute_command` tool for automatic background execution - Introduce new `terminal_ctrl` tool for killing processes in terminals - Update environment details to include terminal ID information for process management - Refactor tests to focus on kill command generation logic BREAKING CHANGE: New tool parameters and tools added, may require updates to tool usage patterns
1 parent 3a47c55 commit ad01db6

File tree

11 files changed

+220
-7
lines changed

11 files changed

+220
-7
lines changed

.changeset/fluffy-mice-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": minor
3+
---
4+
5+
Allow the agent to execute terminal commands in the background and manage long-running processes

packages/types/src/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const toolNames = [
3636
"update_todo_list",
3737
"run_slash_command",
3838
"generate_image",
39+
"terminal_ctrl", // kilocode_change: new terminal control tool
3940
] as const
4041

4142
export const toolNamesSchema = z.enum(toolNames)

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Task } from "../task/Task"
3737
import { codebaseSearchTool } from "../tools/codebaseSearchTool"
3838
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
3939
import { applyDiffToolLegacy } from "../tools/applyDiffTool"
40+
import { terminalCtrlTool } from "../tools/terminalCtrlTool"
4041

4142
/**
4243
* Processes and presents assistant message content to the user interface.
@@ -223,10 +224,14 @@ export async function presentAssistantMessage(cline: Task) {
223224
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
224225
return `[${block.name} in ${modeName} mode: '${message}']`
225226
}
227+
case "terminal_ctrl":
228+
return `[${block.name}']`
226229
case "run_slash_command":
227230
return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]`
228231
case "generate_image":
229232
return `[${block.name} for '${block.params.path}']`
233+
default:
234+
return `[${block.name}]`
230235
}
231236
}
232237

@@ -552,6 +557,9 @@ export async function presentAssistantMessage(cline: Task) {
552557
askFinishSubTaskApproval,
553558
)
554559
break
560+
case "terminal_ctrl":
561+
await terminalCtrlTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
562+
break
555563
case "run_slash_command":
556564
await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
557565
break

src/core/prompts/tools/execute-command.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ Description: Request to execute a CLI command on the system. Use this when you n
66
Parameters:
77
- 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.
88
- cwd: (optional) The working directory to execute the command in (default: ${args.cwd})
9+
- 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)
10+
911
Usage:
1012
<execute_command>
1113
<command>Your command here</command>
1214
<cwd>Working directory path (optional)</cwd>
1315
</execute_command>
1416
15-
Example: Requesting to execute npm run dev
17+
Example: Requesting to execute npm run dev in background
1618
<execute_command>
1719
<command>npm run dev</command>
20+
<run_in_background>true</run_in_background>
1821
</execute_command>
1922
2023
Example: Requesting to execute ls in a specific directory if directed

src/core/prompts/tools/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getUpdateTodoListDescription } from "./update-todo-list"
2828
import { getRunSlashCommandDescription } from "./run-slash-command"
2929
import { getGenerateImageDescription } from "./generate-image"
3030
import { CodeIndexManager } from "../../../services/code-index/manager"
31+
import { getTerminalCtrlDescription } from "./terminal-ctrl"
3132

3233
// Map of tool names to their description functions
3334
const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {
@@ -58,6 +59,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
5859
apply_diff: (args) =>
5960
args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "",
6061
update_todo_list: (args) => getUpdateTodoListDescription(args),
62+
terminal_ctrl: (args) => getTerminalCtrlDescription(args),
6163
run_slash_command: () => getRunSlashCommandDescription(),
6264
generate_image: (args) => getGenerateImageDescription(args),
6365
}
@@ -177,6 +179,7 @@ export {
177179
getSwitchModeDescription,
178180
getInsertContentDescription,
179181
getSearchAndReplaceDescription,
182+
getTerminalCtrlDescription,
180183
getCodebaseSearchDescription,
181184
getRunSlashCommandDescription,
182185
getGenerateImageDescription,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ToolArgs } from "./types"
2+
3+
export function getTerminalCtrlDescription(args: ToolArgs): string | undefined {
4+
return `## terminal_ctrl
5+
Description: Kill running processes in terminals. This provides clean terminal process management by sending Ctrl+C to terminate processes without intercepting command execution.
6+
7+
Parameters:
8+
- action: (required) Must be "kill" - the only supported action
9+
- terminal_id: (required) The terminal ID containing the process to kill
10+
11+
Usage example: Kill a process running in terminal 1
12+
<terminal_ctrl>
13+
<action>kill</action>
14+
<terminal_id>1</terminal_id>
15+
</terminal_ctrl>`
16+
}

src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vitest.mock("../../prompts/responses", () => ({
2121
formatResponse: {
2222
toolError: vitest.fn((msg) => `Tool Error: ${msg}`),
2323
rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`),
24+
toolResult: vitest.fn((content, images) => `Tool Result: ${content}`),
2425
},
2526
}))
2627
vitest.mock("../../../utils/text-normalization", () => ({
@@ -52,6 +53,7 @@ describe("Command Execution Timeout Integration", () => {
5253
postMessageToWebview: vitest.fn(),
5354
}),
5455
},
56+
ask: vitest.fn(),
5557
say: vitest.fn().mockResolvedValue(undefined),
5658
}
5759

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ beforeEach(() => {
6666

6767
// Get the custom working directory if provided
6868
const customCwd = block.params.cwd
69-
70-
const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd)
69+
const runInBackground = block.params.run_in_background === "true" // kilocode_change
70+
const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd, runInBackground) // kilocode_change - add run_in_background
7171

7272
if (userRejected) {
7373
cline.didRejectTool = true
@@ -309,4 +309,43 @@ describe("executeCommandTool", () => {
309309
expect(mockOptions.commandExecutionTimeout).toBeDefined()
310310
})
311311
})
312+
313+
// kilocode_change start
314+
describe("run_in_background parameter", () => {
315+
it("should extract run_in_background parameter when set to 'true'", async () => {
316+
mockToolUse.params.command = "npm run dev"
317+
mockToolUse.params.run_in_background = "true"
318+
319+
await executeCommandTool(
320+
mockCline as unknown as Task,
321+
mockToolUse,
322+
mockAskApproval as unknown as AskApproval,
323+
mockHandleError as unknown as HandleError,
324+
mockPushToolResult as unknown as PushToolResult,
325+
mockRemoveClosingTag as unknown as RemoveClosingTag,
326+
)
327+
328+
expect(mockExecuteCommand).toHaveBeenCalled()
329+
const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1]
330+
expect(lastCall[3]).toBe(true) // run_in_background parameter should be true
331+
})
332+
333+
it("should default run_in_background to false when parameter is missing", async () => {
334+
mockToolUse.params.command = "echo test"
335+
336+
await executeCommandTool(
337+
mockCline as unknown as Task,
338+
mockToolUse,
339+
mockAskApproval as unknown as AskApproval,
340+
mockHandleError as unknown as HandleError,
341+
mockPushToolResult as unknown as PushToolResult,
342+
mockRemoveClosingTag as unknown as RemoveClosingTag,
343+
)
344+
345+
expect(mockExecuteCommand).toHaveBeenCalled()
346+
const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1]
347+
expect(lastCall[3]).toBe(false) // run_in_background parameter should be false
348+
})
349+
})
350+
// kilocode_change end
312351
})

src/core/tools/executeCommandTool.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export async function executeCommandTool(
3030
) {
3131
let command: string | undefined = block.params.command
3232
const customCwd: string | undefined = block.params.cwd
33+
const runInBackground: boolean = block.params.run_in_background === "true"
3334

3435
try {
3536
if (block.partial) {
@@ -94,6 +95,7 @@ export async function executeCommandTool(
9495
terminalOutputLineLimit,
9596
terminalOutputCharacterLimit,
9697
commandExecutionTimeout,
98+
runInBackground,
9799
}
98100

99101
try {
@@ -141,6 +143,7 @@ export type ExecuteCommandOptions = {
141143
terminalOutputLineLimit?: number
142144
terminalOutputCharacterLimit?: number
143145
commandExecutionTimeout?: number
146+
runInBackground?: boolean
144147
}
145148

146149
export async function executeCommand(
@@ -150,6 +153,7 @@ export async function executeCommand(
150153
command,
151154
customCwd,
152155
terminalShellIntegrationDisabled = false,
156+
runInBackground: runInBackgroundRequested = false,
153157
terminalOutputLineLimit = 500,
154158
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
155159
commandExecutionTimeout = 0,
@@ -174,7 +178,8 @@ export async function executeCommand(
174178
}
175179

176180
let message: { text?: string; images?: string[] } | undefined
177-
let runInBackground = false
181+
let runInBackground = runInBackgroundRequested // kilocode_change
182+
let backgroundProcessStarted = false // kilocode_change
178183
let completed = false
179184
let result: string = ""
180185
let exitDetails: ExitCodeDetails | undefined
@@ -195,9 +200,13 @@ export async function executeCommand(
195200
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
196201
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
197202

198-
if (runInBackground) {
203+
// kilocode_change start- If run_in_backgroundRequested, automatically continue the process
204+
if (runInBackgroundRequested && !backgroundProcessStarted) {
205+
backgroundProcessStarted = true
206+
process.continue()
199207
return
200208
}
209+
// kilocode_change end
201210

202211
try {
203212
const { response, text, images } = await task.ask("command_output", "")

src/core/tools/terminalCtrlTool.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
2+
import { Terminal } from "../../integrations/terminal/Terminal"
3+
import { formatResponse } from "../prompts/responses"
4+
import { Task } from "../task/Task"
5+
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
6+
7+
export async function terminalCtrlTool(
8+
task: Task,
9+
block: ToolUse,
10+
askApproval: AskApproval,
11+
handleError: HandleError,
12+
pushToolResult: PushToolResult,
13+
removeClosingTag: RemoveClosingTag,
14+
) {
15+
const action: string | undefined = block.params.action
16+
const terminalId: string | undefined = block.params.terminal_id
17+
18+
try {
19+
if (block.partial) {
20+
await task.ask("command", removeClosingTag("action", action), block.partial).catch(() => {})
21+
return
22+
}
23+
24+
if (!action) {
25+
task.consecutiveMistakeCount++
26+
task.recordToolError("terminal_ctrl")
27+
pushToolResult(await task.sayAndCreateMissingParamError("terminal_ctrl", "action"))
28+
return
29+
}
30+
31+
if (!terminalId) {
32+
task.consecutiveMistakeCount++
33+
task.recordToolError("terminal_ctrl")
34+
pushToolResult(await task.sayAndCreateMissingParamError("terminal_ctrl", "terminal_id"))
35+
return
36+
}
37+
38+
// Only support kill action for MVP
39+
if (action !== "kill") {
40+
task.consecutiveMistakeCount++
41+
task.recordToolError("terminal_ctrl")
42+
pushToolResult(formatResponse.toolError(`Invalid action "${action}". Only "kill" is supported.`))
43+
return
44+
}
45+
46+
// Get approval for the action
47+
const didApprove = await askApproval("command", `Kill process in terminal ${terminalId}`)
48+
if (!didApprove) {
49+
return
50+
}
51+
52+
try {
53+
const result = await killTerminalProcess(parseInt(terminalId))
54+
pushToolResult(formatResponse.toolResult(result))
55+
} catch (error) {
56+
await handleError("killing terminal process", error)
57+
}
58+
} catch (error) {
59+
await handleError("terminal control operation", error)
60+
}
61+
}
62+
63+
/**
64+
* Kills a process running in a specific terminal by sending Ctrl+C
65+
* @param terminalId The terminal ID containing the process to kill
66+
* @returns Promise<string> Result message
67+
*/
68+
async function killTerminalProcess(terminalId: number): Promise<string> {
69+
const targetTerminal = findTerminal(terminalId)
70+
if (!targetTerminal) {
71+
return getTerminalNotFoundMessage(terminalId)
72+
}
73+
74+
if (!targetTerminal.busy && !targetTerminal.process) {
75+
return `Terminal ${terminalId} is not running any process.`
76+
}
77+
78+
try {
79+
if (targetTerminal instanceof Terminal) {
80+
// For VSCode terminals, send Ctrl+C
81+
targetTerminal.terminal.sendText("\x03")
82+
return `Sent Ctrl+C to terminal ${terminalId}. Process should terminate shortly.`
83+
} else {
84+
// For ExecaTerminal, use the abort method
85+
if (targetTerminal.process) {
86+
targetTerminal.process.abort()
87+
return `Terminated process in terminal ${terminalId}.`
88+
} else {
89+
return `No active process found in terminal ${terminalId}.`
90+
}
91+
}
92+
} catch (error) {
93+
throw new Error(
94+
`Failed to kill process in terminal ${terminalId}: ${error instanceof Error ? error.message : String(error)}`,
95+
)
96+
}
97+
}
98+
99+
/**
100+
* Helper function to find a terminal by ID
101+
*/
102+
function findTerminal(terminalId: number) {
103+
const busyTerminals = TerminalRegistry.getTerminals(true)
104+
const allTerminals = TerminalRegistry.getTerminals(false)
105+
const allTerminalsList = [...busyTerminals, ...allTerminals]
106+
107+
return allTerminalsList.find((t) => t.id === terminalId)
108+
}
109+
110+
/**
111+
* Helper function to get terminal not found message
112+
*/
113+
function getTerminalNotFoundMessage(terminalId: number): string {
114+
const busyTerminals = TerminalRegistry.getTerminals(true)
115+
const allTerminals = TerminalRegistry.getTerminals(false)
116+
const allTerminalsList = [...busyTerminals, ...allTerminals]
117+
118+
return `Terminal ${terminalId} not found. Available terminals: ${allTerminalsList.map((t) => t.id).join(", ")}`
119+
}

0 commit comments

Comments
 (0)