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/workspace/local.ts4 * Description: LocalWorkspace — the only V1 Workspace implementation; files and foreground exec.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { spawn } from "node:child_process";11import { randomBytes } from "node:crypto";12import * as fsp from "node:fs/promises";13import * as path from "node:path";14import { WorkspaceError } from "./workspace.js";15import type { Command, ProcessResult, Workspace } from "./workspace.js";1617/** Files larger than this refuse to load (TOOL_PROTOCOL.md §2.2 — use grep/offset instead). */18const DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;19/** Null-byte sniff window for binary detection. */20const BINARY_SNIFF_BYTES = 4096;21/** Per-stream capture cap for `exec` — full output belongs to spill files, not memory. */22const DEFAULT_MAX_STREAM_BYTES = 1024 * 1024;2324export interface LocalWorkspaceOptions {25 /** Override the read size guard (bytes). Default 10 MiB. */26 maxReadBytes?: number;27 /** Override the per-stream exec capture cap (bytes). Default 1 MiB. */28 maxExecStreamBytes?: number;29 /** Shell used for `exec`. Default: $SHELL, falling back to /bin/sh. */30 shell?: string;31}3233function messageOf(cause: unknown): string {34 return cause instanceof Error ? cause.message : String(cause);35}3637/**38 * Local filesystem + process workspace. This class (with the rest of39 * `src/workspace/`) is the only place in KHAELOR that touches40 * `node:fs` / `node:child_process` (ADR-13).41 */42export class LocalWorkspace implements Workspace {43 private readonly root: string;44 private readonly maxReadBytes: number;45 private readonly maxStreamBytes: number;46 private readonly shell: string;4748 constructor(rootCwd: string, options: LocalWorkspaceOptions = {}) {49 this.root = path.resolve(rootCwd);50 this.maxReadBytes = options.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;51 this.maxStreamBytes = options.maxExecStreamBytes ?? DEFAULT_MAX_STREAM_BYTES;52 this.shell = options.shell ?? process.env["SHELL"] ?? "/bin/sh";53 }5455 cwd(): string {56 return this.root;57 }5859 /** Resolve a possibly-relative path against the workspace root. */60 resolve(p: string): string {61 return path.resolve(this.root, p);62 }6364 async readFile(p: string): Promise<string> {65 const abs = this.resolve(p);66 let stat;67 try {68 stat = await fsp.stat(abs);69 } catch {70 throw new WorkspaceError("file-not-found", `File not found: ${abs}`, { path: abs });71 }72 if (stat.isDirectory()) {73 throw new WorkspaceError("file-is-directory", `Path is a directory, not a file: ${abs}`, {74 path: abs,75 });76 }77 if (stat.size > this.maxReadBytes) {78 throw new WorkspaceError(79 "file-too-large",80 `File is too large to read (${stat.size} bytes > ${this.maxReadBytes} byte limit): ${abs}. ` +81 "Use grep for content search or read a region with offset/limit.",82 { path: abs, size: stat.size, limit: this.maxReadBytes },83 );84 }85 const buffer = await fsp.readFile(abs);86 if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {87 throw new WorkspaceError("file-binary", `File appears to be binary: ${abs} (${stat.size} bytes)`, {88 path: abs,89 size: stat.size,90 });91 }92 return buffer.toString("utf8");93 }9495 async writeFile(p: string, content: string): Promise<void> {96 const abs = this.resolve(p);97 const dir = path.dirname(abs);98 await fsp.mkdir(dir, { recursive: true });99100 // Preserve the mode bits of an existing file across the atomic replace.101 let existingMode: number | undefined;102 try {103 existingMode = (await fsp.stat(abs)).mode & 0o7777;104 } catch {105 // New file — default mode applies.106 }107108 const tmp = path.join(dir, `.${path.basename(abs)}.khaelor-tmp-${randomBytes(6).toString("hex")}`);109 try {110 const handle = await fsp.open(tmp, "w");111 try {112 await handle.writeFile(content, "utf8");113 await handle.sync();114 } finally {115 await handle.close();116 }117 if (existingMode !== undefined) {118 await fsp.chmod(tmp, existingMode);119 }120 await fsp.rename(tmp, abs);121 } catch (cause) {122 await fsp.rm(tmp, { force: true }).catch(() => undefined);123 throw new WorkspaceError("write-failed", `Failed to write ${abs}: ${messageOf(cause)}`, {124 path: abs,125 });126 }127 }128129 exec(command: Command): Promise<ProcessResult> {130 const cwd = command.cwd !== undefined ? this.resolve(command.cwd) : this.root;131 const startedAt = Date.now();132 const maxBytes = this.maxStreamBytes;133134 return new Promise<ProcessResult>((resolvePromise, rejectPromise) => {135 // Own process group (detached) so timeout/abort can kill the whole tree.136 const child = spawn(this.shell, ["-c", command.cmd], {137 cwd,138 env: { ...process.env, ...command.env },139 detached: true,140 stdio: ["ignore", "pipe", "pipe"],141 });142143 let stdout = "";144 let stderr = "";145 let stdoutBytes = 0;146 let stderrBytes = 0;147 let truncated = false;148 let killed = false;149 let settled = false;150151 const killGroup = (): void => {152 const pid = child.pid;153 if (pid === undefined) return;154 try {155 process.kill(-pid, "SIGKILL");156 } catch {157 try {158 child.kill("SIGKILL");159 } catch {160 // Already gone.161 }162 }163 };164165 const timer = setTimeout(() => {166 killed = true;167 killGroup();168 }, command.timeoutMs);169170 const onAbort = (): void => {171 killed = true;172 killGroup();173 };174 command.signal?.addEventListener("abort", onAbort, { once: true });175176 const cleanup = (): void => {177 clearTimeout(timer);178 command.signal?.removeEventListener("abort", onAbort);179 };180181 child.stdout?.on("data", (chunk: Buffer) => {182 if (stdoutBytes >= maxBytes) {183 truncated = true;184 return;185 }186 const room = maxBytes - stdoutBytes;187 stdoutBytes += chunk.length;188 if (chunk.length > room) {189 stdout += chunk.subarray(0, room).toString("utf8");190 truncated = true;191 } else {192 stdout += chunk.toString("utf8");193 }194 });195196 child.stderr?.on("data", (chunk: Buffer) => {197 if (stderrBytes >= maxBytes) {198 truncated = true;199 return;200 }201 const room = maxBytes - stderrBytes;202 stderrBytes += chunk.length;203 if (chunk.length > room) {204 stderr += chunk.subarray(0, room).toString("utf8");205 truncated = true;206 } else {207 stderr += chunk.toString("utf8");208 }209 });210211 child.on("error", (cause) => {212 if (settled) return;213 settled = true;214 cleanup();215 rejectPromise(216 new WorkspaceError("exec-failed", `Command failed to start: ${messageOf(cause)}`, {217 cmd: command.cmd,218 }),219 );220 });221222 child.on("close", (code) => {223 if (settled) return;224 settled = true;225 cleanup();226 resolvePromise({227 exitCode: killed ? null : code,228 stdout,229 stderr,230 durationMs: Date.now() - startedAt,231 truncated,232 });233 });234 });235 }236}237