Skip to content

Commit 744b69c

Browse files
committed
Add background command tools for Agent mode
start_background_command/get_background_output/stop_background_command/ list_background_commands let the agent kick off a dev server or long build without blocking its own loop on run_command's timeout, then poll for output later. Capped at 5 concurrent tasks per workspace with a rolling output buffer, checked against the same dangerous- command blocklist as run_command, and killed on app quit so nothing orphans. Also fixes a stray setState-in-effect (sidebar auto-close on session change) using React's render-time state-adjustment pattern instead.
1 parent a30bc65 commit 744b69c

5 files changed

Lines changed: 238 additions & 4 deletions

File tree

‎app/src/agent-tools.test.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import * as fs from "node:fs";
33
import * as os from "node:os";
44
import * as path from "node:path";
@@ -26,8 +26,25 @@ import {
2626
readNotes,
2727
writeNotes,
2828
fetchUrl,
29+
startBackgroundCommand,
30+
getBackgroundOutput,
31+
stopBackgroundCommand,
32+
listBackgroundCommands,
33+
killAllBackgroundCommands,
2934
} from "./agent-tools";
3035

36+
function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
37+
const start = Date.now();
38+
return new Promise((resolve, reject) => {
39+
const check = () => {
40+
if (predicate()) return resolve();
41+
if (Date.now() - start > timeoutMs) return reject(new Error("waitFor timed out"));
42+
setTimeout(check, 20);
43+
};
44+
check();
45+
});
46+
}
47+
3148
describe("agent-tools", () => {
3249
let workspace: string;
3350

@@ -316,6 +333,59 @@ describe("agent-tools", () => {
316333
});
317334
});
318335

336+
describe("background commands", () => {
337+
afterEach(() => {
338+
killAllBackgroundCommands();
339+
});
340+
341+
it("starts a command and returns a task id immediately", () => {
342+
const { taskId, name } = startBackgroundCommand(workspace, "echo hello-background");
343+
expect(taskId).toBeTruthy();
344+
expect(name).toBe("echo hello-background");
345+
});
346+
347+
it("captures output as the command runs and reports its exit", async () => {
348+
const { taskId } = startBackgroundCommand(workspace, "echo from-bg-task");
349+
await waitFor(() => getBackgroundOutput(taskId).includes("exited with code 0"));
350+
const output = getBackgroundOutput(taskId);
351+
expect(output).toContain("from-bg-task");
352+
expect(output).toContain("exited with code 0");
353+
});
354+
355+
it("reports a still-running task as running, not exited", () => {
356+
const { taskId } = startBackgroundCommand(workspace, "sleep 5");
357+
expect(getBackgroundOutput(taskId)).toContain("running");
358+
});
359+
360+
it("stops a running task on request", async () => {
361+
const { taskId } = startBackgroundCommand(workspace, "sleep 30");
362+
const result = stopBackgroundCommand(taskId);
363+
expect(result).toContain("stopped");
364+
await waitFor(() => getBackgroundOutput(taskId).includes("exited"));
365+
});
366+
367+
it("lists all started tasks with their status", () => {
368+
const a = startBackgroundCommand(workspace, "echo a");
369+
const b = startBackgroundCommand(workspace, "echo b");
370+
const list = listBackgroundCommands();
371+
expect(list.map((t) => t.id)).toEqual(expect.arrayContaining([a.taskId, b.taskId]));
372+
});
373+
374+
it("throws for an unknown task id", () => {
375+
expect(() => getBackgroundOutput("does-not-exist")).toThrow(/No background task/);
376+
expect(() => stopBackgroundCommand("does-not-exist")).toThrow(/No background task/);
377+
});
378+
379+
it("blocks a dangerous command from ever starting", () => {
380+
expect(() => startBackgroundCommand(workspace, "sudo rm -rf /")).toThrow(/blocked/);
381+
});
382+
383+
it("caps the number of concurrently running background tasks", () => {
384+
for (let i = 0; i < 5; i++) startBackgroundCommand(workspace, "sleep 5", ".", `task-${i}`);
385+
expect(() => startBackgroundCommand(workspace, "sleep 5")).toThrow(/Already running/);
386+
});
387+
});
388+
319389
describe("git tools", () => {
320390
beforeEach(() => {
321391
execSync("git init -q", { cwd: workspace });

‎app/src/agent-tools.ts‎

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
4-
import { exec } from "node:child_process";
4+
import { exec, spawn, type ChildProcess } from "node:child_process";
55
import { promisify } from "node:util";
66
import { randomUUID } from "node:crypto";
77
import type { ToolDefinition } from "./providers/types";
@@ -153,6 +153,47 @@ export const AGENT_TOOLS: ToolDefinition[] = [
153153
required: ["language", "code"],
154154
},
155155
},
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+
},
156197
{
157198
name: "git_status",
158199
description: "Show the working tree status (git status) for the workspace.",
@@ -679,6 +720,105 @@ export async function runCode(
679720
}
680721
}
681722

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+
682822
function gitCommand(workspaceRoot: string, args: string): Promise<string> {
683823
return runCommand(workspaceRoot, `git ${args}`, ".");
684824
}
@@ -947,6 +1087,19 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec
9471087
const language = args.language === "python" ? "python" : "javascript";
9481088
return runCode(workspaceRoot, language, String(args.code ?? ""), args.cwd ? String(args.cwd) : ".");
9491089
}
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();
9501103
case "git_status":
9511104
return gitStatus(workspaceRoot);
9521105
case "git_diff":

‎app/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,12 +779,14 @@ app.whenReady().then(async () => {
779779
app.on("window-all-closed", () => {
780780
ollama.stop();
781781
localServers.stopAll();
782+
agentTools.killAllBackgroundCommands();
782783
mcpClient.disconnectAll();
783784
if (process.platform !== "darwin") app.quit();
784785
});
785786

786787
app.on("before-quit", () => {
787788
ollama.stop();
788789
localServers.stopAll();
790+
agentTools.killAllBackgroundCommands();
789791
void llamacpp.dispose();
790792
});

‎frontend/src/components/layout.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,15 @@ export default function Layout() {
535535
const [showOnboarding, setShowOnboarding] = useState(false);
536536
const [keybindings, setKeybindings] = useState<Record<KeybindingAction, string>>(DEFAULT_KEYBINDINGS);
537537
const [sidebarOpen, setSidebarOpen] = useState(false);
538-
539-
useEffect(() => setSidebarOpen(false), [sessionId]);
538+
// Closes the mobile sidebar on navigating to a session — adjusted during
539+
// render (not an effect) per React's "resetting state when a prop
540+
// changes" pattern, since the reset needs to happen before the closed
541+
// sidebar's first paint rather than flash open-then-closed.
542+
const [prevSessionId, setPrevSessionId] = useState(sessionId);
543+
if (sessionId !== prevSessionId) {
544+
setPrevSessionId(sessionId);
545+
setSidebarOpen(false);
546+
}
540547

541548
useEffect(() => {
542549
if (!hasApi) return;

‎frontend/src/pages/Chat.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ const READ_ONLY_TOOLS = new Set([
123123
"github_list_repositories",
124124
"github_repository_tree",
125125
"github_read_file",
126+
"get_background_output",
127+
"list_background_commands",
126128
]);
127129

128130
// Vision models can already reason over any attached image — these just save

0 commit comments

Comments
 (0)