agent-comms 1.13.0 → 1.15.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 (256) 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 +5 -2
  16. package/dist/bridges/pi/index.d.ts.map +1 -1
  17. package/dist/bridges/pi/index.js +125 -20
  18. package/dist/bridges/pi/index.js.map +1 -1
  19. package/dist/bridges/user/controller.d.ts +10 -1
  20. package/dist/bridges/user/controller.d.ts.map +1 -1
  21. package/dist/bridges/user/controller.js +37 -1
  22. package/dist/bridges/user/controller.js.map +1 -1
  23. package/dist/bridges/user/tui.js +4 -0
  24. package/dist/bridges/user/tui.js.map +1 -1
  25. package/dist/bridges/user/web/build.d.ts +4 -4
  26. package/dist/bridges/user/web/build.js +53 -34
  27. package/dist/bridges/user/web/build.js.map +1 -1
  28. package/dist/bridges/user/web/frontend/components/App.d.ts +28 -0
  29. package/dist/bridges/user/web/frontend/components/App.d.ts.map +1 -0
  30. package/dist/bridges/user/web/frontend/components/App.js +7 -0
  31. package/dist/bridges/user/web/frontend/components/App.js.map +1 -0
  32. package/dist/bridges/user/web/frontend/components/ChatArea.d.ts +14 -0
  33. package/dist/bridges/user/web/frontend/components/ChatArea.d.ts.map +1 -0
  34. package/dist/bridges/user/web/frontend/components/ChatArea.js +38 -0
  35. package/dist/bridges/user/web/frontend/components/ChatArea.js.map +1 -0
  36. package/dist/bridges/user/web/frontend/components/CreateRoomForm.d.ts +11 -0
  37. package/dist/bridges/user/web/frontend/components/CreateRoomForm.d.ts.map +1 -0
  38. package/dist/bridges/user/web/frontend/components/CreateRoomForm.js +33 -0
  39. package/dist/bridges/user/web/frontend/components/CreateRoomForm.js.map +1 -0
  40. package/dist/bridges/user/web/frontend/components/JoinForm.d.ts +11 -0
  41. package/dist/bridges/user/web/frontend/components/JoinForm.d.ts.map +1 -0
  42. package/dist/bridges/user/web/frontend/components/JoinForm.js +28 -0
  43. package/dist/bridges/user/web/frontend/components/JoinForm.js.map +1 -0
  44. package/dist/bridges/user/web/frontend/components/Message.d.ts +8 -0
  45. package/dist/bridges/user/web/frontend/components/Message.d.ts.map +1 -0
  46. package/dist/bridges/user/web/frontend/components/Message.js +15 -0
  47. package/dist/bridges/user/web/frontend/components/Message.js.map +1 -0
  48. package/dist/bridges/user/web/frontend/components/MessageList.d.ts +8 -0
  49. package/dist/bridges/user/web/frontend/components/MessageList.d.ts.map +1 -0
  50. package/dist/bridges/user/web/frontend/components/MessageList.js +16 -0
  51. package/dist/bridges/user/web/frontend/components/MessageList.js.map +1 -0
  52. package/dist/bridges/user/web/frontend/components/ProjectTree.d.ts +17 -0
  53. package/dist/bridges/user/web/frontend/components/ProjectTree.d.ts.map +1 -0
  54. package/dist/bridges/user/web/frontend/components/ProjectTree.js +62 -0
  55. package/dist/bridges/user/web/frontend/components/ProjectTree.js.map +1 -0
  56. package/dist/bridges/user/web/frontend/components/RelayPanel.d.ts +15 -0
  57. package/dist/bridges/user/web/frontend/components/RelayPanel.d.ts.map +1 -0
  58. package/dist/bridges/user/web/frontend/components/RelayPanel.js +46 -0
  59. package/dist/bridges/user/web/frontend/components/RelayPanel.js.map +1 -0
  60. package/dist/bridges/user/web/frontend/components/Sidebar.d.ts +21 -0
  61. package/dist/bridges/user/web/frontend/components/Sidebar.d.ts.map +1 -0
  62. package/dist/bridges/user/web/frontend/components/Sidebar.js +37 -0
  63. package/dist/bridges/user/web/frontend/components/Sidebar.js.map +1 -0
  64. package/dist/bridges/user/web/frontend/dom.d.ts +12 -22
  65. package/dist/bridges/user/web/frontend/dom.d.ts.map +1 -1
  66. package/dist/bridges/user/web/frontend/dom.js +24 -49
  67. package/dist/bridges/user/web/frontend/dom.js.map +1 -1
  68. package/dist/bridges/user/web/frontend/input.d.ts +15 -0
  69. package/dist/bridges/user/web/frontend/input.d.ts.map +1 -1
  70. package/dist/bridges/user/web/frontend/input.js +39 -1
  71. package/dist/bridges/user/web/frontend/input.js.map +1 -1
  72. package/dist/bridges/user/web/frontend/main.d.ts +3 -3
  73. package/dist/bridges/user/web/frontend/main.d.ts.map +1 -1
  74. package/dist/bridges/user/web/frontend/main.js +135 -52
  75. package/dist/bridges/user/web/frontend/main.js.map +1 -1
  76. package/dist/bridges/user/web/frontend/mesh-client.d.ts +43 -0
  77. package/dist/bridges/user/web/frontend/mesh-client.d.ts.map +1 -0
  78. package/dist/bridges/user/web/frontend/mesh-client.js +132 -0
  79. package/dist/bridges/user/web/frontend/mesh-client.js.map +1 -0
  80. package/dist/bridges/user/web/frontend/mesh-worker.d.ts +14 -0
  81. package/dist/bridges/user/web/frontend/mesh-worker.d.ts.map +1 -0
  82. package/dist/bridges/user/web/frontend/mesh-worker.js +274 -0
  83. package/dist/bridges/user/web/frontend/mesh-worker.js.map +1 -0
  84. package/dist/bridges/user/web/frontend/messages.d.ts +17 -0
  85. package/dist/bridges/user/web/frontend/messages.d.ts.map +1 -0
  86. package/dist/bridges/user/web/frontend/messages.js +82 -0
  87. package/dist/bridges/user/web/frontend/messages.js.map +1 -0
  88. package/dist/bridges/user/web/frontend/project-tree.d.ts +9 -0
  89. package/dist/bridges/user/web/frontend/project-tree.d.ts.map +1 -0
  90. package/dist/bridges/user/web/frontend/project-tree.js +173 -0
  91. package/dist/bridges/user/web/frontend/project-tree.js.map +1 -0
  92. package/dist/bridges/user/web/frontend/relay-client.d.ts +40 -0
  93. package/dist/bridges/user/web/frontend/relay-client.d.ts.map +1 -0
  94. package/dist/bridges/user/web/frontend/relay-client.js +97 -0
  95. package/dist/bridges/user/web/frontend/relay-client.js.map +1 -0
  96. package/dist/bridges/user/web/frontend/relay-worker.d.ts +15 -0
  97. package/dist/bridges/user/web/frontend/relay-worker.d.ts.map +1 -0
  98. package/dist/bridges/user/web/frontend/relay-worker.js +366 -0
  99. package/dist/bridges/user/web/frontend/relay-worker.js.map +1 -0
  100. package/dist/bridges/user/web/frontend/state.d.ts +8 -1
  101. package/dist/bridges/user/web/frontend/state.d.ts.map +1 -1
  102. package/dist/bridges/user/web/frontend/state.js +17 -1
  103. package/dist/bridges/user/web/frontend/state.js.map +1 -1
  104. package/dist/bridges/user/web/frontend/sw.d.ts +16 -0
  105. package/dist/bridges/user/web/frontend/sw.d.ts.map +1 -0
  106. package/dist/bridges/user/web/frontend/sw.js +145 -0
  107. package/dist/bridges/user/web/frontend/sw.js.map +1 -0
  108. package/dist/bridges/user/web/frontend/types.d.ts +52 -0
  109. package/dist/bridges/user/web/frontend/types.d.ts.map +1 -1
  110. package/dist/bridges/user/web/frontend/url-sync.d.ts +48 -0
  111. package/dist/bridges/user/web/frontend/url-sync.d.ts.map +1 -0
  112. package/dist/bridges/user/web/frontend/url-sync.js +74 -0
  113. package/dist/bridges/user/web/frontend/url-sync.js.map +1 -0
  114. package/dist/bridges/user/web/server.d.ts +10 -3
  115. package/dist/bridges/user/web/server.d.ts.map +1 -1
  116. package/dist/bridges/user/web/server.js +280 -15
  117. package/dist/bridges/user/web/server.js.map +1 -1
  118. package/dist/bridges/user/web/test/web-server.integration.test.d.ts +7 -0
  119. package/dist/bridges/user/web/test/web-server.integration.test.d.ts.map +1 -0
  120. package/dist/bridges/user/web/test/web-server.integration.test.js +187 -0
  121. package/dist/bridges/user/web/test/web-server.integration.test.js.map +1 -0
  122. package/dist/core/bridge.d.ts +43 -0
  123. package/dist/core/bridge.d.ts.map +1 -1
  124. package/dist/core/bridge.js +197 -0
  125. package/dist/core/bridge.js.map +1 -1
  126. package/dist/core/comms-store.d.ts +22 -1
  127. package/dist/core/comms-store.d.ts.map +1 -1
  128. package/dist/core/discovery-mdns.d.ts +33 -0
  129. package/dist/core/discovery-mdns.d.ts.map +1 -0
  130. package/dist/core/discovery-mdns.js +182 -0
  131. package/dist/core/discovery-mdns.js.map +1 -0
  132. package/dist/core/discovery-tailscale.d.ts +40 -0
  133. package/dist/core/discovery-tailscale.d.ts.map +1 -0
  134. package/dist/core/discovery-tailscale.js +158 -0
  135. package/dist/core/discovery-tailscale.js.map +1 -0
  136. package/dist/core/discovery.d.ts +82 -0
  137. package/dist/core/discovery.d.ts.map +1 -0
  138. package/dist/core/discovery.js +194 -0
  139. package/dist/core/discovery.js.map +1 -0
  140. package/dist/core/federation.d.ts +100 -0
  141. package/dist/core/federation.d.ts.map +1 -0
  142. package/dist/core/federation.js +411 -0
  143. package/dist/core/federation.js.map +1 -0
  144. package/dist/core/identity.d.ts +31 -0
  145. package/dist/core/identity.d.ts.map +1 -0
  146. package/dist/core/identity.js +251 -0
  147. package/dist/core/identity.js.map +1 -0
  148. package/dist/core/index.d.ts +12 -1
  149. package/dist/core/index.d.ts.map +1 -1
  150. package/dist/core/index.js +7 -1
  151. package/dist/core/index.js.map +1 -1
  152. package/dist/core/mesh-store.d.ts +75 -26
  153. package/dist/core/mesh-store.d.ts.map +1 -1
  154. package/dist/core/mesh-store.js +570 -355
  155. package/dist/core/mesh-store.js.map +1 -1
  156. package/dist/core/push-manager.d.ts +41 -0
  157. package/dist/core/push-manager.d.ts.map +1 -0
  158. package/dist/core/push-manager.js +60 -0
  159. package/dist/core/push-manager.js.map +1 -0
  160. package/dist/core/store.d.ts +21 -1
  161. package/dist/core/store.d.ts.map +1 -1
  162. package/dist/core/store.js +44 -0
  163. package/dist/core/store.js.map +1 -1
  164. package/dist/core/tcp-transport.d.ts +77 -0
  165. package/dist/core/tcp-transport.d.ts.map +1 -0
  166. package/dist/core/tcp-transport.js +596 -0
  167. package/dist/core/tcp-transport.js.map +1 -0
  168. package/dist/core/test/bridge.unit.test.d.ts +5 -0
  169. package/dist/core/test/bridge.unit.test.d.ts.map +1 -0
  170. package/dist/core/test/bridge.unit.test.js +120 -0
  171. package/dist/core/test/bridge.unit.test.js.map +1 -0
  172. package/dist/core/test/identity.unit.test.d.ts +5 -0
  173. package/dist/core/test/identity.unit.test.d.ts.map +1 -0
  174. package/dist/core/test/identity.unit.test.js +70 -0
  175. package/dist/core/test/identity.unit.test.js.map +1 -0
  176. package/dist/core/tls-transport.d.ts +69 -0
  177. package/dist/core/tls-transport.d.ts.map +1 -0
  178. package/dist/core/tls-transport.js +602 -0
  179. package/dist/core/tls-transport.js.map +1 -0
  180. package/dist/core/tool.d.ts +23 -2
  181. package/dist/core/tool.d.ts.map +1 -1
  182. package/dist/core/tool.js +269 -1
  183. package/dist/core/tool.js.map +1 -1
  184. package/dist/core/transport.d.ts +166 -0
  185. package/dist/core/transport.d.ts.map +1 -0
  186. package/dist/core/transport.js +20 -0
  187. package/dist/core/transport.js.map +1 -0
  188. package/dist/core/types.d.ts +146 -0
  189. package/dist/core/types.d.ts.map +1 -1
  190. package/dist/core/types.js +77 -0
  191. package/dist/core/types.js.map +1 -1
  192. package/dist/core/vapid.d.ts +22 -0
  193. package/dist/core/vapid.d.ts.map +1 -0
  194. package/dist/core/vapid.js +50 -0
  195. package/dist/core/vapid.js.map +1 -0
  196. package/dist/core/web-push.d.ts +37 -0
  197. package/dist/core/web-push.d.ts.map +1 -0
  198. package/dist/core/web-push.js +198 -0
  199. package/dist/core/web-push.js.map +1 -0
  200. package/dist/core/wire-protocol.d.ts +131 -0
  201. package/dist/core/wire-protocol.d.ts.map +1 -0
  202. package/dist/core/wire-protocol.js +54 -0
  203. package/dist/core/wire-protocol.js.map +1 -0
  204. package/dist/core/ws-transport.d.ts +71 -0
  205. package/dist/core/ws-transport.d.ts.map +1 -0
  206. package/dist/core/ws-transport.js +560 -0
  207. package/dist/core/ws-transport.js.map +1 -0
  208. package/dist/test/approval.integration.test.d.ts +9 -0
  209. package/dist/test/approval.integration.test.d.ts.map +1 -0
  210. package/dist/test/approval.integration.test.js +361 -0
  211. package/dist/test/approval.integration.test.js.map +1 -0
  212. package/dist/test/delivery-receipt.helper.d.ts +16 -0
  213. package/dist/test/delivery-receipt.helper.d.ts.map +1 -0
  214. package/dist/test/delivery-receipt.helper.js +409 -0
  215. package/dist/test/delivery-receipt.helper.js.map +1 -0
  216. package/dist/test/delivery-receipt.runner.d.ts +13 -0
  217. package/dist/test/delivery-receipt.runner.d.ts.map +1 -0
  218. package/dist/test/delivery-receipt.runner.js +51 -0
  219. package/dist/test/delivery-receipt.runner.js.map +1 -0
  220. package/dist/test/federation.integration.test.d.ts +15 -0
  221. package/dist/test/federation.integration.test.d.ts.map +1 -0
  222. package/dist/test/federation.integration.test.js +218 -0
  223. package/dist/test/federation.integration.test.js.map +1 -0
  224. package/dist/test/listener-policy.integration.test.d.ts +9 -0
  225. package/dist/test/listener-policy.integration.test.d.ts.map +1 -0
  226. package/dist/test/listener-policy.integration.test.js +239 -0
  227. package/dist/test/listener-policy.integration.test.js.map +1 -0
  228. package/dist/test/tls-transport.integration.test.d.ts +8 -0
  229. package/dist/test/tls-transport.integration.test.d.ts.map +1 -0
  230. package/dist/test/tls-transport.integration.test.js +158 -0
  231. package/dist/test/tls-transport.integration.test.js.map +1 -0
  232. package/dist/test/visibility.integration.test.d.ts +12 -0
  233. package/dist/test/visibility.integration.test.d.ts.map +1 -0
  234. package/dist/test/visibility.integration.test.js +181 -0
  235. package/dist/test/visibility.integration.test.js.map +1 -0
  236. package/package.json +7 -3
  237. package/dist/bridges/user/web/frontend/render.d.ts +0 -30
  238. package/dist/bridges/user/web/frontend/render.d.ts.map +0 -1
  239. package/dist/bridges/user/web/frontend/render.js +0 -129
  240. package/dist/bridges/user/web/frontend/render.js.map +0 -1
  241. package/dist/bridges/user/web/frontend/test/dom.unit.test.d.ts +0 -5
  242. package/dist/bridges/user/web/frontend/test/dom.unit.test.d.ts.map +0 -1
  243. package/dist/bridges/user/web/frontend/test/dom.unit.test.js +0 -149
  244. package/dist/bridges/user/web/frontend/test/dom.unit.test.js.map +0 -1
  245. package/dist/bridges/user/web/frontend/test/input.unit.test.d.ts +0 -5
  246. package/dist/bridges/user/web/frontend/test/input.unit.test.d.ts.map +0 -1
  247. package/dist/bridges/user/web/frontend/test/input.unit.test.js +0 -131
  248. package/dist/bridges/user/web/frontend/test/input.unit.test.js.map +0 -1
  249. package/dist/bridges/user/web/frontend/test/render.unit.test.d.ts +0 -5
  250. package/dist/bridges/user/web/frontend/test/render.unit.test.d.ts.map +0 -1
  251. package/dist/bridges/user/web/frontend/test/render.unit.test.js +0 -436
  252. package/dist/bridges/user/web/frontend/test/render.unit.test.js.map +0 -1
  253. package/dist/bridges/user/web/frontend/test/state.unit.test.d.ts +0 -5
  254. package/dist/bridges/user/web/frontend/test/state.unit.test.d.ts.map +0 -1
  255. package/dist/bridges/user/web/frontend/test/state.unit.test.js +0 -119
  256. package/dist/bridges/user/web/frontend/test/state.unit.test.js.map +0 -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,157 +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
