agent-comms 2.20.2 → 2.21.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/core/agent-registry.d.ts +38 -0
  5. package/dist/core/agent-registry.d.ts.map +1 -0
  6. package/dist/core/agent-registry.js +117 -0
  7. package/dist/core/agent-registry.js.map +1 -0
  8. package/dist/core/connection-approval.d.ts +48 -0
  9. package/dist/core/connection-approval.d.ts.map +1 -0
  10. package/dist/core/connection-approval.js +89 -0
  11. package/dist/core/connection-approval.js.map +1 -0
  12. package/dist/core/delivery-engine.d.ts +82 -0
  13. package/dist/core/delivery-engine.d.ts.map +1 -0
  14. package/dist/core/delivery-engine.js +464 -0
  15. package/dist/core/delivery-engine.js.map +1 -0
  16. package/dist/core/federation-bridge.d.ts +32 -0
  17. package/dist/core/federation-bridge.d.ts.map +1 -0
  18. package/dist/core/federation-bridge.js +113 -0
  19. package/dist/core/federation-bridge.js.map +1 -0
  20. package/dist/core/mesh-store-shared.d.ts +39 -0
  21. package/dist/core/mesh-store-shared.d.ts.map +1 -0
  22. package/dist/core/mesh-store-shared.js +32 -0
  23. package/dist/core/mesh-store-shared.js.map +1 -0
  24. package/dist/core/mesh-store.d.ts +65 -256
  25. package/dist/core/mesh-store.d.ts.map +1 -1
  26. package/dist/core/mesh-store.js +211 -1917
  27. package/dist/core/mesh-store.js.map +1 -1
  28. package/dist/core/peer-lifecycle.d.ts +36 -0
  29. package/dist/core/peer-lifecycle.d.ts.map +1 -0
  30. package/dist/core/peer-lifecycle.js +85 -0
  31. package/dist/core/peer-lifecycle.js.map +1 -0
  32. package/dist/core/room-lifecycle.d.ts +82 -0
  33. package/dist/core/room-lifecycle.d.ts.map +1 -0
  34. package/dist/core/room-lifecycle.js +459 -0
  35. package/dist/core/room-lifecycle.js.map +1 -0
  36. package/dist/core/room-messaging.d.ts +31 -0
  37. package/dist/core/room-messaging.d.ts.map +1 -0
  38. package/dist/core/room-messaging.js +120 -0
  39. package/dist/core/room-messaging.js.map +1 -0
  40. package/dist/core/room-protocol.d.ts +85 -0
  41. package/dist/core/room-protocol.d.ts.map +1 -0
  42. package/dist/core/room-protocol.js +479 -0
  43. package/dist/core/room-protocol.js.map +1 -0
  44. package/dist/core/room-wire-extensions.d.ts +36 -0
  45. package/dist/core/room-wire-extensions.d.ts.map +1 -0
  46. package/dist/core/room-wire-extensions.js +52 -0
  47. package/dist/core/room-wire-extensions.js.map +1 -0
  48. package/dist/core/stale-agent-checker.d.ts +28 -0
  49. package/dist/core/stale-agent-checker.d.ts.map +1 -0
  50. package/dist/core/stale-agent-checker.js +77 -0
  51. package/dist/core/stale-agent-checker.js.map +1 -0
  52. package/package.json +1 -1
@@ -1,70 +1,34 @@
1
1
  /**
2
2
  * MeshStore — transport-agnostic peer mesh for agent communication.
3
3
  *
4
- * Each bridge instance is a peer in the mesh. Peers discover each other
5
- * via a coordinator (the first instance to bind the well-known port).
6
- * All state is held in memory and synchronised between peers.
7
- * Delivery events are pushed directly over the transport — no polling,
8
- * no filesystem.
4
+ * Each bridge instance is a peer in the mesh. Peers discover each other via a coordinator (the first instance to bind the well-known port). All state is held in memory and synchronised between peers. Delivery events are pushed directly over the transport — no polling, no filesystem.
9
5
  *
10
- * Transport is set via setTransport() (e.g. WireMeshTransport for encrypted
11
- * connections) before init() or any other transport-using method is called
12
- * -- there is no default, since every real bridge builds its own transport
13
- * from this store's own events getter, which needs the store to already
14
- * exist.
6
+ * Transport is set via setTransport() (e.g. WireMeshTransport for encrypted connections) before init() or any other transport-using method is called -- there is no default, since every real bridge builds its own transport from this store's own events getter, which needs the store to already exist.
7
+ *
8
+ * MeshStore itself is an orchestrator: it owns the shared state (the core agents/rooms/messages/dms/deliveryQueues Maps and a handful of smaller fields) and constructs the collaborators that implement almost every behaviour against direct references into that state -- DeliveryEngine, FederationBridge, RoomProtocol, RoomMessaging, RoomLifecycle, AgentRegistry, ConnectionApproval, StaleAgentChecker, and PeerLifecycle. Every public method below that isn't inherently a MeshStore-level concern (transport/identity wiring, init/shutdown lifecycle, the events getter, mesh visibility, listener management, federation-adapter passthroughs) is a thin delegating wrapper to whichever collaborator now owns the real implementation, kept here only because CommsStore/MeshOnlyFeatures and a handful of concrete-only call sites (tests, bridge-mesh.ts, the web server, etc.) reach these names directly on a MeshStore-typed value.
15
9
  */
16
10
  import * as os from "node:os";
17
11
  import { nanoid } from "./nanoid.js";
18
12
  import { CommsError } from "./store.js";
19
- import { normaliseWireState } from "./wire-protocol.js";
20
- import { dmRoomPath, ownerNamedRoomPath, parseRoomPath, slugRoomName, } from "./room-path.js";
21
13
  import { DiscoveryManager } from "./discovery.js";
22
14
  import { MdnsDiscoveryBackend } from "./discovery-mdns.js";
23
15
  import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js";
24
16
  import { FederationManager } from "./federation.js";
25
17
  import { getCertificateFingerprint } from "./identity.js";
26
- import { bytesFromHex, bytesToHex, deviceIdFromHex, deviceIdToHex, } from "wire-mesh-core/domain/device-id";
27
- import { mintCapabilityToken, mintRevocationEntry, } from "wire-mesh-core/domain/tokens";
28
- import { roomInviteSchema, roomJoinOkSchema, roomLeaveSchema, roomMembersOkSchema, roomReadSchema, roomSendSchema, } from "wire-mesh-core/generated/protocol";
29
- import { ROOM_MEMBER_CAPABILITY, verifyRoomToken, } from "./room-token-verification.js";
30
- import { deleteIssuedRoomGrant, deleteRoomToken, loadIssuedRoomGrant, loadRoomTokens, saveIssuedRoomGrant, saveRoomToken, } from "./identity-store.js";
31
- import { randomId } from "./random-id.js";
32
- import { RoomType, StreamingBehavior } from "./types.js";
18
+ import { COORDINATOR_HOST } from "./mesh-store-shared.js";
19
+ import { DeliveryEngine } from "./delivery-engine.js";
20
+ import { FederationBridge } from "./federation-bridge.js";
21
+ import { RoomProtocol } from "./room-protocol.js";
22
+ import { RoomMessaging } from "./room-messaging.js";
23
+ import { RoomLifecycle } from "./room-lifecycle.js";
24
+ import { AgentRegistry } from "./agent-registry.js";
25
+ import { ConnectionApproval } from "./connection-approval.js";
26
+ import { StaleAgentChecker } from "./stale-agent-checker.js";
27
+ import { PeerLifecycle } from "./peer-lifecycle.js";
33
28
  // ---------------------------------------------------------------------------
34
29
  // Constants
35
30
  // ---------------------------------------------------------------------------
36
31
  const DEFAULT_COORDINATOR_PORT = 19876;
37
- const COORDINATOR_HOST = "127.0.0.1";
38
- /**
39
- * Lifetime of a freshly minted room:member grant (owner root grant or member join/invite grant alike). Deliberately generous rather than the "short expires, periodic re-issue" pattern the design calls for to bound kick-convergence to gossip-independent expiry -- that re-issue mechanism is P3.7's own deliverable (riding room.members refreshes), and shipping a short expiry before it exists would let ordinary grants go stale with nothing to renew them. 30 days comfortably outlives any realistic room lifetime for now; P3.7 tightens this once re-issue-on-refresh lands.
40
- */
41
- const ROOM_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
42
- /**
43
- * Bound on pending delivery events held per target agent. Events beyond the
44
- * bound drop oldest-first: a long-offline agent's queue cannot grow without
45
- * limit in memory or in synced snapshots (#28).
46
- */
47
- const MAX_QUEUED_DELIVERIES_PER_AGENT = 100;
48
- /**
49
- * Merge an incoming append-only message history into the local one: add
50
- * entries the local list does not have and union read receipts on the ones
51
- * it does. Local ordering is preserved; unseen entries are appended.
52
- */
53
- function mergeMessageHistories(local, incoming) {
54
- const byId = new Map(local.map((m) => [m.id, m]));
55
- for (const msg of incoming) {
56
- const existing = byId.get(msg.id);
57
- if (existing === undefined) {
58
- local.push(msg);
59
- byId.set(msg.id, msg);
60
- continue;
61
- }
62
- for (const reader of msg.readBy) {
63
- if (!existing.readBy.includes(reader))
64
- existing.readBy.push(reader);
65
- }
66
- }
67
- }
68
32
  // ---------------------------------------------------------------------------
69
33
  // MeshStore
70
34
  // ---------------------------------------------------------------------------
