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>
70 lines
2.0 KiB
TypeScript
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;
|
|
}
|