+ }
59
+ lastLocalDeliveryKey;
60
+ localDeliveryKeys = new Set();
61
+ discovery;
62
+ federation;
98
63
  constructor(coordinatorPort = DEFAULT_COORDINATOR_PORT) {
99
64
  this.peerId = nanoid(8);
100
65
  this.startedAt = new Date().toISOString();
101
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;
102
89
  }
103
90
  // -----------------------------------------------------------------------
104
91
  // Mesh lifecycle
105
92
  // -----------------------------------------------------------------------
106
93
  async init() {
107
- await this.startDataServer();
108
- await this.tryJoinMesh();
109
- // Unref all root handles so the event loop can exit when pi shuts down.
110
- // Sockets/servers still function normally for I/O but don't keep the
111
- // process alive. shutdown() will destroy them explicitly.
112
- this.dataServer?.unref();
113
- this.coordinatorServer?.unref();
114
- this.coordinatorSocket?.unref();
115
- }
116
- startDataServer() {
117
- return new Promise((resolve, reject) => {
118
- this.dataServer = net.createServer((socket) => {
119
- this.handleDataConnection(socket);
120
- });
121
- this.dataServer.listen(0, COORDINATOR_HOST, () => {
122
- const addr = this.dataServer?.address();
123
- if (addr && typeof addr === "object") {
124
- this.dataPort = addr.port;
125
- }
126
- resolve();
127
- });
128
- 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,
129
100
  });
130
- }
131
- async tryJoinMesh() {
132
- try {
133
- await this.connectToCoordinator();
134
- }
135
- catch {
136
- await this.becomeCoordinator();
137
- }
138
- }
139
- connectToCoordinator() {
140
- return new Promise((resolve, reject) => {
141
- const socket = net.createConnection({ port: this.coordinatorPort, host: COORDINATOR_HOST }, () => {
142
- this.coordinatorSocket = socket;
143
- const intro = {
144
- method: "introduce",
145
- peerId: this.peerId,
146
- dataPort: this.dataPort,
147
- };
148
- socket.write(encode(intro));
149
- // Read the peer_list response
150
- const buf = new MessageBuffer();
151
- socket.on("data", (data) => {
152
- const items = buf.append(data.toString());
153
- for (const item of items) {
154
- if (isMeshMessage(item)) {
155
- 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;
156
128
  }
129
+ // EADDRINUSE — another process became coordinator. Retry connect.
157
130
  }
158
- });
159
- socket.on("error", () => {
160
- /* ignore late errors */
161
- });
162
- clearTimeout(timer);
163
- resolve();
164
- });
165
- const timer = setTimeout(() => {
166
- socket.destroy();
167
- reject(new Error("Coordinator connection timeout"));
168
- }, 2000);
169
- socket.on("error", (err) => {
170
- clearTimeout(timer);
171
- socket.destroy();
172
- reject(err);
173
- });
174
- });
175
- }
176
- handleCoordinatorResponse(msg) {
177
- if (msg.method === "peer_list") {
178
- for (const peer of msg.peers) {
179
- this.peerInfo.set(peer.id, peer);
180
- 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
+ }
181
136
  }
182
137
  }
