agent-comms 1.14.0 → 1.15.1

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 (184) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/bridges/claude-code/channel.d.ts.map +1 -1
  4. package/dist/bridges/claude-code/channel.js +6 -1
  5. package/dist/bridges/claude-code/channel.js.map +1 -1
  6. package/dist/bridges/codex/tool.d.ts.map +1 -1
  7. package/dist/bridges/codex/tool.js +6 -1
  8. package/dist/bridges/codex/tool.js.map +1 -1
  9. package/dist/bridges/mcp/index.d.ts.map +1 -1
  10. package/dist/bridges/mcp/index.js +6 -1
  11. package/dist/bridges/mcp/index.js.map +1 -1
  12. package/dist/bridges/opencode/plugin.d.ts.map +1 -1
  13. package/dist/bridges/opencode/plugin.js +5 -0
  14. package/dist/bridges/opencode/plugin.js.map +1 -1
  15. package/dist/bridges/pi/index.d.ts.map +1 -1
  16. package/dist/bridges/pi/index.js +39 -17
  17. package/dist/bridges/pi/index.js.map +1 -1
  18. package/dist/bridges/user/controller.d.ts +9 -1
  19. package/dist/bridges/user/controller.d.ts.map +1 -1
  20. package/dist/bridges/user/controller.js +25 -1
  21. package/dist/bridges/user/controller.js.map +1 -1
  22. package/dist/bridges/user/tui.js +2 -0
  23. package/dist/bridges/user/tui.js.map +1 -1
  24. package/dist/bridges/user/web/build.d.ts +2 -0
  25. package/dist/bridges/user/web/build.d.ts.map +1 -1
  26. package/dist/bridges/user/web/build.js +44 -0
  27. package/dist/bridges/user/web/build.js.map +1 -1
  28. package/dist/bridges/user/web/frontend/components/App.d.ts +5 -0
  29. package/dist/bridges/user/web/frontend/components/App.d.ts.map +1 -1
  30. package/dist/bridges/user/web/frontend/components/App.js +1 -1
  31. package/dist/bridges/user/web/frontend/components/App.js.map +1 -1
  32. package/dist/bridges/user/web/frontend/components/ProjectTree.js +6 -3
  33. package/dist/bridges/user/web/frontend/components/ProjectTree.js.map +1 -1
  34. package/dist/bridges/user/web/frontend/components/RelayPanel.d.ts +15 -0
  35. package/dist/bridges/user/web/frontend/components/RelayPanel.d.ts.map +1 -0
  36. package/dist/bridges/user/web/frontend/components/RelayPanel.js +46 -0
  37. package/dist/bridges/user/web/frontend/components/RelayPanel.js.map +1 -0
  38. package/dist/bridges/user/web/frontend/components/Sidebar.d.ts +7 -2
  39. package/dist/bridges/user/web/frontend/components/Sidebar.d.ts.map +1 -1
  40. package/dist/bridges/user/web/frontend/components/Sidebar.js +15 -9
  41. package/dist/bridges/user/web/frontend/components/Sidebar.js.map +1 -1
  42. package/dist/bridges/user/web/frontend/input.js +1 -1
  43. package/dist/bridges/user/web/frontend/input.js.map +1 -1
  44. package/dist/bridges/user/web/frontend/main.js +73 -5
  45. package/dist/bridges/user/web/frontend/main.js.map +1 -1
  46. package/dist/bridges/user/web/frontend/mesh-client.d.ts +43 -0
  47. package/dist/bridges/user/web/frontend/mesh-client.d.ts.map +1 -0
  48. package/dist/bridges/user/web/frontend/mesh-client.js +132 -0
  49. package/dist/bridges/user/web/frontend/mesh-client.js.map +1 -0
  50. package/dist/bridges/user/web/frontend/mesh-worker.d.ts +14 -0
  51. package/dist/bridges/user/web/frontend/mesh-worker.d.ts.map +1 -0
  52. package/dist/bridges/user/web/frontend/mesh-worker.js +274 -0
  53. package/dist/bridges/user/web/frontend/mesh-worker.js.map +1 -0
  54. package/dist/bridges/user/web/frontend/relay-client.d.ts +40 -0
  55. package/dist/bridges/user/web/frontend/relay-client.d.ts.map +1 -0
  56. package/dist/bridges/user/web/frontend/relay-client.js +97 -0
  57. package/dist/bridges/user/web/frontend/relay-client.js.map +1 -0
  58. package/dist/bridges/user/web/frontend/relay-worker.d.ts +15 -0
  59. package/dist/bridges/user/web/frontend/relay-worker.d.ts.map +1 -0
  60. package/dist/bridges/user/web/frontend/relay-worker.js +366 -0
  61. package/dist/bridges/user/web/frontend/relay-worker.js.map +1 -0
  62. package/dist/bridges/user/web/frontend/sw.d.ts +16 -0
  63. package/dist/bridges/user/web/frontend/sw.d.ts.map +1 -0
  64. package/dist/bridges/user/web/frontend/sw.js +145 -0
  65. package/dist/bridges/user/web/frontend/sw.js.map +1 -0
  66. package/dist/bridges/user/web/frontend/url-sync.d.ts +48 -0
  67. package/dist/bridges/user/web/frontend/url-sync.d.ts.map +1 -0
  68. package/dist/bridges/user/web/frontend/url-sync.js +74 -0
  69. package/dist/bridges/user/web/frontend/url-sync.js.map +1 -0
  70. package/dist/bridges/user/web/server.d.ts +10 -3
  71. package/dist/bridges/user/web/server.d.ts.map +1 -1
  72. package/dist/bridges/user/web/server.js +254 -12
  73. package/dist/bridges/user/web/server.js.map +1 -1
  74. package/dist/core/bridge.d.ts +28 -0
  75. package/dist/core/bridge.d.ts.map +1 -1
  76. package/dist/core/bridge.js +137 -0
  77. package/dist/core/bridge.js.map +1 -1
  78. package/dist/core/comms-store.d.ts +22 -1
  79. package/dist/core/comms-store.d.ts.map +1 -1
  80. package/dist/core/discovery-mdns.d.ts +33 -0
  81. package/dist/core/discovery-mdns.d.ts.map +1 -0
  82. package/dist/core/discovery-mdns.js +182 -0
  83. package/dist/core/discovery-mdns.js.map +1 -0
  84. package/dist/core/discovery-tailscale.d.ts +40 -0
  85. package/dist/core/discovery-tailscale.d.ts.map +1 -0
  86. package/dist/core/discovery-tailscale.js +158 -0
  87. package/dist/core/discovery-tailscale.js.map +1 -0
  88. package/dist/core/discovery.d.ts +82 -0
  89. package/dist/core/discovery.d.ts.map +1 -0
  90. package/dist/core/discovery.js +194 -0
  91. package/dist/core/discovery.js.map +1 -0
  92. package/dist/core/federation.d.ts +100 -0
  93. package/dist/core/federation.d.ts.map +1 -0
  94. package/dist/core/federation.js +411 -0
  95. package/dist/core/federation.js.map +1 -0
  96. package/dist/core/identity.d.ts +31 -0
  97. package/dist/core/identity.d.ts.map +1 -0
  98. package/dist/core/identity.js +251 -0
  99. package/dist/core/identity.js.map +1 -0
  100. package/dist/core/index.d.ts +11 -0
  101. package/dist/core/index.d.ts.map +1 -1
  102. package/dist/core/index.js +6 -0
  103. package/dist/core/index.js.map +1 -1
  104. package/dist/core/mesh-store.d.ts +72 -26
  105. package/dist/core/mesh-store.d.ts.map +1 -1
  106. package/dist/core/mesh-store.js +467 -352
  107. package/dist/core/mesh-store.js.map +1 -1
  108. package/dist/core/push-manager.d.ts +41 -0
  109. package/dist/core/push-manager.d.ts.map +1 -0
  110. package/dist/core/push-manager.js +60 -0
  111. package/dist/core/push-manager.js.map +1 -0
  112. package/dist/core/store.d.ts +21 -1
  113. package/dist/core/store.d.ts.map +1 -1
  114. package/dist/core/store.js +44 -0
  115. package/dist/core/store.js.map +1 -1
  116. package/dist/core/tcp-transport.d.ts +77 -0
  117. package/dist/core/tcp-transport.d.ts.map +1 -0
  118. package/dist/core/tcp-transport.js +596 -0
  119. package/dist/core/tcp-transport.js.map +1 -0
  120. package/dist/core/test/identity.unit.test.d.ts +5 -0
  121. package/dist/core/test/identity.unit.test.d.ts.map +1 -0
  122. package/dist/core/test/identity.unit.test.js +70 -0
  123. package/dist/core/test/identity.unit.test.js.map +1 -0
  124. package/dist/core/tls-transport.d.ts +69 -0
  125. package/dist/core/tls-transport.d.ts.map +1 -0
  126. package/dist/core/tls-transport.js +602 -0
  127. package/dist/core/tls-transport.js.map +1 -0
  128. package/dist/core/tool.d.ts +23 -2
  129. package/dist/core/tool.d.ts.map +1 -1
  130. package/dist/core/tool.js +269 -1
  131. package/dist/core/tool.js.map +1 -1
  132. package/dist/core/transport.d.ts +166 -0
  133. package/dist/core/transport.d.ts.map +1 -0
  134. package/dist/core/transport.js +20 -0
  135. package/dist/core/transport.js.map +1 -0
  136. package/dist/core/types.d.ts +136 -0
  137. package/dist/core/types.d.ts.map +1 -1
  138. package/dist/core/types.js +71 -0
  139. package/dist/core/types.js.map +1 -1
  140. package/dist/core/vapid.d.ts +22 -0
  141. package/dist/core/vapid.d.ts.map +1 -0
  142. package/dist/core/vapid.js +50 -0
  143. package/dist/core/vapid.js.map +1 -0
  144. package/dist/core/web-push.d.ts +37 -0
  145. package/dist/core/web-push.d.ts.map +1 -0
  146. package/dist/core/web-push.js +198 -0
  147. package/dist/core/web-push.js.map +1 -0
  148. package/dist/core/wire-protocol.d.ts +131 -0
  149. package/dist/core/wire-protocol.d.ts.map +1 -0
  150. package/dist/core/wire-protocol.js +54 -0
  151. package/dist/core/wire-protocol.js.map +1 -0
  152. package/dist/core/ws-transport.d.ts +71 -0
  153. package/dist/core/ws-transport.d.ts.map +1 -0
  154. package/dist/core/ws-transport.js +560 -0
  155. package/dist/core/ws-transport.js.map +1 -0
  156. package/dist/test/approval.integration.test.d.ts +9 -0
  157. package/dist/test/approval.integration.test.d.ts.map +1 -0
  158. package/dist/test/approval.integration.test.js +361 -0
  159. package/dist/test/approval.integration.test.js.map +1 -0
  160. package/dist/test/delivery-receipt.helper.d.ts +16 -0
  161. package/dist/test/delivery-receipt.helper.d.ts.map +1 -0
  162. package/dist/test/delivery-receipt.helper.js +409 -0
  163. package/dist/test/delivery-receipt.helper.js.map +1 -0
  164. package/dist/test/delivery-receipt.runner.d.ts +13 -0
  165. package/dist/test/delivery-receipt.runner.d.ts.map +1 -0
  166. package/dist/test/delivery-receipt.runner.js +51 -0
  167. package/dist/test/delivery-receipt.runner.js.map +1 -0
  168. package/dist/test/federation.integration.test.d.ts +15 -0
  169. package/dist/test/federation.integration.test.d.ts.map +1 -0
  170. package/dist/test/federation.integration.test.js +218 -0
  171. package/dist/test/federation.integration.test.js.map +1 -0
  172. package/dist/test/listener-policy.integration.test.d.ts +9 -0
  173. package/dist/test/listener-policy.integration.test.d.ts.map +1 -0
  174. package/dist/test/listener-policy.integration.test.js +239 -0
  175. package/dist/test/listener-policy.integration.test.js.map +1 -0
  176. package/dist/test/tls-transport.integration.test.d.ts +8 -0
  177. package/dist/test/tls-transport.integration.test.d.ts.map +1 -0
  178. package/dist/test/tls-transport.integration.test.js +158 -0
  179. package/dist/test/tls-transport.integration.test.js.map +1 -0
  180. package/dist/test/visibility.integration.test.d.ts +12 -0
  181. package/dist/test/visibility.integration.test.d.ts.map +1 -0
  182. package/dist/test/visibility.integration.test.js +181 -0
  183. package/dist/test/visibility.integration.test.js.map +1 -0
  184. package/package.json +4 -1
