Skip to content

Commit decd1a5

Browse files
committed
🤖 feat: add background bash process execution
Adds ability to run long-running bash processes in the background without blocking the AI conversation. Useful for builds, test suites, dev servers, and other processes that take longer than typical tool timeouts. Core components: - CircularBuffer<T>: O(1) ring buffer for stdout/stderr (1000 lines each) - BackgroundProcessManager: lifecycle management with spawn/list/terminate/cleanup - Process IDs use 'bg-{8-hex-chars}' format for uniqueness New tools: - bash tool: enhanced with run_in_background parameter - bash_background_read: check status and retrieve buffered output - bash_background_list: discover processes after context loss - bash_background_terminate: graceful shutdown (SIGTERM → SIGKILL) Features: - Automatic cleanup on workspace deletion - Output buffers preserved after process exit - Filtering support in read tool (tail, regex) - Workspace isolation (processes scoped to workspace) Test coverage: 84 tests across all components
1 parent 714677a commit decd1a5

20 files changed

+1681
-21
lines changed

src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
269269
}
270270

271271
const diffOutput = diffResult.data.output ?? "";
272-
const truncationInfo = diffResult.data.truncated;
272+
const truncationInfo =
273+
"truncated" in diffResult.data ? diffResult.data.truncated : undefined;
273274

274275
const fileDiffs = parseDiff(diffOutput);
275276
const allHunks = extractAllHunks(fileDiffs);

src/common/types/tools.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export interface BashToolArgs {
88
script: string;
99
timeout_secs?: number; // Optional: defaults to 3 seconds for interactivity
10+
run_in_background?: boolean; // Run without blocking (for long-running processes)
1011
}
1112

1213
interface CommonBashFields {
@@ -26,6 +27,12 @@ export type BashToolResult =
2627
totalLines: number;
2728
};
2829
})
30+
| (CommonBashFields & {
31+
success: true;
32+
output: string;
33+
exitCode: 0;
34+
backgroundProcessId: string; // Background spawn succeeded
35+
})
2936
| (CommonBashFields & {
3037
success: false;
3138
output?: string;
@@ -190,6 +197,56 @@ export interface StatusSetToolArgs {
190197
url?: string;
191198
}
192199

200+
// Bash Background Tool Types
201+
export interface BashBackgroundReadArgs {
202+
process_id: string;
203+
stdout_tail?: number; // Last N lines of stdout
204+
stderr_tail?: number; // Last N lines of stderr
205+
stdout_regex?: string; // Filter stdout by regex
206+
stderr_regex?: string; // Filter stderr by regex
207+
}
208+
209+
export type BashBackgroundReadResult =
210+
| {
211+
success: true;
212+
process_id: string;
213+
status: "running" | "exited" | "killed" | "failed";
214+
script: string;
215+
pid?: number; // OS process ID
216+
uptime_ms: number;
217+
exitCode?: number;
218+
stdout: string[];
219+
stderr: string[];
220+
}
221+
| {
222+
success: false;
223+
error: string;
224+
};
225+
226+
export interface BashBackgroundTerminateArgs {
227+
process_id: string;
228+
}
229+
230+
export type BashBackgroundTerminateResult =
231+
| { success: true; message: string }
232+
| { success: false; error: string };
233+
234+
// Bash Background List Tool Types
235+
export type BashBackgroundListArgs = Record<string, never>;
236+
237+
export interface BashBackgroundListProcess {
238+
process_id: string;
239+
status: "running" | "exited" | "killed" | "failed";
240+
script: string;
241+
pid?: number;
242+
uptime_ms: number;
243+
exitCode?: number;
244+
}
245+
246+
export type BashBackgroundListResult =
247+
| { success: true; processes: BashBackgroundListProcess[] }
248+
| { success: false; error: string };
249+
193250
export type StatusSetToolResult =
194251
| {
195252
success: true;

src/common/utils/tools/toolDefinitions.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ export const TOOL_DEFINITIONS = {
5252
.describe(
5353
`Timeout (seconds, default: ${BASH_DEFAULT_TIMEOUT_SECS}). Start small and increase on retry; avoid large initial values to keep UX responsive`
5454
),
55+
run_in_background: z
56+
.boolean()
57+
.default(false)
58+
.describe(
59+
"Run this command in the background without blocking. " +
60+
"Use for processes running >5s (dev servers, builds, file watchers). " +
61+
"Do NOT use for quick commands (<5s), interactive processes (no stdin support), " +
62+
"or processes requiring real-time output (use foreground with larger timeout instead). " +
63+
"Returns immediately with process ID for status checking and termination. " +
64+
"Output is buffered (max 1000 lines per stream, oldest evicted when full). " +
65+
"Poll frequently with bash_background_read for high-output processes. " +
66+
"Process persists across tool calls until terminated or workspace is removed."
67+
),
5568
}),
5669
},
5770
file_read: {
@@ -229,6 +242,56 @@ export const TOOL_DEFINITIONS = {
229242
})
230243
.strict(),
231244
},
245+
bash_background_read: {
246+
description:
247+
"Check status and read output from a background bash process. " +
248+
"Use this to inspect long-running processes started with run_in_background=true. " +
249+
"Supports filtering output with tail and regex options.",
250+
schema: z.object({
251+
process_id: z
252+
.string()
253+
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
254+
.describe("Background process ID returned from bash tool"),
255+
stdout_tail: z
256+
.number()
257+
.int()
258+
.positive()
259+
.optional()
260+
.describe("Return last N lines of stdout (default: all buffered)"),
261+
stderr_tail: z
262+
.number()
263+
.int()
264+
.positive()
265+
.optional()
266+
.describe("Return last N lines of stderr (default: all buffered)"),
267+
stdout_regex: z
268+
.string()
269+
.optional()
270+
.describe("Filter stdout lines by regex pattern (applied before tail)"),
271+
stderr_regex: z
272+
.string()
273+
.optional()
274+
.describe("Filter stderr lines by regex pattern (applied before tail)"),
275+
}),
276+
},
277+
bash_background_list: {
278+
description:
279+
"List all background processes for the current workspace. " +
280+
"Useful for discovering running processes after context loss or resuming a conversation.",
281+
schema: z.object({}),
282+
},
283+
bash_background_terminate: {
284+
description:
285+
"Terminate a background bash process. " +
286+
"Sends SIGTERM, waits briefly, then sends SIGKILL if needed. " +
287+
"Process output remains available for inspection after termination.",
288+
schema: z.object({
289+
process_id: z
290+
.string()
291+
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
292+
.describe("Background process ID to terminate"),
293+
}),
294+
},
232295
web_fetch: {
233296
description:
234297
`Fetch a web page and extract its main content as clean markdown. ` +

src/common/utils/tools/tools.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { type Tool } from "ai";
22
import { createFileReadTool } from "@/node/services/tools/file_read";
33
import { createBashTool } from "@/node/services/tools/bash";
4+
import { createBashBackgroundReadTool } from "@/node/services/tools/bash_background_read";
5+
import { createBashBackgroundListTool } from "@/node/services/tools/bash_background_list";
6+
import { createBashBackgroundTerminateTool } from "@/node/services/tools/bash_background_terminate";
47
import { createFileEditReplaceStringTool } from "@/node/services/tools/file_edit_replace_string";
58
// DISABLED: import { createFileEditReplaceLinesTool } from "@/node/services/tools/file_edit_replace_lines";
69
import { createFileEditInsertTool } from "@/node/services/tools/file_edit_insert";
@@ -12,6 +15,7 @@ import { log } from "@/node/services/log";
1215

1316
import type { Runtime } from "@/node/runtime/Runtime";
1417
import type { InitStateManager } from "@/node/services/initStateManager";
18+
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
1519

1620
/**
1721
* Configuration for tools that need runtime context
@@ -29,6 +33,10 @@ export interface ToolConfiguration {
2933
runtimeTempDir: string;
3034
/** Overflow policy for bash tool output (optional, not exposed to AI) */
3135
overflow_policy?: "truncate" | "tmpfile";
36+
/** Background process manager for bash tool (optional, AI-only) */
37+
backgroundProcessManager?: BackgroundProcessManager;
38+
/** Workspace ID for tracking background processes (optional for token estimation) */
39+
workspaceId?: string;
3240
}
3341

