SPB Git

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%
9.5 KB · 246 lines typescript
Raw Blame History
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