/** * High-level presence agent (RFC-001 §4.1 steps 10–11, §4.5). * * Ties the pieces together for one agent: join the fleet presence room, set * native Matrix presence online (for Element's dot), and run the authoritative * `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does * on spin — no enrollment/token-minting/introductions (those are P2). */ import { HeartbeatEmitter, startHeartbeatLoop, type HeartbeatAgentIdentity, type HeartbeatLoopHandle, } from './heartbeat.js'; import type { MinimalMatrixClient } from './matrix-client.js'; import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js'; export interface PresenceAgentOptions { client: MinimalMatrixClient; agent: HeartbeatAgentIdentity; /** Fleet presence room id (or alias) to heartbeat into. */ roomId: string; /** Heartbeat cadence; defaults to the policy interval. */ intervalMs?: number; policy?: LivenessPolicy; missionId?: string; onError?: (err: unknown) => void; } export class PresenceAgent { private readonly intervalMs: number; private readonly emitter: HeartbeatEmitter; private loop: HeartbeatLoopHandle | undefined; private resolvedRoomId: string | undefined; constructor(private readonly opts: PresenceAgentOptions) { const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY; this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs; this.emitter = new HeartbeatEmitter({ agent: opts.agent, intervalMs: this.intervalMs, missionId: opts.missionId, }); } /** Join the fleet room and go present. Returns the resolved room id. */ async connect(): Promise { this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId); await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat'); return this.resolvedRoomId; } /** Start the heartbeat loop (emits immediately, then every intervalMs). */ start(status: () => PresenceStatus = () => 'online'): void { const roomId = this.resolvedRoomId ?? this.opts.roomId; this.loop = startHeartbeatLoop({ emitter: this.emitter, intervalMs: this.intervalMs, status, onError: this.opts.onError, send: async (content) => { await this.opts.client.sendHeartbeat(roomId, content); }, }); } get currentSeq(): number { return this.emitter.currentSeq; } /** * Stop only the heartbeat loop, sending NO graceful signal. This models a * hard crash/kill: the authoritative liveness path must detect it purely from * the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence * change. Idempotent. */ pauseHeartbeat(): void { this.loop?.stop(); this.loop = undefined; } /** Graceful stop: stop heartbeating and drop native presence to offline. */ async stop(): Promise { this.pauseHeartbeat(); try { await this.opts.client.setPresence(this.opts.agent.mxid, 'offline'); } catch (err) { this.opts.onError?.(err); } } }