@@ -1,74 +1,30 @@
1
1
  /**
2
- * MeshStore — TCP localhost peer mesh for agent communication.
2
+ * MeshStore — transport-agnostic peer mesh for agent communication.
3
3
  *
4
4
  * Each bridge instance is a peer in the mesh. Peers discover each other
5
5
  * via a coordinator (the first instance to bind the well-known port).
6
6
  * All state is held in memory and synchronised between peers.
7
- * Delivery events are pushed directly over TCP — no polling, no filesystem.
7
+ * Delivery events are pushed directly over the transport — no polling,
8
+ * no filesystem.
8
9
  *
9
- * Falls back to FileStore if the mesh is unavailable.
10
+ * Transport is injected via the constructor. TcpTransport for localhost
11
+ * TCP, TlsTransport for encrypted connections, etc.
10
12
  */
11
- import * as net from "node:net";
13
+ import * as os from "node:os";
12
14
  import { nanoid } from "./nanoid.js";
13
15
  import { CommsError } from "./store.js";
16
+ import { TcpTransport } from "./tcp-transport.js";
17
+ import { dmKey } from "./wire-protocol.js";
18
+ import { DiscoveryManager } from "./discovery.js";
19
+ import { MdnsDiscoveryBackend } from "./discovery-mdns.js";
20
+ import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js";
21
+ import { FederationManager } from "./federation.js";
14
22
  // ---------------------------------------------------------------------------