183
- else if (msg.method === "peer_joined") {
184
- this.peerInfo.set(msg.peer.id, msg.peer);
185
- 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"}`);
186
140
  }
187
- }
188
- becomeCoordinator() {
189
- return new Promise((resolve, reject) => {
190
- this.coordinatorServer = net.createServer((socket) => {
191
- this.handleCoordinatorConnection(socket);
192
- });
193
- this.coordinatorServer.listen(this.coordinatorPort, COORDINATOR_HOST, () => {
194
- this.isCoordinator = true;
195
- this.startStaleCheck();
196
- this.peerInfo.set(this.peerId, {
197
- id: this.peerId,
198
- port: this.dataPort,
199
- startedAt: this.startedAt,
200
- });
201
- resolve();
202
- });
203
- this.coordinatorServer.on("error", (err) => {
204
- const isAddrInUse = err instanceof Error && "code" in err && err.code === "EADDRINUSE";
205
- if (isAddrInUse) {
206
- this.coordinatorServer = undefined;
207
- void this.connectToCoordinator().then(resolve, reject);
208
- }
209
- else {
210
- reject(err instanceof Error ? err : new Error(String(err)));
211
- }
212
- });
213
- });
141
+ this.transport.unref();
214
142
  }
215
143
  // -----------------------------------------------------------------------
