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/main.ts4 * Description: khaelord entrypoint — start/stop/status/tick and goal/approval management from the command line (v2 design §7).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { join } from "node:path";11import { loadConfig } from "../config/index.js";12import { defaultLogDir, FileLogger } from "../cli/logger.js";13import { workspaceExec } from "../cli/subtasks.js";14import { LocalWorkspace } from "../workspace/index.js";15import { ApprovalQueue } from "./approvals.js";16import { BudgetGuard } from "./budget.js";17import { ChannelRouter, CommandChannel, WebhookChannel } from "./channels.js";18import type { ChannelAdapter } from "./channels.js";19import { daemonDirFor, loadDaemonConfig } from "./config.js";20import { KhaelorDaemon, readDaemonStatus } from "./daemon.js";21import { daemonRequest, pidAlive } from "./client.js";22import { GoalStore } from "./goals.js";23import type { GoalEscalation, GoalType } from "./goals.js";24import { buildGoalRunner } from "./runner.js";2526const HELP = `khaelord — the KHAELOR autonomous daemon (v2 §7)2728Usage29 khaelord start run the daemon in the foreground (use nohup/launchd/systemd to detach)30 khaelord stop stop a running daemon31 khaelord status show pid, budget, active runs32 khaelord tick force one scheduler pass on the running daemon's project3334 khaelord goal add "<description>" [--type maintain|achieve|watch] [--schedule "<cron>"|heartbeat]35 [--check "<cmd>"] [--budget <usd>/day] [--runs <n>/day]36 [--escalation notify|draft-pr|auto-merge-if-verified]37 khaelord goal list list goals with status38 khaelord goal pause <id> | resume <id>3940 khaelord approvals list pending approvals41 khaelord approve <id> | deny <id> resolve an approval4243Configuration: .khaelor/daemon/config.json (budget, activeHours, heartbeatMinutes, model, channels, pricing).44Every decision the daemon takes is an event in .khaelor/daemon/goals/<id>.events.jsonl — replayable, auditable.45`;4647function out(text: string): void {48 process.stdout.write(`${text}\n`);49}5051function fail(text: string): never {52 process.stderr.write(`khaelord: ${text}\n`);53 process.exit(1);54}5556function flagValue(argv: string[], flag: string): string | undefined {57 const index = argv.indexOf(flag);58 if (index === -1) return undefined;59 return argv[index + 1];60}6162async function cmdStart(cwd: string): Promise<void> {63 const daemonDir = daemonDirFor(cwd);64 const existing = await readDaemonStatus(daemonDir);65 if (existing !== null && pidAlive(existing.pid)) {66 fail(`already running (pid ${existing.pid})`);67 }68 const logger = new FileLogger(join(defaultLogDir(), "khaelord.log"), { debug: true });69 const daemonConfig = await loadDaemonConfig(cwd);70 const sessionConfig = await loadConfig({71 cwd,72 ...(daemonConfig.model.runs !== undefined ? { flags: { model: daemonConfig.model.runs } } : {}),73 });74 if (!sessionConfig.hasApiKey) fail("ANTHROPIC_API_KEY is not set — the daemon cannot run goals.");7576 const workspace = new LocalWorkspace(cwd);77 const exec = workspaceExec(workspace);78 const channels: ChannelAdapter[] = [];79 if (daemonConfig.channels.webhook !== undefined) channels.push(new WebhookChannel(daemonConfig.channels.webhook));80 if (daemonConfig.channels.command !== undefined) channels.push(new CommandChannel(daemonConfig.channels.command));8182 const daemon = new KhaelorDaemon({83 daemonDir,84 config: daemonConfig,85 goals: new GoalStore(daemonDir),86 budget: new BudgetGuard(daemonDir, daemonConfig.budget),87 approvals: new ApprovalQueue(daemonDir),88 channels: new ChannelRouter(channels, (channel, error) => {89 logger.log("warn", `channel ${channel} failed`, { error: String(error) });90 }),91 runGoal: buildGoalRunner({92 config: sessionConfig,93 projectRoot: cwd,94 exec,95 logger,96 ...(daemonConfig.pricing !== undefined ? { pricing: daemonConfig.pricing } : {}),97 }),98 execCheck: async (cmd) => {99 const result = await workspace.exec({ cmd, timeoutMs: 120_000 });100 return { exitCode: result.exitCode };101 },102 log: (level, message) => {103 logger.log(level === "info" ? "info" : level, message);104 out(`[${level}] ${message}`);105 },106 });107108 const shutdown = (): void => {109 void daemon.stop().then(() => process.exit(0));110 };111 process.on("SIGINT", shutdown);112 process.on("SIGTERM", shutdown);113 await daemon.start();114 out(`khaelord running — project ${cwd}`);115 out(`control socket: ${daemon.socketPath}`);116 // Foreground loop; the interval keeps the process alive.117}118119async function cmdGoal(cwd: string, argv: string[]): Promise<void> {120 const store = new GoalStore(daemonDirFor(cwd));121 const sub = argv[0];122 if (sub === "add") {123 const description = argv[1];124 if (description === undefined || description.startsWith("--")) fail("goal add needs a description");125 const type = (flagValue(argv, "--type") ?? "watch") as GoalType;126 if (!["maintain", "achieve", "watch"].includes(type)) fail(`invalid --type ${type}`);127 const schedule = flagValue(argv, "--schedule") ?? "heartbeat";128 const check = flagValue(argv, "--check");129 const escalation = (flagValue(argv, "--escalation") ?? "notify") as GoalEscalation;130 if (!["notify", "draft-pr", "auto-merge-if-verified"].includes(escalation)) {131 fail(`invalid --escalation ${escalation}`);132 }133 const budgetRaw = flagValue(argv, "--budget");134 const runsRaw = flagValue(argv, "--runs");135 const maxUsdPerDay = budgetRaw !== undefined ? Number.parseFloat(budgetRaw) : undefined;136 const maxRunsPerDay = runsRaw !== undefined ? Number.parseInt(runsRaw, 10) : undefined;137 const goal = await store.create({138 description,139 type,140 schedule,141 ...(check !== undefined ? { check } : {}),142 escalation,143 budget: {144 ...(maxUsdPerDay !== undefined && !Number.isNaN(maxUsdPerDay) ? { maxUsdPerDay } : {}),145 ...(maxRunsPerDay !== undefined && !Number.isNaN(maxRunsPerDay) ? { maxRunsPerDay } : {}),146 },147 });148 out(`goal ${goal.id} created — ${goal.type} · schedule ${goal.schedule} · escalation ${goal.escalation}`);149 return;150 }151 if (sub === "list") {152 const goals = await store.list();153 if (goals.length === 0) {154 out("no goals — add one with: khaelord goal add \"<description>\"");155 return;156 }157 for (const goal of goals) {158 const runs = await store.runsToday(goal.id);159 out(160 `${goal.id} [${goal.status}] ${goal.type} · ${goal.schedule} · $${goal.budget.maxUsdPerDay}/day · runs today ${runs}/${goal.budget.maxRunsPerDay}\n ${goal.description}`,161 );162 }163 return;164 }165 if (sub === "pause" || sub === "resume") {166 const id = argv[1];167 if (id === undefined) fail(`goal ${sub} needs an id`);168 await store.setStatus(id, sub === "pause" ? "paused" : "active");169 out(`goal ${id} ${sub}d`);170 return;171 }172 fail(`unknown goal subcommand: ${String(sub)}`);173}174175export async function daemonMain(argv: string[] = process.argv.slice(2)): Promise<void> {176 const cwd = process.cwd();177 const daemonDir = daemonDirFor(cwd);178 const command = argv[0];179180 switch (command) {181 case undefined:182 case "-h":183 case "--help":184 case "help":185 out(HELP);186 return;187 case "start":188 await cmdStart(cwd);189 return;190 case "stop": {191 const response = await daemonRequest(daemonDir, { cmd: "stop" }).catch(() => null);192 if (response === null) fail("no running daemon (or socket unreachable)");193 out("stopping");194 return;195 }196 case "status": {197 const status = await readDaemonStatus(daemonDir);198 if (status === null || !pidAlive(status.pid)) {199 out("khaelord: not running");200 return;201 }202 const live = await daemonRequest(daemonDir, { cmd: "status" }).catch(() => null);203 out(`khaelord: running (pid ${status.pid}, since ${new Date(status.startedAt).toISOString()})`);204 if (live !== null) {205 out(206 ` active runs: ${String(live["activeRuns"])} · spent today: $${Number(live["spentTodayUsd"] ?? 0).toFixed(2)}`,207 );208 }209 return;210 }211 case "goal":212 await cmdGoal(cwd, argv.slice(1));213 return;214 case "approvals": {215 const response = await daemonRequest(daemonDir, { cmd: "approvals" }).catch(() => null);216 if (response === null) fail("no running daemon");217 const approvals = (response["approvals"] ?? []) as { id: string; capability: string; context: string }[];218 if (approvals.length === 0) out("no pending approvals");219 for (const approval of approvals) {220 out(`${approval.id} ${approval.capability}\n ${approval.context}`);221 }222 return;223 }224 case "approve":225 case "deny": {226 const id = argv[1];227 if (id === undefined) fail(`${command} needs an approval id`);228 const response = await daemonRequest(daemonDir, { cmd: command, id }).catch(() => null);229 if (response === null) fail("no running daemon");230 out(response["ok"] === true ? `${command}d ${id}` : `failed: ${String(response["error"])}`);231 return;232 }233 default:234 fail(`unknown command: ${command}\n\n${HELP}`);235 }236}237238// Direct execution (bin entry).239const isMain = process.argv[1]?.endsWith("daemon/main.js") === true || process.argv[1]?.endsWith("khaelord") === true;240if (isMain) {241 daemonMain().catch((error: unknown) => {242 process.stderr.write(`khaelord: ${error instanceof Error ? error.message : String(error)}\n`);243 process.exit(1);244 });245}246