Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
16 changes: 16 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 @@ -102,6 +110,14 @@ export async function getToolsForModel(
web_fetch: wrap(createWebFetchTool(config)),
};

// Only include background tools when manager is available
// (not available in CLI/debug paths)
if (config.backgroundProcessManager) {
runtimeTools.bash_background_read = wrap(createBashBackgroundReadTool(config));
runtimeTools.bash_background_list = wrap(createBashBackgroundListTool(config));
runtimeTools.bash_background_terminate = wrap(createBashBackgroundTerminateTool(config));
}

// Non-runtime tools execute immediately (no init wait needed)
const nonRuntimeTools: Record<string, Tool> = {
propose_plan: createProposePlanTool(config),
Expand Down
124 changes: 124 additions & 0 deletions src/node/runtime/LocalBackgroundHandle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { BackgroundHandle } from "./Runtime";
import type { DisposableProcess } from "@/node/utils/disposableExec";
import { log } from "@/node/services/log";

/**
* Handle to a local background process.
*
* Buffers early events until callbacks are registered, since the manager
* registers callbacks after spawn() returns (but output may arrive before).
*/
export class LocalBackgroundHandle implements BackgroundHandle {
private stdoutCallback?: (line: string) => void;
private stderrCallback?: (line: string) => void;
private exitCallback?: (exitCode: number) => void;
private terminated = false;

// Buffers for events that arrive before callbacks are registered
private pendingStdout: string[] = [];
private pendingStderr: string[] = [];
private pendingExitCode?: number;

constructor(private readonly disposable: DisposableProcess) {}

onStdout(callback: (line: string) => void): void {
this.stdoutCallback = callback;
// Flush buffered events
for (const line of this.pendingStdout) {
callback(line);
}
this.pendingStdout = [];
}

onStderr(callback: (line: string) => void): void {
this.stderrCallback = callback;
// Flush buffered events
for (const line of this.pendingStderr) {
callback(line);
}
this.pendingStderr = [];
}

onExit(callback: (exitCode: number) => void): void {
this.exitCallback = callback;
// Flush buffered event
if (this.pendingExitCode !== undefined) {
callback(this.pendingExitCode);
this.pendingExitCode = undefined;
}
}

/** Internal: called when stdout line arrives */
_emitStdout(line: string): void {
if (this.stdoutCallback) {
this.stdoutCallback(line);
} else {
this.pendingStdout.push(line);
}
}

/** Internal: called when stderr line arrives */
_emitStderr(line: string): void {
if (this.stderrCallback) {
this.stderrCallback(line);
} else {
this.pendingStderr.push(line);
}
}

/** Internal: called when process exits */
_emitExit(exitCode: number): void {
if (this.exitCallback) {
this.exitCallback(exitCode);
} else {
this.pendingExitCode = exitCode;
}
}

isRunning(): Promise<boolean> {
return Promise.resolve(this.disposable.underlying.exitCode === null);
}

async terminate(): Promise<void> {
if (this.terminated) return;

const pid = this.disposable.underlying.pid;
if (pid === undefined) {
this.terminated = true;
return;
}

try {
// Send SIGTERM to the process group for graceful shutdown
// Use negative PID to kill the entire process group (detached processes are group leaders)
const pgid = -pid;
log.debug(`LocalBackgroundHandle: Sending SIGTERM to process group (PGID: ${pgid})`);
process.kill(pgid, "SIGTERM");

// Wait 2 seconds for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 2000));

// Check if process is still running
if (await this.isRunning()) {
log.debug(`LocalBackgroundHandle: Process still running, sending SIGKILL`);
process.kill(pgid, "SIGKILL");
}
} catch (error) {
// Process may already be dead - that's fine
log.debug(
`LocalBackgroundHandle: Error during terminate: ${error instanceof Error ? error.message : String(error)}`
);
}

this.terminated = true;
}

dispose(): Promise<void> {
return Promise.resolve(this.disposable[Symbol.dispose]());
}

/** Get the underlying child process (for spawn event waiting) */
get child() {
return this.disposable.underlying;
}
}
Loading