|
1 | 1 | import * as fs from "node:fs"; |
2 | 2 | import * as os from "node:os"; |
3 | 3 | import * as path from "node:path"; |
4 | | -import { exec } from "node:child_process"; |
| 4 | +import { exec, spawn, type ChildProcess } from "node:child_process"; |
5 | 5 | import { promisify } from "node:util"; |
6 | 6 | import { randomUUID } from "node:crypto"; |
7 | 7 | import type { ToolDefinition } from "./providers/types"; |
@@ -153,6 +153,47 @@ export const AGENT_TOOLS: ToolDefinition[] = [ |
153 | 153 | required: ["language", "code"], |
154 | 154 | }, |
155 | 155 | }, |
| 156 | + { |
| 157 | + name: "start_background_command", |
| 158 | + description: |
| 159 | + "Start a long-running command (dev server, build watcher, long test run) in the background and return immediately with a task id. Use get_background_output to check on it later and stop_background_command when done — unlike run_command, this doesn't block or time out. Subject to the same safety checks as run_command.", |
| 160 | + parameters: { |
| 161 | + type: "object", |
| 162 | + properties: { |
| 163 | + command: { type: "string", description: "The shell command to run." }, |
| 164 | + cwd: { type: "string", description: 'Working directory, relative to the workspace root. Defaults to "."' }, |
| 165 | + name: { type: "string", description: "Short human-readable label for this task (e.g. \"dev server\")." }, |
| 166 | + }, |
| 167 | + required: ["command"], |
| 168 | + }, |
| 169 | + }, |
| 170 | + { |
| 171 | + name: "get_background_output", |
| 172 | + description: "Get the current status and recent output of a background command started with start_background_command.", |
| 173 | + parameters: { |
| 174 | + type: "object", |
| 175 | + properties: { |
| 176 | + task_id: { type: "string", description: "The task id returned by start_background_command." }, |
| 177 | + }, |
| 178 | + required: ["task_id"], |
| 179 | + }, |
| 180 | + }, |
| 181 | + { |
| 182 | + name: "stop_background_command", |
| 183 | + description: "Stop a running background command by its task id.", |
| 184 | + parameters: { |
| 185 | + type: "object", |
| 186 | + properties: { |
| 187 | + task_id: { type: "string", description: "The task id returned by start_background_command." }, |
| 188 | + }, |
| 189 | + required: ["task_id"], |
| 190 | + }, |
| 191 | + }, |
| 192 | + { |
| 193 | + name: "list_background_commands", |
| 194 | + description: "List all background commands from this session with their status.", |
| 195 | + parameters: { type: "object", properties: {}, required: [] }, |
| 196 | + }, |
156 | 197 | { |
157 | 198 | name: "git_status", |
158 | 199 | description: "Show the working tree status (git status) for the workspace.", |
@@ -679,6 +720,105 @@ export async function runCode( |
679 | 720 | } |
680 | 721 | } |
681 | 722 |
|
| 723 | +interface BackgroundTask { |
| 724 | + id: string; |
| 725 | + name: string; |
| 726 | + command: string; |
| 727 | + process: ChildProcess; |
| 728 | + // Rolling tail of combined stdout+stderr — capped so a chatty dev server |
| 729 | + // can't grow memory unboundedly over a long session. |
| 730 | + output: string; |
| 731 | + exitCode: number | null; |
| 732 | + startedAt: number; |
| 733 | +} |
| 734 | + |
| 735 | +const MAX_BACKGROUND_TASKS = 5; |
| 736 | +const MAX_BACKGROUND_OUTPUT_CHARS = 100_000; |
| 737 | +const BACKGROUND_OUTPUT_TAIL_CHARS = 20_000; |
| 738 | +const backgroundTasks = new Map<string, BackgroundTask>(); |
| 739 | + |
| 740 | +export function startBackgroundCommand( |
| 741 | + workspaceRoot: string, |
| 742 | + command: string, |
| 743 | + relativeCwd = ".", |
| 744 | + name?: string |
| 745 | +): { taskId: string; name: string } { |
| 746 | + const dangerReason = findDangerousCommandReason(command); |
| 747 | + if (dangerReason) throw new Error(dangerReason); |
| 748 | + const runningCount = [...backgroundTasks.values()].filter((t) => t.exitCode === null).length; |
| 749 | + if (runningCount >= MAX_BACKGROUND_TASKS) { |
| 750 | + throw new Error(`Already running ${MAX_BACKGROUND_TASKS} background commands — stop one first.`); |
| 751 | + } |
| 752 | + |
| 753 | + const cwd = resolveSafePath(workspaceRoot, relativeCwd); |
| 754 | + const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] }); |
| 755 | + const id = randomUUID().slice(0, 8); |
| 756 | + const task: BackgroundTask = { |
| 757 | + id, |
| 758 | + name: name?.trim() || command.slice(0, 40), |
| 759 | + command, |
| 760 | + process: child, |
| 761 | + output: "", |
| 762 | + exitCode: null, |
| 763 | + startedAt: Date.now(), |
| 764 | + }; |
| 765 | + const append = (chunk: Buffer) => { |
| 766 | + task.output += chunk.toString(); |
| 767 | + if (task.output.length > MAX_BACKGROUND_OUTPUT_CHARS) { |
| 768 | + task.output = task.output.slice(-MAX_BACKGROUND_OUTPUT_CHARS); |
| 769 | + } |
| 770 | + }; |
| 771 | + child.stdout?.on("data", append); |
| 772 | + child.stderr?.on("data", append); |
| 773 | + child.on("error", (err) => { |
| 774 | + task.output += `\n[failed to start: ${err.message}]`; |
| 775 | + task.exitCode = -1; |
| 776 | + }); |
| 777 | + child.on("exit", (code) => { |
| 778 | + task.exitCode = code ?? -1; |
| 779 | + }); |
| 780 | + backgroundTasks.set(id, task); |
| 781 | + return { taskId: id, name: task.name }; |
| 782 | +} |
| 783 | + |
| 784 | +export function getBackgroundOutput(taskId: string): string { |
| 785 | + const task = backgroundTasks.get(taskId); |
| 786 | + if (!task) throw new Error(`No background task with id "${taskId}".`); |
| 787 | + const status = |
| 788 | + task.exitCode === null |
| 789 | + ? `running (${Math.round((Date.now() - task.startedAt) / 1000)}s)` |
| 790 | + : `exited with code ${task.exitCode}`; |
| 791 | + const tail = |
| 792 | + task.output.length > BACKGROUND_OUTPUT_TAIL_CHARS |
| 793 | + ? `[...earlier output trimmed]\n${task.output.slice(-BACKGROUND_OUTPUT_TAIL_CHARS)}` |
| 794 | + : task.output; |
| 795 | + return `Task ${task.id} (${task.name}): ${status}\n--- output ---\n${tail || "(no output yet)"}`; |
| 796 | +} |
| 797 | + |
| 798 | +export function stopBackgroundCommand(taskId: string): string { |
| 799 | + const task = backgroundTasks.get(taskId); |
| 800 | + if (!task) throw new Error(`No background task with id "${taskId}".`); |
| 801 | + if (task.exitCode !== null) return `Task ${task.id} had already exited with code ${task.exitCode}.`; |
| 802 | + task.process.kill(); |
| 803 | + return `Task ${task.id} (${task.name}) stopped.`; |
| 804 | +} |
| 805 | + |
| 806 | +export function listBackgroundCommands(): { id: string; name: string; command: string; status: string }[] { |
| 807 | + return [...backgroundTasks.values()].map((t) => ({ |
| 808 | + id: t.id, |
| 809 | + name: t.name, |
| 810 | + command: t.command, |
| 811 | + status: t.exitCode === null ? "running" : `exited (${t.exitCode})`, |
| 812 | + })); |
| 813 | +} |
| 814 | + |
| 815 | +export function killAllBackgroundCommands(): void { |
| 816 | + for (const task of backgroundTasks.values()) { |
| 817 | + if (task.exitCode === null) task.process.kill(); |
| 818 | + } |
| 819 | + backgroundTasks.clear(); |
| 820 | +} |
| 821 | + |
682 | 822 | function gitCommand(workspaceRoot: string, args: string): Promise<string> { |
683 | 823 | return runCommand(workspaceRoot, `git ${args}`, "."); |
684 | 824 | } |
@@ -947,6 +1087,19 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec |
947 | 1087 | const language = args.language === "python" ? "python" : "javascript"; |
948 | 1088 | return runCode(workspaceRoot, language, String(args.code ?? ""), args.cwd ? String(args.cwd) : "."); |
949 | 1089 | } |
| 1090 | + case "start_background_command": |
| 1091 | + return startBackgroundCommand( |
| 1092 | + workspaceRoot, |
| 1093 | + String(args.command ?? ""), |
| 1094 | + args.cwd ? String(args.cwd) : ".", |
| 1095 | + args.name ? String(args.name) : undefined |
| 1096 | + ); |
| 1097 | + case "get_background_output": |
| 1098 | + return getBackgroundOutput(String(args.task_id ?? "")); |
| 1099 | + case "stop_background_command": |
| 1100 | + return stopBackgroundCommand(String(args.task_id ?? "")); |
| 1101 | + case "list_background_commands": |
| 1102 | + return listBackgroundCommands(); |
950 | 1103 | case "git_status": |
951 | 1104 | return gitStatus(workspaceRoot); |
952 | 1105 | case "git_diff": |
|
0 commit comments