Skip to content

Commit 63de6fb

Browse files
committed
feat: implemented Bash run_in_background support
1 parent 66e34d3 commit 63de6fb

8 files changed

Lines changed: 293 additions & 4 deletions

File tree

src/prompt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe
351351
},
352352
uniqueItems: true,
353353
},
354+
run_in_background: {
355+
type: "boolean",
356+
description:
357+
"Set to true to run the command in the background. Use this only when you need to perform a blocking task and do not need the result immediately.",
358+
},
354359
},
355360
required: ["command", "sideEffects"],
356361
additionalProperties: false,

src/session.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2172,6 +2172,7 @@ ${skillMd}
21722172
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
21732173
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
21742174
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
2175+
onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion),
21752176
onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
21762177
onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath),
21772178
shouldStop: () => this.isInterrupted(sessionId),
@@ -2677,6 +2678,46 @@ ${skillMd}
26772678
});
26782679
}
26792680

2681+
private addBackgroundProcessCompletionMessage(
2682+
sessionId: string,
2683+
completion: {
2684+
command: string;
2685+
outputPath: string;
2686+
ok: boolean;
2687+
exitCode: number | null;
2688+
signal: string | null;
2689+
error?: string;
2690+
completedAtMs: number;
2691+
startedAtMs: number;
2692+
}
2693+
): void {
2694+
const status = completion.ok ? "completed" : "failed";
2695+
const exitText =
2696+
completion.exitCode !== null
2697+
? `exit code ${completion.exitCode}`
2698+
: completion.signal
2699+
? `signal ${completion.signal}`
2700+
: completion.error || "unknown status";
2701+
const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs);
2702+
const content =
2703+
`Background command "${completion.command}" ${status} with ${exitText} ` +
2704+
`after ${this.formatBackgroundDuration(durationMs)}. Output: ${completion.outputPath}`;
2705+
this.addSessionSystemMessage(sessionId, content, true);
2706+
}
2707+
2708+
private formatBackgroundDuration(durationMs: number): string {
2709+
if (durationMs < 1000) {
2710+
return `${durationMs}ms`;
2711+
}
2712+
const seconds = Math.round(durationMs / 1000);
2713+
if (seconds < 60) {
2714+
return `${seconds}s`;
2715+
}
2716+
const minutes = Math.floor(seconds / 60);
2717+
const remainingSeconds = seconds % 60;
2718+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
2719+
}
2720+
26802721
private removeSessionProcess(sessionId: string, processId: string | number): void {
26812722
const now = new Date().toISOString();
26822723
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));

src/tests/prompt.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ test("getTools requires bash sideEffects permission scopes", () => {
3030
assert.equal(sideEffects.type, "array");
3131
assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true);
3232
assert.equal(sideEffects.items?.enum?.includes("unknown"), true);
33+
const runInBackground = tool.function.parameters.properties.run_in_background as { type?: unknown };
34+
assert.equal(runInBackground.type, "boolean");
3335
});
3436

