diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 7ac5d5b68f..a8351c261e 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -17,6 +17,8 @@ export type ToolGroup = z.infer export const toolNames = [ "execute_command", "write_stdin", + "terminate_session", + "list_sessions", "read_file", "read_command_output", "write_to_file", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 5bead6abdd..ca9b27c243 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -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 + } + break + + case "list_sessions": + // No parameters needed + nativeArgs = {} as NativeArgsFor + break + case "apply_diff": if (args.path !== undefined && args.diff !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7304f5a9af..9aa17f1bff 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 70f7865753..21347d2fa5 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -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, diff --git a/src/core/prompts/tools/native-tools/list_sessions.ts b/src/core/prompts/tools/native-tools/list_sessions.ts new file mode 100644 index 0000000000..1b57d9a365 --- /dev/null +++ b/src/core/prompts/tools/native-tools/list_sessions.ts @@ -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 diff --git a/src/core/prompts/tools/native-tools/terminate_session.ts b/src/core/prompts/tools/native-tools/terminate_session.ts new file mode 100644 index 0000000000..e77019aa23 --- /dev/null +++ b/src/core/prompts/tools/native-tools/terminate_session.ts @@ -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 diff --git a/src/core/tools/ListSessionsTool.ts b/src/core/tools/ListSessionsTool.ts new file mode 100644 index 0000000000..da8bd70d26 --- /dev/null +++ b/src/core/tools/ListSessionsTool.ts @@ -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, task: Task, callbacks: ToolCallbacks): Promise { + 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 { + 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() diff --git a/src/core/tools/TerminateSessionTool.ts b/src/core/tools/TerminateSessionTool.ts new file mode 100644 index 0000000000..1371f89025 --- /dev/null +++ b/src/core/tools/TerminateSessionTool.ts @@ -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 { + 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 { + 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() diff --git a/src/integrations/terminal/ProcessManager.ts b/src/integrations/terminal/ProcessManager.ts index ba28347b84..203f993e6c 100644 --- a/src/integrations/terminal/ProcessManager.ts +++ b/src/integrations/terminal/ProcessManager.ts @@ -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. */ diff --git a/src/integrations/terminal/__tests__/ProcessManager.spec.ts b/src/integrations/terminal/__tests__/ProcessManager.spec.ts index 69e21dbdc4..9a971f954e 100644 --- a/src/integrations/terminal/__tests__/ProcessManager.spec.ts +++ b/src/integrations/terminal/__tests__/ProcessManager.spec.ts @@ -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") + }) + }) }) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1ee38e14e8..3a99aa1874 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -115,6 +115,8 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_stdin: { session_id: number; chars?: string; yield_time_ms?: number; max_output_tokens?: number } + terminate_session: { session_id: number } + list_sessions: Record // 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 = { 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 = { 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"],