216
- // Coordinator protocol
144
+ // TransportEvents — callbacks from the transport layer
217
145
  // -----------------------------------------------------------------------
218
- handleCoordinatorConnection(socket) {
219
- this.coordinatorServerSockets.add(socket);
220
- socket.on("close", () => this.coordinatorServerSockets.delete(socket));
221
- socket.on("error", () => {
222
- this.coordinatorServerSockets.delete(socket);
223
- });
224
- const buffer = new MessageBuffer();
225
- socket.on("data", (data) => {
226
- const items = buffer.append(data.toString());
227
- for (const item of items) {
228
- if (isMeshMessage(item) && item.method === "introduce") {
229
- void this.handleIntroduction(socket, item);
230
- }
231
- }
232
- });
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);
233
155
  }
234
- async handleIntroduction(socket, msg) {
156
+ async handleIntroduction(handle, msg) {
235
157
  const newPeer = {
236
158
  id: msg.peerId,
237
159
  port: msg.dataPort,
@@ -243,45 +165,29 @@ export class MeshStore {
243
165
  method: "peer_list",
244
166
  peers: [...this.peerInfo.values()],
245
167
  };
246
- await writeAsync(socket, encode(peerList));
247
- // Broadcast arrival to all existing data connections
168
+ await this.transport.send(handle, peerList);
169
+ // Broadcast arrival to all existing peers
248
170
  const joined = { method: "peer_joined", peer: newPeer };
249
- await this.broadcastToDataConnections(joined);
171
+ await this.transport.broadcast(joined);
250
172
  // Connect to the new peer's data server
251
- void this.connectToPeerData(newPeer);
173
+ void this.transport.connectToPeer(newPeer, this.peerId);
252
174
  }
253
- // -----------------------------------------------------------------------
254
- // Data connections
255
- // -----------------------------------------------------------------------
256
- handleDataConnection(socket) {
257
- this.dataServerSockets.add(socket);
258
- socket.on("close", () => this.dataServerSockets.delete(socket));
259
- const buffer = new MessageBuffer();
260
- let remotePeerId;
261
- socket.on("data", (data) => {
262
- const items = buffer.append(data.toString());
263
- for (const item of items) {
264
- if (isMeshMessage(item)) {
265
- void this.handleDataMessage(item);
266
- if (item.method === "pong") {
267
- remotePeerId = item.peerId;
268
- }
269
- }
270
- }
271
- if (remotePeerId && !this.peerConnections.has(remotePeerId)) {
272
- this.peerConnections.set(remotePeerId, { socket, buffer });
273
- }
274
- });
275
- socket.on("close", () => {
276
- if (remotePeerId)
277
- this.peerConnections.delete(remotePeerId);
278
- });
279
- socket.on("error", () => {
280
- if (remotePeerId)
281
- this.peerConnections.delete(remotePeerId);
282
- });
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
+ }
283
189
  }
284
- async handleDataMessage(msg) {
190
+ async handleDataMessage(handle, msg) {
285
191
  if (msg.method === "state_sync") {
286
192
  // Merge — don't replace — so our own state isn't lost
287
193
  const incoming = {
@@ -311,11 +217,153 @@ export class MeshStore {
311
217
  await this.applyPatch(msg.patch);
312
218
  }
313
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
+ // -----------------------------------------------------------------------
314
349
  async applyPatch(patch) {
315
350
  switch (patch.type) {
316
- case "agent_upsert":
317
- this.agents.set(patch.agent.id, patch.agent);
351
+ case "agent_upsert": {
352
+ // Merge subscribedRooms to avoid losing local room memberships.
353
+ const existingAgent = this.agents.get(patch.agent.id);
354
+ if (existingAgent) {
355
+ const merged = patch.agent;
356
+ for (const r of existingAgent.subscribedRooms) {
357
+ if (!merged.subscribedRooms.includes(r))
358
+ merged.subscribedRooms.push(r);
359
+ }
360
+ this.agents.set(merged.id, merged);
361
+ }
362
+ else {
363
+ this.agents.set(patch.agent.id, patch.agent);
364
+ }
318
365
  break;
366
+ }
319
367
  case "agent_offline": {
320
368
  const agent = this.agents.get(patch.agentId);
321
369
  if (agent) {
@@ -324,9 +372,23 @@ export class MeshStore {
324
372
  }
325
373
  break;
326
374
  }
327
- case "room_upsert":
328
- this.rooms.set(patch.room.id, patch.room);
375
+ case "room_upsert": {
376
+ // Merge members rather than overwriting — last-write-wins can lose
377
+ // members added locally when a remote patch arrives with a stale list.
378
+ const existing = this.rooms.get(patch.room.id);
379
+ if (existing) {
380
+ const merged = patch.room;
381
+ for (const m of existing.members) {
382
+ if (!merged.members.includes(m))
383
+ merged.members.push(m);
384
+ }
385
+ this.rooms.set(merged.id, merged);
386
+ }
387
+ else {
388
+ this.rooms.set(patch.room.id, patch.room);
389
+ }
329
390
  break;
391
+ }
330
392
  case "room_delete":
331
393
  this.rooms.delete(patch.roomId);
332
394
  break;
@@ -347,14 +409,35 @@ export class MeshStore {
347
409
  arr.push(patch.event);
348
410
  this.deliveryQueues.set(patch.agentId, arr);
349
411
  if (patch.agentId === this.peerId && this.onDelivery) {
350
- void this.onDelivery(patch.agentId, patch.event);
351
- // Auto-mark read — push bridges consume immediately
352
- if (patch.event.type === "room_message") {
353
- await this.markRead(patch.event.message.id, this.peerId, patch.event.message.room);
354
- }
355
- else if (patch.event.type === "dm") {
356
- await this.markRead(patch.event.message.id, this.peerId);
412
+ // Deduplicate against local deliveries
413
+ const eventKey = JSON.stringify(patch.event);
414
+ if (this.localDeliveryKeys.has(eventKey))
415
+ break;
416
+ this.localDeliveryKeys.add(eventKey);
417
+ if (this.localDeliveryKeys.size > 50) {
418
+ const oldest = this.localDeliveryKeys.values().next().value;
419
+ if (oldest !== undefined)
420
+ this.localDeliveryKeys.delete(oldest);
357
421
  }
422
+ void this.onDelivery(patch.agentId, patch.event);
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);
358
441
  }
359
442
  break;
360
443
  }
@@ -363,63 +446,18 @@ export class MeshStore {
363
446
  break;
364
447
  }
365
448
  }
366
- }
367
- async connectToPeerData(peer) {
368
- if (peer.id === this.peerId)
369
- return;
370
- if (this.peerConnections.has(peer.id))
371
- return;
372
- return new Promise((resolve) => {
373
- const socket = net.createConnection({ port: peer.port, host: COORDINATOR_HOST }, () => {
374
- const buf = new MessageBuffer();
375
- this.peerConnections.set(peer.id, { socket, buffer: buf });
376
- // Identify ourselves
377
- const pong = { method: "pong", peerId: this.peerId };
378
- socket.write(encode(pong));
379
- // If we have state and peer doesn't, send state sync
380
- if (this.agents.size > 0) {
381
- const state = {
382
- agents: Object.fromEntries(this.agents),
383
- rooms: Object.fromEntries(this.rooms),
384
- messages: Object.fromEntries(this.messages),
385
- dms: Object.fromEntries(this.dms),
386
- };
387
- socket.write(encode({ method: "state_sync", state }));
388
- }
389
- // Wire up ongoing message handling
390
- socket.on("data", (data) => {
391
- const items = buf.append(data.toString());
392
- for (const item of items) {
393
- if (isMeshMessage(item)) {
394
- void this.handleDataMessage(item);
395
- }
396
- }
397
- });
398
- resolve();
399
- });
400
- socket.on("close", () => this.peerConnections.delete(peer.id));
401
- socket.on("error", () => {
402
- this.peerConnections.delete(peer.id);
403
- socket.destroy();
404
- resolve();
405
- });
406
- });
449
+ if (this.onPatch) {
450
+ await this.onPatch(patch);
451
+ }
407
452
  }
408
453
  // -----------------------------------------------------------------------
409
- // Broadcast (async — writes to TCP sockets)
454
+ // Broadcast
410
455
  // -----------------------------------------------------------------------
411
- async broadcastToDataConnections(msg) {
412
- const data = encode(msg);
413
- const writes = [];
414
- for (const [, peer] of this.peerConnections) {
415
- writes.push(writeAsync(peer.socket, data).catch(() => {
416
- /* broken connection — cleanup handled by close/error listeners */
417
- }));
418
- }
419
- await Promise.all(writes);
420
- }
421
456
  async broadcastPatch(patch) {
422
- 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
+ }
423
461
  }
424
462
  async deliverLocallyAndBroadcast(agentId, event) {
425
463
  // Local delivery
@@ -434,14 +472,34 @@ export class MeshStore {
434
472
  await this.emitDeliveryStatus(event.message.id, agentId, "delivered");
435
473
  }
436
474
  if (agentId === this.peerId && this.onDelivery) {
437
- void this.onDelivery(agentId, event);
438
- // Auto-mark read push bridges consume immediately
439
- if (event.type === "room_message") {
440
- await this.markRead(event.message.id, agentId, event.message.room);
441
- }
442
- else if (event.type === "dm") {
443
- await this.markRead(event.message.id, agentId);
475
+ // Deduplicate: skip if this exact event was already delivered locally.
476
+ // The mesh can echo delivery patches through multiple peer paths,
477
+ // causing applyPatch to fire onDelivery for the same event.
478
+ const eventKey = JSON.stringify(event);
479
+ if (this.localDeliveryKeys.has(eventKey))
480
+ return;
481
+ this.localDeliveryKeys.add(eventKey);
482
+ // Prevent unbounded growth — evict oldest when cap reached
483
+ if (this.localDeliveryKeys.size > 50) {
484
+ const oldest = this.localDeliveryKeys.values().next().value;
485
+ if (oldest !== undefined)
486
+ this.localDeliveryKeys.delete(oldest);
444
487
  }
488
+ void this.onDelivery(agentId, event);
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);
445
503
  }
446
504
  // Remote delivery
447
505
  const patch = { type: "delivery", agentId, event };
@@ -470,6 +528,22 @@ export class MeshStore {
470
528
  });
471
529
  }
472
530
  }
531
+ async notifyRoomsOfNameChange(agentId, oldName, newName) {
532
+ const agent = this.agents.get(agentId);
533
+ if (!agent)
534
+ return;
535
+ const event = {
536
+ type: "name_changed",
537
+ agent: agentId,
538
+ oldName,
539
+ newName,
540
+ };
541
+ for (const roomId of agent.subscribedRooms) {
542
+ await this.deliverToRoom(roomId, event, agentId);
543
+ }
544
+ // Also deliver to the agent itself so it sees confirmation
545
+ await this.deliverLocallyAndBroadcast(agentId, event);
546
+ }
473
547
  async emitDeliveryStatus(messageId, agentId, status, room) {
474
548
  // Find the sender for this message
475
549
  const senderId = this.findMessageSender(messageId, room);
@@ -588,6 +662,10 @@ export class MeshStore {
588
662
  this.agents.set(id, agent);
589
663
  await this.writeIdentity(opts.harness, opts.cwd, id);
590
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
+ }
591
669
  return agent;
592
670
  }
593
671
  async getAgent(id) {
@@ -599,9 +677,13 @@ export class MeshStore {
599
677
  if (!agent)
600
678
  throw new CommsError(`Agent ${id} not found`, "AGENT_NOT_FOUND");
601
679
  const oldStatus = agent.status;
680
+ const oldName = agent.name;
602
681
  Object.assign(agent, patch);
603
682
  this.agents.set(id, agent);
604
683
  await this.broadcastPatch({ type: "agent_upsert", agent });
684
+ if (patch.name !== undefined && patch.name !== oldName) {
685
+ await this.notifyRoomsOfNameChange(id, oldName, patch.name);
686
+ }
605
687
  if (patch.status && patch.status !== oldStatus) {
606
688
  await this.notifyRoomsOfStatus(id, patch.status);
607
689
  }
@@ -619,14 +701,19 @@ export class MeshStore {
619
701
  }
620
702
  async setAgentOffline(id) {
621
703
  const agent = this.agents.get(id);
622
- if (agent) {
623
- agent.status = "offline";
624
- this.agents.set(id, agent);
704
+ if (!agent)
705
+ return;
706
+ if (agent.status === "offline")
707
+ return;
708
+ // Only the owning store should broadcast the status change.
709
+ // Other stores learn about it via the agent_offline mesh patch.
710
+ const isOwner = id === this.peerId;
711
+ agent.status = "offline";
712
+ this.agents.set(id, agent);
713
+ if (isOwner) {
625
714
  await this.notifyRoomsOfStatus(id, "offline");
626
715
  await this.broadcastPatch({ type: "agent_offline", agentId: id });
627
- }
628
- if (this.isCoordinator) {
629
- await this.handoverCoordinator();
716
+ await this.federation.broadcastAgentGone(id);
630
717
  }
631
718
  }
632
719
  // -----------------------------------------------------------------------
@@ -645,6 +732,7 @@ export class MeshStore {
645
732
  description: opts.description,
646
733
  members: [opts.owner],
647
734
  invited: [],
735
+ federated: opts.federated ?? false,
648
736
  };
649
737
  this.rooms.set(id, room);
650
738
  this.messages.set(id, []);
@@ -714,6 +802,11 @@ export class MeshStore {
714
802
  room: roomId,
715
803
  agent: agentId,
716
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
+ }
717
810
  return room;
718
811
  }
719
812
  async leaveRoom(roomId, agentId) {
@@ -734,6 +827,10 @@ export class MeshStore {
734
827
  room: roomId,
735
828
  agent: agentId,
736
829
  });
830
+ // Notify federated links if the room is federated
831
+ if (room.federated) {
832
+ await this.federation.broadcastRoomLeave(roomId, agentId);
833
+ }
737
834
  if (room.members.length === 0 && room.owner === agentId) {
738
835
  await this.destroyRoom(roomId, agentId);
739
836
  }
@@ -829,6 +926,10 @@ export class MeshStore {
829
926
  arr.push(message);
830
927
  this.messages.set(roomId, arr);
831
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
+ }
832
933
  for (const memberId of room.members) {
833
934
  if (memberId !== from) {
834
935
  await this.deliverLocallyAndBroadcast(memberId, {
@@ -928,11 +1029,27 @@ export class MeshStore {
928
1029
  await this.notifyRoomsOfStatus(id, "offline");
929
1030
  }
930
1031
  await this.broadcastPatch({ type: "agent_offline", agentId: id });
1032
+ this.peerInfo.delete(id);
1033
+ }
1034
+ // Also purge long-offline agents to prevent indefinite accumulation
1035
+ const offlineThreshold = Date.now() - 30 * 60 * 1000; // 30 minutes
1036
+ const purgeIds = [];
1037
+ for (const [id, agent] of this.agents) {
1038
+ if (agent.status !== "offline")
1039
+ continue;
1040
+ const startedAt = new Date(agent.startedAt).getTime();
1041
+ if (startedAt < offlineThreshold) {
1042
+ purgeIds.push(id);
1043
+ }
1044
+ }
1045
+ for (const id of purgeIds) {
1046
+ this.agents.delete(id);
1047
+ this.peerInfo.delete(id);
1048
+ this.identityCache.delete(id);
931
1049
  }
932
1050
  }
933
1051
  isProcessAlive(pid) {
934
1052
  try {
935
- // Sending signal 0 doesn't kill the process — it just checks existence
936
1053
  process.kill(pid, 0);
937
1054
  return true;
938
1055
  }
@@ -941,45 +1058,171 @@ export class MeshStore {
941
1058
  }
942
1059
  }
943
1060
  // -----------------------------------------------------------------------
944
- // Coordinator handover
1061
+ // Mesh visibility
945
1062
  // -----------------------------------------------------------------------
946
- async handoverCoordinator() {
947
- if (!this.isCoordinator)
948
- return;
949
- let successor;
950
- for (const [id, info] of this.peerInfo) {
951
- 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)
952
1094
  continue;
953
- if (!successor || info.startedAt < successor.startedAt) {
954
- 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
+ });
955
1102
  }
956
1103
  }
957
- if (successor) {
958
- const peer = this.peerConnections.get(successor.id);
959
- if (peer) {
960
- const msg = {
961
- method: "become_coordinator",
962
- peerList: [...this.peerInfo.values()].filter((p) => p.id !== this.peerId),
963
- };
964
- await writeAsync(peer.socket, encode(msg));
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;
965
1141
  }
966
1142
  }
967
- // Always clean up coordinator state, even with no successor
968
- this.stopStaleCheck();
969
- for (const socket of this.coordinatorServerSockets) {
970
- socket.unref();
971
- socket.destroy();
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);
1199
+ }
1200
+ }
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
+ }
972
1211
  }
973
- this.coordinatorServerSockets.clear();
974
- this.coordinatorServer?.unref();
975
- this.coordinatorServer?.close();
976
- this.coordinatorServer = undefined;
977
- this.isCoordinator = false;
1212
+ return result;
978
1213
  }