3442
/**
@@ -99,6 +107,9 @@ export async function getToolsForModel(
99107
// and line number miscalculations. Use file_edit_replace_string instead.
100108
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
101109
bash: wrap(createBashTool(config)),
110+
bash_background_read: wrap(createBashBackgroundReadTool(config)),
111+
bash_background_list: wrap(createBashBackgroundListTool(config)),
112+
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
102113
web_fetch: wrap(createWebFetchTool(config)),
103114
};
104115

src/node/services/aiService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { getToolsForModel } from "@/common/utils/tools/tools";
2020
import { createRuntime } from "@/node/runtime/runtimeFactory";
2121
import { secretsToRecord } from "@/common/types/secrets";
2222
import type { MuxProviderOptions } from "@/common/types/providerOptions";
23+
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
2324
import { log } from "./log";
2425
import {
2526
transformModelMessages,
@@ -141,12 +142,14 @@ export class AIService extends EventEmitter {
141142
private readonly initStateManager: InitStateManager;
142143
private readonly mockModeEnabled: boolean;
143144
private readonly mockScenarioPlayer?: MockScenarioPlayer;
145+
private readonly backgroundProcessManager?: BackgroundProcessManager;
144146

145147
constructor(
146148
config: Config,
147149
historyService: HistoryService,
148150
partialService: PartialService,
149-
initStateManager: InitStateManager
151+
initStateManager: InitStateManager,
152+
backgroundProcessManager?: BackgroundProcessManager
150153
) {
151154
super();
152155
// Increase max listeners to accommodate multiple concurrent workspace listeners
@@ -156,6 +159,7 @@ export class AIService extends EventEmitter {
156159
this.historyService = historyService;
157160
this.partialService = partialService;
158161
this.initStateManager = initStateManager;
162+
this.backgroundProcessManager = backgroundProcessManager;
159163
this.streamManager = new StreamManager(historyService, partialService);
160164
void this.ensureSessionsDir();
161165
this.setupStreamEventForwarding();
@@ -762,6 +766,8 @@ export class AIService extends EventEmitter {
762766
runtime,
763767
secrets: secretsToRecord(projectSecrets),
764768
runtimeTempDir,
769+
backgroundProcessManager: this.backgroundProcessManager,
770+
workspaceId,
765771
},
766772
workspaceId,
767773
this.initStateManager,

0 commit comments

Comments
 (0)