Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cd9fe36
πŸ€– feat: add background bash process execution with SSH support
ethanndickson Dec 1, 2025
97ac438
feat: enable background bash execution on Windows
ethanndickson Dec 5, 2025
cc16389
fix: SSH background spawn env vars and tilde path expansion
ethanndickson Dec 5, 2025
9db7d21
refactor: remove nullable handle from BackgroundProcess
ethanndickson Dec 5, 2025
b665cfb
fix: use polling instead of fixed delays in background process tests
ethanndickson Dec 5, 2025
14491fc
fix: revert double-quoting of SSH background spawn paths
ethanndickson Dec 5, 2025
e277809
fix: use platform temp dir for background process output
ethanndickson Dec 5, 2025
8e55187
fix: support tilde paths in SSH bgOutputDir
ethanndickson Dec 5, 2025
a12c8ed
fix: use bash shell for POSIX commands on Windows
ethanndickson Dec 5, 2025
798ced5
fix: quote bash path and validate SSH cwd in spawnBackground
ethanndickson Dec 5, 2025
656c68d
docs: clarify in-memory process tracking scope
ethanndickson Dec 5, 2025
2bb296b
fix: expand tilde in SSH terminate exitCodePath
ethanndickson Dec 5, 2025
2f3cbb1
fix: convert Windows paths to POSIX for Git Bash commands
ethanndickson Dec 5, 2025
352ded9
fix: Windows path handling and MSYS2 process termination
ethanndickson Dec 5, 2025
4b9128f
fix: add quotePath to buildTerminateCommand for SSH tilde support
ethanndickson Dec 5, 2025
a3b971b
πŸ€– refactor: simplify SSHRuntime tilde resolution to single cached method
ethanndickson Dec 5, 2025
f0575a7
πŸ€– feat: add display_name field for background processes
ethanndickson Dec 5, 2025
2c09936
πŸ€– fix: use PGID for Windows MSYS2 process termination (no /proc on ma…
ethanndickson Dec 8, 2025
6d815be
πŸ€– fix: update backgroundCommands test for new PID PGID output format
ethanndickson Dec 8, 2025
a1e7320
πŸ€– docs: improve background process tool descriptions for clarity
ethanndickson Dec 8, 2025
f6ac07c
πŸ€– test: skip flaky AI-dependent background bash integration tests
ethanndickson Dec 8, 2025
5dbc14c
Revert "πŸ€– test: skip flaky AI-dependent background bash integration t…
ethanndickson Dec 8, 2025
211d10c
fix: background bash tests can't start with sleep command
ethanndickson Dec 8, 2025
38f629c
fix: toolOutputContains should check message field for terminate result
ethanndickson Dec 8, 2025
846b365
πŸ€– refactor: universal PGID lookup and terminate for background processes
ethanndickson Dec 8, 2025
6becd7f
πŸ€– fix: use set -m for process group isolation
ethanndickson Dec 8, 2025
df15f39
refactor: simplify background process - remove PGID lookup
ethanndickson Dec 8, 2025
384ff92
fix: write exit code after process exits in terminate command
ethanndickson Dec 8, 2025
68dd866
fix: check process group instead of parent PID in terminate
ethanndickson Dec 8, 2025
13ed15c
docs: clarify PID === PGID in SSHBackgroundHandle comment
ethanndickson Dec 8, 2025
63dfc3a
fix: address Copilot review comments
ethanndickson Dec 8, 2025
badae77
test: add process group termination and display_name tests
ethanndickson Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mux",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
}

const diffOutput = diffResult.data.output ?? "";
const truncationInfo = diffResult.data.truncated;
const truncationInfo =
"truncated" in diffResult.data ? diffResult.data.truncated : undefined;

