/** * KHAELOR * File: src/tools/bash.ts * Description: The bash tool — foreground shell execution with the timeout-to-background redirect (TOOL_PROTOCOL §7). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ManagedProcess } from "../workspace/index.js"; import { formatDuration, resolveToolPath } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import { needsTruncation, truncateMiddleOut } from "./truncate.js"; import type { ToolContext, ToolResult, TruncationInfo } from "./types.js"; import { CANCELLED_RESULT_CONTENT } from "./types.js"; export interface BashInput { command: string; timeout_ms?: number; workdir?: string; } const DESCRIPTION = "Run a shell command in the workspace and wait for it to finish. Use this for short-lived " + "commands: builds, tests, git, package scripts, file operations. Do NOT use it for long-running " + "processes such as dev servers, watchers, or REPLs — start those with the process tool so they run " + "in the background while you keep working. Commands run in a non-interactive shell from the " + "working directory (use the workdir parameter instead of 'cd'). stdout and stderr are returned " + "interleaved with the exit code. If a command is still running when the time ceiling is reached, " + "it is moved to the background process manager and you get its process id plus the output so far. " + "Quote paths containing spaces."; const DEFAULT_TIMEOUT_MS = 120_000; const MAX_TIMEOUT_MS = 300_000; const POLL_MS = 25; /** Output limits (§7.3): 400 lines / 30 KB, middle-out head 250 / tail 150. */ const LIMITS = { maxLines: 400, maxBytes: 30 * 1024, headLines: 250, tailLines: 150 }; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function findProcess(ctx: ToolContext, id: string): ManagedProcess | undefined { return ctx.processes.list().find((p) => p.id === id); } /** Read the full captured output of a managed process (spill log is the source of truth). */ async function fullOutput(ctx: ToolContext, proc: ManagedProcess): Promise { try { return await ctx.workspace.readFile(proc.logPath); } catch { // Binary/oversized log — fall back to the ring buffer page. const page = ctx.processes.read(proc.id, { offset: 1 }); return page.lines.join("\n"); } } async function executeBash(input: BashInput, ctx: ToolContext): Promise { const startedAt = Date.now(); const cwd = input.workdir !== undefined ? resolveToolPath(ctx.workspace.cwd(), input.workdir) : ctx.workspace.cwd(); const timeoutMs = Math.min( Math.max(1, input.timeout_ms ?? DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS, ); const title = (suffix: string): string => `Run ${input.command} · ${suffix}`; // Run under the process manager from the start: on timeout nothing is // killed or re-parented — the command simply stays managed (§7.2, ADR-8). let proc: ManagedProcess; try { proc = await ctx.processes.start(input.command, cwd); } catch (cause) { return { content: `Command failed to start: ${cause instanceof Error ? cause.message : String(cause)}. Check the command and try again.`, isError: true, metadata: { title: title("spawn failed"), durationMs: Date.now() - startedAt }, }; } const deadline = startedAt + timeoutMs; let current = findProcess(ctx, proc.id) ?? proc; while (current.status === "running" && Date.now() < deadline) { if (ctx.signal.aborted) { // A foreground bash command was meant to be short-lived — kill it (§7.2). await ctx.processes.stop(proc.id); return { content: CANCELLED_RESULT_CONTENT, isError: true, metadata: { title: title("cancelled"), durationMs: Date.now() - startedAt }, }; } await sleep(POLL_MS); current = findProcess(ctx, proc.id) ?? current; } const durationMs = Date.now() - startedAt; if (current.status === "running") { // Hard ceiling reached → redirect, not kill (§7.2). const page = ctx.processes.read(proc.id); const soFar = page.lines.join("\n"); return { content: `Command still running after ${Math.round(timeoutMs / 1000)}s — moved to background as process ${proc.id}.\n` + `Output so far:\n${soFar.length > 0 ? soFar : "(no output yet)"}\n` + `Use process {"action":"read","id":"${proc.id}"} to see new output, or {"action":"stop","id":"${proc.id}"} to stop it.`, metadata: { title: title(`moved to background · ${proc.id}`), exitCode: null, processId: proc.id, durationMs, extra: { cwd }, }, }; } const exitCode = current.exitCode; const output = await fullOutput(ctx, current); const footer = `[exit code ${exitCode === null ? "null" : exitCode} · ${formatDuration(durationMs)} · cwd ${cwd}]`; let body = output; let truncation: TruncationInfo | undefined; if (needsTruncation(output, LIMITS)) { const spillPath = await ctx.spill("bash", output); const truncated = truncateMiddleOut(output, LIMITS, spillPath); body = truncated.text; truncation = truncated.info; } const separator = body.length === 0 || body.endsWith("\n") ? "" : "\n"; return { content: `$ ${input.command}\n${body}${separator}${footer}`, metadata: { title: title(`exit ${exitCode === null ? "null" : exitCode} · ${formatDuration(durationMs)}`), exitCode, durationMs, ...(truncation !== undefined ? { truncation } : {}), extra: { cwd }, }, }; } /** Create the bash tool definition. */ export function createBashTool(): ToolDefinition { return { name: "bash", description: DESCRIPTION, capability: "process.execute", inputSchema: { type: "object", properties: { command: { type: "string", description: "The shell command to execute.", }, timeout_ms: { type: "integer", description: "Time budget in milliseconds before the command is moved to the background. Default 120000, maximum 300000.", }, workdir: { type: "string", description: "Working directory for the command. Defaults to the project working directory. Use this instead of 'cd'.", }, }, required: ["command"], }, execute: executeBash, }; }