@nolag/agents 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,7 +1,5 @@
1
1
  'use strict';
2
2
 
3
- var jsSdk = require('@nolag/js-sdk');
4
-
5
3
  /**
6
4
  * Tiny typed event emitter — framework-agnostic base for NoLagAgents and AgentRoom.
7
5
  *
@@ -68,23 +66,31 @@ const TOPIC_TOOLS = "tools";
68
66
  const TOPIC_APPROVAL = "approval";
69
67
  /** Default room for agent coordination */
70
68
  const DEFAULT_ROOM = "default-workflow";
69
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
70
+ const LOBBY_REFRESH_DELAY_MS = 2000;
71
71
  /** Agents-protocol version: 2 = directed replies (filter-routed results),
72
72
  * NO_HANDLER NACKs, presence protocol advertisement. Absent/1 = legacy
73
73
  * broadcast replies (pre-0.2.0 SDKs). */
74
74
  const AGENTS_PROTOCOL_VERSION = 2;
75
75
 
76
76
  /**
77
- * AgentRoom — wraps a RoomContext from @nolag/js-sdk.
77
+ * AgentRoom — a single agent-coordination room (scoped unit).
78
+ *
79
+ * Wraps a RoomContext from @nolag/js-sdk with typed pub/sub for agent
80
+ * coordination topics, presence-based service discovery, and capability
81
+ * routing.
78
82
  *
79
- * Provides typed pub/sub for agent coordination topics,
80
- * presence-based service discovery, and capability routing.
83
+ * Created via `NoLagAgents.room(name)`. Do not instantiate directly. Presence
84
+ * events are routed in by the parent NoLagAgents (which owns the shared
85
+ * client's connection-level presence handlers); the room only wires its own
86
+ * topic handlers on its RoomContext, and cleanup removes exactly those.
81
87
  *
82
88
  * @example
83
89
  * ```typescript
84
90
  * const room = agents.room('default-workflow');
85
91
  *
86
92
  * // Service discovery - see who's connected
87
- * const agents = room.getConnectedAgents();
93
+ * const connected = room.getConnectedAgents();
88
94
  * const summarizers = room.findAgents('summarize');
89
95
  *
90
96
  * // Capability-filtered task handler
@@ -92,28 +98,32 @@ const AGENTS_PROTOCOL_VERSION = 2;
92
98
  * ```
93
99
  */
