spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tools/bash.ts4 * Description: The bash tool — foreground shell execution with the timeout-to-background redirect (TOOL_PROTOCOL §7).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ManagedProcess } from "../workspace/index.js";11import { formatDuration, resolveToolPath } from "./format.js";12import type { ToolDefinition } from "./registry.js";13import { needsTruncation, truncateMiddleOut } from "./truncate.js";14import type { ToolContext, ToolResult, TruncationInfo } from "./types.js";15import { CANCELLED_RESULT_CONTENT } from "./types.js";1617export interface BashInput {18 command: string;19 timeout_ms?: number;20 workdir?: string;21}2223const DESCRIPTION =24 "Run a shell command in the workspace and wait for it to finish. Use this for short-lived " +25 "commands: builds, tests, git, package scripts, file operations. Do NOT use it for long-running " +26 "processes such as dev servers, watchers, or REPLs — start those with the process tool so they run " +27 "in the background while you keep working. Commands run in a non-interactive shell from the " +28 "working directory (use the workdir parameter instead of 'cd'). stdout and stderr are returned " +29 "interleaved with the exit code. If a command is still running when the time ceiling is reached, " +30 "it is moved to the background process manager and you get its process id plus the output so far. " +31 "Quote paths containing spaces.";3233const DEFAULT_TIMEOUT_MS = 120_000;34const MAX_TIMEOUT_MS = 300_000;35const POLL_MS = 25;36/** Output limits (§7.3): 400 lines / 30 KB, middle-out head 250 / tail 150. */37const LIMITS = { maxLines: 400, maxBytes: 30 * 1024, headLines: 250, tailLines: 150 };3839function sleep(ms: number): Promise<void> {40 return new Promise((resolve) => setTimeout(resolve, ms));41}4243function findProcess(ctx: ToolContext, id: string): ManagedProcess | undefined {44 return ctx.processes.list().find((p) => p.id === id);45}4647/** Read the full captured output of a managed process (spill log is the source of truth). */48async function fullOutput(ctx: ToolContext, proc: ManagedProcess): Promise<string> {49 try {50 return await ctx.workspace.readFile(proc.logPath);51 } catch {52 // Binary/oversized log — fall back to the ring buffer page.53 const page = ctx.processes.read(proc.id, { offset: 1 });54 return page.lines.join("\n");55 }56}5758async function executeBash(input: BashInput, ctx: ToolContext): Promise<ToolResult> {59 const startedAt = Date.now();60 const cwd =61 input.workdir !== undefined62 ? resolveToolPath(ctx.workspace.cwd(), input.workdir)63 : ctx.workspace.cwd();64 const timeoutMs = Math.min(65 Math.max(1, input.timeout_ms ?? DEFAULT_TIMEOUT_MS),66 MAX_TIMEOUT_MS,67 );68 const title = (suffix: string): string => `Run ${input.command} · ${suffix}`;6970 // Run under the process manager from the start: on timeout nothing is71 // killed or re-parented — the command simply stays managed (§7.2, ADR-8).72 let proc: ManagedProcess;73 try {74 proc = await ctx.processes.start(input.command, cwd);75 } catch (cause) {76 return {77 content: `Command failed to start: ${cause instanceof Error ? cause.message : String(cause)}. Check the command and try again.`,78 isError: true,79 metadata: { title: title("spawn failed"), durationMs: Date.now() - startedAt },80 };81 }8283 const deadline = startedAt + timeoutMs;84 let current = findProcess(ctx, proc.id) ?? proc;85 while (current.status === "running" && Date.now() < deadline) {86 if (ctx.signal.aborted) {87 // A foreground bash command was meant to be short-lived — kill it (§7.2).88 await ctx.processes.stop(proc.id);89 return {90 content: CANCELLED_RESULT_CONTENT,91 isError: true,92 metadata: { title: title("cancelled"), durationMs: Date.now() - startedAt },93 };94 }95 await sleep(POLL_MS);96 current = findProcess(ctx, proc.id) ?? current;97 }9899 const durationMs = Date.now() - startedAt;100101 if (current.status === "running") {102 // Hard ceiling reached → redirect, not kill (§7.2).103 const page = ctx.processes.read(proc.id);104 const soFar = page.lines.join("\n");105 return {106 content:107 `Command still running after ${Math.round(timeoutMs / 1000)}s — moved to background as process ${proc.id}.\n` +108 `Output so far:\n${soFar.length > 0 ? soFar : "(no output yet)"}\n` +109 `Use process {"action":"read","id":"${proc.id}"} to see new output, or {"action":"stop","id":"${proc.id}"} to stop it.`,110 metadata: {111 title: title(`moved to background · ${proc.id}`),112 exitCode: null,113 processId: proc.id,114 durationMs,115 extra: { cwd },116 },117 };118 }119120 const exitCode = current.exitCode;121 const output = await fullOutput(ctx, current);122 const footer = `[exit code ${exitCode === null ? "null" : exitCode} · ${formatDuration(durationMs)} · cwd ${cwd}]`;123124 let body = output;125 let truncation: TruncationInfo | undefined;126 if (needsTruncation(output, LIMITS)) {127 const spillPath = await ctx.spill("bash", output);128 const truncated = truncateMiddleOut(output, LIMITS, spillPath);129 body = truncated.text;130 truncation = truncated.info;131 }132133 const separator = body.length === 0 || body.endsWith("\n") ? "" : "\n";134 return {135 content: `$ ${input.command}\n${body}${separator}${footer}`,136 metadata: {137 title: title(`exit ${exitCode === null ? "null" : exitCode} · ${formatDuration(durationMs)}`),138 exitCode,139 durationMs,140 ...(truncation !== undefined ? { truncation } : {}),141 extra: { cwd },142 },143 };144}145146/** Create the bash tool definition. */147export function createBashTool(): ToolDefinition<BashInput> {148 return {149 name: "bash",150 description: DESCRIPTION,151 capability: "process.execute",152 inputSchema: {153 type: "object",154 properties: {155 command: {156 type: "string",157 description: "The shell command to execute.",158 },159 timeout_ms: {160 type: "integer",161 description:162 "Time budget in milliseconds before the command is moved to the background. Default 120000, maximum 300000.",163 },164 workdir: {165 type: "string",166 description:167 "Working directory for the command. Defaults to the project working directory. Use this instead of 'cd'.",168 },169 },170 required: ["command"],171 },172 execute: executeBash,173 };174}175