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/daemon/daemon.ts4 * Description: KhaelorDaemon — scheduler tick, heartbeat, goal dispatch, budget enforcement, control socket (v2 design §7.3).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";11import { createServer } from "node:net";12import type { Server, Socket } from "node:net";13import { join } from "node:path";14import { ulid } from "../shared/index.js";15import type { ApprovalQueue } from "./approvals.js";16import type { BudgetGuard } from "./budget.js";17import type { ChannelRouter } from "./channels.js";18import type { DaemonConfig } from "./config.js";19import { nextCronRun, parseActiveHours, parseCron, withinActiveHours } from "./cron.js";20import type { Goal, GoalStore } from "./goals.js";2122/** What the injected goal runner reports back — real numbers only. */23export interface GoalRunResult {24 outcome: "done" | "failed";25 detail: string;26 costUsd: number;27 sessionId: string | null;28 verifyOk: boolean | null;29 branch: string | null;30}3132export type GoalRunner = (goal: Goal, runId: string) => Promise<GoalRunResult>;3334/** Exec seam for goal `check` commands. */35export type CheckExec = (cmd: string) => Promise<{ exitCode: number | null }>;3637export interface DaemonDeps {38 daemonDir: string;39 config: DaemonConfig;40 goals: GoalStore;41 budget: BudgetGuard;42 approvals: ApprovalQueue;43 channels: ChannelRouter;44 runGoal: GoalRunner;45 execCheck: CheckExec;46 log: (level: "info" | "warn" | "error", message: string) => void;47 /** Tick interval override (tests). Default 30 s. */48 tickMs?: number;49}5051interface GoalClock {52 lastConsideredAt: number;53 nextCronAt: number | null;54}5556/**57 * The daemon core (v2 §7.3): a tick loop over the goal set. Every decision —58 * skip, run, escalate, budget stop — lands in the goal's event log, so 3 AM59 * behavior is always explainable (autonomy WITH auditability).60 */61export class KhaelorDaemon {62 readonly #deps: DaemonDeps;63 readonly #clocks = new Map<string, GoalClock>();64 #timer: NodeJS.Timeout | null = null;65 #server: Server | null = null;66 #running = false;67 #startedAt = 0;68 #activeRuns = 0;6970 constructor(deps: DaemonDeps) {71 this.#deps = deps;72 }7374 get statusFilePath(): string {75 return join(this.#deps.daemonDir, "daemon.json");76 }7778 get socketPath(): string {79 return join(this.#deps.daemonDir, "daemon.sock");80 }8182 async start(): Promise<void> {83 if (this.#running) return;84 this.#running = true;85 this.#startedAt = Date.now();86 await mkdir(this.#deps.daemonDir, { recursive: true });87 await this.#deps.budget.load();88 await writeFile(89 this.statusFilePath,90 `${JSON.stringify({ pid: process.pid, startedAt: this.#startedAt }, null, 2)}\n`,91 "utf8",92 );93 await this.#startControlSocket();94 const tickMs = this.#deps.tickMs ?? 30_000;95 this.#timer = setInterval(() => {96 void this.tick().catch((error: unknown) => {97 this.#deps.log("error", `tick failed: ${error instanceof Error ? error.message : String(error)}`);98 });99 }, tickMs);100 this.#deps.log("info", `khaelord started (pid ${process.pid}, tick ${tickMs}ms)`);101 await this.tick();102 }103104 async stop(): Promise<void> {105 if (!this.#running) return;106 this.#running = false;107 if (this.#timer !== null) clearInterval(this.#timer);108 this.#timer = null;109 if (this.#server !== null) {110 await new Promise<void>((resolve) => this.#server?.close(() => resolve()));111 this.#server = null;112 }113 await unlink(this.statusFilePath).catch(() => undefined);114 await unlink(this.socketPath).catch(() => undefined);115 this.#deps.log("info", "khaelord stopped");116 }117118 /** One scheduler pass — public for tests and for a manual `khaelord tick`. */119 async tick(now = new Date()): Promise<void> {120 const hours = parseActiveHours(this.#deps.config.activeHours);121 if (!withinActiveHours(hours, now)) return;122 const goals = await this.#deps.goals.list();123 for (const goal of goals) {124 if (goal.status !== "active") continue;125 if (!this.#isDue(goal, now)) continue;126 await this.#consider(goal, now);127 }128 }129130 #isDue(goal: Goal, now: Date): boolean {131 const clock = this.#clocks.get(goal.id) ?? { lastConsideredAt: 0, nextCronAt: null };132 if (goal.schedule === "heartbeat") {133 const interval = this.#deps.config.heartbeatMinutes * 60_000;134 if (now.getTime() - clock.lastConsideredAt < interval) return false;135 clock.lastConsideredAt = now.getTime();136 this.#clocks.set(goal.id, clock);137 return true;138 }139 const spec = parseCron(goal.schedule);140 if (spec === null) return false;141 if (clock.nextCronAt === null) {142 clock.nextCronAt = nextCronRun(spec, new Date(clock.lastConsideredAt || now.getTime()))?.getTime() ?? null;143 this.#clocks.set(goal.id, clock);144 return false; // first sighting schedules, never fires immediately145 }146 if (now.getTime() >= clock.nextCronAt) {147 clock.lastConsideredAt = now.getTime();148 clock.nextCronAt = nextCronRun(spec, now)?.getTime() ?? null;149 this.#clocks.set(goal.id, clock);150 return true;151 }152 return false;153 }154155 async #consider(goal: Goal, now: Date): Promise<void> {156 // Bicouche routing, cheap tier first (v2 §7.6): the goal's own check157 // command decides whether there is anything to do at all.158 if (goal.check !== undefined && goal.check.length > 0) {159 try {160 const result = await this.#deps.execCheck(goal.check);161 if (result.exitCode === 0) {162 await this.#deps.goals.appendEvent(goal.id, {163 ts: now.getTime(),164 type: "run.skipped",165 payload: { reason: "check passed (HEARTBEAT_OK)" },166 });167 return;168 }169 } catch (error) {170 this.#deps.log("warn", `goal ${goal.id} check errored: ${String(error)}`);171 }172 }173174 const runsToday = await this.#deps.goals.runsToday(goal.id, now);175 if (runsToday >= goal.budget.maxRunsPerDay) {176 await this.#deps.goals.appendEvent(goal.id, {177 ts: now.getTime(),178 type: "run.skipped",179 payload: { reason: `maxRunsPerDay reached (${goal.budget.maxRunsPerDay})` },180 });181 return;182 }183 const allowed = this.#deps.budget.canStart(goal.id, goal.budget.maxUsdPerDay, now);184 if (!allowed.ok) {185 await this.#deps.goals.appendEvent(goal.id, {186 ts: now.getTime(),187 type: "run.skipped",188 payload: { reason: allowed.reason ?? "budget" },189 });190 await this.#deps.channels.send({191 kind: "budget-exhausted",192 goalId: goal.id,193 title: `Budget stop: ${goal.description.slice(0, 60)}`,194 body: allowed.reason ?? "budget exhausted",195 ts: now.getTime(),196 });197 return;198 }199200 const runId = ulid().slice(0, 12).toLowerCase();201 await this.#deps.goals.appendEvent(goal.id, {202 ts: now.getTime(),203 type: "run.started",204 payload: { runId },205 });206 this.#activeRuns += 1;207 let result: GoalRunResult;208 try {209 result = await this.#deps.runGoal(goal, runId);210 } catch (error) {211 result = {212 outcome: "failed",213 detail: error instanceof Error ? error.message : String(error),214 costUsd: 0,215 sessionId: null,216 verifyOk: null,217 branch: null,218 };219 } finally {220 this.#activeRuns -= 1;221 }222 await this.#deps.budget.record(goal.id, result.costUsd);223 await this.#deps.goals.appendEvent(goal.id, {224 ts: Date.now(),225 type: "run.completed",226 payload: {227 runId,228 outcome: result.outcome,229 detail: result.detail.slice(0, 1000),230 costUsd: result.costUsd,231 sessionId: result.sessionId,232 verifyOk: result.verifyOk,233 branch: result.branch,234 },235 });236 await this.#deps.channels.send({237 kind: "run-completed",238 goalId: goal.id,239 runId,240 title: `${result.outcome === "done" ? "✓" : "✗"} ${goal.description.slice(0, 60)}`,241 body:242 `outcome: ${result.outcome} · verify: ${result.verifyOk === null ? "n/a" : result.verifyOk ? "ok" : "FAILED"}` +243 `${result.branch !== null ? ` · branch: ${result.branch}` : ""}\n${result.detail.slice(0, 500)}`,244 ts: Date.now(),245 });246 }247248 // ── control socket: status | goals | approvals | approve/deny | stop ──249250 async #startControlSocket(): Promise<void> {251 await unlink(this.socketPath).catch(() => undefined);252 this.#server = createServer((socket: Socket) => {253 let buffer = "";254 socket.on("data", (chunk) => {255 buffer += chunk.toString("utf8");256 const newline = buffer.indexOf("\n");257 if (newline === -1) return;258 const line = buffer.slice(0, newline);259 buffer = buffer.slice(newline + 1);260 void this.#handleControl(line)261 .then((response) => {262 socket.end(`${JSON.stringify(response)}\n`);263 })264 .catch((error: unknown) => {265 socket.end(`${JSON.stringify({ ok: false, error: String(error) })}\n`);266 });267 });268 });269 await new Promise<void>((resolve, reject) => {270 this.#server?.once("error", reject);271 this.#server?.listen(this.socketPath, () => resolve());272 });273 }274275 async #handleControl(line: string): Promise<Record<string, unknown>> {276 let request: Record<string, unknown>;277 try {278 request = JSON.parse(line) as Record<string, unknown>;279 } catch {280 return { ok: false, error: "invalid JSON" };281 }282 switch (request["cmd"]) {283 case "status":284 return {285 ok: true,286 pid: process.pid,287 startedAt: this.#startedAt,288 activeRuns: this.#activeRuns,289 spentTodayUsd: this.#deps.budget.spentToday(),290 budget: this.#deps.budget.config,291 };292 case "goals":293 return { ok: true, goals: await this.#deps.goals.list() };294 case "approvals":295 return { ok: true, approvals: await this.#deps.approvals.list("pending") };296 case "approve":297 case "deny": {298 const id = request["id"];299 if (typeof id !== "string") return { ok: false, error: "missing id" };300 const resolved = await this.#deps.approvals.resolve(301 id,302 request["cmd"] === "approve" ? "approved" : "denied",303 );304 return resolved !== null ? { ok: true, approval: resolved } : { ok: false, error: "unknown or already resolved" };305 }306 case "stop":307 setTimeout(() => {308 void this.stop().then(() => process.exit(0));309 }, 50);310 return { ok: true, stopping: true };311 default:312 return { ok: false, error: `unknown cmd: ${String(request["cmd"])}` };313 }314 }315}316317/** Read the daemon status file (pid liveness checked by the caller). */318export async function readDaemonStatus(319 daemonDir: string,320): Promise<{ pid: number; startedAt: number } | null> {321 try {322 const raw = JSON.parse(await readFile(join(daemonDir, "daemon.json"), "utf8")) as Record<323 string,324 unknown325 >;326 if (typeof raw["pid"] === "number" && typeof raw["startedAt"] === "number") {327 return { pid: raw["pid"], startedAt: raw["startedAt"] };328 }329 } catch {330 // no status file331 }332 return null;333}334