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/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