diff --git a/index.ts b/index.ts index a744a5a..fb3247d 100644 --- a/index.ts +++ b/index.ts @@ -13,11 +13,17 @@ import { registerFabricTools } from './src/tools.js'; import { FabricClient } from './src/fabric-client.js'; import { IdentityRegistry } from './src/identity.js'; import { syncFabricCommands } from './src/command-sync.js'; +import { PresenceSync } from './src/presence-sync.js'; import path from 'node:path'; import os from 'node:os'; let runtimeRef: unknown = null; let inbound: FabricInbound | null = null; +let presence: PresenceSync | null = null; +// Periodic re-harvest of presence accounts so newly-connected agents +// (registered through tool-based identity flow AFTER initial start) +// get picked up. Cleared on gateway_stop. +let presenceRefreshTimer: ReturnType | null = null; export { fabricChannelPlugin } from './src/channel.js'; @@ -82,7 +88,24 @@ export default defineChannelPluginEntry({ api.logger, accounts, ); - void inbound.start(); + // start() resolves once all accounts have attempted login; per- + // agent failures are logged but don't reject. Once it resolves we + // can harvest the presence accounts (those that DID log in have + // their fabricUserId + first guild endpoint populated). + void inbound.start().then(() => { + if (!inbound) return; + presence = new PresenceSync(api.logger); + presence.setAccounts(inbound.getPresenceAccounts()); + presence.start(); + api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`); + + // Re-harvest every 5 min: catches agents added via tool-based + // identity provisioning after gateway_start (recruitment flow). + // setAccounts is idempotent — duplicates collapse on agentId. + presenceRefreshTimer = setInterval(() => { + if (inbound && presence) presence.setAccounts(inbound.getPresenceAccounts()); + }, 5 * 60_000); + }); api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`); void syncFabricCommands(client, cfg, accounts, api.logger); }); @@ -93,6 +116,9 @@ export default defineChannelPluginEntry({ // BEFORE deliver()). gateway_stop only flushes any leftover buffer. api.on('gateway_stop', () => { void flushAllFabric(); + if (presenceRefreshTimer) { clearInterval(presenceRefreshTimer); presenceRefreshTimer = null; } + presence?.stop(); + presence = null; inbound?.stop(); inbound = null; }); diff --git a/src/inbound.ts b/src/inbound.ts index 06c1973..3cafa01 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -263,8 +263,52 @@ export class FabricInbound { this.sockets = []; } + /** + * Per-account metadata harvested during `start()` — used by + * PresenceSync to know where to push each agent's HF status. + * + * `fabricUserId` is filled from `session.user.id` after agent-login. + * `guildBaseUrl` is the FIRST guild the agent is connected to (multi- + * guild presence push is a future concern; for sim/prod-v1 each agent + * is in one guild). + * + * Returns ONLY agents that successfully connected — failed-login + * agents have no fabricUserId yet and are excluded. + */ + getPresenceAccounts(): Array<{ + agentId: string; + fabricUserId: string; + guildBaseUrl: string; + fabricApiKey: string; + }> { + const out: Array<{ agentId: string; fabricUserId: string; guildBaseUrl: string; fabricApiKey: string }> = []; + for (const entry of this.identity.list()) { + if (!entry.fabricUserId) continue; + const presenceGuildUrl = this.firstGuildEndpointByAgent.get(entry.agentId); + if (!presenceGuildUrl) continue; + out.push({ + agentId: entry.agentId, + fabricUserId: entry.fabricUserId, + guildBaseUrl: presenceGuildUrl, + fabricApiKey: entry.fabricApiKey, + }); + } + return out; + } + + // Filled by connectAgent for each (agent, guild). Tracks ONLY the first + // guild per agent (used as the presence-push target). + private firstGuildEndpointByAgent = new Map(); + private async connectAgent(agentId: string, session: FabricSession): Promise { const selfUserId = session.user.id; + // First-guild capture for presence-sync push target. session.guilds is + // already in priority order from Center; we take the first one with a + // valid endpoint and stop. Multi-guild presence is a future concern. + if (!this.firstGuildEndpointByAgent.has(agentId)) { + const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0); + if (firstGuild) this.firstGuildEndpointByAgent.set(agentId, firstGuild.endpoint); + } for (const g of session.guilds) { const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token; if (!tok) continue;