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%
12.1 KB · 345 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tools/process.ts4 * Description: The process tool — model-facing background process manager actions (TOOL_PROTOCOL §8).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ManagedProcess } from "../workspace/index.js";11import { isWorkspaceError } from "../workspace/index.js";12import { formatElapsed } from "./format.js";13import type { ToolDefinition } from "./registry.js";14import { missingConditionalParam } from "./schema.js";15import type { ToolContext, ToolResult } from "./types.js";1617export type ProcessAction = "start" | "list" | "read" | "write" | "stop";1819export interface ProcessInput {20  action: ProcessAction;21  command?: string;22  id?: string;23  input?: string;24  offset?: number;25}2627const DESCRIPTION =28  "Manage long-running background processes: dev servers, watchers, REPLs, anything that should keep " +29  "running while you continue working. Actions: 'start' launches a command in the background and " +30  "returns its process id immediately; 'list' shows all managed processes with status; 'read' returns " +31  "output produced since your last read (or from line 'offset' if given); 'write' sends text to the " +32  "process's stdin (include \\n to submit a line); 'stop' terminates the process and its children. " +33  "Background processes keep running while you edit files and run other commands — start a server, " +34  "keep working, then read its output to check on it. They survive user interruptions but end when " +35  "the session ends. Do not use this for short commands; use bash.";3637const START_WAIT_MS = 2000;38const START_POLL_MS = 100;39const WRITE_ECHO_WAIT_MS = 500;4041function sleep(ms: number): Promise<void> {42  return new Promise((resolve) => setTimeout(resolve, ms));43}4445function errorResult(content: string, title: string, startedAt: number): ToolResult {46  return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } };47}4849function unknownIdMessage(ctx: ToolContext, id: string): string {50  const active = ctx.processes51    .list()52    .filter((p) => p.status === "running")53    .map((p) => `${p.id} (${p.command}, ${p.status})`);54  const activeText =55    active.length > 0 ? `Active: ${active.join(", ")}.` : "No background processes are running.";56  return `No process "${id}". ${activeText} Use {"action":"list"} to see all.`;57}5859function findProcess(ctx: ToolContext, id: string): ManagedProcess | undefined {60  return ctx.processes.list().find((p) => p.id === id);61}6263async function actionStart(command: string, ctx: ToolContext, startedAt: number): Promise<ToolResult> {64  const cwd = ctx.workspace.cwd();65  let proc: ManagedProcess;66  try {67    proc = await ctx.processes.start(command, cwd);68  } catch (cause) {69    return errorResult(70      `Failed to start "${command}": ${cause instanceof Error ? cause.message : String(cause)}.`,71      `Process start ${command} · failed`,72      startedAt,73    );74  }7576  // Wait up to 2 s for initial output — catches instant failures (§8.3).77  const deadline = Date.now() + START_WAIT_MS;78  let latest = findProcess(ctx, proc.id) ?? proc;79  while (Date.now() < deadline && latest.status === "running") {80    if (ctx.processes.read(proc.id, { offset: 1 }).totalLines > 0) break;81    await sleep(START_POLL_MS);82    latest = findProcess(ctx, proc.id) ?? latest;83  }84  // Small settle so fast output after spawn is included.85  await sleep(50);86  latest = findProcess(ctx, proc.id) ?? latest;8788  const page = ctx.processes.read(proc.id); // cursor read — later reads return only new output89  const firstOutput = page.lines.map((l) => `  ${l}`).join("\n");9091  if (latest.status !== "running") {92    return {93      content:94        `Process ${proc.id} exited immediately with code ${latest.exitCode === null ? "null" : latest.exitCode}: ${command}\n` +95        `${firstOutput.length > 0 ? firstOutput : "  (no output)"}\n` +96        `Full log: ${proc.logPath}`,97      metadata: {98        title: `Process start ${command} · ${proc.id} exited`,99        processId: proc.id,100        exitCode: latest.exitCode,101        durationMs: Date.now() - startedAt,102        extra: { status: latest.status },103      },104    };105  }106107  return {108    content:109      `Started ${proc.id} (pid ${proc.pid}): ${command}\n` +110      `cwd ${cwd} · log ${proc.logPath}\n` +111      `First output (waited up to 2s):\n${firstOutput.length > 0 ? firstOutput : "  (no output yet)"}\n` +112      `Use process {"action":"read","id":"${proc.id}"} for new output.`,113    metadata: {114      title: `Process start ${command} · ${proc.id} running`,115      processId: proc.id,116      durationMs: Date.now() - startedAt,117      extra: { status: "running" },118    },119  };120}121122function actionList(ctx: ToolContext, startedAt: number): ToolResult {123  const procs = ctx.processes.list();124  if (procs.length === 0) {125    return {126      content: `No background processes. Use {"action":"start","command":"..."} to launch one.`,127      metadata: { title: "Process list · 0 processes", durationMs: Date.now() - startedAt },128    };129  }130  const sorted = [...procs].sort((a, b) => {131    if ((a.status === "running") !== (b.status === "running")) {132      return a.status === "running" ? -1 : 1;133    }134    return a.id.localeCompare(b.id, undefined, { numeric: true });135  });136  const idWidth = Math.max(...sorted.map((p) => p.id.length));137  const cmdWidth = Math.min(30, Math.max(...sorted.map((p) => Math.min(p.command.length, 30))));138  const rows = sorted.map((p) => {139    const cmd = p.command.length > 30 ? `${p.command.slice(0, 29)}…` : p.command;140    const tail =141      p.status === "running"142        ? `${formatElapsed(Date.now() - p.startedAt)}  pid ${p.pid}`143        : `code ${p.exitCode === null ? "null" : p.exitCode}`;144    return `${p.id.padEnd(idWidth)}  ${cmd.padEnd(cmdWidth)}  ${p.status.padEnd(7)}  ${tail}`;145  });146  const running = sorted.filter((p) => p.status === "running").length;147  return {148    content: `PROCESSES\n${rows.join("\n")}`,149    metadata: {150      title: `Process list · ${procs.length} ${procs.length === 1 ? "process" : "processes"} (${running} running)`,151      durationMs: Date.now() - startedAt,152      extra: { running },153    },154  };155}156157function actionRead(158  id: string,159  offset: number | undefined,160  ctx: ToolContext,161  startedAt: number,162): ToolResult {163  const proc = findProcess(ctx, id);164  if (proc === undefined) {165    return errorResult(unknownIdMessage(ctx, id), `Process read ${id} · unknown`, startedAt);166  }167  const page = ctx.processes.read(id, offset !== undefined ? { offset } : undefined);168  const statusText =169    page.status === "running"170      ? "still running"171      : `${page.status} with code ${page.exitCode === null ? "null" : page.exitCode}`;172173  if (page.lines.length === 0) {174    return {175      content: `No new output from ${id} since last read (${statusText}, ${page.totalLines} lines total). Use offset to re-read earlier output.`,176      metadata: {177        title: `Process read ${id} · 0 new lines`,178        processId: id,179        durationMs: Date.now() - startedAt,180        extra: { status: page.status, newLines: 0 },181      },182    };183  }184185  const endLine = page.startLine + page.lines.length - 1;186  const marker = page.truncated187    ? `\n[Output paged. Continue with {"action":"read","id":"${id}","offset":${endLine + 1}} or read ${page.logPath}.]`188    : "";189  return {190    content: `Output of ${id} since last read (lines ${page.startLine}–${endLine} of ${page.totalLines}):\n${page.lines.join("\n")}${marker}`,191    metadata: {192      title: `Process read ${id} · ${page.lines.length} new lines`,193      processId: id,194      durationMs: Date.now() - startedAt,195      extra: { status: page.status, newLines: page.lines.length },196    },197  };198}199200async function actionWrite(201  id: string,202  input: string,203  ctx: ToolContext,204  startedAt: number,205): Promise<ToolResult> {206  try {207    await ctx.processes.write(id, input);208  } catch (cause) {209    if (isWorkspaceError(cause)) {210      if (cause.code === "process-unknown") {211        return errorResult(unknownIdMessage(ctx, id), `Process write ${id} · unknown`, startedAt);212      }213      return errorResult(cause.message, `Process write ${id} · failed`, startedAt);214    }215    throw cause;216  }217  // Automatic short read of any response — saves the model a round trip (§8.3).218  await sleep(WRITE_ECHO_WAIT_MS);219  const page = ctx.processes.read(id);220  const bytes = Buffer.byteLength(input, "utf8");221  const echo = page.lines.length > 0 ? `\nOutput:\n${page.lines.join("\n")}` : "";222  return {223    content: `Sent ${bytes} bytes to ${id} stdin.${echo}`,224    metadata: {225      title: `Process write ${id} · ${bytes} bytes`,226      processId: id,227      durationMs: Date.now() - startedAt,228      extra: { status: page.status, newLines: page.lines.length },229    },230  };231}232233async function actionStop(id: string, ctx: ToolContext, startedAt: number): Promise<ToolResult> {234  const proc = findProcess(ctx, id);235  if (proc === undefined) {236    return errorResult(unknownIdMessage(ctx, id), `Process stop ${id} · unknown`, startedAt);237  }238  const { exitCode } = await ctx.processes.stop(id);239  const ran = formatElapsed(Date.now() - proc.startedAt);240  const codeText = exitCode === null ? "null (SIGTERM)" : String(exitCode);241  return {242    content: `Stopped ${id} (${proc.command}) · exit code ${codeText} · ran ${ran}. Full log: ${proc.logPath}`,243    metadata: {244      title: `Process stop ${id} · exited`,245      processId: id,246      exitCode,247      durationMs: Date.now() - startedAt,248      extra: { status: "stopped" },249    },250  };251}252253async function executeProcess(input: ProcessInput, ctx: ToolContext): Promise<ToolResult> {254  const startedAt = Date.now();255  switch (input.action) {256    case "start": {257      if (input.command === undefined || input.command.length === 0) {258        return errorResult(259          missingConditionalParam("process", "command", `for action "start"`),260          "Process start · invalid",261          startedAt,262        );263      }264      return actionStart(input.command, ctx, startedAt);265    }266    case "list":267      return actionList(ctx, startedAt);268    case "read": {269      if (input.id === undefined) {270        return errorResult(271          missingConditionalParam("process", "id", `for action "read"`),272          "Process read · invalid",273          startedAt,274        );275      }276      return actionRead(input.id, input.offset, ctx, startedAt);277    }278    case "write": {279      if (input.id === undefined) {280        return errorResult(281          missingConditionalParam("process", "id", `for action "write"`),282          "Process write · invalid",283          startedAt,284        );285      }286      if (input.input === undefined) {287        return errorResult(288          missingConditionalParam("process", "input", `for action "write"`),289          "Process write · invalid",290          startedAt,291        );292      }293      return actionWrite(input.id, input.input, ctx, startedAt);294    }295    case "stop": {296      if (input.id === undefined) {297        return errorResult(298          missingConditionalParam("process", "id", `for action "stop"`),299          "Process stop · invalid",300          startedAt,301        );302      }303      return actionStop(input.id, ctx, startedAt);304    }305  }306}307308/** Create the process tool definition. */309export function createProcessTool(): ToolDefinition<ProcessInput> {310  return {311    name: "process",312    description: DESCRIPTION,313    capability: "process.execute",314    inputSchema: {315      type: "object",316      properties: {317        action: {318          type: "string",319          enum: ["start", "list", "read", "write", "stop"],320          description: "The operation to perform.",321        },322        command: {323          type: "string",324          description: "Shell command to launch. Required for 'start'.",325        },326        id: {327          type: "string",328          description: 'Process id, e.g. "p3". Required for \'read\', \'write\', \'stop\'.',329        },330        input: {331          type: "string",332          description: "Text to send to stdin. Required for 'write'. End with \\n to submit a line.",333        },334        offset: {335          type: "integer",336          description:337            "For 'read': 1-based output line to read from, instead of 'new output since last read'.",338        },339      },340      required: ["action"],341    },342    execute: executeProcess,343  };344}345