const fileDiffs = parseDiff(diffOutput);
const allHunks = extractAllHunks(fileDiffs);
Expand Down
11 changes: 10 additions & 1 deletion src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PartialService } from "@/node/services/partialService";
import { InitStateManager } from "@/node/services/initStateManager";
import { AIService } from "@/node/services/aiService";
import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession";
import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
import {
isCaughtUpMessage,
isStreamAbort,
Expand Down Expand Up @@ -267,7 +268,14 @@ async function main(): Promise<void> {
const historyService = new HistoryService(config);
const partialService = new PartialService(config, historyService);
const initStateManager = new InitStateManager(config);
const aiService = new AIService(config, historyService, partialService, initStateManager);
const backgroundProcessManager = new BackgroundProcessManager();
const aiService = new AIService(
config,
historyService,
partialService,
initStateManager,
backgroundProcessManager
);
ensureProvidersConfig(config);

const session = new AgentSession({
Expand All @@ -277,6 +285,7 @@ async function main(): Promise<void> {
partialService,
aiService,
initStateManager,
backgroundProcessManager,
});

await session.ensureMetadata({
Expand Down
10 changes: 10 additions & 0 deletions src/common/orpc/schemas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,33 @@ export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh"]);
*
* This allows two-way compatibility: users can upgrade/downgrade without breaking workspaces.
*/
// Common field for background process output directory
const bgOutputDirField = z
.string()
.optional()
.meta({ description: "Directory for background process output (e.g., /tmp/mux-bashes)" });

export const RuntimeConfigSchema = z.union([
// Legacy local with srcBaseDir (treated as worktree)
z.object({
type: z.literal("local"),
srcBaseDir: z.string().meta({
description: "Base directory where all workspaces are stored (legacy worktree config)",
}),
bgOutputDir: bgOutputDirField,
}),
// New project-dir local (no srcBaseDir)
z.object({
type: z.literal("local"),
bgOutputDir: bgOutputDirField,
}),
// Explicit worktree runtime
z.object({
type: z.literal("worktree"),
srcBaseDir: z
.string()
.meta({ description: "Base directory where all workspaces are stored (e.g., ~/.mux/src)" }),
bgOutputDir: bgOutputDirField,
}),
// SSH runtime
z.object({
Expand All @@ -40,6 +49,7 @@ export const RuntimeConfigSchema = z.union([
srcBaseDir: z
.string()
.meta({ description: "Base directory on remote host where all workspaces are stored" }),
bgOutputDir: bgOutputDirField,
identityFile: z
.string()
.optional()
Expand Down
37 changes: 37 additions & 0 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
export interface BashToolArgs {
script: string;
timeout_secs?: number; // Optional: defaults to 3 seconds for interactivity
run_in_background?: boolean; // Run without blocking (for long-running processes)
display_name?: string; // Human-readable name for background processes
}

interface CommonBashFields {
Expand All @@ -26,6 +28,14 @@ export type BashToolResult =
totalLines: number;
};
})
| (CommonBashFields & {
success: true;
output: string;
exitCode: 0;
backgroundProcessId: string; // Background spawn succeeded
stdout_path: string; // Path to stdout log file
stderr_path: string; // Path to stderr log file
})
| (CommonBashFields & {
success: false;
output?: string;
Expand Down Expand Up @@ -190,6 +200,33 @@ export interface StatusSetToolArgs {
url?: string;
}

// Bash Background Tool Types
export interface BashBackgroundTerminateArgs {
process_id: string;
}

export type BashBackgroundTerminateResult =
| { success: true; message: string; display_name?: string }
| { success: false; error: string };

// Bash Background List Tool Types
export type BashBackgroundListArgs = Record<string, never>;

export interface BashBackgroundListProcess {
process_id: string;
status: "running" | "exited" | "killed" | "failed";
script: string;
uptime_ms: number;
exitCode?: number;
stdout_path: string; // Path to stdout log file
stderr_path: string; // Path to stderr log file
display_name?: string; // Human-readable name (e.g., "Dev Server")
}

export type BashBackgroundListResult =
| { success: true; processes: BashBackgroundListProcess[] }
| { success: false; error: string };

export type StatusSetToolResult =
| {
success: true;
Expand Down
42 changes: 42 additions & 0 deletions src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ export const TOOL_DEFINITIONS = {
.describe(
`Timeout (seconds, default: ${BASH_DEFAULT_TIMEOUT_SECS}). Start small and increase on retry; avoid large initial values to keep UX responsive`
),
run_in_background: z
.boolean()
.default(false)
.describe(
"Run this command in the background without blocking. " +
"Use for processes running >5s (dev servers, builds, file watchers). " +
"Do NOT use for quick commands (<5s), interactive processes (no stdin support), " +
"or processes requiring real-time output (use foreground with larger timeout instead). " +
"Returns immediately with process_id (e.g., bg-a1b2c3d4), stdout_path, and stderr_path. " +
"Read output with bash (e.g., tail -50 <stdout_path>). " +
"Terminate with bash_background_terminate using the process_id. " +
"Process persists until terminated or workspace is removed."
),
display_name: z
.string()
.optional()
.describe(
"Human-readable name for background processes (e.g., 'Dev Server', 'TypeCheck Watch'). " +
"Only used when run_in_background=true."
),
}),
},
file_read: {
Expand Down Expand Up @@ -229,6 +249,26 @@ export const TOOL_DEFINITIONS = {
})
.strict(),
},
bash_background_list: {
description:
"List all background processes started with bash(run_in_background=true). " +
"Returns process_id, status, script, stdout_path, stderr_path for each process. " +
"Use to find process_id for termination or check output file paths.",
schema: z.object({}),
},
bash_background_terminate: {
description:
"Terminate a background process started with bash(run_in_background=true). " +
"Use process_id from the original bash response or from bash_background_list. " +
"Sends SIGTERM, waits briefly, then SIGKILL if needed. " +
"Output files remain available after termination.",
schema: z.object({
process_id: z
.string()
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
.describe("Background process ID to terminate"),
}),
},
web_fetch: {
description:
`Fetch a web page and extract its main content as clean markdown. ` +
Expand Down Expand Up @@ -272,6 +312,8 @@ export function getAvailableTools(modelString: string): string[] {
// Base tools available for all models
const baseTools = [
"bash",
"bash_background_list",
"bash_background_terminate",
"file_read",
"file_edit_replace_string",
// "file_edit_replace_lines", // DISABLED: causes models to break repo state
Expand Down
9 changes: 9 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type Tool } from "ai";
import { createFileReadTool } from "@/node/services/tools/file_read";
import { createBashTool } from "@/node/services/tools/bash";
import { createBashBackgroundListTool } from "@/node/services/tools/bash_background_list";
import { createBashBackgroundTerminateTool } from "@/node/services/tools/bash_background_terminate";
import { createFileEditReplaceStringTool } from "@/node/services/tools/file_edit_replace_string";
// DISABLED: import { createFileEditReplaceLinesTool } from "@/node/services/tools/file_edit_replace_lines";
import { createFileEditInsertTool } from "@/node/services/tools/file_edit_insert";
Expand All @@ -12,6 +14,7 @@ import { log } from "@/node/services/log";

import type { Runtime } from "@/node/runtime/Runtime";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";

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

/**
Expand Down Expand Up @@ -101,6 +108,8 @@ export async function getToolsForModel(
// and line number miscalculations. Use file_edit_replace_string instead.
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
bash: wrap(createBashTool(config)),
bash_background_list: wrap(createBashBackgroundListTool(config)),
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
web_fetch: wrap(createWebFetchTool(config)),
};

Expand Down
24 changes: 24 additions & 0 deletions src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,30 @@ if (gotTheLock) {
}
});

// Track if we're in the middle of disposing to prevent re-entry
let isDisposing = false;

app.on("before-quit", (event) => {
// Skip if already disposing or no services to clean up
if (isDisposing || !services) {
return;
}

// Prevent quit, clean up, then quit again
event.preventDefault();
isDisposing = true;

// Race dispose against timeout to ensure app quits even if disposal hangs
const disposePromise = services.dispose().catch((err) => {
console.error("Error during ServiceContainer dispose:", err);
});
const timeoutPromise = new Promise<void>((resolve) => setTimeout(resolve, 5000));

void Promise.race([disposePromise, timeoutPromise]).finally(() => {
app.quit();
});
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
Expand Down
81 changes: 81 additions & 0 deletions src/node/runtime/LocalBackgroundHandle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { BackgroundHandle } from "./Runtime";
import { parseExitCode, buildTerminateCommand } from "./backgroundCommands";
import { log } from "@/node/services/log";
import { execAsync } from "@/node/utils/disposableExec";
import { getBashPath } from "@/node/utils/main/bashPath";
import * as fs from "fs/promises";
import * as path from "path";

/**
* Handle to a local background process.
*
* Uses file-based status detection (same approach as SSHBackgroundHandle):
* - Process is running if exit_code file doesn't exist
* - Exit code is read from exit_code file (written by bash trap on exit)
*
* Output is written directly to files via shell redirection (nohup ... > file),
* so the process continues writing even if mux closes.
*/
export class LocalBackgroundHandle implements BackgroundHandle {
private terminated = false;

constructor(
private readonly pid: number,
public readonly outputDir: string
) {}

/**
* Get the exit code from the exit_code file.
* Returns null if process is still running (file doesn't exist yet).
*/
async getExitCode(): Promise<number | null> {
try {
const exitCodePath = path.join(this.outputDir, "exit_code");
const content = await fs.readFile(exitCodePath, "utf-8");
return parseExitCode(content);
} catch {
// File doesn't exist or can't be read - process still running or crashed
return null;
}
}

/**
* Terminate the process by killing the process group.
* Sends SIGTERM (15), waits 2 seconds, then SIGKILL (9) if still running.
*
* Uses buildTerminateCommand for parity with SSH - works on Linux, macOS, and Windows MSYS2.
*/
async terminate(): Promise<void> {
if (this.terminated) return;

try {
const exitCodePath = path.join(this.outputDir, "exit_code");
const terminateCmd = buildTerminateCommand(this.pid, exitCodePath);
log.debug(`LocalBackgroundHandle: Terminating process group ${this.pid}`);
using proc = execAsync(terminateCmd, { shell: getBashPath() });
await proc.result;
} catch (error) {
// Process may already be dead - that's fine
log.debug(
`LocalBackgroundHandle.terminate: Error: ${error instanceof Error ? error.message : String(error)}`
);
}

this.terminated = true;
}

/**
* Clean up resources.
* No local resources to clean - process runs independently via nohup.
*/
async dispose(): Promise<void> {
// No resources to clean up - we don't own the process
}

/**
* Write meta.json to the output directory.
*/
async writeMeta(metaJson: string): Promise<void> {
await fs.writeFile(path.join(this.outputDir, "meta.json"), metaJson);
}
}
Loading