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