/** * KHAELOR * File: src/workspace/processes.ts * Description: ProcessManager — background processes with ring buffers, spill logs, and group kill (TOOL_PROTOCOL §8). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; import * as path from "node:path"; import { WorkspaceError } from "./workspace.js"; export type ManagedProcessStatus = "running" | "exited" | "stopped" | "failed"; /** A background process managed for the session (TOOL_PROTOCOL §8.2). */ export interface ManagedProcess { /** "p1", "p2" … session-scoped handle — NOT the OS pid. */ id: string; /** OS pid, with start time recorded (PID-reuse guard, Hermes §6.2). */ pid: number; command: string; status: ManagedProcessStatus; exitCode: number | null; startedAt: number; cwd: string; /** Full output spill — never truncated. */ logPath: string; } /** One page of process output. */ export interface ProcessRead { id: string; lines: string[]; /** 1-based absolute line number of lines[0]; 0 when lines is empty. */ startLine: number; totalLines: number; /** True when the page cap cut this read short — continue with offset, or read logPath. */ truncated: boolean; status: ManagedProcessStatus; exitCode: number | null; logPath: string; } /** An already-spawned child handed over by the `bash` timeout redirect (TOOL_PROTOCOL §7.2). */ export interface SpawnedChild { child: ChildProcess; command: string; cwd: string; startedAt: number; /** Output captured before adoption — replayed into the ring + log. */ outputSoFar?: string; } /** * Injected lifecycle listener. The kernel wires this to the event bus * (ProcessStarted / ProcessExited durable events) — the workspace never * imports `session`; the dependency is inverted here. */ export interface ProcessEventSink { onProcessStarted?(process: ManagedProcess): void; onProcessExited?(process: ManagedProcess): void; } /** Model-facing background process manager (TOOL_PROTOCOL §8.2). */ export interface ProcessManager { start(command: string, cwd: string): Promise; list(): ManagedProcess[]; read(id: string, opts?: { offset?: number }): ProcessRead; write(id: string, input: string): Promise; /** SIGTERM → grace → SIGKILL, whole process group. */ stop(id: string): Promise<{ exitCode: number | null }>; /** bash timeout redirect (§7.2). */ adopt(spawned: SpawnedChild): ManagedProcess; /** Session end only — NOT on interrupt (ADR-11). */ stopAll(): Promise; } /** Per-process in-memory ring: 10,000 lines / 2 MB (overflow loses nothing — the log has it). */ const DEFAULT_MAX_RING_LINES = 10_000; const DEFAULT_MAX_RING_BYTES = 2 * 1024 * 1024; /** Read page cap: 300 lines / 20 KB (TOOL_PROTOCOL §8.2). */ const DEFAULT_READ_PAGE_LINES = 300; const DEFAULT_READ_PAGE_BYTES = 20 * 1024; /** SIGTERM → grace → SIGKILL. */ const DEFAULT_GRACE_MS = 3000; export interface LocalProcessManagerOptions { /** Directory for spill logs, e.g. ~/.khaelor/process-logs//. */ logDir: string; eventSink?: ProcessEventSink; /** Shell used for `start`. Default: $SHELL, falling back to /bin/sh. */ shell?: string; graceMs?: number; maxRingLines?: number; maxRingBytes?: number; readPageLines?: number; readPageBytes?: number; } interface ProcRecord { info: ManagedProcess; child: ChildProcess; /** Complete output lines currently held in memory. */ ring: string[]; ringBytes: number; /** Total complete lines ever produced (ring + evicted-to-log). */ totalLines: number; /** Trailing output not yet terminated by a newline. */ partial: string; /** Absolute line number last returned by a cursor read (0 initially). */ cursor: number; exited: boolean; failed: boolean; stopRequested: boolean; exitWaiters: Array<() => void>; } export class LocalProcessManager implements ProcessManager { private readonly logDir: string; private readonly sink: ProcessEventSink | undefined; private readonly shell: string; private readonly graceMs: number; private readonly maxRingLines: number; private readonly maxRingBytes: number; private readonly readPageLines: number; private readonly readPageBytes: number; private readonly procs = new Map(); private counter = 0; constructor(options: LocalProcessManagerOptions) { this.logDir = path.resolve(options.logDir); this.sink = options.eventSink; this.shell = options.shell ?? process.env["SHELL"] ?? "/bin/sh"; this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS; this.maxRingLines = options.maxRingLines ?? DEFAULT_MAX_RING_LINES; this.maxRingBytes = options.maxRingBytes ?? DEFAULT_MAX_RING_BYTES; this.readPageLines = options.readPageLines ?? DEFAULT_READ_PAGE_LINES; this.readPageBytes = options.readPageBytes ?? DEFAULT_READ_PAGE_BYTES; mkdirSync(this.logDir, { recursive: true }); } async start(command: string, cwd: string): Promise { const child = spawn(this.shell, ["-c", command], { cwd, env: process.env, detached: true, stdio: ["pipe", "pipe", "pipe"], }); return this.register(child, command, cwd, Date.now(), undefined); } adopt(spawned: SpawnedChild): ManagedProcess { return this.register( spawned.child, spawned.command, spawned.cwd, spawned.startedAt, spawned.outputSoFar, ); } list(): ManagedProcess[] { return [...this.procs.values()].map((rec) => ({ ...rec.info })); } read(id: string, opts?: { offset?: number }): ProcessRead { const rec = this.require(id); const cursorRead = opts?.offset === undefined; const start = opts?.offset ?? rec.cursor + 1; const total = rec.totalLines; if (start < 1 || start > total) { return { id, lines: [], startLine: 0, totalLines: total, truncated: false, status: rec.info.status, exitCode: rec.info.exitCode, logPath: rec.info.logPath, }; } const ringFirst = total - rec.ring.length + 1; let lines: string[]; if (start >= ringFirst) { lines = rec.ring.slice(start - ringFirst); } else { // Evicted from the ring — nothing is lost; re-read from the spill log. const raw = readFileSync(rec.info.logPath, "utf8"); const all = raw.split("\n"); if (all.length > 0 && all[all.length - 1] === "") all.pop(); lines = all.slice(start - 1, total); } // Page cap: N lines / M bytes with continuation via offset or logPath. let truncated = false; if (lines.length > this.readPageLines) { lines = lines.slice(0, this.readPageLines); truncated = true; } let bytes = 0; for (let i = 0; i < lines.length; i++) { bytes += (lines[i] as string).length + 1; if (bytes > this.readPageBytes && i > 0) { lines = lines.slice(0, i); truncated = true; break; } } if (cursorRead) { rec.cursor = start - 1 + lines.length; } return { id, lines, startLine: lines.length > 0 ? start : 0, totalLines: total, truncated, status: rec.info.status, exitCode: rec.info.exitCode, logPath: rec.info.logPath, }; } async write(id: string, input: string): Promise { const rec = this.require(id); if (rec.info.status !== "running") { throw new WorkspaceError( "process-not-running", `Process ${id} is not running (status ${rec.info.status}, exit code ${String(rec.info.exitCode)}). Full log: ${rec.info.logPath}`, { id, status: rec.info.status }, ); } const stdin = rec.child.stdin; if (stdin === null || !stdin.writable) { throw new WorkspaceError("process-not-running", `Process ${id} has no writable stdin.`, { id }); } await new Promise((resolvePromise, rejectPromise) => { stdin.write(input, (cause) => { if (cause) { rejectPromise( new WorkspaceError("process-not-running", `Failed to write to ${id} stdin: ${cause.message}`, { id, }), ); } else { resolvePromise(); } }); }); } async stop(id: string): Promise<{ exitCode: number | null }> { const rec = this.require(id); if (rec.exited) { return { exitCode: rec.info.exitCode }; } rec.stopRequested = true; this.signalGroup(rec, "SIGTERM"); const graceful = await this.waitExit(rec, this.graceMs); if (!graceful) { this.signalGroup(rec, "SIGKILL"); await this.waitExit(rec, this.graceMs); } return { exitCode: rec.info.exitCode }; } async stopAll(): Promise { const running = [...this.procs.values()].filter((rec) => !rec.exited); await Promise.all(running.map((rec) => this.stop(rec.info.id))); } private register( child: ChildProcess, command: string, cwd: string, startedAt: number, outputSoFar: string | undefined, ): ManagedProcess { this.counter += 1; const id = `p${this.counter}`; const logPath = path.join(this.logDir, `${id}.log`); const info: ManagedProcess = { id, pid: child.pid ?? -1, command, status: "running", exitCode: null, startedAt, cwd, logPath, }; const rec: ProcRecord = { info, child, ring: [], ringBytes: 0, totalLines: 0, partial: "", cursor: 0, exited: false, failed: false, stopRequested: false, exitWaiters: [], }; this.procs.set(id, rec); // Create the log file immediately so it exists even before any output. appendFileSync(logPath, ""); if (outputSoFar !== undefined && outputSoFar.length > 0) { this.ingest(rec, outputSoFar); } const onData = (chunk: Buffer): void => { this.ingest(rec, chunk.toString("utf8")); }; child.stdout?.on("data", onData); child.stderr?.on("data", onData); child.on("error", () => { rec.failed = true; this.finalize(rec, null); }); child.on("close", (code) => { this.finalize(rec, code); }); this.sink?.onProcessStarted?.({ ...info }); return info; } private ingest(rec: ProcRecord, text: string): void { // Everything goes to the spill log first — the ring may evict, the log never does. appendFileSync(rec.info.logPath, text); const combined = rec.partial + text; const parts = combined.split("\n"); rec.partial = parts.pop() ?? ""; for (const line of parts) { this.pushLine(rec, line); } } private pushLine(rec: ProcRecord, line: string): void { rec.ring.push(line); rec.ringBytes += line.length + 1; rec.totalLines += 1; while ( rec.ring.length > 0 && (rec.ring.length > this.maxRingLines || rec.ringBytes > this.maxRingBytes) ) { const evicted = rec.ring.shift() as string; rec.ringBytes -= evicted.length + 1; } } private finalize(rec: ProcRecord, exitCode: number | null): void { if (rec.exited) return; rec.exited = true; if (rec.partial.length > 0) { // Flush the trailing unterminated line so reads see the final output. this.pushLine(rec, rec.partial); rec.partial = ""; } rec.info.exitCode = exitCode; rec.info.status = rec.failed ? "failed" : rec.stopRequested ? "stopped" : "exited"; const waiters = rec.exitWaiters.splice(0); for (const waiter of waiters) waiter(); this.sink?.onProcessExited?.({ ...rec.info }); } /** * Signal the whole process group. PID-reuse guard (Hermes §6.2): never * signal after we have observed the exit, and only signal the exact pid * recorded at spawn time. */ private signalGroup(rec: ProcRecord, signal: NodeJS.Signals): void { if (rec.exited) return; const pid = rec.child.pid; if (pid === undefined || pid !== rec.info.pid) return; try { process.kill(-pid, signal); } catch { try { rec.child.kill(signal); } catch { // Already gone. } } } private waitExit(rec: ProcRecord, ms: number): Promise { if (rec.exited) return Promise.resolve(true); return new Promise((resolvePromise) => { const timer = setTimeout(() => { const index = rec.exitWaiters.indexOf(waiter); if (index !== -1) rec.exitWaiters.splice(index, 1); resolvePromise(false); }, ms); const waiter = (): void => { clearTimeout(timer); resolvePromise(true); }; rec.exitWaiters.push(waiter); }); } private require(id: string): ProcRecord { const rec = this.procs.get(id); if (rec === undefined) { const active = [...this.procs.values()] .map((r) => `${r.info.id} (${r.info.command}, ${r.info.status})`) .join(", "); throw new WorkspaceError( "process-unknown", `No process "${id}".${active.length > 0 ? ` Known: ${active}.` : " No processes have been started."}`, { id }, ); } return rec; } }