15
23
  // Constants
16
24
  // ---------------------------------------------------------------------------
17
25
  const DEFAULT_COORDINATOR_PORT = 19876;
18
26
  const COORDINATOR_HOST = "127.0.0.1";
19
27
  // ---------------------------------------------------------------------------
20
- // Framing
21
- // ---------------------------------------------------------------------------
22
- function encode(msg) {
23
- return JSON.stringify(msg) + "\n";
24
- }
25
- class MessageBuffer {
26
- buffer = "";
27
- append(data) {
28
- this.buffer += data;
29
- const results = [];
30
- let idx = this.buffer.indexOf("\n");
31
- while (idx !== -1) {
32
- const line = this.buffer.slice(0, idx);
33
- this.buffer = this.buffer.slice(idx + 1);
34
- if (line.length > 0) {
35
- try {
36
- results.push(JSON.parse(line));
37
- }
38
- catch {
39
- /* skip malformed lines */
40
- }
41
- }
42
- idx = this.buffer.indexOf("\n");
43
- }
44
- return results;
45
- }
46
- }
47
- function isMeshMessage(value) {
48
- if (typeof value !== "object" || value === null)
49
- return false;
50
- if (!("method" in value))
51
- return false;
52
- return typeof value.method === "string";
53
- }
54
- function dmKey(a, b) {
55
- const sorted = [a, b].sort();
56
- return `${sorted[0] ?? a}--${sorted[1] ?? b}`;
57
- }
58
- // ---------------------------------------------------------------------------
59
- // Async socket write
60
- // ---------------------------------------------------------------------------
61
- function writeAsync(socket, data) {
62
- return new Promise((resolve, reject) => {
63
- socket.write(data, "utf-8", (err) => {
64
- if (err)
65
- reject(err);
66
- else
67
- resolve();
68
- });
69
- });
70
- }
71
- // ---------------------------------------------------------------------------
72
28
  // MeshStore
73
29
  // ---------------------------------------------------------------------------