979
1214
  // -----------------------------------------------------------------------
980
1215
  // Shutdown
981
1216
  // -----------------------------------------------------------------------
982
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;
983
1226
  const agent = this.agents.get(this.peerId);
984
1227
  if (agent) {
985
1228
  agent.status = "offline";
@@ -988,37 +1231,9 @@ export class MeshStore {
988
1231
  agentId: this.peerId,
989
1232
  });
990
1233
  }
991
- await this.handoverCoordinator();
992
- // Destroy the coordinator client socket (not tracked in peerConnections)
993
- this.coordinatorSocket?.unref();
994
- this.coordinatorSocket?.destroy();
995
- this.coordinatorSocket = undefined;
996
- // Destroy all identified peer connections
997
- for (const [, peer] of this.peerConnections) {
998
- peer.socket.unref();
999
- peer.socket.destroy();
1000
- }
1001
- this.peerConnections.clear();
1002
- // Destroy all data server accepted sockets (including unidentified)
1003
- for (const socket of this.dataServerSockets) {
1004
- socket.unref();
1005
- socket.destroy();
1006
- }
1007
- this.dataServerSockets.clear();
1008
- // Destroy all coordinator server accepted sockets
1009
- for (const socket of this.coordinatorServerSockets) {
1010
- socket.unref();
1011
- socket.destroy();
1012
- }
1013
- this.coordinatorServerSockets.clear();
1014
1234
  this.stopStaleCheck();
1015
- // Close servers — stop accepting new connections
1016
- this.dataServer?.unref();
1017
- this.dataServer?.close();
1018
- this.dataServer = undefined;
1019
- this.coordinatorServer?.unref();
1020
- this.coordinatorServer?.close();
1021
- this.coordinatorServer = undefined;
1235
+ await this.federation.shutdown();
1236
+ await this.transport.shutdown();
1022
1237
  }
1023
1238
  }
1024
1239
  //# sourceMappingURL=mesh-store.js.map