@@ -78,15 +42,26 @@ export class MeshStore {
78
42
  dms = new Map();
79
43
  deliveryQueues = new Map();
80
44
  identityCache = new Map();
81
- /** Directed room-domain requests (room.send, room.read) that failed because their target member wasn't reachable at send time, held for retry when that member's own connection is (re)established -- the wire-authenticated fan-out's substitute for the legacy full-state-sync's own automatic eventual consistency, since a direct request to a disconnected peer fails immediately with no protocol-level retry of its own. Keyed by member device-id hex, bounded oldest-first per member with the same cap ordinary delivery queues use. */
82
- pendingRoomRequests = new Map();
45
+ peerInfo = new Map();
46
+ localDeliveryKeys = new Set();
47
+ pendingMarkReadTimers = [];
48
+ /** DM paths this store has itself sent an outbound room.join for -- section 6's own basis for auto-approving the counterpart's reciprocal room.join without a second human decision. Shared between RoomProtocol (reads, in handleRoomJoin) and RoomLifecycle (writes, in requestDmAccess). Never cleared: a DM conversation, once opened, stays open, and holding a stale entry here costs nothing beyond a few bytes per DM this node has ever initiated. */
49
+ dmRequestsInitiatedByMe = new Set();
83
50
  transport;
84
51
  storeIdentity;
85
- peerInfo = new Map();
86
- staleCheckTimer;
87
52
  isShutDown = false;
88
53
  initialised = false;
89
- pendingMarkReadTimers = [];
54
+ discovery;
55
+ federation;
56
+ deliveryEngine;
57
+ federationBridge;
58
+ roomProtocol;
59
+ roomMessaging;
60
+ roomLifecycle;
61
+ agentRegistry;
62
+ connectionApproval;
63
+ staleAgentChecker;
64
+ peerLifecycle;
90
65
  /** This store's own current AgentStatus, synchronously -- the value WireMeshTransport's presence re-advertisement timer reads on every tick. undefined before registerAgent has ever run (no self agent record exists yet), in which case there is nothing yet to advertise. */
91
66
  get selfStatus() {
92
67
  return this.agents.get(this.peerId)?.status;
@@ -96,17 +71,11 @@ export class MeshStore {
96
71
  return (this.requireTransport().isCoordinator ||
97
72
  this.requireTransport().hasCoordinatorConnection);
98
73
  }
99
- // -- Pending inbound connections awaiting approval --
100
- pendingInboundConnections = new Map();
101
- // -- Pending room.join requests awaiting owner approval, keyed by `${roomPath}::${requesterId}` -- the held-open manage-request's own resolve() function is stored here so acceptRoomJoin/rejectRoomJoin can settle it in place, mirroring pendingInboundConnections' own pattern one layer up (room admission, not mesh admission).
102
- pendingRoomJoins = new Map();
103
- // -- DM paths this store has itself sent an outbound room.join for -- section 6's own basis for auto-approving the counterpart's reciprocal room.join without a second human decision. Never cleared: a DM conversation, once opened, stays open, and holding a stale entry here costs nothing beyond a few bytes per DM this node has ever initiated.
104
- dmRequestsInitiatedByMe = new Set();
105
74
  onDelivery;
106
75
  /** Fires for every state patch — both locally generated and remote. */
107
76
  onPatch;
108
77
  /**
109
- * Fires for a transport-level error a caller may want to observe (a connection rejected for presenting a certificate that doesn't match its claimed peer ID, this store's own inability to join or create a mesh, etc). Previously wired to `this.events.onError` inside this class's own methods, but the `events` getter never actually implemented `onError` on the object it returns, so every one of those calls was silently a no-op -- this store had no way to observe its own transport failures at all. Left undefined by default (matching onDelivery/onPatch): a caller that wants visibility sets it, exactly like those two.
78
+ * Fires for a transport-level error a caller may want to observe (a connection rejected for presenting a certificate that doesn't match its claimed peer ID, this store's own inability to join or create a mesh, etc). Left undefined by default (matching onDelivery/onPatch): a caller that wants visibility sets it, exactly like those two.
110
79
  */
111
80
  onError;
112
81
  /** Serialise the full mesh state for state_sync messages. */
@@ -119,10 +88,6 @@ export class MeshStore {
119
88
  deliveryQueues: Object.fromEntries(this.deliveryQueues),
120
89
  };
121
90
  }
122
- lastLocalDeliveryKey;
123
- localDeliveryKeys = new Set();
124
- discovery;
125
- federation;
126
91
  constructor(coordinatorPort = DEFAULT_COORDINATOR_PORT) {
127
92
  this.peerId = nanoid(8);
128
93
  this.startedAt = new Date().toISOString();
@@ -131,16 +96,102 @@ export class MeshStore {
131
96
  this.discovery = new DiscoveryManager();
132
97
  this.discovery.registerBackend(new MdnsDiscoveryBackend());
133
98
  this.discovery.registerBackend(new TailscaleDiscoveryBackend());
99
+ this.deliveryEngine = new DeliveryEngine({
100
+ agents: this.agents,
101
+ rooms: this.rooms,
102
+ messages: this.messages,
103
+ dms: this.dms,
104
+ deliveryQueues: this.deliveryQueues,
105
+ localDeliveryKeys: this.localDeliveryKeys,
106
+ pendingMarkReadTimers: this.pendingMarkReadTimers,
107
+ getPeerId: () => this.peerId,
108
+ requireIdentity: () => this.requireIdentity(),
109
+ requireTransport: () => this.requireTransport(),
110
+ getOnDelivery: () => this.onDelivery,
111
+ getOnPatch: () => this.onPatch,
112
+ isShutDown: () => this.isShutDown,
113
+ // RoomProtocol doesn't exist yet at this point in the constructor -- this closure resolves `this.roomProtocol` lazily, only once markRead actually calls it at runtime, well after the constructor has finished. Mirrors the lazy-`this`-capture pattern FederationManager's own callbacks use below.
114
+ sendRoomRequestToMember: (memberId, roomPath, token, params) => this.roomProtocol.sendRoomRequestToMember(memberId, roomPath, token, params),
115
+ });
116
+ this.federationBridge = new FederationBridge({
117
+ agents: this.agents,
118
+ rooms: this.rooms,
119
+ messages: this.messages,
120
+ deliveryEngine: this.deliveryEngine,
121
+ });
134
122
  // Federation manager — coordinator-to-coordinator links
135
123
  this.federation = new FederationManager(this.peerId, // mesh ID is the coordinator's peer ID
136
- `mesh-${this.peerId}`, {
137
- onAgentVisible: (agent) => this.handleFedAgentVisible(agent),
138
- onAgentGone: (agentId) => this.handleFedAgentGone(agentId),
139
- onRoomMessage: (roomId, message) => this.handleFedRoomMessage(roomId, message),
140
- onRoomJoin: (roomId, agentId, agentName) => this.handleFedRoomJoin(roomId, agentId, agentName),
141
- onRoomLeave: (roomId, agentId) => this.handleFedRoomLeave(roomId, agentId),
142
- getVisibleAgents: () => this.getVisibleAgentsForFed(),
143
- getFederatedRoomMemberships: () => this.getFederatedRoomMembershipsForFed(),
124
+ `mesh-${this.peerId}`, this.federationBridge);
125
+ this.roomProtocol = new RoomProtocol({
126
+ rooms: this.rooms,
127
+ messages: this.messages,
128
+ dms: this.dms,
129
+ agents: this.agents,
130
+ dmRequestsInitiatedByMe: this.dmRequestsInitiatedByMe,
131
+ getPeerId: () => this.peerId,
132
+ requireIdentity: () => this.requireIdentity(),
133
+ requireTransport: () => this.requireTransport(),
134
+ deliveryEngine: this.deliveryEngine,
135
+ // RoomLifecycle doesn't exist yet at this point -- deferred the same way DeliveryEngine's sendRoomRequestToMember closure above is.
136
+ revokeMemberGrant: (roomId, memberId) => this.roomLifecycle.revokeMemberGrant(roomId, memberId),
137
+ });
138
+ this.roomMessaging = new RoomMessaging({
139
+ rooms: this.rooms,
140
+ messages: this.messages,
141
+ dms: this.dms,
142
+ agents: this.agents,
143
+ requireIdentity: () => this.requireIdentity(),
144
+ roomProtocol: this.roomProtocol,
145
+ federation: this.federation,
146
+ });
147
+ this.roomLifecycle = new RoomLifecycle({
148
+ rooms: this.rooms,
149
+ messages: this.messages,
150
+ agents: this.agents,
151
+ dmRequestsInitiatedByMe: this.dmRequestsInitiatedByMe,
152
+ getPeerId: () => this.peerId,
153
+ requireIdentity: () => this.requireIdentity(),
154
+ requireTransport: () => this.requireTransport(),
155
+ deliveryEngine: this.deliveryEngine,
156
+ federation: this.federation,
157
+ });
158
+ this.agentRegistry = new AgentRegistry({
159
+ agents: this.agents,
160
+ identityCache: this.identityCache,
161
+ startedAt: this.startedAt,
162
+ getPeerId: () => this.peerId,
163
+ deliveryEngine: this.deliveryEngine,
164
+ federation: this.federation,
165
+ });
166
+ this.connectionApproval = new ConnectionApproval({
167
+ agents: this.agents,
168
+ peerInfo: this.peerInfo,
169
+ startedAt: this.startedAt,
170
+ getPeerId: () => this.peerId,
171
+ requireTransport: () => this.requireTransport(),
172
+ getOnDelivery: () => this.onDelivery,
173
+ queueDelivery: (agentId, event) => {
174
+ this.deliveryEngine.queueDelivery(agentId, event);
175
+ },
176
+ });
177
+ this.staleAgentChecker = new StaleAgentChecker({
178
+ agents: this.agents,
179
+ deliveryQueues: this.deliveryQueues,
180
+ identityCache: this.identityCache,
181
+ peerInfo: this.peerInfo,
182
+ notifyRoomsOfStatus: (agentId, status) => this.deliveryEngine.notifyRoomsOfStatus(agentId, status),
183
+ broadcastPatch: (patch) => this.deliveryEngine.broadcastPatch(patch),
184
+ });
185
+ this.peerLifecycle = new PeerLifecycle({
186
+ peerInfo: this.peerInfo,
187
+ agents: this.agents,
188
+ coordinatorPort: this.coordinatorPort,
189
+ getPeerId: () => this.peerId,
190
+ requireTransport: () => this.requireTransport(),
191
+ serialise: () => this.serialise(),
192
+ roomProtocol: this.roomProtocol,
193
+ deliveryEngine: this.deliveryEngine,
194
+ staleAgentChecker: this.staleAgentChecker,
144
195
  });
145
196
  }
146
197
  /** Sets the transport (e.g. WireMeshTransport for encrypted connections). Must be called before init() or any other transport-using method. */
@@ -181,12 +232,7 @@ export class MeshStore {
181
232
  });
182
233
  // Try joining an existing mesh; fall back to becoming coordinator.
183
234
  //
184
- // Single attempt: connect to an existing coordinator, or become one.
185
- // If the coordinator port is occupied but unresponsive (e.g. an orphan
186
- // process from a previous session), degrade gracefully instead of
187
- // retrying. Retrying tls.connect after a failed handshake to a
188
- // non-TLS endpoint can freeze the event loop (Node.js TLS session
189
- // cache bug), so we only try once.
235
+ // Single attempt: connect to an existing coordinator, or become one. If the coordinator port is occupied but unresponsive (e.g. an orphan process from a previous session), degrade gracefully instead of retrying. Retrying tls.connect after a failed handshake to a non-TLS endpoint can freeze the event loop (Node.js TLS session cache bug), so we only try once.
190
236
  let connected = false;
191
237
  try {
192
238
  await this.requireTransport().connectToCoordinator(COORDINATOR_HOST, this.coordinatorPort, this.peerId, this.requireTransport().dataPort);
@@ -195,7 +241,7 @@ export class MeshStore {
195
241
  catch {
196
242
  try {
197
243
  await this.requireTransport().becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
198
- this.startStaleCheck();
244
+ this.staleAgentChecker.start();
199
245
  connected = true;
200
246
  }
201
247
  catch (coordErr) {
@@ -214,1839 +260,189 @@ export class MeshStore {
214
260
  this.requireTransport().unref();
215
261
  }
216
262
  // -----------------------------------------------------------------------
217
- // TransportEvents — callbacks from the transport layer
218
- // -----------------------------------------------------------------------
219
- handlePeerList(peers) {
220
- for (const peer of peers) {
221
- this.peerInfo.set(peer.id, peer);
222
- // The list always includes this store's own entry — dialling yourself is a wasted connection attempt (and, on some platforms, an immediate self-inflicted ECONNRESET) that never needs to happen.
223
- if (peer.id === this.peerId)
224
- continue;
225
- void this.requireTransport().connectToPeer(peer, this.peerId);
226
- }
227
- }
228
- handlePeerJoined(peer) {
229
- this.peerInfo.set(peer.id, peer);
230
- if (peer.id === this.peerId)
231
- return;
232
- void this.requireTransport().connectToPeer(peer, this.peerId);
233
- }
234
- async handleIntroduction(handle, msg) {
235
- const newPeer = {
236
- id: msg.peerId,
237
- port: msg.dataPort,
238
- startedAt: new Date().toISOString(),
239
- };
240
- this.peerInfo.set(msg.peerId, newPeer);
241
- // Send full peer list to the new peer
242
- const peerList = {
243
- method: "peer_list",
244
- peers: [...this.peerInfo.values()],
245
- };
246
- await this.requireTransport().send(handle, peerList);
247
- // Broadcast arrival to all existing peers
248
- const joined = { method: "peer_joined", peer: newPeer };
249
- await this.requireTransport().broadcast(joined);
250
- // Connect to the new peer's data server
251
- void this.requireTransport().connectToPeer(newPeer, this.peerId);
252
- }
253
- async handlePeerConnected(handle, _info) {
254
- // If we have state and the peer doesn't, send state sync
255
- if (this.agents.size > 0) {
256
- const state = this.serialise();
257
- await this.requireTransport().send(handle, {
258
- method: "state_sync",
259
- state,
260
- });
261
- }
262
- await this.flushPendingRoomRequests(handle.id);
263
- }
264
- /**
265
- * Merge a peer's state snapshot into the local state. Agents and rooms
266
- * accept the incoming copy when it carries a revision at least as high as
267
- * the local one, so a peer holding stale entities converges when it
268
- * receives a fresher snapshot, while its own stale copies are rejected by
269
- * peers that stayed current (#27). Message and DM histories are
270
- * append-only: add unseen entries and union read receipts.
271
- */
272
- applyStateSync(state) {
273
- const incoming = {
274
- agents: new Map(Object.entries(state.agents)),
275
- rooms: new Map(Object.entries(state.rooms)),
276
- messages: new Map(Object.entries(state.messages)),
277
- dms: new Map(Object.entries(state.dms)),
278
- };
279
- {
280
- for (const [id, agent] of incoming.agents) {
281
- const existingVersion = this.agents.get(id)?.version;
282
- if (existingVersion !== undefined && agent.version < existingVersion) {
283
- continue;
284
- }
285
- if (existingVersion === agent.version) {
286
- const existing = this.agents.get(id);
287
- if (existing) {
288
- for (const r of existing.subscribedRooms) {
289
- if (!agent.subscribedRooms.includes(r))
290
- agent.subscribedRooms.push(r);
291
- }
292
- }
293
- }
294
- this.agents.set(id, agent);
295
- }
296
- for (const [id, room] of incoming.rooms) {
297
- const existingVersion = this.rooms.get(id)?.version;
298
- if (existingVersion !== undefined && room.version < existingVersion) {
299
- continue;
300
- }
301
- this.mergeRoom(room);
302
- }
303
- for (const [id, msgs] of incoming.messages) {
304
- const existing = this.messages.get(id);
305
- if (existing === undefined) {
306
- this.messages.set(id, msgs);
307
- continue;
308
- }
309
- mergeMessageHistories(existing, msgs);
310
- }
311
- for (const [id, dmMsgs] of incoming.dms) {
312
- const existing = this.dms.get(id);
313
- if (existing === undefined) {
314
- this.dms.set(id, dmMsgs);
315
- continue;
316
- }
317
- mergeMessageHistories(existing, dmMsgs);
318
- }
319
- for (const [agentId, events] of Object.entries(state.deliveryQueues)) {
320
- const seen = new Set((this.deliveryQueues.get(agentId) ?? []).map((e) => JSON.stringify(e)));
321
- for (const event of events) {
322
- if (seen.has(JSON.stringify(event)))
323
- continue;
324
- this.queueDelivery(agentId, event);
325
- // Replay fires only for events with consumption evidence (#28):
326
- // messages are consumed by reading (readBy), invites by acceptance
327
- // or decline (no longer in the invited list). Transient
328
- // notifications (member_joined, room_members, connection_request,
329
- // delivery status) carry no consumption evidence, so replaying
330
- // them could only ever duplicate-notify; the state they describe
331
- // arrives via the synced room and agent records instead. They
332
- // still merge into the queue, so drain bridges see them.
333
- if (event.type === "room_message" || event.type === "dm") {
334
- this.fireLocalDelivery(agentId, event);
335
- }
336
- else if (event.type === "room_invite") {
337
- const stillInvited = this.rooms
338
- .get(event.room)
339
- ?.invited.includes(agentId);
340
- if (stillInvited === true) {
341
- this.fireLocalDelivery(agentId, event);
342
- }
343
- }
344
- }
345
- }
346
- }
347
- }
348
- async handleDataMessage(handle, msg) {
349
- if (msg.method === "state_sync") {
350
- this.applyStateSync(normaliseWireState(msg.state));
351
- }
352
- else if (msg.method === "state_update") {
353
- await this.applyPatch(msg.patch);
354
- }
355
- }
356
- async handleBecomeCoordinator(peerList) {
357
- // Take over as coordinator using the data server we already have
358
- await this.requireTransport().becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
359
- this.peerInfo.clear();
360
- for (const peer of peerList) {
361
- this.peerInfo.set(peer.id, peer);
362
- void this.requireTransport().connectToPeer(peer, this.peerId);
363
- }
364
- this.startStaleCheck();
365
- }
366
- handlePeerDisconnected(handle) {
367
- this.peerInfo.delete(handle.id);
368
- }
369
- // -----------------------------------------------------------------------
370
- // Connection approval
371
- // -----------------------------------------------------------------------
372
- handleConnectionRequest(handle, request) {
373
- this.pendingInboundConnections.set(handle.id, {
374
- peerId: request.peerId,
375
- dataPort: request.dataPort,
376
- name: request.name,
377
- fingerprint: request.fingerprint,
378
- });
379
- // Deliver connection_request event to the owning agent
380
- const connectionId = handle.id;
381
- const event = {
382
- type: "connection_request",
383
- connectionId,
384
- peerId: request.peerId,
385
- dataPort: request.dataPort,
386
- name: request.name,
387
- fingerprint: request.fingerprint,
388
- };
389
- this.queueDelivery(this.peerId, event);
390
- if (this.onDelivery) {
391
- void this.onDelivery(this.peerId, event);
392
- }
393
- }
394
- /** Accept a pending inbound connection. */
395
- async acceptConnection(connectionId) {
396
- const pending = this.pendingInboundConnections.get(connectionId);
397
- if (!pending) {
398
- throw new Error(`No pending connection ${connectionId}`);
399
- }
400
- this.pendingInboundConnections.delete(connectionId);
401
- const handle = { id: connectionId };
402
- await this.requireTransport().acceptConnection(handle);
403
- }
404
- /** Reject a pending inbound connection. */
405
- async rejectConnection(connectionId, reason) {
406
- const pending = this.pendingInboundConnections.get(connectionId);
407
- if (!pending) {
408
- throw new Error(`No pending connection ${connectionId}`);
409
- }
410
- this.pendingInboundConnections.delete(connectionId);
411
- const handle = { id: connectionId };
412
- await this.requireTransport().rejectConnection(handle, reason);
413
- }
414
- /** List all pending inbound connections awaiting approval. */
415
- listPendingConnections() {
416
- return [...this.pendingInboundConnections.entries()].map(([id, info]) => ({
417
- connectionId: id,
418
- ...info,
419
- }));
420
- }
421
- /** Initiate an outbound connection to a remote coordinator requiring approval.
422
- * Fires the connect_request and returns immediately. The connection
423
- * completes asynchronously when the coordinator accepts or rejects. */
424
- connectToRemote(host, port) {
425
- const agent = this.agents.get(this.peerId);
426
- // Fire-and-forget: don't await the full approval handshake.
427
- // The coordinator will either accept (triggering normal introduction flow)
428
- // or reject (closing the socket). Handle rejection to avoid unhandled rejection.
429
- this.requireTransport()
430
- .connectToRemote(host, port, this.peerId, this.requireTransport().dataPort, agent?.name ?? "", "")
431
- .catch(() => {
432
- // Rejection is expected when the coordinator denies the connection.
433
- // Log silently — the calling tool already returned success.
434
- });
435
- return Promise.resolve();
436
- }
437
- /** Start only the data server without connecting to a coordinator.
438
- * Used for testing scenarios where the peer connects via connectToRemote. */
439
- async startDataServerOnly() {
440
- await this.requireTransport().startDataServer();
441
- this.peerInfo.set(this.peerId, {
442
- id: this.peerId,
443
- port: this.requireTransport().dataPort,
444
- startedAt: this.startedAt,
445
- });
446
- this.requireTransport().unref();
447
- }
448
- // -----------------------------------------------------------------------
449
263
  // Transport events accessor (for bridges to wire up)
450
264
  // -----------------------------------------------------------------------
451
265
  /** Returns the TransportEvents object that bridges should pass to the transport constructor. */
452
266
  get events() {
453
267
  return {
454
268
  onMessage: (handle, msg) => {
455
- void this.handleDataMessage(handle, msg);
269
+ void this.peerLifecycle.handleDataMessage(handle, msg);
456
270
  },
457
271
  onPeerConnected: (handle, info) => {
458
- void this.handlePeerConnected(handle, info);
272
+ void this.peerLifecycle.handlePeerConnected(handle, info);
459
273
  },
460
274
  onPeerDisconnected: (handle) => {
461
- this.handlePeerDisconnected(handle);
275
+ this.peerLifecycle.handlePeerDisconnected(handle);
462
276
  },
463
277
  onIntroduction: (handle, msg) => {
464
- void this.handleIntroduction(handle, msg);
278
+ void this.peerLifecycle.handleIntroduction(handle, msg);
465
279
  },
466
280
  onPeerList: (peers) => {
467
- this.handlePeerList(peers);
281
+ this.peerLifecycle.handlePeerList(peers);
468
282
  },
469
283
  onPeerJoined: (peer) => {
470
- this.handlePeerJoined(peer);
284
+ this.peerLifecycle.handlePeerJoined(peer);
471
285
  },
472
286
  onBecomeCoordinator: (peerList) => {
473
- void this.handleBecomeCoordinator(peerList);
287
+ void this.peerLifecycle.handleBecomeCoordinator(peerList);
474
288
  },
475
289
  onConnectionRequest: (handle, request) => {
476
- this.handleConnectionRequest(handle, request);
290
+ this.connectionApproval.handleConnectionRequest(handle, request);
477
291
  },
478
292
  onError: (error) => {
479
293
  this.onError?.(error);
480
294
  },
481
295
  onRevocationAnnounce: (entry) => {
482
- void this.handleRevocationAnnounce(entry);
296
+ void this.deliveryEngine.handleRevocationAnnounce(entry);
483
297
  },
484
298
  onPresenceAdvert: (handle, status) => {
485
- this.handlePresenceAdvert(handle.id, status);
299
+ this.deliveryEngine.handlePresenceAdvert(handle.id, status);
486
300
  },
487
301
  };
488
302
  }
489
- /** Applies a peer's gossiped presence status to this store's own local record, without broadcasting it back onto the mesh -- gossip itself is already how this fact propagates (each side's WireMeshTransport re-advertises its own status periodically), so re-broadcasting a received one would just bounce it around indefinitely. Silently ignored for a device-id this store doesn't yet have an agent record for: presence gossip is a transport-layer fact about an already-known peer's current status, not itself a source of truth for who that peer is -- that still comes only from agent_upsert/registerAgent, which carries the name/harness/cwd/tags a fabricated partial record here never could. */
490
- handlePresenceAdvert(agentId, status) {
491
- const agent = this.agents.get(agentId);
492
- if (agent === undefined || agent.status === status)
493
- return;
494
- agent.status = status;
495
- this.bump(agent);
496
- void this.notifyRoomsOfStatus(agentId, status);
497
- }
498
- /** Verifies a gossiped revocation-entry and, if it verifies, records it in this store's own RevocationView -- future verifyRoomToken calls against this token's (token-id, issuer) pair fail with "revoked" from this point on. A failing entry is dropped silently: the same "hostile input produces a verdict, never a throw" contract verifyRevocationEntry itself already guarantees, so there is nothing further for a caller to react to. */
499
- async handleRevocationAnnounce(entry) {
500
- const { identity, revocation } = this.requireIdentity();
501
- await revocation.record(entry, { identity });
502
- }
503
- /** Append to a target agent's delivery queue, bounded oldest-first (#28). */
504
- queueDelivery(agentId, event) {
505
- const arr = this.deliveryQueues.get(agentId) ?? [];
506
- arr.push(event);
507
- if (arr.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
508
- arr.splice(0, arr.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
509
- }
510
- this.deliveryQueues.set(agentId, arr);
511
- }
512
303
  /**
513
- * Merge an incoming room record into the local one. Scalar fields follow
514
- * the version gate the caller already applied (an equal or higher version
515
- * reaches here), while membership is always element-merged per agent by
516
- * highest operation revision, so concurrent joins of different agents
517
- * survive and a kick racing a join converges with the kick honoured
518
- * (#27). The members and invited views are re-derived from the merged
519
- * operations.
304
+ * Room verb handlers this store registers with its own WireMeshTransport, keyed by params.verb per room-router.ts's own dispatch discipline.
520
305
  */
521
- mergeRoom(incoming) {
522
- const existing = this.rooms.get(incoming.id);
523
- if (existing === undefined) {
524
- this.refreshMembership(incoming);
525
- this.rooms.set(incoming.id, incoming);
526
- return;
527
- }
528
- existing.version = Math.max(existing.version, incoming.version);
529
- existing.name = incoming.name;
530
- existing.type = incoming.type;
531
- existing.owner = incoming.owner;
532
- existing.createdAt = incoming.createdAt;
533
- existing.description = incoming.description;
534
- if (incoming.federated !== undefined)
535
- existing.federated = incoming.federated;
536
- existing.memberJoins = MeshStore.mergeMemberOps(existing.memberJoins, incoming.memberJoins);
537
- existing.memberLeaves = MeshStore.mergeMemberOps(existing.memberLeaves, incoming.memberLeaves);
538
- existing.invitedJoins = MeshStore.mergeMemberOps(existing.invitedJoins, incoming.invitedJoins);
539
- existing.invitedLeaves = MeshStore.mergeMemberOps(existing.invitedLeaves, incoming.invitedLeaves);
540
- this.refreshMembership(existing);
541
- }
542
- /**
543
- * Derive the members and invited views from the per-agent operation maps.
544
- * An agent is in the list when their latest join strictly outranks their
545
- * latest leave; equal revisions mean the leave wins, so a kick racing a
546
- * concurrent join converges with the kick honoured (#27).
547
- */
548
- refreshMembership(room) {
549
- room.members = Object.keys(room.memberJoins).filter((id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0));
550
- room.invited = Object.keys(room.invitedJoins).filter((id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0));
551
- }
552
- /** Record a membership operation at the room's current revision. */
553
- recordMemberOp(room, list, op, agentId) {
554
- const joins = list === "member" ? room.memberJoins : room.invitedJoins;
555
- const leaves = list === "member" ? room.memberLeaves : room.invitedLeaves;
556
- const stamp = room.version;
557
- if (op === "join")
558
- joins[agentId] = stamp;
559
- else
560
- leaves[agentId] = stamp;
561
- }
562
- /** Merge per-agent operation maps by highest revision per agent. */
563
- static mergeMemberOps(local, incoming) {
564
- const merged = { ...local };
565
- for (const [id, stamp] of Object.entries(incoming)) {
566
- if (stamp > (merged[id] ?? 0))
567
- merged[id] = stamp;
568
- }
569
- return merged;
570
- }
571
- /** Bump an entity's sync revision; call before broadcasting a local mutation. */
572
- bump(entity) {
573
- entity.version += 1;
574
- return entity;
575
- }
576
- // -----------------------------------------------------------------------
577
- // State patch application
578
- // -----------------------------------------------------------------------
579
- async applyPatch(patch) {
580
- switch (patch.type) {
581
- case "agent_upsert": {
582
- const existingAgent = this.agents.get(patch.agent.id);
583
- if (existingAgent !== undefined &&
584
- patch.agent.version < existingAgent.version) {
585
- // Stale copy from a peer that missed updates (#27).
586
- break;
587
- }
588
- const merged = patch.agent;
589
- if (existingAgent?.version === patch.agent.version) {
590
- // Concurrent mutations from the same base: keep subscriptions
591
- // gained locally. A strictly higher version replaces the record.
592
- for (const r of existingAgent.subscribedRooms) {
593
- if (!merged.subscribedRooms.includes(r))
594
- merged.subscribedRooms.push(r);
595
- }
596
- }
597
- this.agents.set(merged.id, merged);
598
- break;
599
- }
600
- case "agent_offline": {
601
- const agent = this.agents.get(patch.agentId);
602
- if (agent) {
603
- agent.status = "offline";
604
- this.agents.set(patch.agentId, agent);
605
- }
606
- break;
607
- }
608
- case "room_upsert": {
609
- const existing = this.rooms.get(patch.room.id);
610
- if (existing !== undefined && patch.room.version < existing.version) {
611
- // Stale copy from a peer that missed updates (#27).
612
- break;
613
- }
614
- this.mergeRoom(patch.room);
615
- break;
616
- }
617
- case "room_delete":
618
- this.rooms.delete(patch.roomId);
619
- break;
620
- case "message_add": {
621
- const arr = this.messages.get(patch.roomId) ?? [];
622
- arr.push(patch.message);
623
- this.messages.set(patch.roomId, arr);
624
- break;
625
- }
626
- case "dm_add": {
627
- const arr = this.dms.get(patch.key) ?? [];
628
- arr.push(patch.message);
629
- this.dms.set(patch.key, arr);
630
- break;
631
- }
632
- case "delivery": {
633
- this.queueDelivery(patch.agentId, patch.event);
634
- if (patch.agentId === this.peerId && this.onDelivery) {
635
- // Deduplicate against local deliveries
636
- const eventKey = JSON.stringify(patch.event);
637
- if (this.localDeliveryKeys.has(eventKey))
638
- break;
639
- this.localDeliveryKeys.add(eventKey);
640
- if (this.localDeliveryKeys.size > 50) {
641
- const oldest = this.localDeliveryKeys.values().next().value;
642
- if (oldest !== undefined)
643
- this.localDeliveryKeys.delete(oldest);
644
- }
645
- void this.onDelivery(patch.agentId, patch.event);
646
- // Auto-mark read — scheduled as a macrotask to yield to the event
647
- // loop. Without this yield, the delivery → markRead → broadcast
648
- // → peer receives → handleDataMessage chain monopolises the
649
- // microtask queue and starves macrotasks (timers, new connections,
650
- // sendRoomMessage return values).
651
- const evt = patch.event;
652
- const timer = setTimeout(() => {
653
- if (this.isShutDown)
654
- return;
655
- if (evt.type === "room_message") {
656
- void this.markRead(evt.message.id, this.peerId, evt.message.room);
657
- }
658
- else if (evt.type === "dm") {
659
- void this.markRead(evt.message.id, this.peerId);
660
- }
661
- }, 0);
662
- if (!this.isShutDown)
663
- this.pendingMarkReadTimers.push(timer);
664
- }
665
- break;
666
- }
667
- }
668
- if (this.onPatch) {
669
- await this.onPatch(patch);
670
- }
671
- }
672
- // -----------------------------------------------------------------------
673
- // Broadcast
674
- // -----------------------------------------------------------------------
675
- async broadcastPatch(patch) {
676
- await this.requireTransport().broadcast({ method: "state_update", patch });
677
- if (this.onPatch) {
678
- await this.onPatch(patch);
679
- }
680
- }
681
- async deliverLocallyAndBroadcast(agentId, event) {
682
- // Local delivery
683
- this.queueDelivery(agentId, event);
684
- // Auto-emit delivered status for messages
685
- if (event.type === "room_message") {
686
- await this.emitDeliveryStatus(event.message.id, agentId, "delivered", event.message.room);
687
- }
688
- else if (event.type === "dm") {
689
- await this.emitDeliveryStatus(event.message.id, agentId, "delivered");
690
- }
691
- this.fireLocalDelivery(agentId, event);
692
- // Remote delivery
693
- const patch = { type: "delivery", agentId, event };
694
- await this.broadcastPatch(patch);
695
- }
696
- /**
697
- * Fire onDelivery for an event targeting this peer's own agent, deduped
698
- * against events already delivered in this process. Used both for live
699
- * deliveries and for replays of events that accumulated while this
700
- * process was down (#28): the dedup set is per-process, so a replayed
701
- * event this process never saw fires, and one it already handled does
702
- * not.
703
- */
704
- fireLocalDelivery(agentId, event) {
705
- if (agentId !== this.peerId || !this.onDelivery)
706
- return;
707
- // A room message or DM this agent has already read was already pushed
708
- // and consumed: read receipts mutate the event between snapshots, so a
709
- // plain structural key would miss and re-fire on the next sync (#28).
710
- if ((event.type === "room_message" || event.type === "dm") &&
711
- event.message.readBy.includes(agentId)) {
712
- return;
713
- }
714
- const eventKey = JSON.stringify(event);
715
- if (this.localDeliveryKeys.has(eventKey))
716
- return;
717
- this.localDeliveryKeys.add(eventKey);
718
- // Prevent unbounded growth — evict oldest when cap reached
719
- if (this.localDeliveryKeys.size > 50) {
720
- const oldest = this.localDeliveryKeys.values().next().value;
721
- if (oldest !== undefined)
722
- this.localDeliveryKeys.delete(oldest);
723
- }
724
- void this.onDelivery(agentId, event);
725
- // Delivered to this process, so no longer pending for it. Peers that
726
- // never fired the event keep their copies, which is what a restart
727
- // replays from (#28). Key-based match: replayed events are JSON clones.
728
- const queued = this.deliveryQueues.get(agentId);
729
- if (queued !== undefined) {
730
- const idx = queued.findIndex((e) => JSON.stringify(e) === eventKey);
731
- if (idx !== -1)
732
- queued.splice(idx, 1);
733
- }
734
- // Auto-mark read — scheduled as a macrotask to yield to the event loop.
735
- const timer = setTimeout(() => {
736
- if (this.isShutDown)
737
- return;
738
- if (event.type === "room_message") {
739
- void this.markRead(event.message.id, agentId, event.message.room);
740
- }
741
- else if (event.type === "dm") {
742
- void this.markRead(event.message.id, agentId);
743
- }
744
- }, 0);
745
- if (!this.isShutDown)
746
- this.pendingMarkReadTimers.push(timer);
747
- }
748
- async deliverToRoom(roomId, event, excludeAgent) {
749
- const room = this.rooms.get(roomId);
750
- if (!room)
751
- return;
752
- for (const memberId of room.members) {
753
- if (memberId === excludeAgent)
754
- continue;
755
- await this.deliverLocallyAndBroadcast(memberId, event);
756
- }
757
- }
758
- async notifyRoomsOfStatus(agentId, status) {
759
- const agent = this.agents.get(agentId);
760
- if (!agent)
761
- return;
762
- for (const roomId of agent.subscribedRooms) {
763
- await this.deliverToRoom(roomId, {
764
- type: "member_status",
765
- room: roomId,
766
- agent: agentId,
767
- status,
768
- });
769
- }
770
- }
771
- async notifyRoomsOfNameChange(agentId, oldName, newName) {
772
- const agent = this.agents.get(agentId);
773
- if (!agent)
774
- return;
775
- const event = {
776
- type: "name_changed",
777
- agent: agentId,
778
- oldName,
779
- newName,
780
- };
781
- for (const roomId of agent.subscribedRooms) {
782
- await this.deliverToRoom(roomId, event, agentId);
783
- }
784
- // Also deliver to the agent itself so it sees confirmation
785
- await this.deliverLocallyAndBroadcast(agentId, event);
786
- }
787
- async emitDeliveryStatus(messageId, agentId, status, room) {
788
- // Find the sender for this message
789
- const senderId = this.findMessageSender(messageId, room);
790
- if (!senderId)
791
- return;
792
- await this.deliverLocallyAndBroadcast(senderId, {
793
- type: "delivery_status",
794
- messageId,
795
- agent: agentId,
796
- status,
797
- room,
798
- });
799
- }
800
- findMessageSender(messageId, room) {
801
- if (room) {
802
- const msgs = this.messages.get(room);
803
- if (msgs) {
804
- const msg = msgs.find((m) => m.id === messageId);
805
- if (msg)
806
- return msg.from;
807
- }
808
- }
809
- else {
810
- // DM — search all DM queues
811
- for (const [, msgs] of this.dms) {
812
- const msg = msgs.find((m) => m.id === messageId);
813
- if (msg)
814
- return msg.from;
815
- }
816
- }
817
- return undefined;
818
- }
819
- /** Like findMessageSender, but also returns the room-path a room.read needs to address: room itself for a room message, or the specific DM key (this.dms is keyed by dmRoomPath/"self:...", not the bare pair) the message was actually found under. */
820
- findMessageLocation(messageId, room) {
821
- if (room !== undefined) {
822
- const msg = this.messages.get(room)?.find((m) => m.id === messageId);
823
- return msg === undefined ? undefined : { roomPath: room, from: msg.from };
824
- }
825
- for (const [key, msgs] of this.dms) {
826
- const msg = msgs.find((m) => m.id === messageId);
827
- if (msg)
828
- return { roomPath: key, from: msg.from };
829
- }
830
- return undefined;
831
- }
832
- /**
833
- * Marks a message read by readBy (always this store's own identity -- see the auto-mark-read timers in fireLocalDelivery and drainDelivery, its only callers) and notifies the message's own author via a directed, wire-authenticated room.read (P3.5), replacing the legacy message_read patch's mesh-wide broadcast: only the author is a genuine audience for "did you read my message" per core/room's own always-voluntary read-receipt design, and a direct request to them is strictly more than the old broadcast actually needed. Silently does nothing beyond the local readBy update when the author is this store itself (a self-DM, or an already-own message) or when this store holds no room:member token for the message's own room-path -- read receipts stay best-effort by design, never a reason to throw.
834
- */
835
- async markRead(messageId, readBy, room) {
836
- const location = this.findMessageLocation(messageId, room);
837
- if (location === undefined)
838
- return;
839
- const { roomPath, from } = location;
840
- const history = room !== undefined ? this.messages.get(room) : this.dms.get(roomPath);
841
- const message = history?.find((m) => m.id === messageId);
842
- if (message && !message.readBy.includes(readBy)) {
843
- message.readBy.push(readBy);
844
- }
845
- if (from === readBy)
846
- return;
847
- const { slot, clock } = this.requireIdentity();
848
- const token = loadRoomTokens(slot)[roomPath];
849
- if (token === undefined)
850
- return;
851
- const params = {
852
- verb: "room.read",
853
- messages: [bytesFromHex(messageId)],
854
- at: clock.now(),
855
- };
856
- await this.sendRoomRequestToMember(from, roomPath, token, params);
306
+ get roomVerbHandlers() {
307
+ return this.roomProtocol.roomVerbHandlers;
857
308
  }
858
309
  // -----------------------------------------------------------------------
859
310
  // CommsStore — Identity
860
311
  // -----------------------------------------------------------------------
861
312
  async readIdentity(harness, cwd) {
862
- await Promise.resolve();
863
- return this.identityCache.get(`${harness}--${cwd}`);
313
+ return this.agentRegistry.readIdentity(harness, cwd);
864
314
  }
865
315
  async writeIdentity(harness, cwd, id) {
866
- await Promise.resolve();
867
- this.identityCache.set(`${harness}--${cwd}`, { id });
316
+ return this.agentRegistry.writeIdentity(harness, cwd, id);
868
317
  }
869
318
  // -----------------------------------------------------------------------
870
319
  // CommsStore — Agent registry
871
320
  // -----------------------------------------------------------------------
872
321
  async registerAgent(opts) {
873
- const existing = await this.readIdentity(opts.harness, opts.cwd);
874
- if (existing) {
875
- return this.updateAgent(existing.id, {
876
- name: opts.name,
877
- visibility: opts.visibility,
878
- tags: opts.tags,
879
- status: "active",
880
- pid: opts.pid,
881
- });
882
- }
883
- const id = this.peerId;
884
- const agent = {
885
- id,
886
- version: 1,
887
- name: opts.name,
888
- harness: opts.harness,
889
- cwd: opts.cwd,
890
- pid: opts.pid,
891
- startedAt: this.startedAt,
892
- visibility: opts.visibility,
893
- status: "active",
894
- tags: opts.tags,
895
- subscribedRooms: [],
896
- };
897
- this.agents.set(id, agent);
898
- await this.writeIdentity(opts.harness, opts.cwd, id);
899
- await this.broadcastPatch({ type: "agent_upsert", agent });
900
- // Broadcast presence to federated links
901
- if (agent.visibility === "visible") {
902
- await this.federation.broadcastAgentVisible(agent);
903
- }
904
- return agent;
322
+ return this.agentRegistry.registerAgent(opts);
905
323
  }
906
324
  async getAgent(id) {
907
- await Promise.resolve();
908
- return this.agents.get(id);
325
+ return this.agentRegistry.getAgent(id);
909
326
  }
910
327
  async updateAgent(id, patch) {
911
- const agent = this.agents.get(id);
912
- if (!agent)
913
- throw new CommsError(`Agent ${id} not found`, "AGENT_NOT_FOUND");
914
- const oldStatus = agent.status;
915
- const oldName = agent.name;
916
- Object.assign(agent, patch);
917
- this.bump(agent);
918
- this.agents.set(id, agent);
919
- await this.broadcastPatch({ type: "agent_upsert", agent });
920
- if (patch.name !== undefined && patch.name !== oldName) {
921
- await this.notifyRoomsOfNameChange(id, oldName, patch.name);
922
- }
923
- if (patch.status && patch.status !== oldStatus) {
924
- await this.notifyRoomsOfStatus(id, patch.status);
925
- }
926
- return agent;
328
+ return this.agentRegistry.updateAgent(id, patch);
927
329
  }
928
330
  async listAgents(requesterId) {
929
- await Promise.resolve();
930
- const result = [];
931
- for (const agent of this.agents.values()) {
932
- if (agent.visibility === "ghost" && agent.id !== requesterId)
933
- continue;
934
- result.push(agent);
935
- }
936
- return result;
331
+ return this.agentRegistry.listAgents(requesterId);
937
332
  }
938
333
  async setAgentOffline(id) {
939
- const agent = this.agents.get(id);
940
- if (!agent)
941
- return;
942
- if (agent.status === "offline")
943
- return;
944
- // Only the owning store should broadcast the status change.
945
- // Other stores learn about it via the agent_offline mesh patch.
946
- const isOwner = id === this.peerId;
947
- agent.status = "offline";
948
- this.bump(agent);
949
- this.agents.set(id, agent);
950
- if (isOwner) {
951
- await this.notifyRoomsOfStatus(id, "offline");
952
- await this.broadcastPatch({ type: "agent_offline", agentId: id });
953
- await this.federation.broadcastAgentGone(id);
954
- }
334
+ return this.agentRegistry.setAgentOffline(id);
955
335
  }
956
336
  // -----------------------------------------------------------------------
957
337
  // CommsStore — Rooms
958
338
  // -----------------------------------------------------------------------
959
- /**
960
- * Mints and persists the room owner's own self-signed room:member grant: issuer = bearer = owner, no parent, delegationsRemaining: 0. This is deliberately NOT the parent every later member grant chains through -- a delegations-remaining: 0 parent cannot mint any child at all (mintCapabilityToken refuses a child whose own delegationsRemaining isn't strictly less than its parent's, and there is no value less than 0), so a later join/invite grant is its own independent, parent-less, owner-issued root-level token instead (still satisfying the "chain roots at the path's own owner" obligation, since rootIssuer is just the token's own issuer when it carries no parent). This root grant exists purely so the owner has a token to present for its own room actions, uniformly with every other member, per the design's own "every code path that checks membership does the same thing regardless of who it is checking" reasoning.
961
- */
962
- async mintOwnerRootGrant(roomPath, owner) {
963
- const { identity, clock, slot } = this.requireIdentity();
964
- const verdict = await mintCapabilityToken({
965
- identity,
966
- clock,
967
- tokenId: randomId(),
968
- bearer: deviceIdFromHex(owner),
969
- capability: "room:member",
970
- scope: { kind: "room", path: roomPath },
971
- expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
972
- delegationsRemaining: 0,
973
- });
974
- if (!verdict.ok) {
975
- throw new Error(`MeshStore: failed to mint room owner grant for ${roomPath}: ${verdict.reason}`);
976
- }
977
- saveRoomToken(slot, roomPath, verdict.token);
978
- }
979
- /**
980
- * Room verb handlers this store registers with its own WireMeshTransport, keyed by params.verb per room-router.ts's own dispatch discipline. A getter (not a plain field) so every access reads through the current `this` binding without needing a constructor-time closure -- mirrors the `events` getter's own reasoning below.
981
- */
982
- get roomVerbHandlers() {
983
- return {
984
- "room.join": (request, handle) => this.handleRoomJoin(request, handle),
985
- "room.send": (request, handle) => this.handleRoomSend(request, handle),
986
- "room.read": (request, handle) => this.handleRoomRead(request, handle),
987
- "room.members": (request, handle) => this.handleRoomMembers(request, handle),
988
- "room.invite": (request) => this.handleRoomInvite(request),
989
- "room.leave": (request, handle) => this.handleRoomLeave(request, handle),
990
- };
991
- }
992
- /** Reads the "reply" message-ref out of a room.send's own params (if any) and returns the hex id it names -- room.send's replyTo carries a single parent message, so the first reply-relation ref is the one that matters; any further refs are a future relation this handler doesn't yet act on. */
993
- static replyToFromRefs(refs) {
994
- const reply = refs?.find((ref) => ref.relation === "reply");
995
- return reply === undefined ? undefined : bytesToHex(reply.id);
996
- }
997
- /** Reads room.send's own "streaming-behavior" extension field (open params tail, not a named schema field) and validates it against the same StreamingBehavior contract every other delivery path already enforces -- an unrecognised or malformed value is dropped rather than rejecting the whole send, matching core/room's own obligation to ignore what it doesn't understand instead of failing closed on an extension field. */
998
- static streamingBehaviorFromParams(params) {
999
- const raw = params["streaming-behavior"];
1000
- if (raw === undefined)
1001
- return undefined;
1002
- const result = StreamingBehavior.safeParse(raw);
1003
- return result.success ? result.data : undefined;
1004
- }
1005
- /**
1006
- * Receiving side of a directed room.send (P3.5): verifies the presented token against all six of core/room's own obligations, then delivers the message locally exactly once -- the manage-response this returns IS the delivery receipt, so there is no separate "delivered" event to emit the way the legacy broadcastPatch path needed one. Branches on the room-path's own shape: an owner-named path stores a RoomMessage in this room's own history and fires a room_message event; a DM path stores a DmMessage keyed by the same dm-path sendDm already uses and fires a dm event -- both ride the identical room:member-gated verb, since a DM is just a room-path variant, not a separate verb.
1007
- */
1008
- async handleRoomSend(request, handle) {
1009
- const roomPath = request.scope.path;
1010
- if (roomPath === undefined) {
1011
- return { result: "error", code: "missing_scope_path" };
1012
- }
1013
- if (request.token === undefined) {
1014
- return { result: "error", code: "unauthorized" };
1015
- }
1016
- const { identity, clock, revocation } = this.requireIdentity();
1017
- const verdict = await verifyRoomToken(request.token, {
1018
- identity,
1019
- clock,
1020
- revocation,
1021
- expectedBearer: deviceIdFromHex(handle.id),
1022
- roomPath,
1023
- });
1024
- if (!verdict.ok) {
1025
- return { result: "error", code: "unauthorized" };
1026
- }
1027
- const parsedParams = roomSendSchema.safeParse(request.command.params);
1028
- if (!parsedParams.success) {
1029
- return { result: "error", code: "malformed_params" };
1030
- }
1031
- const params = parsedParams.data;
1032
- const replyTo = MeshStore.replyToFromRefs(params.refs);
1033
- const streamingBehavior = MeshStore.streamingBehaviorFromParams(params);
1034
- const id = bytesToHex(params["message-id"]);
1035
- const timestamp = new Date(params["sent-at"]).toISOString();
1036
- const parsedPath = parseRoomPath(roomPath);
1037
- let event;
1038
- if (parsedPath.kind === "dm") {
1039
- const message = {
1040
- id,
1041
- from: handle.id,
1042
- to: this.peerId,
1043
- content: params.text,
1044
- timestamp,
1045
- readBy: [handle.id],
1046
- ...(streamingBehavior !== undefined && { streamingBehavior }),
1047
- };
1048
- const history = this.dms.get(roomPath) ?? [];
1049
- history.push(message);
1050
- this.dms.set(roomPath, history);
1051
- event = { type: "dm", message };
1052
- }
1053
- else {
1054
- const message = {
1055
- id,
1056
- from: handle.id,
1057
- room: roomPath,
1058
- content: params.text,
1059
- timestamp,
1060
- readBy: [handle.id],
1061
- ...(replyTo !== undefined && { replyTo }),
1062
- ...(streamingBehavior !== undefined && { streamingBehavior }),
1063
- };
1064
- const history = this.messages.get(roomPath) ?? [];
1065
- history.push(message);
1066
- this.messages.set(roomPath, history);
1067
- event = { type: "room_message", message };
1068
- }
1069
- this.queueDelivery(this.peerId, event);
1070
- this.fireLocalDelivery(this.peerId, event);
1071
- return { result: "ok" };
1072
- }
1073
- /**
1074
- * Receiving side of a directed room.read (P3.5): verifies the presented token the same way handleRoomSend does, then for each read message-id in the batch, updates this store's own local copy of that message's readBy (this store holds one because it's the message's own author -- the reason it's the one being notified) and fires a delivery_status event locally, replacing what markRead used to broadcast via the legacy message_read patch. Read receipts stay voluntary and best-effort by design: a message-id this store doesn't recognise (already expired from history, or simply never this store's own) is silently skipped rather than treated as an error.
1075
- */
1076
- async handleRoomRead(request, handle) {
1077
- const roomPath = request.scope.path;
1078
- if (roomPath === undefined) {
1079
- return { result: "error", code: "missing_scope_path" };
1080
- }
1081
- if (request.token === undefined) {
1082
- return { result: "error", code: "unauthorized" };
1083
- }
1084
- const { identity, clock, revocation } = this.requireIdentity();
1085
- const verdict = await verifyRoomToken(request.token, {
1086
- identity,
1087
- clock,
1088
- revocation,
1089
- expectedBearer: deviceIdFromHex(handle.id),
1090
- roomPath,
1091
- });
1092
- if (!verdict.ok) {
1093
- return { result: "error", code: "unauthorized" };
1094
- }
1095
- const parsedParams = roomReadSchema.safeParse(request.command.params);
1096
- if (!parsedParams.success) {
1097
- return { result: "error", code: "malformed_params" };
1098
- }
1099
- const params = parsedParams.data;
1100
- const isDm = parseRoomPath(roomPath).kind === "dm";
1101
- for (const messageIdBytes of params.messages) {
1102
- const messageId = bytesToHex(messageIdBytes);
1103
- const history = isDm
1104
- ? this.dms.get(roomPath)
1105
- : this.messages.get(roomPath);
1106
- const message = history?.find((m) => m.id === messageId);
1107
- if (message === undefined)
1108
- continue;
1109
- if (!message.readBy.includes(handle.id)) {
1110
- message.readBy.push(handle.id);
1111
- }
1112
- const event = {
1113
- type: "delivery_status",
1114
- messageId,
1115
- agent: handle.id,
1116
- status: "read",
1117
- ...(isDm ? {} : { room: roomPath }),
1118
- };
1119
- this.queueDelivery(this.peerId, event);
1120
- this.fireLocalDelivery(this.peerId, event);
1121
- }
1122
- return { result: "ok" };
1123
- }
1124
- /**
1125
- * Receiving side of room.members (P3.6): a plain membership + room-state refresh for an already-admitted member, verified the same way handleRoomSend/handleRoomRead are -- room.members grants nothing new, it just answers "who's here, and what's this room called" on demand, the same information room.join's own response already carries at admission time. A DM path has no Room record (this.rooms never holds one for a dm-shaped path) and no name/description/type to report, so its own members list is derived directly from the path's own two participants instead.
1126
- */
1127
- async handleRoomMembers(request, handle) {
1128
- const roomPath = request.scope.path;
1129
- if (roomPath === undefined) {
1130
- return { result: "error", code: "missing_scope_path" };
1131
- }
1132
- if (request.token === undefined) {
1133
- return { result: "error", code: "unauthorized" };
1134
- }
1135
- const { identity, clock, revocation } = this.requireIdentity();
1136
- const verdict = await verifyRoomToken(request.token, {
1137
- identity,
1138
- clock,
1139
- revocation,
1140
- expectedBearer: deviceIdFromHex(handle.id),
1141
- roomPath,
1142
- });
1143
- if (!verdict.ok) {
1144
- return { result: "error", code: "unauthorized" };
1145
- }
1146
- const parsedPath = parseRoomPath(roomPath);
1147
- const room = this.rooms.get(roomPath);
1148
- const members = parsedPath.kind === "dm"
1149
- ? parsedPath.participants.map((device) => ({
1150
- device: deviceIdFromHex(device),
1151
- }))
1152
- : (room?.members ?? []).map((memberId) => ({
1153
- device: deviceIdFromHex(memberId),
1154
- }));
1155
- return {
1156
- result: "ok",
1157
- members,
1158
- ...MeshStore.roomStateExtension(room),
1159
- };
1160
- }
1161
- /**
1162
- * Sends one directed room.send to a single member's own session, attaching this store's own persisted room:member token for the given room path -- the primitive P3.5's own directed fan-out (deliverToRoom) will loop over per member once it replaces the legacy broadcastPatch path this store still uses for message delivery today. Throws if this store holds no token for the room: never a member, or a token that expired or was revoked with nothing fresh persisted in its place.
1163
- */
1164
- async sendRoomMessageDirected(roomPath, memberId, text) {
1165
- const { slot, clock } = this.requireIdentity();
1166
- const token = loadRoomTokens(slot)[roomPath];
1167
- if (token === undefined) {
1168
- throw new CommsError(`No room:member token for ${roomPath}`, "NOT_A_MEMBER");
1169
- }
1170
- const outcome = await this.requireTransport().sendRoomRequest(memberId, {
1171
- verb: ROOM_MEMBER_CAPABILITY,
1172
- params: {
1173
- verb: "room.send",
1174
- "message-id": randomId(),
1175
- "sent-at": clock.now(),
1176
- text,
1177
- },
1178
- }, { kind: "room", path: roomPath }, token);
1179
- if (outcome.result !== "ok") {
1180
- throw new CommsError(`room.send to ${memberId} for ${roomPath} failed (${outcome.code})`, "SEND_FAILED");
1181
- }
1182
- }
1183
- /** Records a room-domain request that couldn't reach memberId right now, for a later flushPendingRoomRequests to retry once that member reconnects. Bounded oldest-first with the same cap ordinary delivery queues use, so an indefinitely-offline member cannot grow this without limit. */
1184
- queuePendingRoomRequest(memberId, roomPath, params) {
1185
- const queue = this.pendingRoomRequests.get(memberId) ?? [];
1186
- queue.push({ roomPath, params });
1187
- if (queue.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
1188
- queue.splice(0, queue.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
1189
- }
1190
- this.pendingRoomRequests.set(memberId, queue);
1191
- }
1192
- /**
1193
- * Sends one directed room-domain request (room.send, room.read, or any future room:member-gated verb) to a single member, queuing it for retry instead of throwing when the member isn't currently reachable -- the fan-out's own per-recipient primitive, distinct from sendRoomMessageDirected's deliberate throw-on-failure contract for a caller sending to one specific, known recipient. Silently drops a request this store no longer holds a token for (no longer a member of the room) rather than queuing something that will only fail again on retry.
1194
- */
1195
- async sendRoomRequestToMember(memberId, roomPath, token, params) {
1196
- // sendRoomRequest can reject outright (e.g. the connection drops mid-request, per wire-mesh-core's own rejectPendingManageRequests), not just resolve with an error outcome -- both are exactly the same "memberId isn't reachable right now" fact from this method's own point of view, so both queue for retry rather than one of them propagating as an uncaught rejection out of what every caller treats as a fire-and-forget send.
1197
- let outcome;
1198
- try {
1199
- outcome = await this.requireTransport().sendRoomRequest(memberId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomPath }, token);
1200
- }
1201
- catch {
1202
- this.queuePendingRoomRequest(memberId, roomPath, params);
1203
- return;
1204
- }
1205
- if (outcome.result !== "ok") {
1206
- this.queuePendingRoomRequest(memberId, roomPath, params);
1207
- }
1208
- }
1209
- /** Retries every room.send queued for memberId since it was last reachable, dropping (not re-queuing) any whose room this store no longer holds a token for. Called once a connection to memberId is (re)established -- handlePeerConnected fires for both a fresh introduction and a reconnection after downtime, exactly the two cases a queued send needs to be retried on. */
1210
- async flushPendingRoomRequests(memberId) {
1211
- const queue = this.pendingRoomRequests.get(memberId);
1212
- if (queue === undefined || queue.length === 0)
1213
- return;
1214
- this.pendingRoomRequests.delete(memberId);
1215
- const { slot } = this.requireIdentity();
1216
- for (const pending of queue) {
1217
- const token = loadRoomTokens(slot)[pending.roomPath];
1218
- if (token === undefined)
1219
- continue;
1220
- await this.sendRoomRequestToMember(memberId, pending.roomPath, token, pending.params);
1221
- }
1222
- }
1223
- /**
1224
- * Owner-side admission for an incoming room.join request, against either a named room this store owns or a DM path this store is a participant of. Named-room admission always needs a human decision; DM admission needs one only for the party being contacted first -- the reply half of the two-round consent flow (section 6) auto-approves, since a reply on a path this node itself opened is not unsolicited contact.
1225
- */
1226
- async handleRoomJoin(request, handle) {
1227
- const roomPath = request.scope.path;
1228
- if (roomPath === undefined) {
1229
- return { result: "error", code: "missing_scope_path" };
1230
- }
1231
- const parsed = parseRoomPath(roomPath);
1232
- if (parsed.kind === "owner-named") {
1233
- if (parsed.owner !== this.peerId) {
1234
- return { result: "error", code: "not_owner" };
1235
- }
1236
- return this.admitRoomJoin(roomPath, handle, false);
1237
- }
1238
- if (!parsed.participants.includes(this.peerId)) {
1239
- return { result: "error", code: "not_participant" };
1240
- }
1241
- // The reciprocal half of section 6's own two-round DM flow: this node's own outbound room.join to the same path (recorded by joinRemoteRoom before this response was even awaited) is the consent that makes the counterpart's own reply not unsolicited contact.
1242
- const autoApprove = this.dmRequestsInitiatedByMe.has(roomPath);
1243
- return this.admitRoomJoin(roomPath, handle, autoApprove);
1244
- }
1245
- /** Shared admission continuation for both room.join branches above: waits for a human decision (unless auto-approved), then mints and returns the requester's own independent, parent-less room:member grant. */
1246
- async admitRoomJoin(roomPath, handle, autoApprove) {
1247
- const decision = autoApprove
1248
- ? { kind: "accept" }
1249
- : await new Promise((resolve) => {
1250
- this.pendingRoomJoins.set(`${roomPath}::${handle.id}`, {
1251
- roomPath,
1252
- requesterId: handle.id,
1253
- resolve,
1254
- });
1255
- });
1256
- if (!autoApprove)
1257
- this.pendingRoomJoins.delete(`${roomPath}::${handle.id}`);
1258
- if (decision.kind === "reject") {
1259
- return {
1260
- result: "error",
1261
- code: "denied",
1262
- ...(decision.reason !== undefined ? { message: decision.reason } : {}),
1263
- };
1264
- }
1265
- // The granted token itself belongs to the requester's own node, which persists it itself once it receives this response -- but this identity slot must also remember the token-id it just issued (saveIssuedRoomGrant below), since revoking a specific member's grant later (kickFromRoom) has no other way to name which token-id to revoke: a token-id is never presented back on the wire, so a room owner's own memory of having minted it is the only record.
1266
- const { identity, clock, slot } = this.requireIdentity();
1267
- const tokenId = randomId();
1268
- const verdict = await mintCapabilityToken({
1269
- identity,
1270
- clock,
1271
- tokenId,
1272
- bearer: deviceIdFromHex(handle.id),
1273
- capability: "room:member",
1274
- scope: { kind: "room", path: roomPath },
1275
- expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
1276
- delegationsRemaining: 0,
1277
- });
1278
- if (!verdict.ok) {
1279
- return { result: "error", code: "mint_failed" };
1280
- }
1281
- saveIssuedRoomGrant(slot, roomPath, handle.id, tokenId);
1282
- const room = this.rooms.get(roomPath);
1283
- if (room !== undefined) {
1284
- this.bump(room);
1285
- this.recordMemberOp(room, "member", "join", handle.id);
1286
- this.refreshMembership(room);
1287
- this.rooms.set(roomPath, room);
1288
- }
1289
- const members = (room?.members ?? [this.peerId, handle.id]).map((memberId) => ({ device: deviceIdFromHex(memberId) }));
1290
- return {
1291
- result: "ok",
1292
- "granted-token": verdict.token,
1293
- members,
1294
- ...MeshStore.roomStateExtension(room),
1295
- };
1296
- }
1297
- /**
1298
- * Room metadata (name/description/type) riding room-join-ok/room-members-ok's own open extension tail (P3.6), agent-comms' own convention for the "namespaced room-state extension" the design names -- core/room itself has no concept of a display name or description, so this is application data, not a wire-level field. Absent entirely for a DM path (room is undefined there; a DM has no name/description/type to report) rather than a hollow placeholder.
1299
- */
1300
- static roomStateExtension(room) {
1301
- if (room === undefined)
1302
- return {};
1303
- return {
1304
- "room-state": {
1305
- name: room.name,
1306
- description: room.description,
1307
- type: room.type,
1308
- },
1309
- };
1310
- }
1311
- /** The receiving-side counterpart of roomStateExtension: narrows an incoming response's own "room-state" field (an unknown, since it rides the wire schema's open catchall tail) to the shape this store's own convention actually sends, or undefined for a peer running without it (an older version, or a DM's own room-join-ok, which never carries one). */
1312
- static parseRoomStateExtension(value) {
1313
- if (typeof value !== "object" || value === null)
1314
- return undefined;
1315
- if (!("name" in value) || !("description" in value) || !("type" in value))
1316
- return undefined;
1317
- if (typeof value.name !== "string" || typeof value.description !== "string")
1318
- return undefined;
1319
- const parsedType = RoomType.safeParse(value.type);
1320
- if (!parsedType.success)
1321
- return undefined;
1322
- return {
1323
- name: value.name,
1324
- description: value.description,
1325
- type: parsedType.data,
1326
- };
1327
- }
1328
- /**
1329
- * The inviter's own display name and cwd, riding room-invite's own open extension tail -- agent-comms' own application data, the same convention roomStateExtension already establishes for room metadata. Unlike room-state (which needs gossip on the requester's own side of room.join/members), the inviter here is always this store's own local agent record: inviteToRoom only ever succeeds for the room's real owner, which per this store's own organising fact (one bridge is one agent is one device) is always this.peerId, so the record is always this store's own registration, never a gossip-dependent lookup.
1330
- */
1331
- static inviterAgentExtension(inviter) {
1332
- return { "inviter-agent": { name: inviter.name, cwd: inviter.cwd } };
1333
- }
1334
- /** The receiving-side counterpart of inviterAgentExtension: narrows an incoming room.invite's own "inviter-agent" field to the shape this store's own convention sends, or undefined for a peer running without it (an older version) -- the caller falls back to the inviter's bare device-id in that case, the same honest degradation parseRoomStateExtension's own absent case already accepts. */
1335
- static parseInviterAgentExtension(value) {
1336
- if (typeof value !== "object" || value === null)
1337
- return undefined;
1338
- if (!("name" in value) || !("cwd" in value))
1339
- return undefined;
1340
- if (typeof value.name !== "string" || typeof value.cwd !== "string")
1341
- return undefined;
1342
- return { name: value.name, cwd: value.cwd };
1343
- }
1344
- /**
1345
- * Receiving side of a real, wire-level room.invite (P3.8): unlike every other room verb, the request itself is deliberately ungated (the sender already IS the room's own owner, with no need to prove capability to invite) -- the security instead lives entirely in the embedded params.token, which must genuinely name this store's own identity as bearer and root at the room path's own claimed owner. Persists the verified token via saveRoomToken (mirroring joinRemoteRoom's own persistence) and fires a local room_invite delivery event carrying the room's real name/description (room-state) and the inviter's real name/cwd (inviter-agent) when the sender includes them, falling back to the bare device-id and room path otherwise.
1346
- */
1347
- async handleRoomInvite(request) {
1348
- const roomPath = request.scope.path;
1349
- if (roomPath === undefined) {
1350
- return { result: "error", code: "missing_scope_path" };
1351
- }
1352
- const parsedParams = roomInviteSchema.safeParse(request.command.params);
1353
- if (!parsedParams.success) {
1354
- return { result: "error", code: "malformed_params" };
1355
- }
1356
- const { token } = parsedParams.data;
1357
- const { identity, clock, slot, revocation } = this.requireIdentity();
1358
- const verdict = await verifyRoomToken(token, {
1359
- identity,
1360
- clock,
1361
- revocation,
1362
- expectedBearer: deviceIdFromHex(this.peerId),
1363
- roomPath,
1364
- });
1365
- if (!verdict.ok) {
1366
- return { result: "error", code: "unauthorized" };
1367
- }
1368
- saveRoomToken(slot, roomPath, token);
1369
- const parsed = parseRoomPath(roomPath);
1370
- const inviterId = parsed.kind === "owner-named" ? parsed.owner : this.peerId;
1371
- const roomState = MeshStore.parseRoomStateExtension(parsedParams.data["room-state"]);
1372
- const inviterAgent = MeshStore.parseInviterAgentExtension(parsedParams.data["inviter-agent"]);
1373
- const event = {
1374
- type: "room_invite",
1375
- room: roomPath,
1376
- roomDescription: roomState?.description ?? "",
1377
- from: inviterId,
1378
- fromName: inviterAgent?.name ?? inviterId,
1379
- fromCwd: inviterAgent?.cwd ?? "",
1380
- };
1381
- this.queueDelivery(this.peerId, event);
1382
- this.fireLocalDelivery(this.peerId, event);
1383
- return { result: "ok" };
1384
- }
1385
- /**
1386
- * Receiving side of a real, wire-level room.leave (P3.8), covering both an actual member leaving and a decline of a never-joined invite -- the same wire request either way, since both are "give up a room:member grant I hold," per leaveRemoteRoom's own reasoning. Distinguishes the two purely from this store's own membership/invited lists (never from anything the sender claims), revokes the sender's grant for real via revokeMemberGrant, and notifies accordingly: member_left broadcast to the room's other members for a real leave, a local invite_declined event (carrying the sender's own optional reason extension) for a decline -- there is no third party to notify for a decline, since nobody else ever knew about an invite that was never accepted.
1387
- */
1388
- async handleRoomLeave(request, handle) {
1389
- const roomPath = request.scope.path;
1390
- if (roomPath === undefined) {
1391
- return { result: "error", code: "missing_scope_path" };
1392
- }
1393
- if (request.token === undefined) {
1394
- return { result: "error", code: "unauthorized" };
1395
- }
1396
- const { identity, clock, revocation } = this.requireIdentity();
1397
- const verdict = await verifyRoomToken(request.token, {
1398
- identity,
1399
- clock,
1400
- revocation,
1401
- expectedBearer: deviceIdFromHex(handle.id),
1402
- roomPath,
1403
- });
1404
- if (!verdict.ok) {
1405
- return { result: "error", code: "unauthorized" };
1406
- }
1407
- const parsedParams = roomLeaveSchema.safeParse(request.command.params);
1408
- if (!parsedParams.success) {
1409
- return { result: "error", code: "malformed_params" };
1410
- }
1411
- const room = this.rooms.get(roomPath);
1412
- if (room === undefined) {
1413
- return { result: "error", code: "room_not_found" };
1414
- }
1415
- const wasMember = room.members.includes(handle.id);
1416
- const wasInvited = room.invited.includes(handle.id);
1417
- if (!wasMember && !wasInvited) {
1418
- return { result: "ok" };
1419
- }
1420
- await this.revokeMemberGrant(roomPath, handle.id);
1421
- this.bump(room);
1422
- if (wasMember)
1423
- this.recordMemberOp(room, "member", "leave", handle.id);
1424
- if (wasInvited)
1425
- this.recordMemberOp(room, "invited", "leave", handle.id);
1426
- this.refreshMembership(room);
1427
- this.rooms.set(roomPath, room);
1428
- await this.broadcastPatch({ type: "room_upsert", room });
1429
- if (wasMember) {
1430
- await this.deliverToRoom(roomPath, { type: "member_left", room: roomPath, agent: handle.id }, handle.id);
1431
- }
1432
- else {
1433
- const reasonValue = parsedParams.data.reason;
1434
- const decliner = this.agents.get(handle.id);
1435
- const event = {
1436
- type: "invite_declined",
1437
- room: roomPath,
1438
- agent: handle.id,
1439
- agentName: decliner?.name ?? handle.id,
1440
- reason: typeof reasonValue === "string" ? reasonValue : "",
1441
- };
1442
- this.queueDelivery(this.peerId, event);
1443
- this.fireLocalDelivery(this.peerId, event);
1444
- }
1445
- return { result: "ok" };
1446
- }
1447
- /** Every room.join request currently held open awaiting this store's own accept/reject decision. */
1448
- listPendingRoomJoins() {
1449
- return [...this.pendingRoomJoins.values()].map(({ roomPath, requesterId }) => ({ roomPath, requesterId }));
1450
- }
1451
- /** Approves a pending room.join request, resuming handleRoomJoin's own suspended mint-and-respond continuation. */
1452
- acceptRoomJoin(roomPath, requesterId) {
1453
- const key = `${roomPath}::${requesterId}`;
1454
- const pending = this.pendingRoomJoins.get(key);
1455
- if (pending === undefined) {
1456
- throw new CommsError(`No pending room.join for ${requesterId} on ${roomPath}`, "NOT_PENDING");
1457
- }
1458
- pending.resolve({ kind: "accept" });
1459
- }
1460
- /** Denies a pending room.join request, optionally with a reason surfaced to the requester in the resulting manage-error's own message field. */
1461
- rejectRoomJoin(roomPath, requesterId, reason) {
1462
- const key = `${roomPath}::${requesterId}`;
1463
- const pending = this.pendingRoomJoins.get(key);
1464
- if (pending === undefined) {
1465
- throw new CommsError(`No pending room.join for ${requesterId} on ${roomPath}`, "NOT_PENDING");
1466
- }
1467
- pending.resolve({
1468
- kind: "reject",
1469
- ...(reason !== undefined ? { reason } : {}),
1470
- });
1471
- }
1472
339
  async createRoom(opts) {
1473
- // slugRoomName sanitises an arbitrary caller-supplied name (e.g. from a live create_room tool call, not just an internal cwd basename) into the room-path grammar's [A-Za-z0-9_-]+ charset -- createRoom is the one choke point every room creation goes through, so this is the right place to do it rather than trusting every caller to have pre-slugged, the way the old bare-name id never required at all.
1474
- const slugName = slugRoomName(opts.name);
1475
- const localName = opts.type === "secret" ? `_${slugName}` : slugName;
1476
- const id = ownerNamedRoomPath(opts.owner, localName);
1477
- if (this.rooms.has(id))
1478
- throw new CommsError(`Room ${id} already exists`, "ROOM_EXISTS");
1479
- // Every room creation this store performs is local: opts.owner is always this bridge's own identity (create_room's caller passes ctx.agentId, and one bridge process is one agent is one device), so minting the owner's own root grant here always signs under the identity this store was wired with, never someone else's.
1480
- await this.mintOwnerRootGrant(id, opts.owner);
1481
- const room = {
1482
- id,
1483
- version: 1,
1484
- name: slugName,
1485
- type: opts.type,
1486
- owner: opts.owner,
1487
- createdAt: new Date().toISOString(),
1488
- description: opts.description,
1489
- members: [opts.owner],
1490
- invited: [],
1491
- memberJoins: { [opts.owner]: 1 },
1492
- memberLeaves: {},
1493
- invitedJoins: {},
1494
- invitedLeaves: {},
1495
- federated: opts.federated ?? false,
1496
- };
1497
- this.rooms.set(id, room);
1498
- this.messages.set(id, []);
1499
- await this.broadcastPatch({ type: "room_upsert", room });
1500
- return room;
340
+ return this.roomLifecycle.createRoom(opts);
1501
341
  }
1502
342
  async getRoom(id) {
1503
- await Promise.resolve();
1504
- return this.rooms.get(id);
343
+ return this.roomLifecycle.getRoom(id);
1505
344
  }
1506
345
  async listRooms(requesterId) {
1507
- await Promise.resolve();
1508
- const result = [];
1509
- for (const room of this.rooms.values()) {
1510
- if (room.type === "secret" && !room.members.includes(requesterId))
1511
- continue;
1512
- result.push(room);
1513
- }
1514
- return result;
346
+ return this.roomLifecycle.listRooms(requesterId);
1515
347
  }
1516
- /**
1517
- * The remote-join path: roomPath names an owner-named room this store has never seen replicated, so joining it means sending a real wire-level room.join request to the room's own owner and persisting whatever grant comes back, rather than mutating already-known local state. Only ever called for this store's own local agent (one bridge is one agent is one device, per the design's own organising fact) -- there is no wire mechanism by which this node could join a room on a different local agent's behalf.
1518
- */
1519
- async joinRemoteRoom(roomPath, agentId) {
1520
- if (agentId !== this.peerId) {
1521
- throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
1522
- }
1523
- const parsed = parseRoomPath(roomPath);
1524
- if (parsed.kind !== "owner-named") {
1525
- throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
1526
- }
1527
- const outcome = await this.requireTransport().sendRoomRequest(parsed.owner, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } }, { kind: "room", path: roomPath });
1528
- if (outcome.result !== "ok") {
1529
- throw new CommsError(`Join request for ${roomPath} was refused (${outcome.code})`, "JOIN_REFUSED");
1530
- }
1531
- const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
1532
- if (!parsedOutcome.success) {
1533
- throw new CommsError(`Join response for ${roomPath} was malformed`, "MALFORMED_RESPONSE");
1534
- }
1535
- const { "granted-token": grantedToken, members: memberList } = parsedOutcome.data;
1536
- const { slot } = this.requireIdentity();
1537
- saveRoomToken(slot, roomPath, grantedToken);
1538
- const members = memberList.map((member) => deviceIdToHex(member.device));
1539
- const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
1540
- const room = {
1541
- id: roomPath,
1542
- version: 1,
1543
- name: roomState?.name ?? parsed.localName,
1544
- // Falls back to "public" only against a peer running without the room-state extension (an older version); this joiner is always in `members` by construction regardless, so a stale "public" classification here never hides the room from its own member -- the one place type is read (listRooms' secret-room filter).
1545
- type: roomState?.type ?? "public",
1546
- owner: parsed.owner,
1547
- createdAt: new Date().toISOString(),
1548
- description: roomState?.description ?? "",
1549
- members,
1550
- invited: [],
1551
- memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
1552
- memberLeaves: {},
1553
- invitedJoins: {},
1554
- invitedLeaves: {},
1555
- federated: false,
1556
- };
1557
- this.rooms.set(roomPath, room);
1558
- this.messages.set(roomPath, []);
1559
- return room;
1560
- }
1561
- /**
1562
- * Refreshes this store's own local copy of a named room's membership and room-state via a real room.members request (P3.6): the same information room.join's own response carries at admission time, available on demand for a member whose local copy may have drifted (a kick, an invite, a rename since it joined). Sent to the room's own owner, the authoritative source for that room's real state. Named rooms only, matching joinRemoteRoom's own restriction -- a DM's "membership" is already fully known from the path itself (the sorted pair of exactly two participants), and this codebase has no Room-object representation for a DM to refresh into (DM state lives in this.dms, keyed by message history, not this.rooms). Throws if this store holds no room:member token for roomPath -- refreshing membership presupposes already being a member, the same NOT_A_MEMBER contract sendRoomMessageDirected already uses.
1563
- */
348
+ /** Refreshes this store's own local copy of a named room's membership and room-state via a real room.members request (P3.6). Concrete-only -- reached directly by tests. */
1564
349
  async refreshRoomMembers(roomPath) {
1565
- const parsed = parseRoomPath(roomPath);
1566
- if (parsed.kind !== "owner-named") {
1567
- throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
1568
- }
1569
- const { slot } = this.requireIdentity();
1570
- const token = loadRoomTokens(slot)[roomPath];
1571
- if (token === undefined) {
1572
- throw new CommsError(`No room:member token for ${roomPath}`, "NOT_MEMBER");
1573
- }
1574
- const outcome = await this.requireTransport().sendRoomRequest(parsed.owner, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.members" } }, { kind: "room", path: roomPath }, token);
1575
- if (outcome.result !== "ok") {
1576
- throw new CommsError(`room.members refresh for ${roomPath} failed (${outcome.code})`, "REFRESH_FAILED");
1577
- }
1578
- const parsedOutcome = roomMembersOkSchema.safeParse(outcome);
1579
- if (!parsedOutcome.success) {
1580
- throw new CommsError(`room.members response for ${roomPath} was malformed`, "MALFORMED_RESPONSE");
1581
- }
1582
- const members = parsedOutcome.data.members.map((member) => deviceIdToHex(member.device));
1583
- const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
1584
- const existing = this.rooms.get(roomPath);
1585
- const room = {
1586
- id: roomPath,
1587
- version: (existing?.version ?? 0) + 1,
1588
- name: roomState?.name ?? existing?.name ?? parsed.localName,
1589
- type: roomState?.type ?? existing?.type ?? "public",
1590
- owner: parsed.owner,
1591
- createdAt: existing?.createdAt ?? new Date().toISOString(),
1592
- description: roomState?.description ?? existing?.description ?? "",
1593
- members,
1594
- invited: existing?.invited ?? [],
1595
- memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
1596
- memberLeaves: {},
1597
- invitedJoins: existing?.invitedJoins ?? {},
1598
- invitedLeaves: existing?.invitedLeaves ?? {},
1599
- federated: existing?.federated ?? false,
1600
- };
1601
- this.rooms.set(roomPath, room);
1602
- return room;
350
+ return this.roomLifecycle.refreshRoomMembers(roomPath);
1603
351
  }
1604
- /**
1605
- * The requester's own half of section 6's two-round DM consent flow: sends an ungated room.join scoped to dmRoomPath(this, counterpart) directly to the counterpart, records having initiated it so the counterpart's own reciprocal room.join back auto-approves rather than surfacing as a fresh, unsolicited request, and persists whatever grant comes back. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Safe to call again for the same counterpart later (e.g. after an earlier request expired or was rejected) -- it always sends a fresh request rather than checking for an existing token first.
1606
- */
352
+ /** The requester's own half of section 6's two-round DM consent flow. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Concrete-only -- reached directly by tests. */
1607
353
  async requestDmAccess(counterpart) {
1608
- const dmPath = dmRoomPath(this.peerId, counterpart);
1609
- this.dmRequestsInitiatedByMe.add(dmPath);
1610
- const outcome = await this.requireTransport().sendRoomRequest(counterpart, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } }, { kind: "room", path: dmPath });
1611
- if (outcome.result !== "ok") {
1612
- throw new CommsError(`DM access request to ${counterpart} was refused (${outcome.code})`, "JOIN_REFUSED");
1613
- }
1614
- const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
1615
- if (!parsedOutcome.success) {
1616
- throw new CommsError(`DM access response from ${counterpart} was malformed`, "MALFORMED_RESPONSE");
1617
- }
1618
- const { slot } = this.requireIdentity();
1619
- saveRoomToken(slot, dmPath, parsedOutcome.data["granted-token"]);
354
+ return this.roomLifecycle.requestDmAccess(counterpart);
1620
355
  }
1621
- /**
1622
- * Joins a room. For this store's own identity, "already known locally" is not the right gate for skipping real admission: the legacy full-state-sync replicates a room's metadata to every mesh-connected peer the moment it's created, well before that peer has ever been admitted, so a room already present in this.rooms says nothing about whether this store actually holds a valid room:member token for it. The real gate is that token's presence -- absent, this always goes through joinRemoteRoom's real wire-level admission regardless of what this.rooms already knows, so a peer that merely heard about a room never mistakes hearing about it for having joined it. Joining on behalf of a DIFFERENT agentId (this store's own convergence/admin bookkeeping, exercised directly by state-sync-convergence.test.ts) is untouched -- that's a pure local CRDT mutation with no admission concept at all.
1623
- */
1624
356
  async joinRoom(roomId, agentId) {
1625
- if (agentId === this.peerId) {
1626
- const { slot } = this.requireIdentity();
1627
- if (loadRoomTokens(slot)[roomId] === undefined) {
1628
- return this.joinRemoteRoom(roomId, agentId);
1629
- }
1630
- }
1631
- const room = this.rooms.get(roomId);
1632
- if (!room)
1633
- return this.joinRemoteRoom(roomId, agentId);
1634
- const alreadyMember = room.members.includes(agentId);
1635
- if (!alreadyMember && room.type !== "public") {
1636
- if (!room.invited.includes(agentId) && room.owner !== agentId) {
1637
- throw new CommsError(`Not invited to room ${roomId}`, "NOT_INVITED");
1638
- }
1639
- }
1640
- this.bump(room);
1641
- this.recordMemberOp(room, "member", "join", agentId);
1642
- if (alreadyMember || room.type !== "public") {
1643
- // Consuming an invitation (or re-joining) retires the invited entry.
1644
- this.recordMemberOp(room, "invited", "leave", agentId);
1645
- }
1646
- this.refreshMembership(room);
1647
- this.rooms.set(roomId, room);
1648
- const agent = this.agents.get(agentId);
1649
- if (agent && !agent.subscribedRooms.includes(roomId)) {
1650
- agent.subscribedRooms.push(roomId);
1651
- this.bump(agent);
1652
- this.agents.set(agentId, agent);
1653
- await this.broadcastPatch({ type: "agent_upsert", agent });
1654
- }
1655
- await this.broadcastPatch({ type: "room_upsert", room });
1656
- // Send current member list to the joining agent
1657
- const members = [];
1658
- for (const memberId of room.members) {
1659
- const memberAgent = this.agents.get(memberId);
1660
- if (memberAgent) {
1661
- members.push({
1662
- id: memberAgent.id,
1663
- name: memberAgent.name,
1664
- status: memberAgent.status,
1665
- });
1666
- }
1667
- }
1668
- await this.deliverLocallyAndBroadcast(agentId, {
1669
- type: "room_members",
1670
- room: roomId,
1671
- members,
1672
- });
1673
- // Notify existing members of the join
1674
- await this.deliverToRoom(roomId, {
1675
- type: "member_joined",
1676
- room: roomId,
1677
- agent: agentId,
1678
- }, agentId);
1679
- // Notify federated links if the room is federated
1680
- if (room.federated) {
1681
- const agentName = agent?.name ?? agentId;
1682
- await this.federation.broadcastRoomJoin(roomId, agentId, agentName);
1683
- }
1684
- return room;
357
+ return this.roomLifecycle.joinRoom(roomId, agentId);
1685
358
  }
1686
- /**
1687
- * Leaves a room. For this store's own identity leaving a room it does not itself own, the local Room object is only ever this store's own static snapshot from when it joined or was invited (P3.6/P3.8) -- mutating it directly, as the legacy branch below does, would tell nobody but this store itself. The real effect needs a wire round trip to the room's own owner instead, so this always defers to leaveRemoteRoom in that case. Leaving on behalf of a DIFFERENT agentId, or the owner leaving their own room (this store's own authoritative copy), is untouched -- that is the legacy CRDT mutation state-sync-convergence.test.ts exercises directly.
1688
- */
1689
359
  async leaveRoom(roomId, agentId) {
1690
- const room = this.rooms.get(roomId);
1691
- if (!room)
1692
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1693
- if (agentId === this.peerId && room.owner !== this.peerId) {
1694
- return this.leaveRemoteRoom(roomId, room.owner);
1695
- }
1696
- this.bump(room);
1697
- this.recordMemberOp(room, "member", "leave", agentId);
1698
- this.refreshMembership(room);
1699
- this.rooms.set(roomId, room);
1700
- const agent = this.agents.get(agentId);
1701
- if (agent) {
1702
- agent.subscribedRooms = agent.subscribedRooms.filter((id) => id !== roomId);
1703
- this.agents.set(agentId, agent);
1704
- await this.broadcastPatch({ type: "agent_upsert", agent });
1705
- }
1706
- await this.broadcastPatch({ type: "room_upsert", room });
1707
- await this.deliverToRoom(roomId, {
1708
- type: "member_left",
1709
- room: roomId,
1710
- agent: agentId,
1711
- });
1712
- // Notify federated links if the room is federated
1713
- if (room.federated) {
1714
- await this.federation.broadcastRoomLeave(roomId, agentId);
1715
- }
1716
- if (room.members.length === 0 && room.owner === agentId) {
1717
- await this.destroyRoom(roomId, agentId);
1718
- }
360
+ return this.roomLifecycle.leaveRoom(roomId, agentId);
1719
361
  }
1720
- /**
1721
- * The remote-leave path: roomPath names a room this store is a member of but does not own, so leaving for real means telling the owner over a real, wire-authenticated room.leave rather than mutating a local Room record nobody else reads. Also declineInvite's own mechanism (P3.8): the moment a target receives an invite (handleRoomInvite), it already holds a real, persisted room:member token exactly as if it had joined -- declining is simply leaving before ever really participating, and the owner's own receiving side (handleRoomLeave) tells the two cases apart by checking its own membership/invited lists, not by a separate verb. reason rides room.leave's own open extension tail so the owner can still surface a real decline reason without a second wire shape.
1722
- */
1723
- async leaveRemoteRoom(roomPath, ownerId, reason) {
1724
- const { slot } = this.requireIdentity();
1725
- const token = loadRoomTokens(slot)[roomPath];
1726
- if (token === undefined) {
1727
- throw new CommsError(`No room:member token for ${roomPath}`, "NOT_MEMBER");
1728
- }
1729
- const params = {
1730
- verb: "room.leave",
1731
- ...(reason !== undefined ? { reason } : {}),
1732
- };
1733
- const outcome = await this.requireTransport().sendRoomRequest(ownerId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomPath }, token);
1734
- if (outcome.result !== "ok") {
1735
- throw new CommsError(`Leaving ${roomPath} failed (${outcome.code})`, "LEAVE_FAILED");
1736
- }
1737
- deleteRoomToken(slot, roomPath);
1738
- this.rooms.delete(roomPath);
1739
- this.messages.delete(roomPath);
1740
- }
1741
- /**
1742
- * Invites targetId to roomId, over a real, wire-authenticated room.invite (P3.8): mints a fresh room:member grant for the target, records its own token-id the same way admitRoomJoin does (kickFromRoom can revoke an invited member's own grant exactly as it can a joined one), and pushes the grant to the target directly in the invite request itself -- room.invite is deliberately ungated (the room's own owner needs no capability to invite, per core/room's design), so the target's own verification of the embedded token is what proves this invite is genuine, not anything about the connection it arrived on. Retains the local CRDT invited-list bookkeeping (still this store's own record of who it has invited) but no longer broadcasts it: the target learns of the invite from the real request, not a mesh-wide patch.
1743
- */
1744
362
  async inviteToRoom(roomId, targetId, inviterId) {
1745
- const room = this.rooms.get(roomId);
1746
- if (!room)
1747
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1748
- if (room.owner !== inviterId)
1749
- throw new CommsError("Only the room owner can invite", "NOT_OWNER");
1750
- this.bump(room);
1751
- if (!room.invited.includes(targetId) && !room.members.includes(targetId)) {
1752
- this.recordMemberOp(room, "invited", "join", targetId);
1753
- }
1754
- this.refreshMembership(room);
1755
- this.rooms.set(roomId, room);
1756
- const { identity, clock, slot } = this.requireIdentity();
1757
- const tokenId = randomId();
1758
- const verdict = await mintCapabilityToken({
1759
- identity,
1760
- clock,
1761
- tokenId,
1762
- bearer: deviceIdFromHex(targetId),
1763
- capability: ROOM_MEMBER_CAPABILITY,
1764
- scope: { kind: "room", path: roomId },
1765
- expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
1766
- delegationsRemaining: 0,
1767
- });
1768
- if (!verdict.ok) {
1769
- throw new CommsError(`Failed to mint an invite grant for ${targetId}`, "MINT_FAILED");
1770
- }
1771
- saveIssuedRoomGrant(slot, roomId, targetId, tokenId);
1772
- const inviter = this.agents.get(inviterId);
1773
- const params = {
1774
- verb: "room.invite",
1775
- invitee: deviceIdFromHex(targetId),
1776
- token: verdict.token,
1777
- ...MeshStore.roomStateExtension(room),
1778
- ...(inviter !== undefined
1779
- ? MeshStore.inviterAgentExtension(inviter)
1780
- : {}),
1781
- };
1782
- const outcome = await this.requireTransport().sendRoomRequest(targetId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomId });
1783
- if (outcome.result !== "ok") {
1784
- throw new CommsError(`Invite to ${targetId} for ${roomId} failed (${outcome.code})`, "INVITE_FAILED");
1785
- }
363
+ return this.roomLifecycle.inviteToRoom(roomId, targetId, inviterId);
1786
364
  }
1787
- /**
1788
- * Declines a pending invite, over the same real room.leave request leaveRemoteRoom already sends for an actual leave (P3.8): the moment this store received the invite (handleRoomInvite), it already holds a real, persisted room:member token, so declining before ever really participating is simply leaving early -- the owner's own receiving side (handleRoomLeave) tells the two cases apart from its own membership/invited lists, not from a separate verb. Uses parseRoomPath rather than any locally cached Room record to find the owner to leave, since handleRoomInvite never constructs one -- there is nothing here to read a room.owner field off in the first place.
1789
- */
1790
365
  async declineInvite(roomId, agentId, reason) {
1791
- // Every real caller declines on its own behalf (tool.ts always passes ctx.agentId, which for a real bridge process is always this.peerId, per the design's own "one bridge is one agent is one device" organising fact) -- there is no remote-decline-on-someone-else's-behalf mechanism, so a mismatch here means the caller itself is confused about whose invite it is declining.
1792
- if (agentId !== this.peerId) {
1793
- throw new CommsError(`Cannot decline an invite on behalf of ${agentId}`, "NOT_SELF");
1794
- }
1795
- const parsed = parseRoomPath(roomId);
1796
- if (parsed.kind !== "owner-named") {
1797
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1798
- }
1799
- return this.leaveRemoteRoom(roomId, parsed.owner, reason);
1800
- }
1801
- /**
1802
- * Kicks targetId from roomId. Beyond the legacy CRDT membership removal (retired in P3.8 along with every other non-message broadcastPatch caller), this revokes the member's own room:member grant for real: mints a revocation-entry for the token-id this identity recorded when it admitted them (admitRoomJoin's own saveIssuedRoomGrant), records it in this store's own RevocationView immediately (so this identity's own future verifications see the kick without waiting on its own gossip), and announces it to every connected peer so each one's independent verification of the target's token -- not just this room's owner -- also starts failing as "revoked" from here on. Silently skips the revocation step (kick still happens; only the token-side enforcement doesn't) when no issued-grant record exists for this member, e.g. a grant predating this bookkeeping.
1803
- */
1804
- /**
1805
- * Revokes memberId's own room:member grant for roomId for real, if this identity ever recorded issuing one: mints a revocation-entry for its token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and forgets the issued-grant record (a later re-admission mints and records a genuinely fresh one rather than leaving a stale entry alongside it). Silently does nothing when no issued-grant record exists (a grant predating this bookkeeping, or a member who was never actually admitted a token at all) -- shared by kickFromRoom (owner-initiated) and handleRoomLeave (member-initiated, including a decline).
1806
- */
1807
- async revokeMemberGrant(roomId, memberId) {
1808
- const { identity, clock, slot, revocation } = this.requireIdentity();
1809
- const tokenId = loadIssuedRoomGrant(slot, roomId, memberId);
1810
- if (tokenId === undefined)
1811
- return;
1812
- const entry = await mintRevocationEntry({
1813
- identity,
1814
- tokenId,
1815
- revokedAt: clock.now(),
1816
- });
1817
- await revocation.record(entry, { identity });
1818
- await this.requireTransport().broadcastRevocation([entry]);
1819
- deleteIssuedRoomGrant(slot, roomId, memberId);
366
+ return this.roomLifecycle.declineInvite(roomId, agentId, reason);
1820
367
  }
1821
368
  async kickFromRoom(roomId, targetId, kickerId) {
1822
- const room = this.rooms.get(roomId);
1823
- if (!room)
1824
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1825
- if (room.owner !== kickerId)
1826
- throw new CommsError("Only the room owner can kick", "NOT_OWNER");
1827
- await this.revokeMemberGrant(roomId, targetId);
1828
- this.bump(room);
1829
- this.recordMemberOp(room, "member", "leave", targetId);
1830
- this.recordMemberOp(room, "invited", "leave", targetId);
1831
- this.refreshMembership(room);
1832
- this.rooms.set(roomId, room);
1833
- await this.broadcastPatch({ type: "room_upsert", room });
369
+ return this.roomLifecycle.kickFromRoom(roomId, targetId, kickerId);
1834
370
  }
1835
- /**
1836
- * Destroys roomId, revoking every member's own room:member grant for real first (P3.8) -- the same revocation machinery kickFromRoom already uses, since destroying a room out from under its members is exactly as much a membership revocation as kicking them individually would be, just for all of them at once. Room-existence notification (agent_upsert/room_delete) stays on the legacy broadcastPatch for now: replacing it needs the same broader informational-events redesign the rest of P3.8 already tracks as separate, larger work, not something this specific fix should improvise alone.
1837
- */
1838
371
  async destroyRoom(roomId, agentId) {
1839
- const room = this.rooms.get(roomId);
1840
- if (!room)
1841
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1842
- if (room.owner !== agentId)
1843
- throw new CommsError("Only the room owner can destroy", "NOT_OWNER");
1844
- for (const memberId of room.members) {
1845
- await this.revokeMemberGrant(roomId, memberId);
1846
- const member = this.agents.get(memberId);
1847
- if (member) {
1848
- member.subscribedRooms = member.subscribedRooms.filter((id) => id !== roomId);
1849
- this.agents.set(memberId, member);
1850
- await this.broadcastPatch({ type: "agent_upsert", agent: member });
1851
- }
1852
- }
1853
- this.rooms.delete(roomId);
1854
- this.messages.delete(roomId);
1855
- await this.broadcastPatch({ type: "room_delete", roomId });
372
+ return this.roomLifecycle.destroyRoom(roomId, agentId);
1856
373
  }
1857
374
  // -----------------------------------------------------------------------
1858
375
  // CommsStore — Messages
1859
376
  // -----------------------------------------------------------------------
1860
- /**
1861
- * Sends a room message via a real, wire-authenticated room.send fan-out (P3.5): one directed request per member, each carrying this sender's own persisted room:member token, rather than the legacy broadcastPatch's full-state replication. A member unreachable right now is queued for retry (see sendRoomRequestToMember/flushPendingRoomRequests) instead of blocking or failing the whole send -- delivery to any one recipient is independent of every other.
1862
- */
1863
377
  async sendRoomMessage(roomId, from, content, replyTo, streamingBehavior) {
1864
- const room = this.rooms.get(roomId);
1865
- if (!room)
1866
- throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
1867
- if (!room.members.includes(from))
1868
- throw new CommsError(`Not a member of ${roomId}`, "NOT_MEMBER");
1869
- const { slot, clock } = this.requireIdentity();
1870
- const token = loadRoomTokens(slot)[roomId];
1871
- if (token === undefined) {
1872
- throw new CommsError(`No room:member token for ${roomId}`, "NOT_MEMBER");
1873
- }
1874
- const messageId = randomId();
1875
- const id = bytesToHex(messageId);
1876
- const message = {
1877
- id,
1878
- from,
1879
- room: roomId,
1880
- content,
1881
- timestamp: new Date().toISOString(),
1882
- readBy: [from],
1883
- ...(replyTo !== undefined && { replyTo }),
1884
- ...(streamingBehavior !== undefined && { streamingBehavior }),
1885
- };
1886
- const arr = this.messages.get(roomId) ?? [];
1887
- arr.push(message);
1888
- this.messages.set(roomId, arr);
1889
- // Forward to federated links if the room is federated
1890
- if (room.federated) {
1891
- await this.federation.forwardRoomMessage(roomId, message);
1892
- }
1893
- const params = {
1894
- verb: "room.send",
1895
- "message-id": messageId,
1896
- "sent-at": clock.now(),
1897
- text: content,
1898
- ...(replyTo !== undefined && {
1899
- refs: [{ id: bytesFromHex(replyTo), relation: "reply" }],
1900
- }),
1901
- ...(streamingBehavior !== undefined && {
1902
- "streaming-behavior": streamingBehavior,
1903
- }),
1904
- };
1905
- for (const memberId of room.members) {
1906
- if (memberId !== from) {
1907
- await this.sendRoomRequestToMember(memberId, roomId, token, params);
1908
- }
1909
- }
1910
- return message;
378
+ return this.roomMessaging.sendRoomMessage(roomId, from, content, replyTo, streamingBehavior);
1911
379
  }
1912
380
  async readRoomMessages(roomId, since) {
1913
- await Promise.resolve();
1914
- const arr = this.messages.get(roomId) ?? [];
1915
- if (!since)
1916
- return [...arr];
1917
- return arr.filter((m) => m.timestamp > since);
381
+ return this.roomMessaging.readRoomMessages(roomId, since);
1918
382
  }
1919
383
  // -----------------------------------------------------------------------
1920
384
  // CommsStore — DMs
1921
385
  // -----------------------------------------------------------------------
1922
- /**
1923
- * Sends a DM via the same wire-authenticated room.send fan-out sendRoomMessage uses (P3.5): a DM is just a dm-shaped room path with exactly one other member, so it rides the identical mechanism rather than a separate one. Self-DM is the one exception -- a purely local scratchpad note that never leaves the process, so it needs no token and no wire round trip at all.
1924
- */
1925
386
  async sendDm(from, to, content, streamingBehavior) {
1926
- if (to !== from) {
1927
- const recipient = this.agents.get(to);
1928
- if (!recipient)
1929
- throw new CommsError(`Agent ${to} not found`, "AGENT_NOT_FOUND");
1930
- if (recipient.visibility === "ghost")
1931
- throw new CommsError(`Cannot DM agent ${to}`, "AGENT_NOT_FOUND");
1932
- }
1933
- const messageId = randomId();
1934
- const id = bytesToHex(messageId);
1935
- const message = {
1936
- id,
1937
- from,
1938
- to,
1939
- content,
1940
- timestamp: new Date().toISOString(),
1941
- readBy: [from],
1942
- ...(streamingBehavior !== undefined && { streamingBehavior }),
1943
- };
1944
- // Self-DM is a purely local scratchpad note -- it never leaves the process, so it needs no room-path and dmRoomPath's own a===b refusal (a path naming the same device twice is not a valid DM path at all) correctly does not apply here.
1945
- const key = to === from ? `self:${from}` : dmRoomPath(from, to);
1946
- const arr = this.dms.get(key) ?? [];
1947
- arr.push(message);
1948
- this.dms.set(key, arr);
1949
- if (to !== from) {
1950
- const { slot, clock } = this.requireIdentity();
1951
- const token = loadRoomTokens(slot)[key];
1952
- if (token === undefined) {
1953
- throw new CommsError(`No room:member token for ${key}`, "NOT_MEMBER");
1954
- }
1955
- const params = {
1956
- verb: "room.send",
1957
- "message-id": messageId,
1958
- "sent-at": clock.now(),
1959
- text: content,
1960
- ...(streamingBehavior !== undefined && {
1961
- "streaming-behavior": streamingBehavior,
1962
- }),
1963
- };
1964
- await this.sendRoomRequestToMember(to, key, token, params);
1965
- }
1966
- return message;
387
+ return this.roomMessaging.sendDm(from, to, content, streamingBehavior);
1967
388
  }
1968
389
  // -----------------------------------------------------------------------
1969
390
  // CommsStore — Delivery
1970
391
  // -----------------------------------------------------------------------
1971
392
  async deliver(agentId, event) {
1972
- await this.deliverLocallyAndBroadcast(agentId, event);
393
+ return this.deliveryEngine.deliver(agentId, event);
1973
394
  }
1974
395
  async drainDelivery(agentId) {
1975
- await Promise.resolve();
1976
- const events = this.deliveryQueues.get(agentId) ?? [];
1977
- this.deliveryQueues.set(agentId, []);
1978
- // Auto-mark messages as read — drain bridges consume on tool call
1979
- for (const event of events) {
1980
- if (event.type === "room_message") {
1981
- await this.markRead(event.message.id, agentId, event.message.room);
1982
- }
1983
- else if (event.type === "dm") {
1984
- await this.markRead(event.message.id, agentId);
1985
- }
1986
- }
1987
- return events;
396
+ return this.deliveryEngine.drainDelivery(agentId);
1988
397
  }
1989
398
  // -----------------------------------------------------------------------
1990
- // Stale agent cleanup (coordinator only)
399
+ // Concrete-only surface reached directly by tests
1991
400
  // -----------------------------------------------------------------------
1992
- startStaleCheck() {
1993
- if (this.staleCheckTimer)
1994
- return;
1995
- this.staleCheckTimer = setInterval(() => {
1996
- void this.probeStaleAgents();
1997
- }, 5000);
401
+ /** Merge a peer's state snapshot into the local state (#27/#28). Concrete-only -- reached directly by tests. */
402
+ applyStateSync(state) {
403
+ this.deliveryEngine.applyStateSync(state);
1998
404
  }
1999
- stopStaleCheck() {
2000
- if (this.staleCheckTimer) {
2001
- clearInterval(this.staleCheckTimer);
2002
- this.staleCheckTimer = undefined;
2003
- }
405
+ /** Sends one directed room.send to a single member's own session (P3.5). Concrete-only -- reached directly by tests. */
406
+ async sendRoomMessageDirected(roomPath, memberId, text) {
407
+ return this.roomProtocol.sendRoomMessageDirected(roomPath, memberId, text);
2004
408
  }
2005
- async probeStaleAgents() {
2006
- const deadIds = [];
2007
- for (const [id, agent] of this.agents) {
2008
- if (agent.status !== "active")
2009
- continue;
2010
- if (!this.isProcessAlive(agent.pid)) {
2011
- deadIds.push(id);
2012
- }
2013
- }
2014
- for (const id of deadIds) {
2015
- const agent = this.agents.get(id);
2016
- if (agent) {
2017
- agent.status = "offline";
2018
- this.agents.set(id, agent);
2019
- await this.notifyRoomsOfStatus(id, "offline");
2020
- }
2021
- await this.broadcastPatch({ type: "agent_offline", agentId: id });
2022
- this.peerInfo.delete(id);
2023
- }
2024
- // Also purge long-offline agents to prevent indefinite accumulation
2025
- const offlineThreshold = Date.now() - 30 * 60 * 1000; // 30 minutes
2026
- const purgeIds = [];
2027
- for (const [id, agent] of this.agents) {
2028
- if (agent.status !== "offline")
2029
- continue;
2030
- const startedAt = new Date(agent.startedAt).getTime();
2031
- if (startedAt < offlineThreshold) {
2032
- purgeIds.push(id);
2033
- }
2034
- }
2035
- for (const id of purgeIds) {
2036
- this.agents.delete(id);
2037
- this.peerInfo.delete(id);
2038
- this.identityCache.delete(id);
2039
- this.deliveryQueues.delete(id);
2040
- }
409
+ /** Starts only the data server without connecting to a coordinator. Used for testing scenarios where the peer connects via connectToRemote. */
410
+ async startDataServerOnly() {
411
+ return this.connectionApproval.startDataServerOnly();
2041
412
  }
2042
- isProcessAlive(pid) {
2043
- try {
2044
- process.kill(pid, 0);
2045
- return true;
2046
- }
2047
- catch {
2048
- return false;
2049
- }
413
+ // -----------------------------------------------------------------------
414
+ // Connection approval (mesh-only)
415
+ // -----------------------------------------------------------------------
416
+ /** Accept a pending inbound connection. */
417
+ async acceptConnection(connectionId) {
418
+ return this.connectionApproval.acceptConnection(connectionId);
419
+ }
420
+ /** Reject a pending inbound connection. */
421
+ async rejectConnection(connectionId, reason) {
422
+ return this.connectionApproval.rejectConnection(connectionId, reason);
423
+ }
424
+ /** List all pending inbound connections awaiting approval. */
425
+ listPendingConnections() {
426
+ return this.connectionApproval.listPendingConnections();
427
+ }
428
+ /** Initiate an outbound connection to a remote coordinator requiring approval. */
429
+ connectToRemote(host, port) {
430
+ return this.connectionApproval.connectToRemote(host, port);
431
+ }
432
+ // -----------------------------------------------------------------------
433
+ // Room-join approval (mesh-only)
434
+ // -----------------------------------------------------------------------
435
+ /** Every room.join request currently held open awaiting this store's own accept/reject decision. */
436
+ listPendingRoomJoins() {
437
+ return this.roomProtocol.listPendingRoomJoins();
438
+ }
439
+ /** Approves a pending room.join request. */
440
+ acceptRoomJoin(roomPath, requesterId) {
441
+ this.roomProtocol.acceptRoomJoin(roomPath, requesterId);
442
+ }
443
+ /** Denies a pending room.join request, optionally with a reason. */
444
+ rejectRoomJoin(roomPath, requesterId, reason) {
445
+ this.roomProtocol.rejectRoomJoin(roomPath, requesterId, reason);
2050
446
  }
2051
447
  // -----------------------------------------------------------------------
2052
448
  // Mesh visibility
@@ -2127,113 +523,11 @@ export class MeshStore {
2127
523
  return this.federation.stopListening();
2128
524
  }
2129
525
  // -----------------------------------------------------------------------
2130
- // Federation callbacks (inbound from remote meshes)
2131
- // -----------------------------------------------------------------------
2132
- async handleFedAgentVisible(agent) {
2133
- // Store remote agent with a prefixed ID to avoid collisions with local agents
2134
- const remoteId = `fed:${agent.id}@${agent.harness}`;
2135
- const remoteAgent = {
2136
- ...agent,
2137
- id: remoteId,
2138
- tags: [...agent.tags, "federated"],
2139
- };
2140
- this.agents.set(remoteId, remoteAgent);
2141
- await this.broadcastPatch({ type: "agent_upsert", agent: remoteAgent });
2142
- }
2143
- async handleFedAgentGone(agentId) {
2144
- // The agentId comes from the remote mesh — we need to find the prefixed version
2145
- const prefix = `fed:${agentId}@`;
2146
- for (const [localId, agent] of this.agents) {
2147
- if (localId.startsWith(prefix)) {
2148
- agent.status = "offline";
2149
- this.agents.set(localId, agent);
2150
- await this.broadcastPatch({ type: "agent_offline", agentId: localId });
2151
- break;
2152
- }
2153
- }
2154
- }
2155
- async handleFedRoomMessage(roomId, message) {
2156
- const room = this.rooms.get(roomId);
2157
- if (!room?.federated)
2158
- return;
2159
- // Store the message locally
2160
- const arr = this.messages.get(roomId) ?? [];
2161
- arr.push(message);
2162
- this.messages.set(roomId, arr);
2163
- // Deliver to all local room members
2164
- for (const memberId of room.members) {
2165
- await this.deliverLocallyAndBroadcast(memberId, {
2166
- type: "room_message",
2167
- message,
2168
- });
2169
- }
2170
- }
2171
- async handleFedRoomJoin(roomId, agentId, _agentName) {
2172
- const room = this.rooms.get(roomId);
2173
- if (!room?.federated)
2174
- return;
2175
- // Create a shadow agent ID for the remote agent
2176
- const remoteId = `fed:${agentId}`;
2177
- if (!room.members.includes(remoteId)) {
2178
- this.bump(room);
2179
- this.recordMemberOp(room, "member", "join", remoteId);
2180
- this.refreshMembership(room);
2181
- this.rooms.set(roomId, room);
2182
- await this.broadcastPatch({ type: "room_upsert", room });
2183
- }
2184
- // Notify local members
2185
- await this.deliverToRoom(roomId, {
2186
- type: "member_joined",
2187
- room: roomId,
2188
- agent: remoteId,
2189
- }, remoteId);
2190
- }
2191
- async handleFedRoomLeave(roomId, agentId) {
2192
- const room = this.rooms.get(roomId);
2193
- if (!room?.federated)
2194
- return;
2195
- const remoteId = `fed:${agentId}`;
2196
- this.bump(room);
2197
- this.recordMemberOp(room, "member", "leave", remoteId);
2198
- this.refreshMembership(room);
2199
- this.rooms.set(roomId, room);
2200
- await this.broadcastPatch({ type: "room_upsert", room });
2201
- await this.deliverToRoom(roomId, {
2202
- type: "member_left",
2203
- room: roomId,
2204
- agent: remoteId,
2205
- });
2206
- }
2207
- getVisibleAgentsForFed() {
2208
- const result = [];
2209
- for (const agent of this.agents.values()) {
2210
- // Only broadcast agents that belong to this mesh (not federated)
2211
- // and are visible
2212
- if (agent.visibility === "visible" && !agent.id.startsWith("fed:")) {
2213
- result.push(agent);
2214
- }
2215
- }
2216
- return result;
2217
- }
2218
- getFederatedRoomMembershipsForFed() {
2219
- const result = new Map();
2220
- for (const [roomId, room] of this.rooms) {
2221
- if (room.federated) {
2222
- // Only include local members (not federated ones)
2223
- const localMembers = room.members.filter((m) => !m.startsWith("fed:"));
2224
- result.set(roomId, localMembers);
2225
- }
2226
- }
2227
- return result;
2228
- }
2229
- // -----------------------------------------------------------------------
2230
526
  // Shutdown
2231
527
  // -----------------------------------------------------------------------
2232
528
  async shutdown() {
2233
529
  this.isShutDown = true;
2234
- // Clear any pending markRead timers so they don't fire after the
2235
- // transport is shut down (which would attempt sends on closed sockets)
2236
- // or keep the event loop alive after process.exit().
530
+ // Clear any pending markRead timers so they don't fire after the transport is shut down (which would attempt sends on closed sockets) or keep the event loop alive after process.exit().
2237
531
  for (const timer of this.pendingMarkReadTimers) {
2238
532
  clearTimeout(timer);
2239
533
  }
@@ -2241,12 +535,12 @@ export class MeshStore {
2241
535
  const agent = this.agents.get(this.peerId);
2242
536
  if (agent) {
2243
537
  agent.status = "offline";
2244
- await this.broadcastPatch({
538
+ await this.deliveryEngine.broadcastPatch({
2245
539
  type: "agent_offline",
2246
540
  agentId: this.peerId,
2247
541
  });
2248
542
  }
2249
- this.stopStaleCheck();
543
+ this.staleAgentChecker.stop();
2250
544
  await this.federation.shutdown();
2251
545
  await this.requireTransport().shutdown();
2252
546
  }