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/channels.ts4 * Description: Channel Router — webhook + command adapters behind one ChannelAdapter seam (v2 design §7.7).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { spawn } from "node:child_process";1112/** One outbound daemon notification. */13export interface ChannelNotification {14 kind: "run-completed" | "goal-escalated" | "approval-requested" | "budget-exhausted" | "daemon-status";15 goalId?: string;16 runId?: string;17 title: string;18 body: string;19 ts: number;20}2122/**23 * The single channel seam — same philosophy as the isolated ModelClient:24 * rich channels (Telegram, Slack) arrive later as plugins behind this25 * interface, never inside the daemon core (v2 §7.7).26 */27export interface ChannelAdapter {28 readonly name: string;29 send(notification: ChannelNotification): Promise<void>;30}3132/** POST the notification as JSON to a webhook URL. */33export class WebhookChannel implements ChannelAdapter {34 readonly name = "webhook";35 readonly #url: string;3637 constructor(url: string) {38 this.#url = url;39 }4041 async send(notification: ChannelNotification): Promise<void> {42 await fetch(this.#url, {43 method: "POST",44 headers: { "content-type": "application/json" },45 body: JSON.stringify(notification),46 });47 }48}4950/** Pipe the notification JSON to a user script's stdin (mail/Slack/ntfy — user's choice). */51export class CommandChannel implements ChannelAdapter {52 readonly name = "command";53 readonly #command: string;5455 constructor(command: string) {56 this.#command = command;57 }5859 send(notification: ChannelNotification): Promise<void> {60 return new Promise((resolve) => {61 const child = spawn(this.#command, { shell: true, stdio: ["pipe", "ignore", "ignore"] });62 child.on("error", () => resolve());63 child.on("exit", () => resolve());64 child.stdin.end(`${JSON.stringify(notification)}\n`);65 });66 }67}6869/** Fan a notification out to every configured channel; failures never crash the daemon. */70export class ChannelRouter {71 readonly #channels: ChannelAdapter[];72 readonly #onError: (channel: string, error: unknown) => void;7374 constructor(75 channels: ChannelAdapter[],76 onError: (channel: string, error: unknown) => void = () => undefined,77 ) {78 this.#channels = channels;79 this.#onError = onError;80 }8182 async send(notification: ChannelNotification): Promise<void> {83 await Promise.all(84 this.#channels.map(async (channel) => {85 try {86 await channel.send(notification);87 } catch (error) {88 this.#onError(channel.name, error);89 }90 }),91 );92 }93}94