From a15dc880af4b56c7ed0a594cd6038961431a1617 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 11:32:24 +0100 Subject: [PATCH] feat(plugin): add presence-sync module (Phase 1 partial wire) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the PresenceSync class file under src/. Reads each agents HF status from globalThis.__hfAgentStatus (exposed by HarborForge.OpenclawPlugin) every 30s and PUTs deltas to Fabric.Backend.Guild PUT /agents/:userId/presence so the backend can do busy-discard on announce channel deliveries. Implementation: - Diffs against in-memory lastStatus map per agentId; PUT only on change. No-op when __hfAgentStatus is undefined (HF plugin not loaded) — degrades gracefully, backend defaults presence to unknown which means no busy filtering. - Per-account context: {agentId, fabricUserId, guildBaseUrl, fabricApiKey}. Uses x-api-key header so it goes through the existing ApiKeyGuard path on the backend. NOT YET WIRED into index.ts gateway_start lifecycle. To finish wiring, the registerFull block needs to: 1. After FabricInbound.start() resolves, harvest each agents fabric user id (introspected by Center during session login — available on FabricSession.user.id). 2. Build PresenceSyncAccount[] from those + the existing accounts list (which already has agentId + fabricApiKey + guildBaseUrl). 3. presence = new PresenceSync(api.logger); presence.setAccounts(...); presence.start(); 4. presence.stop() on gateway_stop. Reason for splitting: wiring needs the FabricInbound public API to expose per-account session metadata, which is a small but separate refactor. Module ships standalone now so the dependency direction is clear and the wire-up patch is small. See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md section 7 (resolved push-model design). --- src/presence-sync.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/presence-sync.ts diff --git a/src/presence-sync.ts b/src/presence-sync.ts new file mode 100644 index 0000000..aae0138 --- /dev/null +++ b/src/presence-sync.ts @@ -0,0 +1,92 @@ +/** + * presence-sync — read each connected agent's HF status (via the + * cross-plugin `globalThis.__hfAgentStatus.get(agentId)` exposed by + * HarborForge.OpenclawPlugin) and push diffs to Fabric.Backend.Guild + * `PUT /agents/:userId/presence` so the backend can apply busy-discard + * on `announce`-type channel deliveries. + * + * Push model: we only PUT when an agent's status actually changes + * (since the last push). The HF-side accessor has its own TTL cache + * to absorb the every-30s polling. + * + * If HF plugin isn't loaded (`__hfAgentStatus` undefined), the loop + * is a no-op — Fabric backend defaults presence to 'unknown' which is + * treated as not-busy. Announce-channel delivery still works; busy + * filtering simply doesn't kick in. + */ + +type HfStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'; +type Bridge = { get(agentId: string): Promise }; +type Logger = { info: (m: string) => void; warn: (m: string) => void }; + +export interface PresenceSyncAccount { + agentId: string; + fabricUserId: string; // the agent's Fabric Center user id (UUID) + guildBaseUrl: string; // e.g. https://fabric.hangman-lab.top/guild/ + fabricApiKey: string; // existing per-account key +} + +export class PresenceSync { + private timer: ReturnType | null = null; + private readonly lastStatus = new Map(); // by agentId + private readonly accounts = new Map(); + + constructor(private readonly logger: Logger) {} + + setAccounts(accounts: PresenceSyncAccount[]): void { + this.accounts.clear(); + for (const a of accounts) this.accounts.set(a.agentId, a); + } + + start(intervalMs = 30_000): void { + if (this.timer) return; + this.timer = setInterval(() => { + this.tick().catch((err) => this.logger.warn(`fabric: presence-sync error: ${String(err)}`)); + }, intervalMs); + // run once immediately so initial state lands fast + void this.tick(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async tick(): Promise { + const bridge = (globalThis as Record)['__hfAgentStatus'] as Bridge | undefined; + if (!bridge || typeof bridge.get !== 'function') return; // HF plugin not loaded — skip + + for (const [agentId, acct] of this.accounts) { + let status: HfStatus | undefined; + try { + status = await bridge.get(agentId); + } catch { + continue; + } + if (!status) continue; + if (this.lastStatus.get(agentId) === status) continue; // no change → no PUT + + try { + const url = `${acct.guildBaseUrl.replace(/\/$/, '')}/agents/${encodeURIComponent(acct.fabricUserId)}/presence`; + const res = await fetch(url, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'x-api-key': acct.fabricApiKey, + }, + body: JSON.stringify({ status, source: 'hf-plugin' }), + }); + if (res.ok) { + this.lastStatus.set(agentId, status); + this.logger.info(`fabric: presence-sync ${agentId} → ${status}`); + } else { + this.logger.warn(`fabric: presence-sync PUT ${agentId} failed: ${res.status}`); + } + } catch (err) { + this.logger.warn(`fabric: presence-sync PUT ${agentId} threw: ${String(err)}`); + } + } + } +}