feat: add terminate_session and list_sessions tools

- Add terminate_session tool to kill running terminal sessions
- Add list_sessions tool to view active terminal sessions
- Add listSessions and terminateSession methods to ProcessManager
- Create native tool schemas for both tools
- Create tool handlers (TerminateSessionTool, ListSessionsTool)
- Add routing in presentAssistantMessage.ts
- Add parsing support in NativeToolCallParser.ts
- Add 8 new tests for ProcessManager methods (23 total tests)

These tools complement write_stdin to provide complete terminal session management:
- execute_command starts a process (returns session_id if still running)
- write_stdin sends input to running processes
- list_sessions shows all active sessions
- terminate_session kills a running session
This commit is contained in:
Hannes Rudolph 2026-01-27 17:02:00 -07:00
parent 3b99b936f8
commit c9af762114
11 changed files with 612 additions and 1 deletions

View file

@ -17,6 +17,8 @@ export type ToolGroup = z.infer<typeof toolGroupsSchema>
export const toolNames = [
"execute_command",
"write_stdin",
"terminate_session",
"list_sessions",
"read_file",
"read_command_output",
"write_to_file",

View file

@ -411,6 +411,19 @@ export class NativeToolCallParser {
}
break
case "terminate_session":
if (partialArgs.session_id !== undefined) {
nativeArgs = {
session_id: partialArgs.session_id,
}
}
break
case "list_sessions":
// No parameters needed
nativeArgs = {}
break
case "write_to_file":
if (partialArgs.path || partialArgs.content) {
nativeArgs = {
@ -709,6 +722,19 @@ export class NativeToolCallParser {
}
break
case "terminate_session":
if (args.session_id !== undefined) {
nativeArgs = {
session_id: args.session_id,
} as NativeArgsFor<TName>
}
break
case "list_sessions":
// No parameters needed
nativeArgs = {} as NativeArgsFor<TName>
break
case "apply_diff":
if (args.path !== undefined && args.diff !== undefined) {
nativeArgs = {

View file

@ -27,6 +27,8 @@ import { searchFilesTool } from "../tools/SearchFilesTool"
import { browserActionTool } from "../tools/BrowserActionTool"
import { executeCommandTool } from "../tools/ExecuteCommandTool"
import { writeStdinTool } from "../tools/WriteStdinTool"
import { terminateSessionTool } from "../tools/TerminateSessionTool"
import { listSessionsTool } from "../tools/ListSessionsTool"
import { useMcpToolTool } from "../tools/UseMcpToolTool"
import { accessMcpResourceTool } from "../tools/accessMcpResourceTool"
import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool"
@ -864,6 +866,20 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult,
})
break
case "terminate_session":
await terminateSessionTool.handle(cline, block as ToolUse<"terminate_session">, {
askApproval,
handleError,
pushToolResult,
})
break
case "list_sessions":
await listSessionsTool.handle(cline, block as ToolUse<"list_sessions">, {
askApproval,
handleError,
pushToolResult,
})
break
case "use_mcp_tool":
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
askApproval,

View file

@ -10,6 +10,7 @@ import executeCommand from "./execute_command"
import fetchInstructions from "./fetch_instructions"
import generateImage from "./generate_image"
import listFiles from "./list_files"
import listSessions from "./list_sessions"
import newTask from "./new_task"
import readCommandOutput from "./read_command_output"
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
@ -19,6 +20,7 @@ import searchReplace from "./search_replace"
import edit_file from "./edit_file"
import searchFiles from "./search_files"
import switchMode from "./switch_mode"
import terminateSession from "./terminate_session"
import updateTodoList from "./update_todo_list"
import writeStdin from "./write_stdin"
import writeToFile from "./write_to_file"
@ -66,6 +68,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
fetchInstructions,
generateImage,
listFiles,
listSessions,
newTask,
readCommandOutput,
createReadFileTool(readFileOptions),
@ -75,6 +78,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
edit_file,
searchFiles,
switchMode,
terminateSession,
updateTodoList,
writeStdin,
writeToFile,

View file

@ -0,0 +1,47 @@
import type OpenAI from "openai"
/**
* Native tool definition for list_sessions.
*
* This tool allows the LLM to see all active terminal sessions
* that can be interacted with using write_stdin or terminated.
*/
const LIST_SESSIONS_DESCRIPTION = `Lists all active terminal sessions that were started by execute_command.
Use this tool when:
1. You need to know which sessions are still running
2. You forgot or lost track of a session_id
3. You want to see the status of multiple background processes
4. Before using write_stdin or terminate_session when unsure of the session_id
Returns a list of sessions with:
- session_id: The identifier to use with write_stdin or terminate_session
- command: The original command that was executed
- running: Whether the process is still actively running
- last_used: Relative time since last interaction
Example response:
┌──────────┬─────────────────────────────────┬─────────┬──────────────┐
│ Session │ Command │ Status │ Last Used │
├──────────┼─────────────────────────────────┼─────────┼──────────────┤
│ 1 │ npm run dev │ Running │ 30 seconds │
│ 2 │ python manage.py runserver │ Running │ 2 minutes │
│ 3 │ tail -f /var/log/syslog │ Stopped │ 5 minutes │
└──────────┴─────────────────────────────────┴─────────┴──────────────┘
This tool takes no parameters.`
export default {
type: "function",
function: {
name: "list_sessions",
description: LIST_SESSIONS_DESCRIPTION,
parameters: {
type: "object",
properties: {},
required: [],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -0,0 +1,47 @@
import type OpenAI from "openai"
/**
* Native tool definition for terminate_session.
*
* This tool allows the LLM to terminate a running terminal session
* that was started by execute_command and is still active.
*/
const TERMINATE_SESSION_DESCRIPTION = `Terminates a running terminal session by sending an abort signal to the process.
Use this tool when:
1. A long-running command needs to be stopped (e.g., a server, watch process)
2. A command is stuck or unresponsive
3. You no longer need a background process that was started earlier
4. You want to free up resources from idle sessions
The session_id is returned by execute_command when a process is still running.
Parameters:
- session_id: (required) Identifier of the running exec session to terminate
Example: Terminating a development server
{ "session_id": 1234 }
Note: After termination, the session_id is no longer valid. Use list_sessions to see remaining active sessions.`
const SESSION_ID_DESCRIPTION = `Identifier of the running exec session to terminate (returned by execute_command)`
export default {
type: "function",
function: {
name: "terminate_session",
description: TERMINATE_SESSION_DESCRIPTION,
parameters: {
type: "object",
properties: {
session_id: {
type: "number",
description: SESSION_ID_DESCRIPTION,
},
},
required: ["session_id"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -0,0 +1,143 @@
import { Task } from "../task/Task"
import { ToolUse } from "../../shared/tools"
import { formatResponse } from "../prompts/responses"
import { ProcessManager } from "../../integrations/terminal/ProcessManager"
import { BaseTool, ToolCallbacks } from "./BaseTool"
/**
* ListSessionsTool enables the LLM to see all active terminal sessions.
*
* This tool lists all terminal sessions that were started by execute_command
* and can be interacted with using write_stdin or terminated.
*
* ## Use Cases
*
* - Checking which background processes are still running
* - Finding a session_id that was forgotten
* - Auditing resource usage before task completion
* - Verifying that a server/process is still active
*/
export class ListSessionsTool extends BaseTool<"list_sessions"> {
readonly name = "list_sessions" as const
async execute(_params: Record<string, never>, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { handleError, pushToolResult } = callbacks
try {
// Get all sessions from ProcessManager
const processManager = ProcessManager.getInstance()
const sessions = processManager.listSessions(task.taskId)
task.consecutiveMistakeCount = 0
// Format response
const response = this.formatResponse(sessions)
await task.say("tool", response, undefined, false)
pushToolResult(formatResponse.toolResult(response))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await handleError("listing sessions", error instanceof Error ? error : new Error(errorMessage))
task.recordToolError("list_sessions")
}
}
override async handlePartial(task: Task, _block: ToolUse<"list_sessions">): Promise<void> {
await task.say(
"tool",
JSON.stringify({
tool: "list_sessions",
content: "Listing active sessions...",
}),
undefined,
true,
)
}
/**
* Format the sessions list into a readable table.
*/
private formatResponse(
sessions: Array<{
sessionId: number
taskId: string
command: string
running: boolean
lastUsed: number
}>,
): string {
if (sessions.length === 0) {
return `## Active Terminal Sessions
No active sessions found.
Sessions are created when execute_command starts a process that doesn't complete within the yield time.
Use execute_command to start a new process that can be interacted with.`
}
const lines: string[] = []
lines.push("## Active Terminal Sessions")
lines.push("")
lines.push(`Found ${sessions.length} active session${sessions.length !== 1 ? "s" : ""}:`)
lines.push("")
lines.push("| Session | Command | Status | Last Used |")
lines.push("|---------|---------|--------|-----------|")
for (const session of sessions) {
const status = session.running ? "🟢 Running" : "⚪ Stopped"
const lastUsed = this.formatTimeSince(session.lastUsed)
const command = this.truncateCommand(session.command, 40)
lines.push(`| ${session.sessionId} | \`${command}\` | ${status} | ${lastUsed} |`)
}
lines.push("")
lines.push("**Actions:**")
lines.push("- Use `write_stdin` with a session_id to send input to a running process")
lines.push("- Use `terminate_session` with a session_id to stop a process")
return lines.join("\n")
}
/**
* Format time since a timestamp as a human-readable string.
*/
private formatTimeSince(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 60) {
return `${seconds}s ago`
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
/**
* Truncate a command string for display.
*/
private truncateCommand(command: string, maxLength: number): string {
// Remove newlines and extra whitespace
const cleaned = command.replace(/\s+/g, " ").trim()
if (cleaned.length <= maxLength) {
return cleaned
}
return cleaned.slice(0, maxLength - 3) + "..."
}
}
// Export singleton instance
export const listSessionsTool = new ListSessionsTool()

View file

@ -0,0 +1,113 @@
import { Task } from "../task/Task"
import { ToolUse } from "../../shared/tools"
import { formatResponse } from "../prompts/responses"
import { ProcessManager } from "../../integrations/terminal/ProcessManager"
import { BaseTool, ToolCallbacks } from "./BaseTool"
/**
* Parameters for the terminate_session tool.
*/
interface TerminateSessionParams {
/** Session ID of the running process to terminate */
session_id: number
}
/**
* TerminateSessionTool enables the LLM to terminate running terminal sessions.
*
* This tool works in conjunction with execute_command:
* 1. execute_command starts a process and returns a session_id if still running
* 2. terminate_session uses that session_id to abort the process
*
* ## Use Cases
*
* - Stopping a development server that's no longer needed
* - Terminating stuck or unresponsive processes
* - Cleaning up background processes before completing a task
* - Freeing resources from long-running processes
*/
export class TerminateSessionTool extends BaseTool<"terminate_session"> {
readonly name = "terminate_session" as const
async execute(params: TerminateSessionParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { handleError, pushToolResult } = callbacks
try {
const { session_id } = params
// Validate session_id
if (session_id === undefined || session_id === null) {
task.consecutiveMistakeCount++
task.recordToolError("terminate_session")
pushToolResult(await task.sayAndCreateMissingParamError("terminate_session", "session_id"))
return
}
// Get ProcessManager and terminate the session
const processManager = ProcessManager.getInstance()
const result = processManager.terminateSession(session_id)
if (result.success) {
task.consecutiveMistakeCount = 0
// Format success response
const response = this.formatResponse({
sessionId: session_id,
success: true,
message: result.message,
})
await task.say("tool", response, undefined, false)
pushToolResult(formatResponse.toolResult(response))
} else {
task.consecutiveMistakeCount++
task.recordToolError("terminate_session")
task.didToolFailInCurrentTurn = true
const errorMsg = result.message
await task.say("error", errorMsg)
pushToolResult(`Error: ${errorMsg}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await handleError("terminating session", error instanceof Error ? error : new Error(errorMessage))
task.recordToolError("terminate_session")
}
}
override async handlePartial(task: Task, block: ToolUse<"terminate_session">): Promise<void> {
const sessionId = block.params.session_id || block.nativeArgs?.session_id
if (sessionId) {
await task.say(
"tool",
JSON.stringify({
tool: "terminate_session",
session_id: sessionId,
content: `Terminating session ${sessionId}...`,
}),
undefined,
true,
)
}
}
/**
* Format the response message for the tool result.
*/
private formatResponse(params: { sessionId: number; success: boolean; message: string }): string {
const { sessionId, success, message } = params
const lines: string[] = []
lines.push(`## Session ${sessionId} Termination`)
lines.push("")
lines.push(`**Status:** ${success ? "✅ Success" : "❌ Failed"}`)
lines.push(`**Message:** ${message}`)
return lines.join("\n")
}
}
// Export singleton instance
export const terminateSessionTool = new TerminateSessionTool()

View file

@ -210,6 +210,94 @@ export class ProcessManager {
return this.processes.size
}
/**
* List all active sessions with their info.
*
* @param taskId - Optional task ID to filter by
* @returns Array of session info objects
*/
listSessions(taskId?: string): Array<{
sessionId: number
taskId: string
command: string
running: boolean
lastUsed: number
}> {
// Clean up first to get accurate state
this.cleanup()
const sessions: Array<{
sessionId: number
taskId: string
command: string
running: boolean
lastUsed: number
}> = []
for (const [sessionId, entry] of this.processes.entries()) {
if (!taskId || entry.taskId === taskId) {
sessions.push({
sessionId,
taskId: entry.taskId,
command: entry.command,
running: entry.running,
lastUsed: entry.lastUsed,
})
}
}
// Sort by session ID for consistent ordering
return sessions.sort((a, b) => a.sessionId - b.sessionId)
}
/**
* Terminate a session by sending abort signal to the process.
*
* @param sessionId - The session ID to terminate
* @returns Object with success status and optional message
*/
terminateSession(sessionId: number): { success: boolean; message: string } {
const entry = this.processes.get(sessionId)
if (!entry) {
return {
success: false,
message: `Session ${sessionId} not found. Use list_sessions to see active sessions.`,
}
}
if (!entry.running) {
// Session exists but already completed
this.processes.delete(sessionId)
return {
success: true,
message: `Session ${sessionId} was already completed. Entry removed.`,
}
}
try {
// Abort the process
entry.process.abort()
entry.running = false
// Remove from tracking
this.processes.delete(sessionId)
console.log(`[ProcessManager] Terminated session ${sessionId}`)
return {
success: true,
message: `Session ${sessionId} terminated successfully.`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[ProcessManager] Error terminating session ${sessionId}:`, errorMessage)
return {
success: false,
message: `Failed to terminate session ${sessionId}: ${errorMessage}`,
}
}
}
/**
* Clean up completed processes.
*/

View file

@ -222,4 +222,125 @@ describe("ProcessManager", () => {
expect(sessions).toContain(id2)
})
})
describe("listSessions", () => {
it("should return all sessions when no taskId filter", () => {
const manager = ProcessManager.getInstance()
const terminal = createMockTerminal(1)
manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1")
manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 2")
const sessions = manager.listSessions()
expect(sessions).toHaveLength(2)
expect(sessions[0]).toMatchObject({
taskId: "task-1",
command: "echo 1",
running: true,
})
expect(sessions[1]).toMatchObject({
taskId: "task-2",
command: "echo 2",
running: true,
})
})
it("should filter sessions by taskId", () => {
const manager = ProcessManager.getInstance()
const terminal = createMockTerminal(1)
manager.registerProcess(terminal, createMockProcess(), "task-1", "echo 1")
manager.registerProcess(terminal, createMockProcess(), "task-2", "echo 2")
const sessions = manager.listSessions("task-1")
expect(sessions).toHaveLength(1)
expect(sessions[0].taskId).toBe("task-1")
})
it("should return empty array when no sessions exist", () => {
const manager = ProcessManager.getInstance()
const sessions = manager.listSessions()
expect(sessions).toHaveLength(0)
})
it("should sort sessions by session ID", () => {
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")
const sessions = manager.listSessions()
expect(sessions[0].sessionId).toBe(id1)
expect(sessions[1].sessionId).toBe(id2)
})
})
describe("terminateSession", () => {
it("should return error for non-existent session", () => {
const manager = ProcessManager.getInstance()
const result = manager.terminateSession(999)
expect(result.success).toBe(false)
expect(result.message).toContain("not found")
})
it("should remove completed session and return success", () => {
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 result = manager.terminateSession(sessionId)
expect(result.success).toBe(true)
expect(result.message).toContain("already completed")
expect(manager.getProcess(sessionId)).toBeUndefined()
})
it("should abort running process and return success", () => {
const manager = ProcessManager.getInstance()
const terminal = createMockTerminal(1)
const mockAbort = vi.fn()
const process = {
...createMockProcess(),
abort: mockAbort,
} as unknown as RooTerminalProcess
const sessionId = manager.registerProcess(terminal, process, "task-1", "sleep 100")
const result = manager.terminateSession(sessionId)
expect(result.success).toBe(true)
expect(result.message).toContain("terminated successfully")
expect(mockAbort).toHaveBeenCalled()
expect(manager.getProcess(sessionId)).toBeUndefined()
})
it("should handle abort errors gracefully", () => {
const manager = ProcessManager.getInstance()
const terminal = createMockTerminal(1)
const process = {
...createMockProcess(),
abort: () => {
throw new Error("Failed to abort")
},
} as unknown as RooTerminalProcess
const sessionId = manager.registerProcess(terminal, process, "task-1", "sleep 100")
const result = manager.terminateSession(sessionId)
expect(result.success).toBe(false)
expect(result.message).toContain("Failed to abort")
})
})
})

View file

@ -115,6 +115,8 @@ export type NativeToolArgs = {
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 }
terminate_session: { session_id: number }
list_sessions: Record<string, never> // No parameters
write_to_file: { path: string; content: string }
// Add more tools as they are migrated to native protocol
}
@ -252,6 +254,8 @@ export type ToolGroupConfig = {
export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
execute_command: "run commands",
write_stdin: "write to terminal input",
terminate_session: "terminate terminal sessions",
list_sessions: "list active terminal sessions",
read_file: "read files",
read_command_output: "read command output",
fetch_instructions: "fetch instructions",
@ -290,7 +294,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
tools: ["browser_action"],
},
command: {
tools: ["execute_command", "write_stdin", "read_command_output"],
tools: ["execute_command", "write_stdin", "terminate_session", "list_sessions", "read_command_output"],
},
mcp: {
tools: ["use_mcp_tool", "access_mcp_resource"],