3537
test("getSystemPrompt always includes WebSearch docs", () => {
@@ -43,6 +45,12 @@ test("getSystemPrompt includes UpdatePlan docs", () => {
4345
assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true);
4446
});
4547

48+
test("getSystemPrompt includes Bash background guidance", () => {
49+
const prompt = getSystemPrompt("/tmp/project");
50+
assert.equal(prompt.includes("run_in_background: true"), true);
51+
assert.equal(prompt.includes("do not add `&`"), true);
52+
});
53+
4654
test("getSystemPrompt does not include runtime context", () => {
4755
const prompt = getSystemPrompt("/tmp/project");
4856
assert.equal(prompt.includes("# Local Workspace Environment"), false);

src/tests/tool-handlers.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fs from "fs";
44
import * as os from "os";
55
import * as path from "path";
66
import { setTimeout as delay } from "node:timers/promises";
7-
import type { ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor";
7+
import type { BackgroundProcessCompletion, ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor";
88
import { handleBashTool } from "../tools/bash-handler";
99
import { handleEditTool } from "../tools/edit-handler";
1010
import { handleReadTool } from "../tools/read-handler";
@@ -104,6 +104,78 @@ test("Bash timeout control can extend the active command deadline", async () =>
104104
assert.equal(result.metadata?.timeoutMs, 1000);
105105
});
106106

107+
test("Bash can run commands in the background and report completion output", async () => {
108+
const workspace = createTempWorkspace();
109+
let completion: BackgroundProcessCompletion | null = null;
110+
const starts: Array<string | number> = [];
111+
const exits: Array<string | number> = [];
112+
const startedAt = Date.now();
113+
114+
const result = await handleBashTool(
115+
{
116+
command: "printf 'start\\n'; sleep 0.2; printf 'done\\n'",
117+
run_in_background: true,
118+
},
119+
createContext("bash-background", workspace, {
120+
bashTimeoutMs: 10,
121+
bashMinTimeoutMs: 1,
122+
onProcessStart: (pid) => starts.push(pid),
123+
onProcessExit: (pid) => exits.push(pid),
124+
onBackgroundProcessComplete: (event) => {
125+
completion = event;
126+
},
127+
})
128+
);
129+
130+
assert.equal(result.ok, true);
131+
assert.equal(result.metadata?.runInBackground, true);
132+
assert.equal(typeof result.metadata?.backgroundTaskId, "string");
133+
assert.equal(typeof result.metadata?.outputPath, "string");
134+
assert.ok(Date.now() - startedAt < 500);
135+
assert.equal(starts.length, 1);
136+
137+
await waitFor(() => completion !== null, 2000);
138+
139+
assert.ok(completion);
140+
const done = completion as BackgroundProcessCompletion;
141+
assert.equal(done.ok, true);
142+
assert.equal(done.exitCode, 0);
143+
assert.equal(exits.length, 1);
144+
const outputPath = done.outputPath;
145+
const output = fs.readFileSync(outputPath, "utf8");
146+
assert.match(output, /start/);
147+
assert.match(output, /done/);
148+
assert.doesNotMatch(output, /__DEEPCODE_PWD__/);
149+
});
150+
151+
test("Bash background completion reports failed exit codes", async () => {
152+
const workspace = createTempWorkspace();
153+
let completion: BackgroundProcessCompletion | null = null;
154+
155+
const result = await handleBashTool(
156+
{
157+
command: "printf 'bad\\n'; exit 7",
158+
run_in_background: true,
159+
},
160+
createContext("bash-background-failure", workspace, {
161+
onBackgroundProcessComplete: (event) => {
162+
completion = event;
163+
},
164+
})
165+
);
166+
167+
assert.equal(result.ok, true);
168+
await waitFor(() => completion !== null, 2000);
169+
170+
assert.ok(completion);
171+
const done = completion as BackgroundProcessCompletion;
172+
assert.equal(done.ok, false);
173+
assert.equal(done.exitCode, 7);
174+
assert.match(done.error ?? "", /exit code 7/);
175+
const output = fs.readFileSync(done.outputPath, "utf8");
176+
assert.match(output, /bad/);
177+
});
178+
107179
test("UpdatePlan accepts a markdown task list string", async () => {
108180
const workspace = createTempWorkspace();
109181
const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n");

src/tools/bash-handler.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { spawn } from "child_process";
2+
import { randomUUID } from "crypto";
3+
import * as fs from "fs";
4+
import * as os from "os";
5+
import * as path from "path";
26
import { DEFAULT_BASH_TIMEOUT_MS, clampBashTimeoutMs } from "../common/bash-timeout";
37
import { killProcessTree } from "../common/process-tree";
48
import type { ProcessTimeoutControl, ProcessTimeoutInfo, ToolExecutionContext, ToolExecutionResult } from "./executor";
@@ -13,6 +17,7 @@ import {
1317

1418
const MAX_OUTPUT_CHARS = 30000;
1519
const MAX_CAPTURE_CHARS = 10 * 1024 * 1024;
20+
const BACKGROUND_OUTPUT_DIR = path.join(os.tmpdir(), "deepcode-background");
1621
const sessionWorkingDirs = new Map<string, string>();
1722

1823
export function clearSessionWorkingDir(sessionId: string): void {
@@ -51,6 +56,11 @@ export async function handleBashTool(
5156

5257
const startCwd = getSessionCwd(context.sessionId, context.projectRoot);
5358
const { shellPath, shellArgs, marker } = buildShellCommand(command);
59+
const runInBackground = isTrue(args.run_in_background);
60+
61+
if (runInBackground) {
62+
return startBackgroundShellCommand(shellPath, shellArgs, startCwd, command, marker, context);
63+
}
5464

5565
const execution = await executeShellCommand(shellPath, shellArgs, startCwd, command, context);
5666
const result = buildToolCommandResult(
@@ -75,6 +85,10 @@ export async function handleBashTool(
7585
return formatResult(result, "bash");
7686
}
7787

88+
function isTrue(value: unknown): boolean {
89+
return value === true || value === "true";
90+
}
91+
7892
function getSessionCwd(sessionId: string, fallback: string): string {
7993
return sessionWorkingDirs.get(sessionId) ?? fallback;
8094
}
@@ -235,6 +249,125 @@ async function executeShellCommand(
235249
});
236250
}
237251

252+
function startBackgroundShellCommand(
253+
shellPath: string,
254+
shellArgs: string[],
255+
cwd: string,
256+
command: string,
257+
marker: string,
258+
context: ToolExecutionContext
259+
): ToolExecutionResult {
260+
fs.mkdirSync(BACKGROUND_OUTPUT_DIR, { recursive: true });
261+
const taskId = `bash-${randomUUID()}`;
262+
const outputPath = path.join(BACKGROUND_OUTPUT_DIR, `${taskId}.log`);
263+
const startedAtMs = Date.now();
264+
const detached = process.platform !== "win32";
265+
const configuredEnv = context.createOpenAIClient?.().env ?? {};
266+
const child = spawn(shellPath, shellArgs, {
267+
cwd,
268+
env: buildShellEnv(shellPath, configuredEnv),
269+
detached,
270+
windowsHide: true,
271+
stdio: ["ignore", "pipe", "pipe"],
272+
});
273+
const pid = child.pid;
274+
const processId = typeof pid === "number" ? pid : -1;
275+
276+
let stdout = "";
277+
let stderr = "";
278+
let error: string | undefined;
279+
280+
const appendOutputFile = (chunk: string | Buffer) => {
281+
try {
282+
fs.appendFileSync(outputPath, chunk);
283+
} catch {
284+
// Keep the background process running even if temp-file writes fail.
285+
}
286+
};
287+
288+
if (typeof pid === "number") {
289+
context.onProcessStart?.(pid, command);
290+
}
291+
292+
child.stdout?.on("data", (chunk: string | Buffer) => {
293+
stdout = appendChunk(stdout, chunk);
294+
appendOutputFile(chunk);
295+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
296+
if (typeof pid === "number") {
297+
context.onProcessStdout?.(pid, text);
298+
}
299+
});
300+
child.stderr?.on("data", (chunk: string | Buffer) => {
301+
stderr = appendChunk(stderr, chunk);
302+
appendOutputFile(chunk);
303+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
304+
if (typeof pid === "number") {
305+
context.onProcessStdout?.(pid, text);
306+
}
307+
});
308+
309+
child.on("error", (spawnError) => {
310+
error = spawnError.message;
311+
});
312+
313+
child.on("close", (code, signal) => {
314+
const markerResult = stripMarker(stdout, marker);
315+
const finalOutput = joinOutput(markerResult.output, stderr);
316+
const result = buildToolCommandResult(
317+
stdout,
318+
stderr,
319+
marker,
320+
typeof code === "number" ? code : null,
321+
signal ?? null,
322+
shellPath,
323+
cwd
324+
);
325+
updateSessionCwd(context.sessionId, cwd, result.cwd);
326+
writeFinalBackgroundOutput(outputPath, finalOutput);
327+
if (typeof pid === "number") {
328+
context.onProcessExit?.(pid);
329+
}
330+
const ok = !error && result.exitCode === 0 && result.signal === null;
331+
context.onBackgroundProcessComplete?.({
332+
taskId,
333+
processId,
334+
command,
335+
outputPath,
336+
ok,
337+
exitCode: result.exitCode,
338+
signal: result.signal,
339+
error: ok ? undefined : buildErrorMessage(result.exitCode, result.signal, error),
340+
cwd: result.cwd,
341+
shellPath,
342+
startedAtMs,
343+
completedAtMs: Date.now(),
344+
});
345+
});
346+
347+
return {
348+
ok: true,
349+
name: "bash",
350+
output: `Command running in background with ID: ${taskId}. Output is being written to: ${outputPath}`,
351+
metadata: {
352+
backgroundTaskId: taskId,
353+
processId: typeof pid === "number" ? pid : null,
354+
outputPath,
355+
cwd,
356+
shellPath,
357+
startCwd: cwd,
358+
runInBackground: true,
359+
},
360+
};
361+
}
362+
363+
function writeFinalBackgroundOutput(outputPath: string, output: string | undefined): void {
364+
try {
365+
fs.writeFileSync(outputPath, output ?? "", "utf8");
366+
} catch {
367+
// Ignore notification/output persistence failures; the tool result already returned.
368+
}
369+
}
370+
238371
function appendChunk(existing: string, chunk: string | Buffer): string {
239372
if (existing.length >= MAX_CAPTURE_CHARS) {
240373
return existing;

src/tools/executor.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type ToolExecutionContext = {
4141
onProcessExit?: (processId: string | number) => void;
4242
onProcessStdout?: (processId: string | number, chunk: string) => void;
4343
onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
44+
onBackgroundProcessComplete?: (completion: BackgroundProcessCompletion) => void;
4445
onBeforeFileMutation?: (filePath: string) => void;
4546
onAfterFileMutation?: (filePath: string) => void;
4647
bashTimeoutMs?: number;
@@ -52,11 +53,27 @@ export type ToolExecutionHooks = {
5253
onProcessExit?: (processId: string | number) => void;
5354
onProcessStdout?: (processId: string | number, chunk: string) => void;
5455
onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
56+
onBackgroundProcessComplete?: (completion: BackgroundProcessCompletion) => void;
5557
onBeforeFileMutation?: (filePath: string) => void;
5658
onAfterFileMutation?: (filePath: string) => void;
5759
shouldStop?: () => boolean;
5860
};
5961

62+
export type BackgroundProcessCompletion = {
63+
taskId: string;
64+
processId: number;
65+
command: string;
66+
outputPath: string;
67+
ok: boolean;
68+
exitCode: number | null;
69+
signal: string | null;
70+
error?: string;
71+
cwd: string | null;
72+
shellPath: string;
73+
startedAtMs: number;
74+
completedAtMs: number;
75+
};
76+
6077
export type ProcessTimeoutInfo = {
6178
timeoutMs: number;
6279
startedAtMs: number;
@@ -230,6 +247,7 @@ export class ToolExecutor {
230247
onProcessExit: hooks?.onProcessExit,
231248
onProcessStdout: hooks?.onProcessStdout,
232249
onProcessTimeoutControl: hooks?.onProcessTimeoutControl,
250+
onBackgroundProcessComplete: hooks?.onBackgroundProcessComplete,
233251
onBeforeFileMutation: hooks?.onBeforeFileMutation,
234252
onAfterFileMutation: hooks?.onAfterFileMutation,
235253
});

0 commit comments

Comments
 (0)