SPB Git

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%
6.3 KB · 175 lines typescript
Raw Blame History
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