/** * KHAELOR * File: src/tools/process.ts * Description: The process tool — model-facing background process manager actions (TOOL_PROTOCOL §8). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ManagedProcess } from "../workspace/index.js"; import { isWorkspaceError } from "../workspace/index.js"; import { formatElapsed } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import { missingConditionalParam } from "./schema.js"; import type { ToolContext, ToolResult } from "./types.js"; export type ProcessAction = "start" | "list" | "read" | "write" | "stop"; export interface ProcessInput { action: ProcessAction; command?: string; id?: string; input?: string; offset?: number; } const DESCRIPTION = "Manage long-running background processes: dev servers, watchers, REPLs, anything that should keep " + "running while you continue working. Actions: 'start' launches a command in the background and " + "returns its process id immediately; 'list' shows all managed processes with status; 'read' returns " + "output produced since your last read (or from line 'offset' if given); 'write' sends text to the " + "process's stdin (include \\n to submit a line); 'stop' terminates the process and its children. " + "Background processes keep running while you edit files and run other commands — start a server, " + "keep working, then read its output to check on it. They survive user interruptions but end when " + "the session ends. Do not use this for short commands; use bash."; const START_WAIT_MS = 2000; const START_POLL_MS = 100; const WRITE_ECHO_WAIT_MS = 500; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function errorResult(content: string, title: string, startedAt: number): ToolResult { return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } }; } function unknownIdMessage(ctx: ToolContext, id: string): string { const active = ctx.processes .list() .filter((p) => p.status === "running") .map((p) => `${p.id} (${p.command}, ${p.status})`); const activeText = active.length > 0 ? `Active: ${active.join(", ")}.` : "No background processes are running."; return `No process "${id}". ${activeText} Use {"action":"list"} to see all.`; } function findProcess(ctx: ToolContext, id: string): ManagedProcess | undefined { return ctx.processes.list().find((p) => p.id === id); } async function actionStart(command: string, ctx: ToolContext, startedAt: number): Promise { const cwd = ctx.workspace.cwd(); let proc: ManagedProcess; try { proc = await ctx.processes.start(command, cwd); } catch (cause) { return errorResult( `Failed to start "${command}": ${cause instanceof Error ? cause.message : String(cause)}.`, `Process start ${command} · failed`, startedAt, ); } // Wait up to 2 s for initial output — catches instant failures (§8.3). const deadline = Date.now() + START_WAIT_MS; let latest = findProcess(ctx, proc.id) ?? proc; while (Date.now() < deadline && latest.status === "running") { if (ctx.processes.read(proc.id, { offset: 1 }).totalLines > 0) break; await sleep(START_POLL_MS); latest = findProcess(ctx, proc.id) ?? latest; } // Small settle so fast output after spawn is included. await sleep(50); latest = findProcess(ctx, proc.id) ?? latest; const page = ctx.processes.read(proc.id); // cursor read — later reads return only new output const firstOutput = page.lines.map((l) => ` ${l}`).join("\n"); if (latest.status !== "running") { return { content: `Process ${proc.id} exited immediately with code ${latest.exitCode === null ? "null" : latest.exitCode}: ${command}\n` + `${firstOutput.length > 0 ? firstOutput : " (no output)"}\n` + `Full log: ${proc.logPath}`, metadata: { title: `Process start ${command} · ${proc.id} exited`, processId: proc.id, exitCode: latest.exitCode, durationMs: Date.now() - startedAt, extra: { status: latest.status }, }, }; } return { content: `Started ${proc.id} (pid ${proc.pid}): ${command}\n` + `cwd ${cwd} · log ${proc.logPath}\n` + `First output (waited up to 2s):\n${firstOutput.length > 0 ? firstOutput : " (no output yet)"}\n` + `Use process {"action":"read","id":"${proc.id}"} for new output.`, metadata: { title: `Process start ${command} · ${proc.id} running`, processId: proc.id, durationMs: Date.now() - startedAt, extra: { status: "running" }, }, }; } function actionList(ctx: ToolContext, startedAt: number): ToolResult { const procs = ctx.processes.list(); if (procs.length === 0) { return { content: `No background processes. Use {"action":"start","command":"..."} to launch one.`, metadata: { title: "Process list · 0 processes", durationMs: Date.now() - startedAt }, }; } const sorted = [...procs].sort((a, b) => { if ((a.status === "running") !== (b.status === "running")) { return a.status === "running" ? -1 : 1; } return a.id.localeCompare(b.id, undefined, { numeric: true }); }); const idWidth = Math.max(...sorted.map((p) => p.id.length)); const cmdWidth = Math.min(30, Math.max(...sorted.map((p) => Math.min(p.command.length, 30)))); const rows = sorted.map((p) => { const cmd = p.command.length > 30 ? `${p.command.slice(0, 29)}…` : p.command; const tail = p.status === "running" ? `${formatElapsed(Date.now() - p.startedAt)} pid ${p.pid}` : `code ${p.exitCode === null ? "null" : p.exitCode}`; return `${p.id.padEnd(idWidth)} ${cmd.padEnd(cmdWidth)} ${p.status.padEnd(7)} ${tail}`; }); const running = sorted.filter((p) => p.status === "running").length; return { content: `PROCESSES\n${rows.join("\n")}`, metadata: { title: `Process list · ${procs.length} ${procs.length === 1 ? "process" : "processes"} (${running} running)`, durationMs: Date.now() - startedAt, extra: { running }, }, }; } function actionRead( id: string, offset: number | undefined, ctx: ToolContext, startedAt: number, ): ToolResult { const proc = findProcess(ctx, id); if (proc === undefined) { return errorResult(unknownIdMessage(ctx, id), `Process read ${id} · unknown`, startedAt); } const page = ctx.processes.read(id, offset !== undefined ? { offset } : undefined); const statusText = page.status === "running" ? "still running" : `${page.status} with code ${page.exitCode === null ? "null" : page.exitCode}`; if (page.lines.length === 0) { return { content: `No new output from ${id} since last read (${statusText}, ${page.totalLines} lines total). Use offset to re-read earlier output.`, metadata: { title: `Process read ${id} · 0 new lines`, processId: id, durationMs: Date.now() - startedAt, extra: { status: page.status, newLines: 0 }, }, }; } const endLine = page.startLine + page.lines.length - 1; const marker = page.truncated ? `\n[Output paged. Continue with {"action":"read","id":"${id}","offset":${endLine + 1}} or read ${page.logPath}.]` : ""; return { content: `Output of ${id} since last read (lines ${page.startLine}–${endLine} of ${page.totalLines}):\n${page.lines.join("\n")}${marker}`, metadata: { title: `Process read ${id} · ${page.lines.length} new lines`, processId: id, durationMs: Date.now() - startedAt, extra: { status: page.status, newLines: page.lines.length }, }, }; } async function actionWrite( id: string, input: string, ctx: ToolContext, startedAt: number, ): Promise { try { await ctx.processes.write(id, input); } catch (cause) { if (isWorkspaceError(cause)) { if (cause.code === "process-unknown") { return errorResult(unknownIdMessage(ctx, id), `Process write ${id} · unknown`, startedAt); } return errorResult(cause.message, `Process write ${id} · failed`, startedAt); } throw cause; } // Automatic short read of any response — saves the model a round trip (§8.3). await sleep(WRITE_ECHO_WAIT_MS); const page = ctx.processes.read(id); const bytes = Buffer.byteLength(input, "utf8"); const echo = page.lines.length > 0 ? `\nOutput:\n${page.lines.join("\n")}` : ""; return { content: `Sent ${bytes} bytes to ${id} stdin.${echo}`, metadata: { title: `Process write ${id} · ${bytes} bytes`, processId: id, durationMs: Date.now() - startedAt, extra: { status: page.status, newLines: page.lines.length }, }, }; } async function actionStop(id: string, ctx: ToolContext, startedAt: number): Promise { const proc = findProcess(ctx, id); if (proc === undefined) { return errorResult(unknownIdMessage(ctx, id), `Process stop ${id} · unknown`, startedAt); } const { exitCode } = await ctx.processes.stop(id); const ran = formatElapsed(Date.now() - proc.startedAt); const codeText = exitCode === null ? "null (SIGTERM)" : String(exitCode); return { content: `Stopped ${id} (${proc.command}) · exit code ${codeText} · ran ${ran}. Full log: ${proc.logPath}`, metadata: { title: `Process stop ${id} · exited`, processId: id, exitCode, durationMs: Date.now() - startedAt, extra: { status: "stopped" }, }, }; } async function executeProcess(input: ProcessInput, ctx: ToolContext): Promise { const startedAt = Date.now(); switch (input.action) { case "start": { if (input.command === undefined || input.command.length === 0) { return errorResult( missingConditionalParam("process", "command", `for action "start"`), "Process start · invalid", startedAt, ); } return actionStart(input.command, ctx, startedAt); } case "list": return actionList(ctx, startedAt); case "read": { if (input.id === undefined) { return errorResult( missingConditionalParam("process", "id", `for action "read"`), "Process read · invalid", startedAt, ); } return actionRead(input.id, input.offset, ctx, startedAt); } case "write": { if (input.id === undefined) { return errorResult( missingConditionalParam("process", "id", `for action "write"`), "Process write · invalid", startedAt, ); } if (input.input === undefined) { return errorResult( missingConditionalParam("process", "input", `for action "write"`), "Process write · invalid", startedAt, ); } return actionWrite(input.id, input.input, ctx, startedAt); } case "stop": { if (input.id === undefined) { return errorResult( missingConditionalParam("process", "id", `for action "stop"`), "Process stop · invalid", startedAt, ); } return actionStop(input.id, ctx, startedAt); } } } /** Create the process tool definition. */ export function createProcessTool(): ToolDefinition { return { name: "process", description: DESCRIPTION, capability: "process.execute", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["start", "list", "read", "write", "stop"], description: "The operation to perform.", }, command: { type: "string", description: "Shell command to launch. Required for 'start'.", }, id: { type: "string", description: 'Process id, e.g. "p3". Required for \'read\', \'write\', \'stop\'.', }, input: { type: "string", description: "Text to send to stdin. Required for 'write'. End with \\n to submit a line.", }, offset: { type: "integer", description: "For 'read': 1-based output line to read from, instead of 'new output since last read'.", }, }, required: ["action"], }, execute: executeProcess, }; }