Files
claudemesh/apps/cli/src/daemon/events.ts
Alejandro Gutiérrez abaa4bcf87 feat(cli): claudemesh daemon — peer mesh runtime (v0.9.0)
Long-lived process that holds a persistent WS to the broker and exposes
a local IPC surface (UDS + bearer-auth TCP loopback). Implements the
v0.9.0 spec under .artifacts/specs/.

Core:
- daemon up | status | version | down | accept-host
- daemon outbox list [--failed|--pending|--inflight|--done|--aborted]
- daemon outbox requeue <id> [--new-client-id <id>]
- daemon install-service / uninstall-service (macOS launchd, Linux systemd)

IPC routes:
- /v1/version, /v1/health
- /v1/send  (POST)  — full §4.5.1 idempotency lookup table
- /v1/inbox (GET)   — paged history
- /v1/events        — SSE stream of message/peer_join/peer_leave/broker_status
- /v1/peers         — broker passthrough
- /v1/profile       — summary/status/visible/avatar/title/bio/capabilities
- /v1/outbox + /v1/outbox/requeue — operator recovery

Storage (SQLite via node:sqlite / bun:sqlite):
- outbox.db: pending/inflight/done/dead/aborted with audit columns
- inbox.db: dedupe by client_message_id, decrypts DMs via existing crypto
- BEGIN IMMEDIATE serialization for daemon-local accept races

Identity:
- host_fingerprint.json (machine-id || first-stable-mac)
- refuse-on-mismatch policy with `daemon accept-host` recovery

CLI integration:
- claudemesh send detects the daemon and routes through /v1/send when
  present, falling back to bridge socket / cold path otherwise

Tests: 15-case coverage of the §4.5.1 IPC duplicate lookup table.

Spec arc preserved at .artifacts/specs/2026-05-03-daemon-{v1..v10}.md;
v0.9.0 implementation target locked at 2026-05-03-daemon-spec-v0.9.0.md;
deferred items at 2026-05-03-daemon-spec-broker-hardening-followups.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:03:05 +01:00

70 lines
2.0 KiB
TypeScript

// Lightweight in-process event bus + SSE writer. Used by /v1/events SSE
// stream and consumed by hooks (post-v0.9.0).
import type { ServerResponse } from "node:http";
export type DaemonEventKind =
| "message"
| "peer_join"
| "peer_leave"
| "broker_status"
| "system";
export interface DaemonEvent {
kind: DaemonEventKind;
ts: string;
data: Record<string, unknown>;
}
type Subscriber = (e: DaemonEvent) => void;
export class EventBus {
private subs = new Set<Subscriber>();
publish(kind: DaemonEventKind, data: Record<string, unknown>): void {
const e: DaemonEvent = { kind, ts: new Date().toISOString(), data };
for (const s of this.subs) {
try { s(e); } catch { /* one bad subscriber must not poison the rest */ }
}
}
subscribe(fn: Subscriber): () => void {
this.subs.add(fn);
return () => this.subs.delete(fn);
}
}
/** Write an event to an open SSE response. */
export function writeSse(res: ServerResponse, e: DaemonEvent, idCounter: number): void {
res.write(`id: ${idCounter}\n`);
res.write(`event: ${e.kind}\n`);
res.write(`data: ${JSON.stringify({ ts: e.ts, ...e.data })}\n\n`);
}
/** Open an SSE stream on the response and route bus events to it. */
export function bindSseStream(res: ServerResponse, bus: EventBus): () => void {
res.statusCode = 200;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.write(": connected\n\n");
let counter = 0;
const unsubscribe = bus.subscribe((e) => writeSse(res, e, ++counter));
const heartbeat = setInterval(() => {
try { res.write(": keepalive\n\n"); }
catch { /* socket already torn down; cleanup handled below */ }
}, 15_000);
const cleanup = () => {
clearInterval(heartbeat);
unsubscribe();
try { res.end(); } catch { /* ignore */ }
};
res.on("close", cleanup);
res.on("error", cleanup);
return cleanup;
}