74
30
  export class MeshStore {
@@ -81,159 +37,123 @@ export class MeshStore {
81
37
  dms = new Map();
82
38
  deliveryQueues = new Map();
83
39
  identityCache = new Map();
84
- dataServer;
85
- dataPort = 0;
86
- coordinatorServer;
87
- isCoordinator = false;
88
- peerConnections = new Map();
89
- /** All sockets accepted by the data server — destroyed on shutdown. */
90
- dataServerSockets = new Set();
91
- /** All sockets accepted by the coordinator server — destroyed on shutdown. */
92
- coordinatorServerSockets = new Set();
93
- /** Socket connected to the coordinator (client side) — destroyed on shutdown. */
94
- coordinatorSocket;
40
+ transport;
95
41
  peerInfo = new Map();
96
42
  staleCheckTimer;
43
+ isShutDown = false;
44
+ pendingMarkReadTimers = [];
45
+ // -- Pending inbound connections awaiting approval --
46
+ pendingInboundConnections = new Map();
97
47
  onDelivery;
48
+ /** Fires for every state patch — both locally generated and remote. */
49
+ onPatch;
50
+ /** Serialise the full mesh state for state_sync messages. */
51
+ serialise() {
52
+ return {
53
+ agents: Object.fromEntries(this.agents),
54
+ rooms: Object.fromEntries(this.rooms),
55
+ messages: Object.fromEntries(this.messages),
56
+ dms: Object.fromEntries(this.dms),
57
+ };
58
+ }
98
59
  lastLocalDeliveryKey;
99
60
  localDeliveryKeys = new Set();
61
+ discovery;
62
+ federation;
100
63
  constructor(coordinatorPort = DEFAULT_COORDINATOR_PORT) {
101
64
  this.peerId = nanoid(8);
102
65
  this.startedAt = new Date().toISOString();
103
66
  this.coordinatorPort = coordinatorPort;
67
+ // Wire up transport with this store's event handlers.
68
+ // TcpTransport is the default; callers can replace via setTransport().
69
+ this.transport = new TcpTransport(this.events);
70
+ // Discovery manager — registers available backends
71
+ this.discovery = new DiscoveryManager();
72
+ this.discovery.registerBackend(new MdnsDiscoveryBackend());
73
+ this.discovery.registerBackend(new TailscaleDiscoveryBackend());
74
+ // Federation manager — coordinator-to-coordinator links
75
+ this.federation = new FederationManager(this.peerId, // mesh ID is the coordinator's peer ID
76
+ `mesh-${this.peerId}`, {
77
+ onAgentVisible: (agent) => this.handleFedAgentVisible(agent),
78
+ onAgentGone: (agentId) => this.handleFedAgentGone(agentId),
79
+ onRoomMessage: (roomId, message) => this.handleFedRoomMessage(roomId, message),
80
+ onRoomJoin: (roomId, agentId, agentName) => this.handleFedRoomJoin(roomId, agentId, agentName),
81
+ onRoomLeave: (roomId, agentId) => this.handleFedRoomLeave(roomId, agentId),
82
+ getVisibleAgents: () => this.getVisibleAgentsForFed(),
83
+ getFederatedRoomMemberships: () => this.getFederatedRoomMembershipsForFed(),
84
+ });
85
+ }
86
+ /** Replace the transport (e.g. with TlsTransport for encrypted connections). */
87
+ setTransport(transport) {
88
+ this.transport = transport;
104
89
  }
105
90
  // -----------------------------------------------------------------------
106
91
  // Mesh lifecycle
107
92
  // -----------------------------------------------------------------------
108
93
  async init() {
109
- await this.startDataServer();
110
- await this.tryJoinMesh();
111
- // Unref all root handles so the event loop can exit when pi shuts down.
112
- // Sockets/servers still function normally for I/O but don't keep the
113
- // process alive. shutdown() will destroy them explicitly.
114
- this.dataServer?.unref();
115
- this.coordinatorServer?.unref();
116
- this.coordinatorSocket?.unref();
117
- }
118
- startDataServer() {
119
- return new Promise((resolve, reject) => {
120
- this.dataServer = net.createServer((socket) => {
121
- this.handleDataConnection(socket);
122
- });
123
- this.dataServer.listen(0, COORDINATOR_HOST, () => {
124
- const addr = this.dataServer?.address();
125
- if (addr && typeof addr === "object") {
126
- this.dataPort = addr.port;
127
- }
128
- resolve();
129
- });
130
- this.dataServer.on("error", reject);
94
+ await this.transport.startDataServer();
95
+ // Register our own peer info
96
+ this.peerInfo.set(this.peerId, {
97
+ id: this.peerId,
98
+ port: this.transport.dataPort,
99
+ startedAt: this.startedAt,
131
100
  });
132
- }
133
- async tryJoinMesh() {
134
- try {
135
- await this.connectToCoordinator();
136
- }
137
- catch {
138
- await this.becomeCoordinator();
139
- }
140
- }
141
- connectToCoordinator() {
142
- return new Promise((resolve, reject) => {
143
- const socket = net.createConnection({ port: this.coordinatorPort, host: COORDINATOR_HOST }, () => {
144
- this.coordinatorSocket = socket;
145
- const intro = {
146
- method: "introduce",
147
- peerId: this.peerId,
148
- dataPort: this.dataPort,
149
- };
150
- socket.write(encode(intro));
151
- // Read the peer_list response
152
- const buf = new MessageBuffer();
153
- socket.on("data", (data) => {
154
- const items = buf.append(data.toString());
155
- for (const item of items) {
156
- if (isMeshMessage(item)) {
157
- this.handleCoordinatorResponse(item);
101
+ // Try joining an existing mesh; fall back to becoming coordinator.
102
+ // If becomeCoordinator fails with EADDRINUSE (another process won the race),
103
+ // retry connecting — the new coordinator should be ready by now.
104
+ const MAX_RETRIES = 3;
105
+ const RETRY_DELAY_MS = 200;
106
+ let connected = false;
107
+ let lastError;
108
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
109
+ try {
110
+ await this.transport.connectToCoordinator(COORDINATOR_HOST, this.coordinatorPort, this.peerId, this.transport.dataPort);
111
+ connected = true;
112
+ break;
113
+ }
114
+ catch (err) {
115
+ lastError = err instanceof Error ? err : new Error(String(err));
116
+ // Only try to become coordinator on the first attempt
117
+ if (attempt === 0) {
118
+ try {
119
+ await this.transport.becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
120
+ this.startStaleCheck();
121
+ connected = true;
122
+ break;
123
+ }
124
+ catch (coordErr) {
125
+ const msg = coordErr instanceof Error ? coordErr.message : String(coordErr);
126
+ if (!msg.includes("EADDRINUSE")) {
127
+ throw coordErr;
158
128
  }
129
+ // EADDRINUSE — another process became coordinator. Retry connect.
159
130
  }
160
- });
161
- socket.on("error", () => {
162
- /* ignore late errors */
163
- });
164
- clearTimeout(timer);
165
- resolve();
166
- });
167
- const timer = setTimeout(() => {
168
- socket.destroy();
169
- reject(new Error("Coordinator connection timeout"));
170
- }, 2000);
171
- socket.on("error", (err) => {
172
- clearTimeout(timer);
173
- socket.destroy();
174
- reject(err);
175
- });
176
- });
177
- }
178
- handleCoordinatorResponse(msg) {
179
- if (msg.method === "peer_list") {
180
- for (const peer of msg.peers) {
181
- this.peerInfo.set(peer.id, peer);
182
- void this.connectToPeerData(peer);
131
+ }
132
+ // Wait before retrying
133
+ if (attempt < MAX_RETRIES - 1) {
134
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
135
+ }
183
136
  }
184
137
  }
185
- else if (msg.method === "peer_joined") {
186
- this.peerInfo.set(msg.peer.id, msg.peer);
187
- void this.connectToPeerData(msg.peer);
138
+ if (!connected) {
139
+ throw new Error(`Failed to join or create mesh on port ${String(this.coordinatorPort)}: ${lastError?.message ?? "unknown error"}`);
188
140
  }
189
- }
190
- becomeCoordinator() {
191
- return new Promise((resolve, reject) => {
192
- this.coordinatorServer = net.createServer((socket) => {
193
- this.handleCoordinatorConnection(socket);
194
- });
195
- this.coordinatorServer.listen(this.coordinatorPort, COORDINATOR_HOST, () => {
196
- this.isCoordinator = true;
197
- this.startStaleCheck();
198
- this.peerInfo.set(this.peerId, {
199
- id: this.peerId,
200
- port: this.dataPort,
201
- startedAt: this.startedAt,
202
- });
203
- resolve();
204
- });
205
- this.coordinatorServer.on("error", (err) => {
206
- const isAddrInUse = err instanceof Error && "code" in err && err.code === "EADDRINUSE";
207
- if (isAddrInUse) {
208
- this.coordinatorServer = undefined;
209
- void this.connectToCoordinator().then(resolve, reject);
210
- }
211
- else {
212
- reject(err instanceof Error ? err : new Error(String(err)));
213
- }
214
- });
215
- });
141
+ this.transport.unref();
216
142
  }
217
143
  // -----------------------------------------------------------------------
218
- // Coordinator protocol
144
+ // TransportEvents — callbacks from the transport layer
219
145
  // -----------------------------------------------------------------------
220
- handleCoordinatorConnection(socket) {
221
- this.coordinatorServerSockets.add(socket);
222
- socket.on("close", () => this.coordinatorServerSockets.delete(socket));
223
- socket.on("error", () => {
224
- this.coordinatorServerSockets.delete(socket);
225
- });
226
- const buffer = new MessageBuffer();
227
- socket.on("data", (data) => {
228
- const items = buffer.append(data.toString());
229
- for (const item of items) {
230
- if (isMeshMessage(item) && item.method === "introduce") {
231
- void this.handleIntroduction(socket, item);
232
- }
233
- }
234
- });
146
+ handlePeerList(peers) {
147
+ for (const peer of peers) {
148
+ this.peerInfo.set(peer.id, peer);
149
+ void this.transport.connectToPeer(peer, this.peerId);
150
+ }
151
+ }
152
+ handlePeerJoined(peer) {
153
+ this.peerInfo.set(peer.id, peer);
154
+ void this.transport.connectToPeer(peer, this.peerId);
235
155
  }
236
- async handleIntroduction(socket, msg) {
156
+ async handleIntroduction(handle, msg) {
237
157
  const newPeer = {
238
158
  id: msg.peerId,
239
159
  port: msg.dataPort,
@@ -245,45 +165,29 @@ export class MeshStore {
245
165
  method: "peer_list",
246
166
  peers: [...this.peerInfo.values()],
247
167
  };
248
- await writeAsync(socket, encode(peerList));
249
- // Broadcast arrival to all existing data connections
168
+ await this.transport.send(handle, peerList);
169
+ // Broadcast arrival to all existing peers
250
170
  const joined = { method: "peer_joined", peer: newPeer };
251
- await this.broadcastToDataConnections(joined);
171
+ await this.transport.broadcast(joined);
252
172
  // Connect to the new peer's data server
253
- void this.connectToPeerData(newPeer);
173
+ void this.transport.connectToPeer(newPeer, this.peerId);
254
174
  }
255
- // -----------------------------------------------------------------------
256
- // Data connections
257
- // -----------------------------------------------------------------------
258
- handleDataConnection(socket) {
259
- this.dataServerSockets.add(socket);
260
- socket.on("close", () => this.dataServerSockets.delete(socket));
261
- const buffer = new MessageBuffer();
262
- let remotePeerId;
263
- socket.on("data", (data) => {
264
- const items = buffer.append(data.toString());
265
- for (const item of items) {
266
- if (isMeshMessage(item)) {
267
- void this.handleDataMessage(item);
268
- if (item.method === "pong") {
269
- remotePeerId = item.peerId;
270
- }
271
- }
272
- }
273
- if (remotePeerId && !this.peerConnections.has(remotePeerId)) {
274
- this.peerConnections.set(remotePeerId, { socket, buffer });
275
- }
276
- });
277
- socket.on("close", () => {
278
- if (remotePeerId)
279
- this.peerConnections.delete(remotePeerId);
280
- });
281
- socket.on("error", () => {
282
- if (remotePeerId)
283
- this.peerConnections.delete(remotePeerId);
284
- });
175
+ async handlePeerConnected(handle, _info) {
176
+ // If we have state and the peer doesn't, send state sync
177
+ if (this.agents.size > 0) {
178
+ const state = {
179
+ agents: Object.fromEntries(this.agents),
180
+ rooms: Object.fromEntries(this.rooms),
181
+ messages: Object.fromEntries(this.messages),
182
+ dms: Object.fromEntries(this.dms),
183
+ };
184
+ await this.transport.send(handle, {
185
+ method: "state_sync",
186
+ state,
187
+ });
188
+ }
285
189
  }
286
- async handleDataMessage(msg) {
190
+ async handleDataMessage(handle, msg) {
287
191
  if (msg.method === "state_sync") {
288
192
  // Merge — don't replace — so our own state isn't lost
289
193
  const incoming = {
@@ -313,6 +217,135 @@ export class MeshStore {
313
217
  await this.applyPatch(msg.patch);
314
218
  }
315
219
  }
220
+ async handleBecomeCoordinator(peerList) {
221
+ // Take over as coordinator using the data server we already have
222
+ await this.transport.becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
223
+ this.peerInfo.clear();
224
+ for (const peer of peerList) {
225
+ this.peerInfo.set(peer.id, peer);
226
+ void this.transport.connectToPeer(peer, this.peerId);
227
+ }
228
+ this.startStaleCheck();
229
+ }
230
+ handlePeerDisconnected(handle) {
231
+ this.peerInfo.delete(handle.id);
232
+ }
233
+ // -----------------------------------------------------------------------
234
+ // Connection approval
235
+ // -----------------------------------------------------------------------
236
+ handleConnectionRequest(handle, request) {
237
+ this.pendingInboundConnections.set(handle.id, {
238
+ peerId: request.peerId,
239
+ dataPort: request.dataPort,
240
+ name: request.name,
241
+ fingerprint: request.fingerprint,
242
+ });
243
+ // Deliver connection_request event to the owning agent
244
+ const connectionId = handle.id;
245
+ const event = {
246
+ type: "connection_request",
247
+ connectionId,
248
+ peerId: request.peerId,
249
+ dataPort: request.dataPort,
250
+ name: request.name,
251
+ fingerprint: request.fingerprint,
252
+ };
253
+ const arr = this.deliveryQueues.get(this.peerId) ?? [];
254
+ arr.push(event);
255
+ this.deliveryQueues.set(this.peerId, arr);
256
+ if (this.onDelivery) {
257
+ void this.onDelivery(this.peerId, event);
258
+ }
259
+ }
260
+ /** Accept a pending inbound connection. */
261
+ async acceptConnection(connectionId) {
262
+ const pending = this.pendingInboundConnections.get(connectionId);
263
+ if (!pending) {
264
+ throw new Error(`No pending connection ${connectionId}`);
265
+ }
266
+ this.pendingInboundConnections.delete(connectionId);
267
+ const handle = { id: connectionId };
268
+ await this.transport.acceptConnection(handle);
269
+ }
270
+ /** Reject a pending inbound connection. */
271
+ async rejectConnection(connectionId, reason) {
272
+ const pending = this.pendingInboundConnections.get(connectionId);
273
+ if (!pending) {
274
+ throw new Error(`No pending connection ${connectionId}`);
275
+ }
276
+ this.pendingInboundConnections.delete(connectionId);
277
+ const handle = { id: connectionId };
278
+ await this.transport.rejectConnection(handle, reason);
279
+ }
280
+ /** List all pending inbound connections awaiting approval. */
281
+ listPendingConnections() {
282
+ return [...this.pendingInboundConnections.entries()].map(([id, info]) => ({
283
+ connectionId: id,
284
+ ...info,
285
+ }));
286
+ }
287
+ /** Initiate an outbound connection to a remote coordinator requiring approval.
288
+ * Fires the connect_request and returns immediately. The connection
289
+ * completes asynchronously when the coordinator accepts or rejects. */
290
+ async connectToRemote(host, port) {
291
+ const agent = this.agents.get(this.peerId);
292
+ // Fire-and-forget: don't await the full approval handshake.
293
+ // The coordinator will either accept (triggering normal introduction flow)
294
+ // or reject (closing the socket). Handle rejection to avoid unhandled rejection.
295
+ this.transport
296
+ .connectToRemote(host, port, this.peerId, this.transport.dataPort, agent?.name ?? "", "")
297
+ .catch((err) => {
298
+ // Rejection is expected when the coordinator denies the connection.
299
+ // Log silently — the calling tool already returned success.
300
+ void err;
301
+ });
302
+ }
303
+ /** Start only the data server without connecting to a coordinator.
304
+ * Used for testing scenarios where the peer connects via connectToRemote. */
305
+ async startDataServerOnly() {
306
+ await this.transport.startDataServer();
307
+ this.peerInfo.set(this.peerId, {
308
+ id: this.peerId,
309
+ port: this.transport.dataPort,
310
+ startedAt: this.startedAt,
311
+ });
312
+ this.transport.unref();
313
+ }
314
+ // -----------------------------------------------------------------------
315
+ // Transport events accessor (for bridges to wire up)
316
+ // -----------------------------------------------------------------------
317
+ /** Returns the TransportEvents object that bridges should pass to the transport constructor. */
318
+ get events() {
319
+ return {
320
+ onMessage: (handle, msg) => {
321
+ void this.handleDataMessage(handle, msg);
322
+ },
323
+ onPeerConnected: (handle, info) => {
324
+ void this.handlePeerConnected(handle, info);
325
+ },
326
+ onPeerDisconnected: (handle) => {
327
+ this.handlePeerDisconnected(handle);
328
+ },
329
+ onIntroduction: (handle, msg) => {
330
+ void this.handleIntroduction(handle, msg);
331
+ },
332
+ onPeerList: (peers) => {
333
+ this.handlePeerList(peers);
334
+ },
335
+ onPeerJoined: (peer) => {
336
+ this.handlePeerJoined(peer);
337
+ },
338
+ onBecomeCoordinator: (peerList) => {
339
+ void this.handleBecomeCoordinator(peerList);
340
+ },
341
+ onConnectionRequest: (handle, request) => {
342
+ this.handleConnectionRequest(handle, request);
343
+ },
344
+ };
345
+ }
346
+ // -----------------------------------------------------------------------
347
+ // State patch application
348
+ // -----------------------------------------------------------------------
316
349
  async applyPatch(patch) {
317
350
  switch (patch.type) {
318
351
  case "agent_upsert": {
@@ -387,13 +420,24 @@ export class MeshStore {
387
420
  this.localDeliveryKeys.delete(oldest);
388
421
  }
389
422
  void this.onDelivery(patch.agentId, patch.event);
390
- // Auto-mark read — push bridges consume immediately
391
- if (patch.event.type === "room_message") {
392
- await this.markRead(patch.event.message.id, this.peerId, patch.event.message.room);
393
- }
394
- else if (patch.event.type === "dm") {
395
- await this.markRead(patch.event.message.id, this.peerId);
396
- }
423
+ // Auto-mark read — scheduled as a macrotask to yield to the event
424
+ // loop. Without this yield, the delivery → markRead → broadcast
425
+ // peer receives → handleDataMessage chain monopolises the
426
+ // microtask queue and starves macrotasks (timers, new connections,
427
+ // sendRoomMessage return values).
428
+ const evt = patch.event;
429
+ const timer = setTimeout(() => {
430
+ if (this.isShutDown)
431
+ return;
432
+ if (evt.type === "room_message") {
433
+ void this.markRead(evt.message.id, this.peerId, evt.message.room);
434
+ }
435
+ else if (evt.type === "dm") {
436
+ void this.markRead(evt.message.id, this.peerId);
437
+ }
438
+ }, 0);
439
+ if (!this.isShutDown)
440
+ this.pendingMarkReadTimers.push(timer);
397
441
  }
398
442
  break;
399
443
  }
@@ -402,63 +446,18 @@ export class MeshStore {
402
446
  break;
403
447
  }
404
448
  }
405
- }
406
- async connectToPeerData(peer) {
407
- if (peer.id === this.peerId)
408
- return;
409
- if (this.peerConnections.has(peer.id))
410
- return;
411
- return new Promise((resolve) => {
412
- const socket = net.createConnection({ port: peer.port, host: COORDINATOR_HOST }, () => {
413
- const buf = new MessageBuffer();
414
- this.peerConnections.set(peer.id, { socket, buffer: buf });
415
- // Identify ourselves
416
- const pong = { method: "pong", peerId: this.peerId };
417
- socket.write(encode(pong));
418
- // If we have state and peer doesn't, send state sync
419
- if (this.agents.size > 0) {
420
- const state = {
421
- agents: Object.fromEntries(this.agents),
422
- rooms: Object.fromEntries(this.rooms),
423
- messages: Object.fromEntries(this.messages),
424
- dms: Object.fromEntries(this.dms),
425
- };
426
- socket.write(encode({ method: "state_sync", state }));
427
- }
428
- // Wire up ongoing message handling
429
- socket.on("data", (data) => {
430
- const items = buf.append(data.toString());
431
- for (const item of items) {
432
- if (isMeshMessage(item)) {
433
- void this.handleDataMessage(item);
434
- }
435
- }
436
- });
437
- resolve();
438
- });
439
- socket.on("close", () => this.peerConnections.delete(peer.id));
440
- socket.on("error", () => {
441
- this.peerConnections.delete(peer.id);
442
- socket.destroy();
443
- resolve();
444
- });
445
- });
449
+ if (this.onPatch) {
450
+ await this.onPatch(patch);
451
+ }
446
452
  }
447
453
  // -----------------------------------------------------------------------
448
- // Broadcast (async — writes to TCP sockets)
454
+ // Broadcast
449
455
  // -----------------------------------------------------------------------
450
- async broadcastToDataConnections(msg) {
451
- const data = encode(msg);
452
- const writes = [];
453
- for (const [, peer] of this.peerConnections) {
454
- writes.push(writeAsync(peer.socket, data).catch(() => {
455
- /* broken connection — cleanup handled by close/error listeners */
456
- }));
457
- }
458
- await Promise.all(writes);
459
- }
460
456
  async broadcastPatch(patch) {
461
- await this.broadcastToDataConnections({ method: "state_update", patch });
457
+ await this.transport.broadcast({ method: "state_update", patch });
458
+ if (this.onPatch) {
459
+ await this.onPatch(patch);
460
+ }
462
461
  }
463
462
  async deliverLocallyAndBroadcast(agentId, event) {
464
463
  // Local delivery
@@ -487,13 +486,20 @@ export class MeshStore {
487
486
  this.localDeliveryKeys.delete(oldest);
488
487
  }
489
488
  void this.onDelivery(agentId, event);
490
- // Auto-mark read — push bridges consume immediately
491
- if (event.type === "room_message") {
492
- await this.markRead(event.message.id, agentId, event.message.room);
493
- }
494
- else if (event.type === "dm") {
495
- await this.markRead(event.message.id, agentId);
496
- }
489
+ // Auto-mark read — scheduled as a macrotask to yield to the event
490
+ // loop (see matching comment in applyPatch for rationale).
491
+ const timer = setTimeout(() => {
492
+ if (this.isShutDown)
493
+ return;
494
+ if (event.type === "room_message") {
495
+ void this.markRead(event.message.id, agentId, event.message.room);
496
+ }
497
+ else if (event.type === "dm") {
498
+ void this.markRead(event.message.id, agentId);
499
+ }
500
+ }, 0);
501
+ if (!this.isShutDown)
502
+ this.pendingMarkReadTimers.push(timer);
497
503
  }
498
504
  // Remote delivery
499
505
  const patch = { type: "delivery", agentId, event };
@@ -656,6 +662,10 @@ export class MeshStore {
656
662
  this.agents.set(id, agent);
657
663
  await this.writeIdentity(opts.harness, opts.cwd, id);
658
664
  await this.broadcastPatch({ type: "agent_upsert", agent });
665
+ // Broadcast presence to federated links
666
+ if (agent.visibility === "visible") {
667
+ await this.federation.broadcastAgentVisible(agent);
668
+ }
659
669
  return agent;
660
670
  }
661
671
  async getAgent(id) {
@@ -703,9 +713,7 @@ export class MeshStore {
703
713
  if (isOwner) {
704
714
  await this.notifyRoomsOfStatus(id, "offline");
705
715
  await this.broadcastPatch({ type: "agent_offline", agentId: id });
706
- }
707
- if (this.isCoordinator) {
708
- await this.handoverCoordinator();
716
+ await this.federation.broadcastAgentGone(id);
709
717
  }
710
718
  }
711
719
  // -----------------------------------------------------------------------
@@ -724,6 +732,7 @@ export class MeshStore {
724
732
  description: opts.description,
725
733
  members: [opts.owner],
726
734
  invited: [],
735
+ federated: opts.federated ?? false,
727
736
  };
728
737
  this.rooms.set(id, room);
729
738
  this.messages.set(id, []);
@@ -793,6 +802,11 @@ export class MeshStore {
793
802
  room: roomId,
794
803
  agent: agentId,
795
804
  }, agentId);
805
+ // Notify federated links if the room is federated
806
+ if (room.federated) {
807
+ const agentName = agent?.name ?? agentId;
808
+ await this.federation.broadcastRoomJoin(roomId, agentId, agentName);
809
+ }
796
810
  return room;
797
811
  }
798
812
  async leaveRoom(roomId, agentId) {
@@ -813,6 +827,10 @@ export class MeshStore {
813
827
  room: roomId,
814
828
  agent: agentId,
815
829
  });
830
+ // Notify federated links if the room is federated
831
+ if (room.federated) {
832
+ await this.federation.broadcastRoomLeave(roomId, agentId);
833
+ }
816
834
  if (room.members.length === 0 && room.owner === agentId) {
817
835
  await this.destroyRoom(roomId, agentId);
818
836
  }
@@ -908,6 +926,10 @@ export class MeshStore {
908
926
  arr.push(message);
909
927
  this.messages.set(roomId, arr);
910
928
  await this.broadcastPatch({ type: "message_add", roomId, message });
929
+ // Forward to federated links if the room is federated
930
+ if (room.federated) {
931
+ await this.federation.forwardRoomMessage(roomId, message);
932
+ }
911
933
  for (const memberId of room.members) {
912
934
  if (memberId !== from) {
913
935
  await this.deliverLocallyAndBroadcast(memberId, {
@@ -1007,9 +1029,7 @@ export class MeshStore {
1007
1029
  await this.notifyRoomsOfStatus(id, "offline");
1008
1030
  }
1009
1031
  await this.broadcastPatch({ type: "agent_offline", agentId: id });
1010
- // Clean up stale peer from coordinator's registry
1011
1032
  this.peerInfo.delete(id);
1012
- this.peerConnections.delete(id);
1013
1033
  }
1014
1034
  // Also purge long-offline agents to prevent indefinite accumulation
1015
1035
  const offlineThreshold = Date.now() - 30 * 60 * 1000; // 30 minutes
@@ -1018,7 +1038,6 @@ export class MeshStore {
1018
1038
  if (agent.status !== "offline")
1019
1039
  continue;
1020
1040
  const startedAt = new Date(agent.startedAt).getTime();
1021
- // If the agent went offline long ago, purge it
1022
1041
  if (startedAt < offlineThreshold) {
1023
1042
  purgeIds.push(id);
1024
1043
  }
@@ -1026,13 +1045,11 @@ export class MeshStore {
1026
1045
  for (const id of purgeIds) {
1027
1046
  this.agents.delete(id);
1028
1047
  this.peerInfo.delete(id);
1029
- this.peerConnections.delete(id);
1030
1048
  this.identityCache.delete(id);
1031
1049
  }
1032
1050
  }
1033
1051
  isProcessAlive(pid) {
1034
1052
  try {
1035
- // Sending signal 0 doesn't kill the process — it just checks existence
1036
1053
  process.kill(pid, 0);
1037
1054
  return true;
1038
1055
  }
@@ -1041,45 +1058,171 @@ export class MeshStore {
1041
1058
  }
1042
1059
  }
1043
1060
  // -----------------------------------------------------------------------
1044
- // Coordinator handover
1061
+ // Mesh visibility
1045
1062
  // -----------------------------------------------------------------------
1046
- async handoverCoordinator() {
1047
- if (!this.isCoordinator)
1048
- return;
1049
- let successor;
1050
- for (const [id, info] of this.peerInfo) {
1051
- if (id === this.peerId)
1063
+ /** Set mesh discovery visibility. Delegates to discovery manager. */
1064
+ async setVisibility(level, adapter) {
1065
+ await this.discovery.setVisibility(level, adapter);
1066
+ }
1067
+ /** Get current mesh discovery visibility. */
1068
+ getVisibility(adapter) {
1069
+ return this.discovery.getVisibility(adapter);
1070
+ }
1071
+ // -----------------------------------------------------------------------
1072
+ // Listener management (coordinator only)
1073
+ // -----------------------------------------------------------------------
1074
+ async addListener(host, port, policy) {
1075
+ function isListenerPolicy(v) {
1076
+ return (v === "full" || v === "observe" || v === "rooms-only" || v === "gateway");
1077
+ }
1078
+ if (!isListenerPolicy(policy)) {
1079
+ throw new CommsError(`Invalid policy "${policy}"`, "INVALID_POLICY");
1080
+ }
1081
+ return this.transport.addListener(host, port, policy);
1082
+ }
1083
+ async removeListener(id) {
1084
+ return this.transport.removeListener(id);
1085
+ }
1086
+ listListeners() {
1087
+ return this.transport.listListeners();
1088
+ }
1089
+ getNetworkInterfaces() {
1090
+ const interfaces = os.networkInterfaces();
1091
+ const result = [];
1092
+ for (const [name, addrs] of Object.entries(interfaces)) {
1093
+ if (addrs === undefined)
1052
1094
  continue;
1053
- if (!successor || info.startedAt < successor.startedAt) {
1054
- successor = info;
1095
+ for (const addr of addrs) {
1096
+ result.push({
1097
+ name,
1098
+ address: addr.address,
1099
+ family: addr.family,
1100
+ internal: addr.internal,
1101
+ });
1102
+ }
1103
+ }
1104
+ return result;
1105
+ }
1106
+ // -----------------------------------------------------------------------
1107
+ // Federation (coordinator-to-coordinator)
1108
+ // -----------------------------------------------------------------------
1109
+ async fedConnect(host, port, name) {
1110
+ return this.federation.connect(host, port, name);
1111
+ }
1112
+ async fedDisconnect(linkId) {
1113
+ await this.federation.disconnect(linkId);
1114
+ }
1115
+ fedLinks() {
1116
+ return this.federation.listLinks();
1117
+ }
1118
+ // -----------------------------------------------------------------------
1119
+ // Federation callbacks (inbound from remote meshes)
1120
+ // -----------------------------------------------------------------------
1121
+ async handleFedAgentVisible(agent) {
1122
+ // Store remote agent with a prefixed ID to avoid collisions with local agents
1123
+ const remoteId = `fed:${agent.id}@${agent.harness}`;
1124
+ const remoteAgent = {
1125
+ ...agent,
1126
+ id: remoteId,
1127
+ tags: [...agent.tags, "federated"],
1128
+ };
1129
+ this.agents.set(remoteId, remoteAgent);
1130
+ await this.broadcastPatch({ type: "agent_upsert", agent: remoteAgent });
1131
+ }
1132
+ async handleFedAgentGone(agentId) {
1133
+ // The agentId comes from the remote mesh — we need to find the prefixed version
1134
+ const prefix = `fed:${agentId}@`;
1135
+ for (const [localId, agent] of this.agents) {
1136
+ if (localId.startsWith(prefix)) {
1137
+ agent.status = "offline";
1138
+ this.agents.set(localId, agent);
1139
+ await this.broadcastPatch({ type: "agent_offline", agentId: localId });
1140
+ break;
1055
1141
  }
1056
1142
  }
1057
- if (successor) {
1058
- const peer = this.peerConnections.get(successor.id);
1059
- if (peer) {
1060
- const msg = {
1061
- method: "become_coordinator",
1062
- peerList: [...this.peerInfo.values()].filter((p) => p.id !== this.peerId),
1063
- };
1064
- await writeAsync(peer.socket, encode(msg));
1143
+ }
1144
+ async handleFedRoomMessage(roomId, message) {
1145
+ const room = this.rooms.get(roomId);
1146
+ if (!room?.federated)
1147
+ return;
1148
+ // Store the message locally
1149
+ const arr = this.messages.get(roomId) ?? [];
1150
+ arr.push(message);
1151
+ this.messages.set(roomId, arr);
1152
+ // Deliver to all local room members
1153
+ for (const memberId of room.members) {
1154
+ await this.deliverLocallyAndBroadcast(memberId, {
1155
+ type: "room_message",
1156
+ message,
1157
+ });
1158
+ }
1159
+ }
1160
+ async handleFedRoomJoin(roomId, agentId, _agentName) {
1161
+ const room = this.rooms.get(roomId);
1162
+ if (!room?.federated)
1163
+ return;
1164
+ // Create a shadow agent ID for the remote agent
1165
+ const remoteId = `fed:${agentId}`;
1166
+ if (!room.members.includes(remoteId)) {
1167
+ room.members.push(remoteId);
1168
+ this.rooms.set(roomId, room);
1169
+ await this.broadcastPatch({ type: "room_upsert", room });
1170
+ }
1171
+ // Notify local members
1172
+ await this.deliverToRoom(roomId, {
1173
+ type: "member_joined",
1174
+ room: roomId,
1175
+ agent: remoteId,
1176
+ }, remoteId);
1177
+ }
1178
+ async handleFedRoomLeave(roomId, agentId) {
1179
+ const room = this.rooms.get(roomId);
1180
+ if (!room?.federated)
1181
+ return;
1182
+ const remoteId = `fed:${agentId}`;
1183
+ room.members = room.members.filter((m) => m !== remoteId);
1184
+ this.rooms.set(roomId, room);
1185
+ await this.broadcastPatch({ type: "room_upsert", room });
1186
+ await this.deliverToRoom(roomId, {
1187
+ type: "member_left",
1188
+ room: roomId,
1189
+ agent: remoteId,
1190
+ });
1191
+ }
1192
+ getVisibleAgentsForFed() {
1193
+ const result = [];
1194
+ for (const agent of this.agents.values()) {
1195
+ // Only broadcast agents that belong to this mesh (not federated)
1196
+ // and are visible
1197
+ if (agent.visibility === "visible" && !agent.id.startsWith("fed:")) {
1198
+ result.push(agent);
1065
1199
  }
1066
1200
  }
1067
- // Always clean up coordinator state, even with no successor
1068
- this.stopStaleCheck();
1069
- for (const socket of this.coordinatorServerSockets) {
1070
- socket.unref();
1071
- socket.destroy();
1201
+ return result;
1202
+ }
1203
+ getFederatedRoomMembershipsForFed() {
1204
+ const result = new Map();
1205
+ for (const [roomId, room] of this.rooms) {
1206
+ if (room.federated) {
1207
+ // Only include local members (not federated ones)
1208
+ const localMembers = room.members.filter((m) => !m.startsWith("fed:"));
1209
+ result.set(roomId, localMembers);
1210
+ }
1072
1211
  }
1073
- this.coordinatorServerSockets.clear();
1074
- this.coordinatorServer?.unref();
1075
- this.coordinatorServer?.close();
1076
- this.coordinatorServer = undefined;
1077
- this.isCoordinator = false;
1212
+ return result;
1078
1213
  }
1079
1214
  // -----------------------------------------------------------------------
1080
1215
  // Shutdown
1081
1216
  // -----------------------------------------------------------------------
1082
1217
  async shutdown() {
1218
+ this.isShutDown = true;
1219
+ // Clear any pending markRead timers so they don't fire after the
1220
+ // transport is shut down (which would attempt sends on closed sockets)
1221
+ // or keep the event loop alive after process.exit().
1222
+ for (const timer of this.pendingMarkReadTimers) {
1223
+ clearTimeout(timer);
1224
+ }
1225
+ this.pendingMarkReadTimers.length = 0;
1083
1226
  const agent = this.agents.get(this.peerId);
1084
1227
  if (agent) {
1085
1228
  agent.status = "offline";
@@ -1088,37 +1231,9 @@ export class MeshStore {
1088
1231
  agentId: this.peerId,
1089
1232
  });
1090
1233
  }
1091
- await this.handoverCoordinator();
1092
- // Destroy the coordinator client socket (not tracked in peerConnections)
1093
- this.coordinatorSocket?.unref();
1094
- this.coordinatorSocket?.destroy();
1095
- this.coordinatorSocket = undefined;
1096
- // Destroy all identified peer connections
1097
- for (const [, peer] of this.peerConnections) {
1098
- peer.socket.unref();
1099
- peer.socket.destroy();
1100
- }
1101
- this.peerConnections.clear();
1102
- // Destroy all data server accepted sockets (including unidentified)
1103
- for (const socket of this.dataServerSockets) {
1104
- socket.unref();
1105
- socket.destroy();
1106
- }
1107
- this.dataServerSockets.clear();
1108
- // Destroy all coordinator server accepted sockets
1109
- for (const socket of this.coordinatorServerSockets) {
1110
- socket.unref();
1111
- socket.destroy();
1112
- }
1113
- this.coordinatorServerSockets.clear();
1114
1234
  this.stopStaleCheck();
1115
- // Close servers — stop accepting new connections
1116
- this.dataServer?.unref();
1117
- this.dataServer?.close();
1118
- this.dataServer = undefined;
1119
- this.coordinatorServer?.unref();
1120
- this.coordinatorServer?.close();
1121
- this.coordinatorServer = undefined;
1235
+ await this.federation.shutdown();
1236
+ await this.transport.shutdown();
1122
1237
  }
1123
1238
  }
1124
1239
  //# sourceMappingURL=mesh-store.js.map