Skip to content

Commit 0cc4332

Browse files
committed
🤖 feat: add background bash process execution with SSH support
Adds run_in_background=true option to bash tool for long-running processes (dev servers, builds, file watchers). Returns process ID and output file paths for agents to monitor via tail/grep/cat. Key features: - Works on both local and SSH runtimes (unified setsid+nohup pattern) - Output written to files: /tmp/mux-bashes/{workspaceId}/{processId}/ - Exit code captured via bash trap to exit_code file - Process group termination (SIGTERM → wait → SIGKILL) - meta.json tracks process metadata Tools: - bash(run_in_background=true) - spawns process, returns stdout_path/stderr_path - bash_background_list - lists processes with file paths - bash_background_terminate - kills process group Removed bash_background_read (agents read files directly with bash). _Generated with mux_
1 parent b061c16 commit 0cc4332

Some content is hidden

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

42 files changed

+2996
-94
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
@@ -290,7 +290,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
290290
}
291291

292292
const diffOutput = diffResult.data.output ?? "";
293-
const truncationInfo = diffResult.data.truncated;
293+
const truncationInfo =
294+
"truncated" in diffResult.data ? diffResult.data.truncated : undefined;
294295

295296
const fileDiffs = parseDiff(diffOutput);
296297
const allHunks = extractAllHunks(fileDiffs);

src/cli/run.ts

Lines changed: 4 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,8 @@ 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(config, historyService, partialService, initStateManager, backgroundProcessManager);
271273
ensureProvidersConfig(config);
272274

273275
const session = new AgentSession({
@@ -277,6 +279,7 @@ async function main(): Promise<void> {
277279
partialService,
278280
aiService,
279281
initStateManager,
282+
backgroundProcessManager,
280283
});
281284

282285
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: 35 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,14 @@ 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+
stdout_path: string; // Path to stdout log file
36+
stderr_path: string; // Path to stderr log file
37+
})
2938
| (CommonBashFields & {
3039
success: false;
3140
output?: string;
@@ -190,6 +199,32 @@ export interface StatusSetToolArgs {
190199
url?: string;
191200
}
192201

202+
// Bash Background Tool Types
203+
export interface BashBackgroundTerminateArgs {
204+
process_id: string;
205+
}
206+
207+
export type BashBackgroundTerminateResult =
208+
| { success: true; message: string }
209+
| { success: false; error: string };
210+
211+
// Bash Background List Tool Types
212+
export type BashBackgroundListArgs = Record<string, never>;
213+
214+
export interface BashBackgroundListProcess {
215+
process_id: string;
216+
status: "running" | "exited" | "killed" | "failed";
217+
script: string;
218+
uptime_ms: number;
219+
exitCode?: number;
220+
stdout_path: string; // Path to stdout log file
221+
stderr_path: string; // Path to stderr log file
222+
}
223+
224+
export type BashBackgroundListResult =
225+
| { success: true; processes: BashBackgroundListProcess[] }
226+
| { success: false; error: string };
227+
193228
export type StatusSetToolResult =
194229
| {
195230
success: true;

src/common/utils/tools/toolDefinitions.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ 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 and output file paths (stdout_path, stderr_path). " +
64+
"Read output via bash (e.g. tail, grep, cat). " +
65+
"Process persists across tool calls until terminated or workspace is removed."
66+
),
5567
}),
5668
},
5769
file_read: {
@@ -229,6 +241,24 @@ export const TOOL_DEFINITIONS = {
229241
})
230242
.strict(),
231243
},
244+
bash_background_list: {
245+
description:
246+
"List all background processes for the current workspace. " +
247+
"Useful for discovering running processes after context loss or resuming a conversation.",
248+
schema: z.object({}),
249+
},
250+
bash_background_terminate: {
251+
description:
252+
"Terminate a background bash process. " +
253+
"Sends SIGTERM, waits briefly, then sends SIGKILL if needed. " +
254+
"Process output remains available for inspection after termination.",
255+
schema: z.object({
256+
process_id: z
257+
.string()
258+
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
259+
.describe("Background process ID to terminate"),
260+
}),
261+
},
232262
web_fetch: {
233263
description:
234264
`Fetch a web page and extract its main content as clean markdown. ` +
@@ -272,6 +302,8 @@ export function getAvailableTools(modelString: string): string[] {
272302
// Base tools available for all models
273303
const baseTools = [
274304
"bash",
305+
"bash_background_list",
306+
"bash_background_terminate",
275307
"file_read",
276308
"file_edit_replace_string",
277309
// "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
@@ -29,6 +32,10 @@ export interface ToolConfiguration {
2932
runtimeTempDir: string;
3033
/** Overflow policy for bash tool output (optional, not exposed to AI) */
3134
overflow_policy?: "truncate" | "tmpfile";
35+
/** Background process manager for bash tool (optional, AI-only) */
36+
backgroundProcessManager?: BackgroundProcessManager;
37+
/** Workspace ID for tracking background processes (optional for token estimation) */
38+
workspaceId?: string;
3239
}
3340

3441
/**
@@ -99,6 +106,8 @@ export async function getToolsForModel(
99106
// and line number miscalculations. Use file_edit_replace_string instead.
100107
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
101108
bash: wrap(createBashTool(config)),
109+
bash_background_list: wrap(createBashBackgroundListTool(config)),
110+
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
102111
web_fetch: wrap(createWebFetchTool(config)),
103112
};
104113

src/desktop/main.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,29 @@ 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+
services
581+
.dispose()
582+
.catch((err) => {
583+
console.error("Error during ServiceContainer dispose:", err);
584+
})
585+
.finally(() => {
586+
app.quit();
587+
});
588+
});
589+
567590
app.on("window-all-closed", () => {
568591
if (process.platform !== "darwin") {
569592
app.quit();

0 commit comments

Comments
 (0)