Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,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
55 changes: 55 additions & 0 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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)
}

interface CommonBashFields {
Expand All @@ -26,6 +27,12 @@ export type BashToolResult =
totalLines: number;
};
})
| (CommonBashFields & {
success: true;
output: string;
exitCode: 0;
backgroundProcessId: string; // Background spawn succeeded
})
| (CommonBashFields & {
success: false;
output?: string;
Expand Down Expand Up @@ -190,6 +197,54 @@ export interface StatusSetToolArgs {
url?: string;
}

// Bash Background Tool Types
export interface BashBackgroundReadArgs {
process_id: string;
stdout_tail?: number; // Last N lines of stdout
stderr_tail?: number; // Last N lines of stderr
stdout_regex?: string; // Filter stdout by regex
stderr_regex?: string; // Filter stderr by regex
}

export type BashBackgroundReadResult =
| {
success: true;
process_id: string;
status: "running" | "exited" | "killed" | "failed";
script: string;
uptime_ms: number;
exitCode?: number;
stdout: string[];
stderr: string[];
}
| {
success: false;
error: string;
};

export interface BashBackgroundTerminateArgs {
process_id: string;
}

export type BashBackgroundTerminateResult =
| { success: true; message: 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;
}

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

export type StatusSetToolResult =
| {
success: true;
Expand Down
66 changes: 66 additions & 0 deletions src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ 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 for status checking and termination. " +
"Output is buffered (max 1000 lines per stream, oldest evicted when full). " +
"Poll frequently with bash_background_read for high-output processes. " +
"Process persists across tool calls until terminated or workspace is removed."
),
}),
},
file_read: {
Expand Down Expand Up @@ -229,6 +242,56 @@ export const TOOL_DEFINITIONS = {
})
.strict(),
},
bash_background_read: {
description:
"Check status and read output from a background bash process. " +
"Use this to inspect long-running processes started with run_in_background=true. " +
"Supports filtering output with tail and regex options.",
schema: z.object({
process_id: z
.string()
.regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format")
.describe("Background process ID returned from bash tool"),
stdout_tail: z
.number()
.int()
.positive()
.optional()
.describe("Return last N lines of stdout (default: all buffered)"),
stderr_tail: z
.number()
.int()
.positive()
.optional()
.describe("Return last N lines of stderr (default: all buffered)"),
stdout_regex: z
.string()
.optional()
.describe("Filter stdout lines by regex pattern (applied before tail)"),
stderr_regex: z
.string()
.optional()
.describe("Filter stderr lines by regex pattern (applied before tail)"),
}),
},
bash_background_list: {
description:
"List all background processes for the current workspace. " +
"Useful for discovering running processes after context loss or resuming a conversation.",
schema: z.object({}),
},
bash_background_terminate: {
description:
"Terminate a background bash process. " +
"Sends SIGTERM, waits briefly, then sends SIGKILL if needed. " +
"Process output remains available for inspection 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 @@ -271,6 +334,9 @@ export function getAvailableTools(modelString: string): string[] {
// Base tools available for all models
const baseTools = [
"bash",
"bash_background_read",
"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
11 changes: 11 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { type Tool } from "ai";
import { createFileReadTool } from "@/node/services/tools/file_read";
import { createBashTool } from "@/node/services/tools/bash";
import { createBashBackgroundReadTool } from "@/node/services/tools/bash_background_read";
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 +15,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 @@ -29,6 +33,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 @@ -99,6 +107,9 @@ 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_read: wrap(createBashBackgroundReadTool(config)),
bash_background_list: wrap(createBashBackgroundListTool(config)),
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
web_fetch: wrap(createWebFetchTool(config)),
};

Expand Down
8 changes: 7 additions & 1 deletion src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getToolsForModel } from "@/common/utils/tools/tools";
import { createRuntime } from "@/node/runtime/runtimeFactory";
import { secretsToRecord } from "@/common/types/secrets";
import type { MuxProviderOptions } from "@/common/types/providerOptions";
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
import { log } from "./log";
import {
transformModelMessages,
Expand Down Expand Up @@ -141,12 +142,14 @@ export class AIService extends EventEmitter {
private readonly initStateManager: InitStateManager;
private readonly mockModeEnabled: boolean;
private readonly mockScenarioPlayer?: MockScenarioPlayer;
private readonly backgroundProcessManager?: BackgroundProcessManager;

constructor(
config: Config,
historyService: HistoryService,
partialService: PartialService,
initStateManager: InitStateManager
initStateManager: InitStateManager,
backgroundProcessManager?: BackgroundProcessManager
) {
super();
// Increase max listeners to accommodate multiple concurrent workspace listeners
Expand All @@ -156,6 +159,7 @@ export class AIService extends EventEmitter {
this.historyService = historyService;
this.partialService = partialService;
this.initStateManager = initStateManager;
this.backgroundProcessManager = backgroundProcessManager;
this.streamManager = new StreamManager(historyService, partialService);
void this.ensureSessionsDir();
this.setupStreamEventForwarding();
Expand Down Expand Up @@ -762,6 +766,8 @@ export class AIService extends EventEmitter {
runtime,
secrets: secretsToRecord(projectSecrets),
runtimeTempDir,
backgroundProcessManager: this.backgroundProcessManager,
workspaceId,
},
workspaceId,
this.initStateManager,
Expand Down
87 changes: 87 additions & 0 deletions src/node/services/backgroundExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Background process execution abstraction.
*
* This interface allows BackgroundProcessManager to work with different
* execution backends (local processes, SSH remote processes, etc.)
*/

/**
* Configuration for background execution
*/
export interface BackgroundExecConfig {
/** Working directory for command execution */
cwd: string;
/** Environment variables to inject */
env?: Record<string, string>;
/** Process niceness level (-20 to 19) */
niceness?: number;
}

/**
* Handle to a background process.
* Abstracts away whether process is local or remote.
*/
export interface BackgroundHandle {
/**
* Register callback for stdout lines.
* For local: called in real-time as output arrives.
* For SSH: called when output is polled/read.
*/
onStdout(callback: (line: string) => void): void;

/**
* Register callback for stderr lines.
*/
onStderr(callback: (line: string) => void): void;

/**
* Register callback for process exit.
* @param callback Receives exit code (128+signal for signal termination)
*/
onExit(callback: (exitCode: number) => void): void;

/**
* Check if process is still running.
* For local: checks ChildProcess.exitCode
* For SSH: runs `kill -0 $PID` on remote
*/
isRunning(): Promise<boolean>;

/**
* Terminate the process (SIGTERM → wait → SIGKILL).
* For local: process.kill(-pid, signal)
* For SSH: ssh "kill -TERM -$PID"
*/
terminate(): Promise<void>;

/**
* Clean up resources (called after process exits or on error).
* For local: disposes ChildProcess
* For SSH: removes remote temp files
*/
dispose(): Promise<void>;
}

/**
* Result of spawning a background process
*/
export type BackgroundSpawnResult =
| { success: true; handle: BackgroundHandle }
| { success: false; error: string };

/**
* Executor interface for spawning background processes.
*
* Implementations:
* - LocalBackgroundExecutor: Uses BashExecutionService for local processes
* - SSHBackgroundExecutor: Uses nohup/setsid + file-based output (TODO)
*/
export interface BackgroundExecutor {
/**
* Spawn a background process.
* @param script Bash script to execute
* @param config Execution configuration
* @returns BackgroundHandle on success, or error
*/
spawn(script: string, config: BackgroundExecConfig): Promise<BackgroundSpawnResult>;
}
Loading
Loading