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)}`); + } + } + } +}