Skip to content

Commit acb9052

Browse files
🤖 feat: add background bash process execution with SSH support (#920)
### Stack: 1. #923 1. #920 (base) <- This PR --- ## Summary Adds `run_in_background=true` option to the bash tool, enabling agents to spawn long-running processes (dev servers, builds, file watchers) that persist independently. ## Why This Approach We needed background process support that works identically for both local and SSH runtimes. A few considerations drove the design: 1. **PTY-based output doesn't fit remote use cases.** For SSH, maintaining a persistent PTY connection just to let agents read stdout would be fragile and complex. Agents need to search, filter, and tail output—not consume a live stream. 2. **File-based output lets agents use standard tools.** By writing stdout/stderr to files on the target machine, agents can use `tail -f`, `grep`, `head`, etc. to inspect output. We don't need to reimplement these filtering capabilities in our own tooling. 3. **A proper long-lived remote daemon is future work.** Ideally, SSH remotes would have a persistent mux process (or agent binary) that manages background jobs directly. The user's frontend would just connect to it. That's a significant architectural change. This PR provides background shell support without requiring that investment—the file-based approach works today with no remote-side dependencies. ## Architecture ``` AI Tools (bash, bash_background_list, bash_background_terminate) ↓ BackgroundProcessManager (lifecycle, in-memory tracking) ↓ Runtime.spawnBackground() (LocalBaseRuntime / SSHRuntime) ↓ BackgroundHandle (file-based output & status) ↓ backgroundCommands.ts (shared shell builders for Local/SSH parity) ``` ## Key Design Decisions | Decision | Rationale | |----------|-----------| | **File-based output** | Works identically for local and SSH, agents read via `tail`/`cat`/`grep`. | | **set -m + nohup** | Robust process isolation, PID === PGID for clean group termination | | **Workspace-scoped** | Processes tied to workspace, cleaned up on workspace removal | | **Lazy status refresh** | No polling overhead, reads `exit_code` file on list() | | **Cleanup on compaction** | Background processes terminated before compaction, as they're not guaranteed to be included (Claude Code does this too) | ## Tools - `bash(run_in_background=true)` — spawns process, returns `stdout_path`/`stderr_path` - `bash_background_list` — lists processes with status and file paths - `bash_background_terminate` — kills process group (SIGTERM → wait → SIGKILL) ## Output Structure ``` /tmp/mux-bashes/{workspaceId}/{bg-xxx}/ ├── stdout.log # Process stdout ├── stderr.log # Process stderr ├── exit_code # Written by trap on exit └── meta.json # Process metadata ``` ## Technical Details ### Process Spawning The spawn command uses a subshell with job control enabled: ```bash (set -m; nohup bash -c 'WRAPPER_SCRIPT' > stdout.log 2> stderr.log < /dev/null & echo $!) ``` **Key elements:** - `set -m` — Enables bash job control, which makes backgrounded processes become their own process group leader (PID === PGID). This is a bash builtin available on all platforms. - `nohup` — Prevents SIGHUP from killing the process when the terminal closes. - Subshell `(...)` — Isolates the process group so the outer shell exits immediately after echoing the PID. - `< /dev/null` — Detaches stdin so the process doesn't block waiting for input. ### Exit Code Detection The wrapper script sets up a trap to capture the exit code: ```bash trap 'echo $? > exit_code' EXIT && cd /path && export ENV=val && USER_SCRIPT ``` When the process exits (normally or via signal), the trap writes `$?` to the `exit_code` file. Mux reads this file to determine if the process is still running (`exit_code` doesn't exist) or has exited (file contains the code). ### Process Group Termination Because `set -m` ensures PID === PGID, we can kill the entire process tree using a negative PID: ```bash kill -15 -PID; sleep 2; if kill -0 -PID; then kill -9 -PID; echo 137 > exit_code; else echo 143 > exit_code; fi ``` **Sequence:** 1. Send SIGTERM (`-15`) to the process group (`-PID` targets the group) 2. Wait 2 seconds for graceful shutdown 3. Check if any process in the group survives (`kill -0 -PID`) 4. If still alive, send SIGKILL (`-9`) and record exit code 137 5. Otherwise, record exit code 143 (SIGTERM) This ensures child processes spawned by the background job are also terminated, preventing orphaned processes. ### Cross-Platform Compatibility | Feature | Linux | macOS | Windows (MSYS2) | |---------|-------|-------|-----------------| | `set -m` | ✓ bash builtin | ✓ bash builtin | ✓ bash builtin | | `kill -15/-9 -PID` | ✓ | ✓ | ✓ | | `nohup` | ✓ | ✓ | ✓ | | Path format | POSIX | POSIX | Converted via `cygpath` | Using `set -m` instead of platform-specific tools like `setsid` (Linux-only) ensures the same code works everywhere. ## Platform Support - **Linux/macOS/Windows MSYS2**: set -m + nohup pattern (universal) - **SSH**: Same pattern executed remotely ## Testing - 20 unit tests in `backgroundProcessManager.test.ts` (including process group termination) - 7 unit tests for background execution in `bash.test.ts` - 6 unit tests in `bash_background_list.test.ts` (including display_name) - 5 unit tests in `bash_background_terminate.test.ts` - 19 unit tests in `backgroundCommands.test.ts` - 7 runtime tests in `tests/runtime/runtime.test.ts` (Local & SSH) - 3 end-to-end integration tests in `tests/ipc/backgroundBash.test.ts` (real AI calls) _Generated with mux_
1 parent ff3543c commit acb9052

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3115
-99
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "mux",

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

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

268268
const diffOutput = diffResult.data.output ?? "";
269-
const truncationInfo = diffResult.data.truncated;
269+
const truncationInfo =
270+
"truncated" in diffResult.data ? diffResult.data.truncated : undefined;
270271

271272
const fileDiffs = parseDiff(diffOutput);
272273
const allHunks = extractAllHunks(fileDiffs);

src/cli/run.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { PartialService } from "@/node/services/partialService";
1717
import { InitStateManager } from "@/node/services/initStateManager";
1818
import { AIService } from "@/node/services/aiService";
1919
import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession";
20+
import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
2021
import {
2122
isCaughtUpMessage,
2223
isStreamAbort,
@@ -267,7 +268,14 @@ async function main(): Promise<void> {
267268
const historyService = new HistoryService(config);
268269
const partialService = new PartialService(config, historyService);
269270
const initStateManager = new InitStateManager(config);
270-
const aiService = new AIService(config, historyService, partialService, initStateManager);
271+
const backgroundProcessManager = new BackgroundProcessManager();
272+
const aiService = new AIService(
273+
config,
274+
historyService,
275+
partialService,
276+
initStateManager,
277+
backgroundProcessManager
278+
);
271279
ensureProvidersConfig(config);
272280

273281
const session = new AgentSession({
@@ -277,6 +285,7 @@ async function main(): Promise<void> {
277285
partialService,
278286
aiService,
279287
initStateManager,
288+
backgroundProcessManager,
280289
});
281290

282291
await session.ensureMetadata({

src/common/orpc/schemas/runtime.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,33 @@ export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh"]);
1212
*
1313
* This allows two-way compatibility: users can upgrade/downgrade without breaking workspaces.
1414
*/
15+
// Common field for background process output directory
16+
const bgOutputDirField = z
17+
.string()
18+
.optional()
19+
.meta({ description: "Directory for background process output (e.g., /tmp/mux-bashes)" });
20+
1521
export const RuntimeConfigSchema = z.union([
1622
// Legacy local with srcBaseDir (treated as worktree)
1723
z.object({
1824
type: z.literal("local"),
1925
srcBaseDir: z.string().meta({
2026
description: "Base directory where all workspaces are stored (legacy worktree config)",
2127
}),
28+
bgOutputDir: bgOutputDirField,
2229
}),
2330
// New project-dir local (no srcBaseDir)
2431
z.object({
2532
type: z.literal("local"),
33+
bgOutputDir: bgOutputDirField,
2634
}),
2735
// Explicit worktree runtime
2836
z.object({
2937
type: z.literal("worktree"),
3038
srcBaseDir: z
3139
.string()
3240
.meta({ description: "Base directory where all workspaces are stored (e.g., ~/.mux/src)" }),
41+
bgOutputDir: bgOutputDirField,
3342
}),
3443
// SSH runtime
3544
z.object({
@@ -40,6 +49,7 @@ export const RuntimeConfigSchema = z.union([
4049
srcBaseDir: z
4150
.string()
4251
.meta({ description: "Base directory on remote host where all workspaces are stored" }),
52+
bgOutputDir: bgOutputDirField,
4353
identityFile: z
4454
.string()
4555
.optional()

src/common/types/tools.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
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)
11+
display_name?: string; // Human-readable name for background processes
1012
}
1113

1214
interface CommonBashFields {
@@ -26,6 +28,14 @@ export type BashToolResult =
2628
totalLines: number;
2729
};
2830
})
31+
| (CommonBashFields & {
32+
success: true;
33+
output: string;
34+
exitCode: 0;
35+
backgroundProcessId: string; // Background spawn succeeded
36+
stdout_path: string; // Path to stdout log file
37+
stderr_path: string; // Path to stderr log file
38+
})
2939
| (CommonBashFields & {
3040
success: false;
3141
output?: string;
@@ -190,6 +200,33 @@ export interface StatusSetToolArgs {
190200
url?: string;
191201
}
192202

203+
// Bash Background Tool Types
204+
export interface BashBackgroundTerminateArgs {
205+
process_id: string;
206+
}
207+
208+
export type BashBackgroundTerminateResult =
209+
| { success: true; message: string; display_name?: string }
210+
| { success: false; error: string };
211+
212+
// Bash Background List Tool Types
213+
export type BashBackgroundListArgs = Record<string, never>;
214+
215+
export interface BashBackgroundListProcess {
216+
process_id: string;
217+
status: "running" | "exited" | "killed" | "failed";
218+
script: string;
219+
uptime_ms: number;
220+
exitCode?: number;
221+
stdout_path: string; // Path to stdout log file
222+
stderr_path: string; // Path to stderr log file
223+
display_name?: string; // Human-readable name (e.g., "Dev Server")
224+
}
225+
226+
export type BashBackgroundListResult =
227+
| { success: true; processes: BashBackgroundListProcess[] }
228+
| { success: false; error: string };
229+
193230
export type StatusSetToolResult =
194231
| {
195232
success: true;

src/common/utils/tools/toolDefinitions.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ 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 (e.g., bg-a1b2c3d4), stdout_path, and stderr_path. " +
64+
"Read output with bash (e.g., tail -50 <stdout_path>). " +
65+
"Terminate with bash_background_terminate using the process_id. " +
66+
"Process persists until terminated or workspace is removed."
67+
),
68+
display_name: z
69+
.string()
70+
.optional()
71+
.describe(
72+
"Human-readable name for background processes (e.g., 'Dev Server', 'TypeCheck Watch'). " +
73+
"Only used when run_in_background=true."
74+
),
5575
}),
5676
},
5777
file_read: {
@@ -229,6 +249,26 @@ export const TOOL_DEFINITIONS = {
229249
})
230250
.strict(),
231251
},
252+
bash_background_list: {
253+
description:
254+
"List all background processes started with bash(run_in_background=true). " +
255+
"Returns process_id, status, script, stdout_path, stderr_path for each process. " +
256+
"Use to find process_id for termination or check output file paths.",
257+
schema: z.object({}),
258+
},
259+
bash_background_terminate: {
260+
description:
261+
"Terminate a background process started with bash(run_in_background=true). " +
262+
"Use process_id from the original bash response or from bash_background_list. " +
263+
"Sends SIGTERM, waits briefly, then SIGKILL if needed. " +
264+
"Output files remain available after termination.",
265+
schema: z.object({
266+
process_id: z
267+
.string()
268+
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
269+
.describe("Background process ID to terminate"),
270+
}),
271+
},
232272
web_fetch: {
233273
description:
234274
`Fetch a web page and extract its main content as clean markdown. ` +
@@ -272,6 +312,8 @@ export function getAvailableTools(modelString: string): string[] {
272312
// Base tools available for all models
273313
const baseTools = [
274314
"bash",
315+
"bash_background_list",
316+
"bash_background_terminate",
275317
"file_read",
276318
"file_edit_replace_string",
277319
// "file_edit_replace_lines", // DISABLED: causes models to break repo state

src/common/utils/tools/tools.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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 { createBashBackgroundListTool } from "@/node/services/tools/bash_background_list";
5+
import { createBashBackgroundTerminateTool } from "@/node/services/tools/bash_background_terminate";
46
import { createFileEditReplaceStringTool } from "@/node/services/tools/file_edit_replace_string";
57
// DISABLED: import { createFileEditReplaceLinesTool } from "@/node/services/tools/file_edit_replace_lines";
68
import { createFileEditInsertTool } from "@/node/services/tools/file_edit_insert";
@@ -12,6 +14,7 @@ import { log } from "@/node/services/log";
1214

1315
import type { Runtime } from "@/node/runtime/Runtime";
1416
import type { InitStateManager } from "@/node/services/initStateManager";
17+
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
1518

1619
/**
1720
* Configuration for tools that need runtime context
@@ -31,6 +34,10 @@ export interface ToolConfiguration {
3134
runtimeTempDir: string;
3235
/** Overflow policy for bash tool output (optional, not exposed to AI) */
3336
overflow_policy?: "truncate" | "tmpfile";
37+
/** Background process manager for bash tool (optional, AI-only) */
38+
backgroundProcessManager?: BackgroundProcessManager;
39+
/** Workspace ID for tracking background processes (optional for token estimation) */
40+
workspaceId?: string;
3441
}
3542

3643
/**
@@ -101,6 +108,8 @@ export async function getToolsForModel(
101108
// and line number miscalculations. Use file_edit_replace_string instead.
102109
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
103110
bash: wrap(createBashTool(config)),
111+
bash_background_list: wrap(createBashBackgroundListTool(config)),
112+
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
104113
web_fetch: wrap(createWebFetchTool(config)),
105114
};
106115

src/desktop/main.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,30 @@ if (gotTheLock) {
564564
}
565565
});
566566

567+
// Track if we're in the middle of disposing to prevent re-entry
568+
let isDisposing = false;
569+
570+
app.on("before-quit", (event) => {
571+
// Skip if already disposing or no services to clean up
572+
if (isDisposing || !services) {
573+
return;
574+
}
575+
576+
// Prevent quit, clean up, then quit again
577+
event.preventDefault();
578+
isDisposing = true;
579+
580+
// Race dispose against timeout to ensure app quits even if disposal hangs
581+
const disposePromise = services.dispose().catch((err) => {
582+
console.error("Error during ServiceContainer dispose:", err);
583+
});
584+
const timeoutPromise = new Promise<void>((resolve) => setTimeout(resolve, 5000));
585+
586+
void Promise.race([disposePromise, timeoutPromise]).finally(() => {
587+
app.quit();
588+
});
589+
});
590+
567591
app.on("window-all-closed", () => {
568592
if (process.platform !== "darwin") {
569593
app.quit();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { BackgroundHandle } from "./Runtime";
2+
import { parseExitCode, buildTerminateCommand } from "./backgroundCommands";
3+
import { log } from "@/node/services/log";
4+
import { execAsync } from "@/node/utils/disposableExec";
5+
import { getBashPath } from "@/node/utils/main/bashPath";
6+
import * as fs from "fs/promises";
7+
import * as path from "path";
8+
9+
/**
10+
* Handle to a local background process.
11+
*
12+
* Uses file-based status detection (same approach as SSHBackgroundHandle):
13+
* - Process is running if exit_code file doesn't exist
14+
* - Exit code is read from exit_code file (written by bash trap on exit)
15+
*
16+
* Output is written directly to files via shell redirection (nohup ... > file),
17+
* so the process continues writing even if mux closes.
18+
*/
19+
export class LocalBackgroundHandle implements BackgroundHandle {
20+
private terminated = false;
21+
22+
constructor(
23+
private readonly pid: number,
24+
public readonly outputDir: string
25+
) {}
26+
27+
/**
28+
* Get the exit code from the exit_code file.
29+
* Returns null if process is still running (file doesn't exist yet).
30+
*/
31+
async getExitCode(): Promise<number | null> {
32+
try {
33+
const exitCodePath = path.join(this.outputDir, "exit_code");
34+
const content = await fs.readFile(exitCodePath, "utf-8");
35+
return parseExitCode(content);
36+
} catch {
37+
// File doesn't exist or can't be read - process still running or crashed
38+
return null;
39+
}
40+
}
41+
42+
/**
43+
* Terminate the process by killing the process group.
44+
* Sends SIGTERM (15), waits 2 seconds, then SIGKILL (9) if still running.
45+
*
46+
* Uses buildTerminateCommand for parity with SSH - works on Linux, macOS, and Windows MSYS2.
47+
*/
48+
async terminate(): Promise<void> {
49+
if (this.terminated) return;
50+
51+
try {
52+
const exitCodePath = path.join(this.outputDir, "exit_code");
53+
const terminateCmd = buildTerminateCommand(this.pid, exitCodePath);
54+
log.debug(`LocalBackgroundHandle: Terminating process group ${this.pid}`);
55+
using proc = execAsync(terminateCmd, { shell: getBashPath() });
56+
await proc.result;
57+
} catch (error) {
58+
// Process may already be dead - that's fine
59+
log.debug(
60+
`LocalBackgroundHandle.terminate: Error: ${error instanceof Error ? error.message : String(error)}`
61+
);
62+
}
63+
64+
this.terminated = true;
65+
}
66+
67+
/**
68+
* Clean up resources.
69+
* No local resources to clean - process runs independently via nohup.
70+
*/
71+
async dispose(): Promise<void> {
72+
// No resources to clean up - we don't own the process
73+
}
74+
75+
/**
76+
* Write meta.json to the output directory.
77+
*/
78+
async writeMeta(metaJson: string): Promise<void> {
79+
await fs.writeFile(path.join(this.outputDir, "meta.json"), metaJson);
80+
}
81+
}

0 commit comments

Comments
 (0)