/** * KHAELOR * File: src/daemon/channels.ts * Description: Channel Router — webhook + command adapters behind one ChannelAdapter seam (v2 design §7.7). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { spawn } from "node:child_process"; /** One outbound daemon notification. */ export interface ChannelNotification { kind: "run-completed" | "goal-escalated" | "approval-requested" | "budget-exhausted" | "daemon-status"; goalId?: string; runId?: string; title: string; body: string; ts: number; } /** * The single channel seam — same philosophy as the isolated ModelClient: * rich channels (Telegram, Slack) arrive later as plugins behind this * interface, never inside the daemon core (v2 §7.7). */ export interface ChannelAdapter { readonly name: string; send(notification: ChannelNotification): Promise; } /** POST the notification as JSON to a webhook URL. */ export class WebhookChannel implements ChannelAdapter { readonly name = "webhook"; readonly #url: string; constructor(url: string) { this.#url = url; } async send(notification: ChannelNotification): Promise { await fetch(this.#url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(notification), }); } } /** Pipe the notification JSON to a user script's stdin (mail/Slack/ntfy — user's choice). */ export class CommandChannel implements ChannelAdapter { readonly name = "command"; readonly #command: string; constructor(command: string) { this.#command = command; } send(notification: ChannelNotification): Promise { return new Promise((resolve) => { const child = spawn(this.#command, { shell: true, stdio: ["pipe", "ignore", "ignore"] }); child.on("error", () => resolve()); child.on("exit", () => resolve()); child.stdin.end(`${JSON.stringify(notification)}\n`); }); } } /** Fan a notification out to every configured channel; failures never crash the daemon. */ export class ChannelRouter { readonly #channels: ChannelAdapter[]; readonly #onError: (channel: string, error: unknown) => void; constructor( channels: ChannelAdapter[], onError: (channel: string, error: unknown) => void = () => undefined, ) { this.#channels = channels; this.#onError = onError; } async send(notification: ChannelNotification): Promise { await Promise.all( this.#channels.map(async (channel) => { try { await channel.send(notification); } catch (error) { this.#onError(channel.name, error); } }), ); } }