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/approvals.ts4 * Description: Approval Queue — asynchronous permission requests for autonomous runs; a suspended run costs zero (v2 design §7.5).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, readFile, writeFile } from "node:fs/promises";11import { dirname, join } from "node:path";12import { ulid } from "../shared/index.js";1314export type ApprovalStatus = "pending" | "approved" | "denied";1516export interface ApprovalRequest {17 id: string;18 runId: string;19 goalId: string;20 capability: string;21 /** Human context: "Draft PR for goal 'deps up to date', diff +42 −13, verify ✓". */22 context: string;23 status: ApprovalStatus;24 requestedAt: number;25 resolvedAt: number | null;26}2728/**29 * Persisted at .khaelor/daemon/approvals.json. The agent never blocks on an30 * approval: the run checkpoints (its JSONL simply pauses) and resumes when31 * the answer arrives via `khaelord approve <id>` or a channel reply.32 */33export class ApprovalQueue {34 readonly #path: string;35 #entries: ApprovalRequest[] = [];36 #loaded = false;3738 constructor(daemonDir: string) {39 this.#path = join(daemonDir, "approvals.json");40 }4142 async #ensureLoaded(): Promise<void> {43 if (this.#loaded) return;44 try {45 const raw = JSON.parse(await readFile(this.#path, "utf8"));46 if (Array.isArray(raw)) this.#entries = raw as ApprovalRequest[];47 } catch {48 this.#entries = [];49 }50 this.#loaded = true;51 }5253 async #save(): Promise<void> {54 await mkdir(dirname(this.#path), { recursive: true });55 await writeFile(this.#path, `${JSON.stringify(this.#entries, null, 2)}\n`, "utf8");56 }5758 async request(input: {59 runId: string;60 goalId: string;61 capability: string;62 context: string;63 }): Promise<ApprovalRequest> {64 await this.#ensureLoaded();65 const entry: ApprovalRequest = {66 id: ulid().slice(0, 10).toLowerCase(),67 runId: input.runId,68 goalId: input.goalId,69 capability: input.capability,70 context: input.context,71 status: "pending",72 requestedAt: Date.now(),73 resolvedAt: null,74 };75 this.#entries.push(entry);76 await this.#save();77 return entry;78 }7980 async list(status?: ApprovalStatus): Promise<ApprovalRequest[]> {81 await this.#ensureLoaded();82 return status === undefined83 ? [...this.#entries]84 : this.#entries.filter((entry) => entry.status === status);85 }8687 async resolve(id: string, decision: "approved" | "denied"): Promise<ApprovalRequest | null> {88 await this.#ensureLoaded();89 const entry = this.#entries.find((candidate) => candidate.id === id);90 if (entry === undefined || entry.status !== "pending") return null;91 entry.status = decision;92 entry.resolvedAt = Date.now();93 await this.#save();94 return entry;95 }96}97