94
100
  class AgentRoom extends EventEmitter {
95
- constructor(name, roomContext, client, log, agentId, presence) {
101
+ /** @internal */
102
+ constructor(name, roomContext, log, agentId, appName, isConnected, presence) {
96
103
  super();
97
104
  /** Registry of connected agents discovered via presence */
98
105
  this._agents = new Map();
106
+ // Stored topic handler refs — cleanup removes exactly these, never all
107
+ // handlers for a topic (the client may be shared with other consumers).
108
+ this._topicHandlers = [];
99
109
  this.name = name;
100
110
  this.agentId = agentId;
101
111
  this._roomContext = roomContext;
102
- this._client = client;
103
112
  this._log = log;
113
+ this._appName = appName;
114
+ this._isConnected = isConnected;
104
115
  this._presence = presence;
105
116
  this._wireTopicListeners();
106
- this._wirePresenceListeners();
107
117
  // Set presence if provided (with the SDK's protocol version advertised
108
- // so counterparts can detect incompatible reply semantics)
118
+ // so counterparts can detect incompatible reply semantics, and a __scope
119
+ // tag so co-attached wrappers on other apps filter our presence out).
109
120
  if (presence) {
110
- const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
111
- this._presence = withProtocol;
112
- this._log(`setting presence in room ${name}:`, withProtocol);
113
- this._roomContext.setPresence(withProtocol);
121
+ this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
122
+ this._log(`setting presence in room ${name}:`, this._presence);
123
+ this._roomContext.setPresence({ ...this._presence, __scope: appName });
114
124
  }
115
125
  // Fetch initial presence snapshot
116
- this._fetchInitialPresence();
126
+ void this._fetchInitialPresence();
117
127
  }
118
128
  // ============================================================
119
129
  // SERVICE DISCOVERY
@@ -145,10 +155,9 @@ class AgentRoom extends EventEmitter {
145
155
  // ============================================================
146
156
  /** Update this agent's presence data (protocol version auto-injected) */
147
157
  setPresence(data) {
148
- const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
149
- this._presence = withProtocol;
158
+ this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
150
159
  this._log(`updating presence in room ${this.name}`);
151
- this._roomContext.setPresence(withProtocol);
160
+ this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
152
161
  }
153
162
  /** Fetch current presence snapshot for this room */
154
163
  async fetchPresence() {
@@ -160,24 +169,13 @@ class AgentRoom extends EventEmitter {
160
169
  return [];
161
170
  }
162
171
  }
163
- /**
164
- * @internal Emit a presence event (used by NoLagAgents for lobby forwarding)
165
- */
166
- _emitPresence(event, actorId, data) {
167
- if (event === 'presenceLeave') {
168
- this.emit('presenceLeave', actorId);
169
- }
170
- else {
171
- this.emit(event, actorId, data || {});
172
- }
173
- }
174
- // ============================================================
175
- // PUBLISH (with automatic agentId injection)
176
- // ============================================================
177
172
  /** Get the underlying RoomContext for advanced usage */
178
173
  get context() {
179
174
  return this._roomContext;
180
175
  }
176
+ // ============================================================
177
+ // PUBLISH (with automatic agentId injection)
178
+ // ============================================================
181
179
  /** Publish to the tasks topic */
182
180
  publishTask(envelope) {
183
181
  // Auto-set createdBy if not set
@@ -239,8 +237,82 @@ class AgentRoom extends EventEmitter {
239
237
  this._publish(TOPIC_APPROVAL, data, { retain: true });
240
238
  }
241
239
  // ============================================================
240
+ // INTERNAL (called by NoLagAgents)
241
+ // ============================================================
242
+ /** @internal Re-apply local presence after a reconnect (core does not restore it) */
243
+ _updateLocalPresence() {
244
+ if (this._presence) {
245
+ this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
246
+ }
247
+ }
248
+ /** @internal Route a presence:join event in from the parent */
249
+ _handlePresenceJoin(actorId, data) {
250
+ const d = data || {};
251
+ const agent = {
252
+ actorId,
253
+ name: d.name || actorId,
254
+ role: d.role || "agent",
255
+ capabilities: d.capabilities || [],
256
+ metadata: d.metadata,
257
+ connectedAt: Date.now(),
258
+ protocol: typeof d.protocol === "number" ? d.protocol : 1,
259
+ };
260
+ this._agents.set(actorId, agent);
261
+ this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
262
+ this.emit("presenceJoin", actorId, d);
263
+ }
264
+ /** @internal Route a presence:leave event in from the parent */
265
+ _handlePresenceLeave(actorId) {
266
+ const agent = this._agents.get(actorId);
267
+ this._agents.delete(actorId);
268
+ this._log(`agent left room ${this.name}:`, agent?.name || actorId);
269
+ this.emit("presenceLeave", actorId);
270
+ }
271
+ /** @internal Route a presence:update event in from the parent */
272
+ _handlePresenceUpdate(actorId, data) {
273
+ const d = data || {};
274
+ const existing = this._agents.get(actorId);
275
+ const agent = {
276
+ actorId,
277
+ name: d.name || existing?.name || actorId,
278
+ role: d.role || existing?.role || "agent",
279
+ capabilities: d.capabilities || existing?.capabilities || [],
280
+ metadata: d.metadata || existing?.metadata,
281
+ connectedAt: existing?.connectedAt || Date.now(),
282
+ protocol: typeof d.protocol === "number" ? d.protocol : (existing?.protocol ?? 1),
283
+ };
284
+ this._agents.set(actorId, agent);
285
+ this.emit("presenceUpdate", actorId, d);
286
+ }
287
+ /**
288
+ * @internal Unsubscribe topics (when connected) and remove exactly this
289
+ * room's handler refs. Handler-specific removal only: the client may be
290
+ * shared, and a bare off(topic) would strip other consumers' handlers too.
291
+ */
292
+ _cleanup() {
293
+ this._log(`room cleanup: ${this.name}`);
294
+ // Server unsubscribes need a live socket; skip when disconnected
295
+ // (best-effort — the core would no-op with an error callback anyway).
296
+ if (this._isConnected()) {
297
+ const topics = new Set(this._topicHandlers.map((t) => t.topic));
298
+ for (const topic of topics) {
299
+ this._roomContext.unsubscribe(topic);
300
+ }
301
+ }
302
+ for (const { topic, handler } of this._topicHandlers) {
303
+ this._roomContext.off(topic, handler);
304
+ }
305
+ this._topicHandlers = [];
306
+ this._agents.clear();
307
+ this.removeAllListeners();
308
+ }
309
+ // ============================================================
242
310
  // INTERNALS
243
311
  // ============================================================
312
+ _on(topic, handler) {
313
+ this._topicHandlers.push({ topic, handler });
314
+ this._roomContext.on(topic, handler);
315
+ }
244
316
  _publish(topic, data, options) {
245
317
  this._log(`publish to ${topic} in room ${this.name}`);
246
318
  if (options) {
@@ -251,15 +323,15 @@ class AgentRoom extends EventEmitter {
251
323
  }
252
324
  }
253
325
  _toConnectedAgent(actor) {
254
- const presence = actor.presence || actor.data || {};
326
+ const presence = (actor.presence || actor.data || {});
255
327
  return {
256
- actorId: actor.actorTokenId || actor.actorId || '',
257
- name: presence.name || actor.actorTokenId || '',
258
- role: presence.role || 'agent',
328
+ actorId: actor.actorTokenId || actor.actorId || "",
329
+ name: presence.name || actor.actorTokenId || "",
330
+ role: presence.role || "agent",
259
331
  capabilities: presence.capabilities || [],
260
332
  metadata: presence.metadata,
261
333
  connectedAt: actor.joinedAt || Date.now(),
262
- protocol: typeof presence.protocol === 'number' ? presence.protocol : 1,
334
+ protocol: typeof presence.protocol === "number" ? presence.protocol : 1,
263
335
  status: actor.status,
264
336
  };
265
337
  }
@@ -280,62 +352,6 @@ class AgentRoom extends EventEmitter {
280
352
  // fetchPresence may not be available yet
281
353
  }
282
354
  }
283
- _wirePresenceListeners() {
284
- if (!this._client)
285
- return;
286
- const client = this._client;
287
- client.on?.('presence:join', (evt) => {
288
- if (evt?.roomId === this.name || !evt?.roomId) {
289
- const id = evt?.actorId || evt?.actorTokenId;
290
- const data = evt?.data || evt?.presence || {};
291
- if (id) {
292
- const agent = {
293
- actorId: id,
294
- name: data.name || id,
295
- role: data.role || 'agent',
296
- capabilities: data.capabilities || [],
297
- metadata: data.metadata,
298
- connectedAt: Date.now(),
299
- protocol: typeof data.protocol === 'number' ? data.protocol : 1,
300
- };
301
- this._agents.set(id, agent);
302
- this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
303
- this.emit('presenceJoin', id, data);
304
- }
305
- }
306
- });
307
- client.on?.('presence:leave', (evt) => {
308
- if (evt?.roomId === this.name || !evt?.roomId) {
309
- const id = evt?.actorId || evt?.actorTokenId;
310
- if (id) {
311
- const agent = this._agents.get(id);
312
- this._agents.delete(id);
313
- this._log(`agent left room ${this.name}:`, agent?.name || id);
314
- this.emit('presenceLeave', id);
315
- }
316
- }
317
- });
318
- client.on?.('presence:update', (evt) => {
319
- if (evt?.roomId === this.name || !evt?.roomId) {
320
- const id = evt?.actorId || evt?.actorTokenId;
321
- const data = evt?.data || evt?.presence || {};
322
- if (id) {
323
- const existing = this._agents.get(id);
324
- const agent = {
325
- actorId: id,
326
- name: data.name || existing?.name || id,
327
- role: data.role || existing?.role || 'agent',
328
- capabilities: data.capabilities || existing?.capabilities || [],
329
- metadata: data.metadata || existing?.metadata,
330
- connectedAt: existing?.connectedAt || Date.now(),
331
- protocol: typeof data.protocol === 'number' ? data.protocol : (existing?.protocol ?? 1),
332
- };
333
- this._agents.set(id, agent);
334
- this.emit('presenceUpdate', id, data);
335
- }
336
- }
337
- });
338
- }
339
355
  _wireTopicListeners() {
340
356
  // Work distribution topics honour the connection-level loadBalance
341
357
  // setting, so a pool shares each message one-of-N (no double handling):
@@ -369,14 +385,14 @@ class AgentRoom extends EventEmitter {
369
385
  { topic: TOPIC_INBOX, event: "inbox" },
370
386
  ];
371
387
  for (const { topic, event } of simpleMap) {
372
- this._roomContext.on(topic, (data) => {
388
+ this._on(topic, (data) => {
373
389
  this._log(`received ${topic} in room ${this.name}`);
374
390
  this.emit(event, data);
375
391
  });
376
392
  }
377
393
  // Multiplexed: results topic carries task results AND tool responses,
378
394
  // both filter-directed to this agent.
379
- this._roomContext.on(TOPIC_RESULTS, (data) => {
395
+ this._on(TOPIC_RESULTS, (data) => {
380
396
  this._log(`received ${TOPIC_RESULTS} in room ${this.name}`);
381
397
  if (data?.type === "tool_response") {
382
398
  this.emit("toolResponse", data);
@@ -386,7 +402,7 @@ class AgentRoom extends EventEmitter {
386
402
  }
387
403
  });
388
404
  // Multiplexed: approval topic carries requests + responses
389
- this._roomContext.on(TOPIC_APPROVAL, (data) => {
405
+ this._on(TOPIC_APPROVAL, (data) => {
390
406
  this._log(`received ${TOPIC_APPROVAL} in room ${this.name}`);
391
407
  if (data?.type === "approval_response") {
392
408
  this.emit("approvalResponse", data);
@@ -398,7 +414,7 @@ class AgentRoom extends EventEmitter {
398
414
  // Tools topic carries requests; tool_response is still accepted here for
399
415
  // backward compatibility with responders on older SDK versions (their
400
416
  // responses are only reliable when the requester is not load-balanced).
401
- this._roomContext.on(TOPIC_TOOLS, (data) => {
417
+ this._on(TOPIC_TOOLS, (data) => {
402
418
  this._log(`received ${TOPIC_TOOLS} in room ${this.name}`);
403
419
  if (data?.type === "tool_response") {
404
420
  this.emit("toolResponse", data);
@@ -438,6 +454,29 @@ function createLogger(prefix, enabled) {
438
454
  function createTimestamp() {
439
455
  return Date.now();
440
456
  }
457
+ // ============ Wrapper registry ============
458
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
459
+ // one connection would collide on topics, presence and the lobby.
460
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
461
+ const wrapperRegistry = new WeakMap();
462
+ /** Register a wrapper against a client + appName; warns on collision. */
463
+ function registerWrapper(client, appName, wrapperName) {
464
+ let apps = wrapperRegistry.get(client);
465
+ if (!apps) {
466
+ apps = new Map();
467
+ wrapperRegistry.set(client, apps);
468
+ }
469
+ const existing = apps.get(appName);
470
+ if (existing) {
471
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
472
+ `Use one wrapper per (client, app) — detach the other instance first.`);
473
+ }
474
+ apps.set(appName, wrapperName);
475
+ }
476
+ /** Release a wrapper's (client, appName) registration on detach. */
477
+ function releaseWrapper(client, appName) {
478
+ wrapperRegistry.get(client)?.delete(appName);
479
+ }
441
480
 
442
481
  /**
443
482
  * NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
@@ -445,193 +484,438 @@ function createTimestamp() {
445
484
  * Provides typed rooms for multi-agent patterns: Handoff, Blackboard,
446
485
  * Inbox, Tools, Approval, and Observe.
447
486
  *
487
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
488
+ * client (shared by any number of wrappers on distinct apps) and the
489
+ * wrapper attaches to it at construction and releases it via `detach()`.
490
+ *
448
491
  * @example
449
492
  * ```typescript
493
+ * import { NoLag } from '@nolag/js-sdk';
450
494
  * import { NoLagAgents } from '@nolag/agents';
451
495
  *
452
- * const agents = new NoLagAgents(token, {
496
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
497
+ * const agents = new NoLagAgents({
498
+ * client,
453
499
  * appName: 'my-workflow',
454
500
  * agentId: 'worker-1',
455
501
  * presence: { name: 'worker-1', role: 'agent', capabilities: ['summarize'] },
456
502
  * });
457
- * await agents.connect();
503
+ *
504
+ * await client.connect(); // the app owns the connection
505
+ * await agents.ready(); // wrapper setup done (identity, rooms, lobby)
458
506
  *
459
507
  * const room = agents.room('default-workflow');
460
- * room.handoff.onTask(['summarize'], async (task, respond) => {
461
- * const result = await summarize(task.payload);
462
- * respond('success', { result });
463
- * });
508
+ * room.on('task', (task) => console.log('New task:', task));
509
+ *
510
+ * agents.detach(); // wrapper releases its handlers and topics
511
+ * client.disconnect(); // the app closes the socket
464
512
  * ```
465
513
  */
466
514
  class NoLagAgents extends EventEmitter {
467
- constructor(token, options = {}) {
515
+ constructor(options) {
468
516
  super();
469
- this._client = null;
470
- this._appContext = null;
471
517
  this._rooms = new Map();
472
- this._connected = false;
473
- this._token = token;
518
+ this._lobby = null;
519
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
520
+ this._epoch = 0;
521
+ this._detached = false;
522
+ this._isReady = false;
523
+ this._lobbyRefreshTimer = null;
524
+ // Stored client handler refs. INVARIANT: every client.on() below has a
525
+ // matching client.off() in detach() — never bare off(event), never inline
526
+ // closures on the client.
527
+ this._onConnectRef = () => this._onConnect();
528
+ this._onDisconnectRef = (reason) => {
529
+ this._log("disconnected:", reason);
530
+ this.emit("disconnected", reason);
531
+ };
532
+ this._onReconnectRef = () => {
533
+ this._log("reconnecting...");
534
+ this.emit("reconnecting");
535
+ };
536
+ this._onErrorRef = (error) => {
537
+ this._log("error:", error?.message ?? error);
538
+ this.emit("error", error);
539
+ };
540
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
541
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
542
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
543
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
544
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
545
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
546
+ if (!options?.client) {
547
+ throw new TypeError("NoLagAgents requires an injected NoLag client: new NoLagAgents({ client, ... })");
548
+ }
549
+ this._client = options.client;
474
550
  this._options = {
475
551
  appName: options.appName ?? DEFAULT_APP_NAME,
476
552
  agentId: options.agentId ?? generateId(),
553
+ name: options.name,
554
+ role: options.role,
477
555
  debug: options.debug ?? false,
478
556
  rooms: options.rooms ?? [DEFAULT_ROOM],
479
557
  lobby: options.lobby,
480
- presence: options.presence,
481
- clientOptions: options.clientOptions,
558
+ presence: options.presence ?? this._presenceFromIdentity(options),
482
559
  };
483
560
  this._log = createLogger("NoLagAgents", this._options.debug);
561
+ this._readyPromise = new Promise((resolve, reject) => {
562
+ this._readyResolve = resolve;
563
+ this._readyReject = reject;
564
+ });
565
+ // ready() rejection is only meaningful to callers that await it
566
+ this._readyPromise.catch(() => { });
567
+ registerWrapper(this._client, this._options.appName, "NoLagAgents");
568
+ // Construction = attach: wire everything now, with stored refs.
569
+ this._client.on("connect", this._onConnectRef);
570
+ this._client.on("disconnect", this._onDisconnectRef);
571
+ this._client.on("reconnect", this._onReconnectRef);
572
+ this._client.on("error", this._onErrorRef);
573
+ this._client.on("presence:join", this._onPresenceJoinRef);
574
+ this._client.on("presence:leave", this._onPresenceLeaveRef);
575
+ this._client.on("presence:update", this._onPresenceUpdateRef);
576
+ this._client.on("lobbyPresence:join", this._onLobbyJoinRef);
577
+ this._client.on("lobbyPresence:leave", this._onLobbyLeaveRef);
578
+ this._client.on("lobbyPresence:update", this._onLobbyUpdateRef);
579
+ // Attach-to-connected: if the client is already authenticated, run setup.
580
+ // The microtask lets the caller wire wrapper event handlers synchronously
581
+ // first; a racing real 'connect' event wins via the epoch guard.
582
+ queueMicrotask(() => {
583
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
584
+ this._onConnect();
585
+ }
586
+ });
484
587
  }
588
+ // ============ Public Properties ============
485
589
  /** The agent's unique ID */
486
590
  get agentId() {
487
591
  return this._options.agentId;
488
592
  }
489
- /** Whether the client is connected */
593
+ /** Whether the underlying connection is established (connected ≠ ready) */
490
594
  get connected() {
491
- return this._connected;
595
+ return !this._detached && this._client.connected;
596
+ }
597
+ /** The injected core client (owned by the app, not the wrapper) */
598
+ get client() {
599
+ return this._client;
492
600
  }
493
601
  /** Map of joined rooms */
494
602
  get rooms() {
495
603
  return this._rooms;
496
604
  }
497
- /** Connect to NoLag and join configured rooms */
498
- async connect() {
499
- this._log("connecting...");
500
- this._client = jsSdk.NoLag(this._token, {
501
- ...this._options.clientOptions,
502
- });
503
- this._appContext = this._client.setApp(this._options.appName);
504
- this._client.on("connected", () => {
505
- this._connected = true;
506
- this._log("connected");
605
+ // ============ Lifecycle ============
606
+ /**
607
+ * Resolves once the wrapper's first setup completed (identity, configured
608
+ * rooms and — when configured — the lobby ready; equivalently, once
609
+ * 'connected' has fired). Rejects only if detach() is called before that.
610
+ * Client auth failures surface via the app's own `await client.connect()`.
611
+ */
612
+ ready() {
613
+ return this._readyPromise;
614
+ }
615
+ /**
616
+ * Detach from the client: remove every handler this wrapper added,
617
+ * unsubscribe its topics and lobby (when connected), clear state.
618
+ * Terminal and idempotent; never touches the socket. To use agents again,
619
+ * construct a new instance.
620
+ */
621
+ detach() {
622
+ if (this._detached)
623
+ return;
624
+ this._log("detaching...");
625
+ this._detached = true;
626
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
627
+ if (this._lobbyRefreshTimer) {
628
+ clearTimeout(this._lobbyRefreshTimer);
629
+ this._lobbyRefreshTimer = null;
630
+ }
631
+ // Remove all client handlers by stored ref
632
+ this._client.off("connect", this._onConnectRef);
633
+ this._client.off("disconnect", this._onDisconnectRef);
634
+ this._client.off("reconnect", this._onReconnectRef);
635
+ this._client.off("error", this._onErrorRef);
636
+ this._client.off("presence:join", this._onPresenceJoinRef);
637
+ this._client.off("presence:leave", this._onPresenceLeaveRef);
638
+ this._client.off("presence:update", this._onPresenceUpdateRef);
639
+ this._client.off("lobbyPresence:join", this._onLobbyJoinRef);
640
+ this._client.off("lobbyPresence:leave", this._onLobbyLeaveRef);
641
+ this._client.off("lobbyPresence:update", this._onLobbyUpdateRef);
642
+ // Rooms: handler-specific off + connected-gated server unsubscribe
643
+ for (const name of [...this._rooms.keys()]) {
644
+ this._rooms.get(name)._cleanup();
645
+ this._rooms.delete(name);
646
+ }
647
+ // Lobby: server unsubscribe is best-effort and needs a live socket
648
+ if (this._lobby && this._client.connected) {
649
+ try {
650
+ this._lobby.unsubscribe();
651
+ }
652
+ catch {
653
+ /* best-effort */
654
+ }
655
+ }
656
+ this._lobby = null;
657
+ releaseWrapper(this._client, this._options.appName);
658
+ if (!this._isReady) {
659
+ this._readyReject(new Error("NoLagAgents detached before ready"));
660
+ }
661
+ }
662
+ // ============ Private: Epoch Setup ============
663
+ _onConnect() {
664
+ this._epoch++;
665
+ void this._runSetup(this._epoch);
666
+ }
667
+ /**
668
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
669
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
670
+ * epoch started or the wrapper detached — checked after every await.
671
+ */
672
+ async _runSetup(epoch) {
673
+ const stale = () => epoch !== this._epoch || this._detached;
674
+ this._log(this._isReady ? "restoring after reconnect..." : "setting up...");
675
+ this._log("agentId:", this._options.agentId, "→ actorId:", this._client.actorId);
676
+ if (!this._isReady) {
677
+ // First successful setup: auto-join configured rooms.
678
+ for (const roomName of this._options.rooms) {
679
+ this._joinRoomInternal(roomName);
680
+ }
681
+ }
682
+ else {
683
+ // Reconnect: the core auto-restores topic subscriptions, but not
684
+ // room-scoped presence — re-apply each room's local presence.
685
+ for (const room of this._rooms.values()) {
686
+ room._updateLocalPresence();
687
+ }
688
+ }
689
+ // Lobby is OPTIONAL: only when configured. Subscribe every epoch
690
+ // (idempotent server-side) and diff-hydrate from the returned snapshot —
691
+ // one path for setup and reconnect restore.
692
+ if (this._options.lobby) {
693
+ if (!this._lobby) {
694
+ this._lobby = this._client.setApp(this._options.appName).setLobby(this._options.lobby);
695
+ }
696
+ try {
697
+ const state = await this._lobby.subscribe();
698
+ if (stale())
699
+ return;
700
+ this._diffHydrateLobby(state);
701
+ this._log("lobby subscribed:", this._options.lobby);
702
+ }
703
+ catch (err) {
704
+ if (stale())
705
+ return;
706
+ this._log("lobby subscription failed:", err);
707
+ }
708
+ }
709
+ if (stale())
710
+ return;
711
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
712
+ // epoch aborted by a racing reconnect must not strand ready().
713
+ if (!this._isReady) {
714
+ this._isReady = true;
715
+ this._readyResolve();
507
716
  this.emit("connected");
508
- });
509
- this._client.on("disconnected", (reason) => {
510
- this._connected = false;
511
- this._log("disconnected:", reason);
512
- this.emit("disconnected", reason);
513
- });
514
- this._client.on("reconnected", () => {
515
- this._connected = true;
516
- this._log("reconnected");
717
+ }
718
+ else {
517
719
  this.emit("reconnected");
518
- });
519
- this._client.on("error", (err) => {
520
- this._log("error:", err.message);
521
- this.emit("error", err);
522
- });
523
- await this._client.connect();
524
- // Auto-join configured rooms
525
- for (const roomName of this._options.rooms) {
526
- this.room(roomName);
527
720
  }
528
- // Auto-subscribe to lobby if configured (for cross-room presence observation)
721
+ // Deferred lobby refetch: catches agents who joined during the setup
722
+ // window (only when the lobby is configured).
529
723
  if (this._options.lobby) {
530
- await this.subscribeLobby(this._options.lobby);
724
+ this._scheduleLobbyRefresh(epoch);
531
725
  }
532
726
  }
727
+ _scheduleLobbyRefresh(epoch) {
728
+ if (this._lobbyRefreshTimer)
729
+ clearTimeout(this._lobbyRefreshTimer);
730
+ this._lobbyRefreshTimer = setTimeout(() => {
731
+ this._lobbyRefreshTimer = null;
732
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
733
+ return;
734
+ }
735
+ this._lobby
736
+ .fetchPresence()
737
+ .then((state) => {
738
+ if (epoch !== this._epoch || this._detached)
739
+ return;
740
+ this._diffHydrateLobby(state);
741
+ })
742
+ .catch(() => {
743
+ /* best-effort */
744
+ });
745
+ }, LOBBY_REFRESH_DELAY_MS);
746
+ }
747
+ // ============ Room Management ============
533
748
  /**
534
- * Subscribe to a lobby for cross-room presence observation.
535
- * Lobby presence events are forwarded to all AgentRooms.
749
+ * Get or create an AgentRoom wrapper.
750
+ * If the room hasn't been joined yet, it will be joined automatically.
751
+ */
752
+ room(name) {
753
+ this._assertUsable();
754
+ const existing = this._rooms.get(name);
755
+ if (existing)
756
+ return existing;
757
+ return this._joinRoomInternal(name);
758
+ }
759
+ // ============ Lobby (cross-room presence observation) ============
760
+ /**
761
+ * Subscribe to a lobby for cross-room presence observation. Lobby presence
762
+ * events are forwarded into all AgentRooms. Prefer the `lobby` constructor
763
+ * option — this method is for on-demand subscription after ready.
536
764
  *
537
765
  * Returns the initial presence snapshot.
538
766
  */
539
767
  async subscribeLobby(lobbySlug) {
540
- if (!this._appContext) {
541
- throw new Error("Not connected. Call connect() before subscribing to lobbies.");
542
- }
768
+ this._assertUsable();
543
769
  this._log(`subscribing to lobby: ${lobbySlug}`);
544
- const lobby = this._appContext.setLobby(lobbySlug);
545
- // Listen for lobby presence events on the client
546
- // (lobby.on() uses lobby UUID internally which may not match)
547
- this._client.on('lobbyPresence:join', (evt) => {
548
- const id = evt?.actorId;
549
- const data = evt?.data || {};
550
- if (id) {
551
- this._log(`lobby presence:join — ${data.name || id}`);
552
- for (const room of this._rooms.values()) {
553
- const agents = room._agents;
554
- if (!agents.has(id)) {
555
- agents.set(id, {
556
- actorId: id,
557
- name: data.name || id,
558
- role: data.role || 'agent',
559
- capabilities: data.capabilities || [],
560
- metadata: data.metadata,
561
- connectedAt: Date.now(),
562
- });
563
- }
564
- room._emitPresence('presenceJoin', id, data);
565
- }
566
- }
567
- });
568
- this._client.on('lobbyPresence:leave', (evt) => {
569
- const id = evt?.actorId;
570
- if (id) {
571
- this._log(`lobby presence:leave — ${id}`);
572
- for (const room of this._rooms.values()) {
573
- const agents = room._agents;
574
- agents.delete(id);
575
- room._emitPresence('presenceLeave', id);
576
- }
577
- }
578
- });
579
- this._client.on('lobbyPresence:update', (evt) => {
580
- const id = evt?.actorId;
581
- const data = evt?.data || {};
582
- if (id) {
583
- for (const room of this._rooms.values()) {
584
- const agents = room._agents;
585
- const existing = agents.get(id);
586
- if (existing) {
587
- if (data.name)
588
- existing.name = data.name;
589
- if (data.role)
590
- existing.role = data.role;
591
- if (data.capabilities)
592
- existing.capabilities = data.capabilities;
593
- if (data.metadata)
594
- existing.metadata = data.metadata;
595
- }
596
- room._emitPresence('presenceUpdate', id, data);
597
- }
598
- }
599
- });
770
+ this._options.lobby = lobbySlug;
771
+ if (!this._lobby) {
772
+ this._lobby = this._client.setApp(this._options.appName).setLobby(lobbySlug);
773
+ }
600
774
  try {
601
- const initialState = await lobby.subscribe();
602
- this._log(`lobby subscribed, initial state:`, Object.keys(initialState || {}));
603
- return initialState || {};
775
+ const state = await this._lobby.subscribe();
776
+ if (!this._detached)
777
+ this._diffHydrateLobby(state);
778
+ return state || {};
604
779
  }
605
780
  catch (err) {
606
- this._log(`lobby subscription failed:`, err);
781
+ this._log("lobby subscription failed:", err);
607
782
  return {};
608
783
  }
609
784
  }
610
- /** Disconnect from NoLag */
611
- disconnect() {
612
- this._log("disconnecting...");
613
- this._client?.disconnect();
614
- this._rooms.clear();
615
- this._client = null;
616
- this._appContext = null;
617
- this._connected = false;
785
+ // ============ Private: Guards ============
786
+ _assertUsable() {
787
+ if (this._detached) {
788
+ throw new Error("NoLagAgents has been detached — construct a new instance");
789
+ }
790
+ if (!this._isReady) {
791
+ throw new Error('NoLagAgents not ready — await ready() or the "connected" event');
792
+ }
793
+ }
794
+ // ============ Private: Room Setup ============
795
+ _joinRoomInternal(name) {
796
+ this._log(`joining room: ${name}`);
797
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
798
+ const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence);
799
+ this._rooms.set(name, room);
800
+ return room;
618
801
  }
802
+ // ============ Private: Scope Filtering ============
619
803
  /**
620
- * Get or create an AgentRoom wrapper.
621
- * If the room hasn't been joined yet, it will be joined automatically.
804
+ * On a shared client, presence events from other apps' wrappers arrive on
805
+ * the same connection-level events. Wrappers stamp their presence with a
806
+ * `__scope` (their appName); a mismatched tag means another app's data.
807
+ * Untagged presence is accepted (older peers in this same app).
622
808
  */
623
- room(name) {
624
- let agentRoom = this._rooms.get(name);
625
- if (agentRoom)
626
- return agentRoom;
627
- if (!this._appContext) {
628
- throw new Error("Not connected. Call connect() before accessing rooms.");
809
+ _foreignScope(data) {
810
+ const scope = data?.__scope;
811
+ return typeof scope === "string" && scope !== this._options.appName;
812
+ }
813
+ // ============ Private: Room Presence → Rooms ============
814
+ _handleRoomPresenceJoin(data) {
815
+ if (data.actorTokenId === this._client.actorId)
816
+ return;
817
+ const presence = (data.presence || {});
818
+ if (this._foreignScope(presence))
819
+ return;
820
+ const roomId = data.roomId;
821
+ for (const room of this._targetRooms(roomId)) {
822
+ room._handlePresenceJoin(data.actorTokenId, presence);
629
823
  }
630
- this._log(`joining room: ${name}`);
631
- const roomContext = this._appContext.setRoom(name);
632
- agentRoom = new AgentRoom(name, roomContext, this._client, this._log, this._options.agentId, this._options.presence);
633
- this._rooms.set(name, agentRoom);
634
- return agentRoom;
824
+ }
825
+ _handleRoomPresenceLeave(data) {
826
+ if (data.actorTokenId === this._client.actorId)
827
+ return;
828
+ const roomId = data.roomId;
829
+ for (const room of this._targetRooms(roomId)) {
830
+ room._handlePresenceLeave(data.actorTokenId);
831
+ }
832
+ }
833
+ _handleRoomPresenceUpdate(data) {
834
+ if (data.actorTokenId === this._client.actorId)
835
+ return;
836
+ const presence = (data.presence || {});
837
+ if (this._foreignScope(presence))
838
+ return;
839
+ const roomId = data.roomId;
840
+ for (const room of this._targetRooms(roomId)) {
841
+ room._handlePresenceUpdate(data.actorTokenId, presence);
842
+ }
843
+ }
844
+ /** Rooms a presence event targets: the named room, or all when unscoped. */
845
+ _targetRooms(roomId) {
846
+ if (roomId && this._rooms.has(roomId))
847
+ return [this._rooms.get(roomId)];
848
+ if (roomId)
849
+ return [];
850
+ return [...this._rooms.values()];
851
+ }
852
+ // ============ Private: Lobby → Rooms ============
853
+ _handleLobbyJoin(event) {
854
+ const { actorId, data } = event;
855
+ if (actorId === this._client.actorId)
856
+ return;
857
+ const presence = (data || {});
858
+ if (this._foreignScope(presence))
859
+ return;
860
+ this._log(`lobby presence:join — ${presence.name || actorId}`);
861
+ for (const room of this._rooms.values()) {
862
+ room._handlePresenceJoin(actorId, presence);
863
+ }
864
+ }
865
+ _handleLobbyLeave(event) {
866
+ const { actorId, data } = event;
867
+ if (actorId === this._client.actorId)
868
+ return;
869
+ const presence = (data || {});
870
+ if (this._foreignScope(presence))
871
+ return;
872
+ this._log(`lobby presence:leave — ${actorId}`);
873
+ for (const room of this._rooms.values()) {
874
+ room._handlePresenceLeave(actorId);
875
+ }
876
+ }
877
+ _handleLobbyUpdate(event) {
878
+ const { actorId, data } = event;
879
+ if (actorId === this._client.actorId)
880
+ return;
881
+ const presence = (data || {});
882
+ if (this._foreignScope(presence))
883
+ return;
884
+ for (const room of this._rooms.values()) {
885
+ room._handlePresenceUpdate(actorId, presence);
886
+ }
887
+ }
888
+ /**
889
+ * Reconcile the rooms' agent registries against a fresh lobby snapshot,
890
+ * routing each present actor in as a join. One path for initial hydration,
891
+ * reconnect restore, and the deferred refetch.
892
+ */
893
+ _diffHydrateLobby(state) {
894
+ for (const roomId of Object.keys(state)) {
895
+ const roomPresence = state[roomId];
896
+ for (const actorId of Object.keys(roomPresence)) {
897
+ if (actorId === this._client.actorId)
898
+ continue;
899
+ const raw = roomPresence[actorId];
900
+ // Server returns full actor records with presence nested under .presence
901
+ const presence = (raw?.presence ?? raw);
902
+ if (this._foreignScope(presence))
903
+ continue;
904
+ for (const room of this._rooms.values()) {
905
+ room._handlePresenceJoin(actorId, presence);
906
+ }
907
+ }
908
+ }
909
+ }
910
+ // ============ Private: Helpers ============
911
+ /** Derive presence identity from name/role options when no presence given. */
912
+ _presenceFromIdentity(options) {
913
+ if (!options.name && !options.role)
914
+ return undefined;
915
+ return {
916
+ name: options.name ?? (options.agentId ?? "agent"),
917
+ role: options.role ?? "agent",
918
+ };
635
919
  }
636
920
  }
637
921