agent-comms 8.15.9 → 8.15.10

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/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,782 @@
1
+ /**
2
+ * Web UI server — HTTP + oRPC WebSocket for browser-based chat.
3
+ *
4
+ * Serves a single-page frontend and exposes a JSON API over HTTP, plus a real-time oRPC endpoint (meshRouter, implementing contract.ts's meshContract) on /ws/mesh -- state sync, state patches, delivery events, and every mutating action all go through this one endpoint now; there is no longer a separate hand-rolled chat socket or a second, differently-shaped mesh socket.
5
+ *
6
+ * REST endpoints: GET / → frontend HTML GET /api/agents → list agents GET /api/rooms → list rooms GET /api/rooms/:id/messages → read room messages POST /api/action → execute any CommsAction (test scaffolding only -- the real frontend drives everything through /ws/mesh) GET /api/mesh/graph → the mesh's connection graph (agent-comms#199/#201) GET /api/mesh/trace?target=<deviceHex>&timeoutMs=<n> → live path trace to a device
7
+ *
8
+ * When AGENT_COMMS_WEB_CONSOLE_DIST points at a built wire-mesh web-console (see web-console-static.ts), GET /web-console/* also serves that as an alternate, generic reference-client UI alongside the frontend above.
9
+ */
10
+
11
+ import * as http from "node:http";
12
+ import { fileURLToPath } from "node:url";
13
+ import { WebSocketServer } from "ws";
14
+ import { RPCHandler } from "@orpc/server/websocket";
15
+ import { PushManager } from "../../../core/push-manager.js";
16
+ import { ChatController } from "../controller.js";
17
+ import type { MeshStore } from "../../../core/mesh-store.js";
18
+ import { CommsError } from "../../../core/store.js";
19
+ import type { MeshStatePatch } from "../../../core/wire-protocol.js";
20
+ import type { WebUrlStatus } from "../../../core/tool.js";
21
+ import {
22
+ createRoomAction,
23
+ declineInviteAction,
24
+ destroyRoomAction,
25
+ dmAction,
26
+ inviteAction,
27
+ joinRoomAction,
28
+ kickAction,
29
+ leaveRoomAction,
30
+ listAgentsAction,
31
+ listRoomsAction,
32
+ readRoomAction,
33
+ renameAgentAction,
34
+ sendAction,
35
+ } from "./actions.js";
36
+ import {
37
+ getMeshGraphRead,
38
+ getMeshTraceRead,
39
+ getRoomMessagesRead,
40
+ } from "./reads.js";
41
+ import { MeshEventPublisher } from "./event-publisher.js";
42
+ import { meshRouter } from "./router.js";
43
+ import type { DeliveryEvent } from "../../../core/types.js";
44
+ import {
45
+ resolveWebConsoleDist,
46
+ serveWebConsole,
47
+ } from "./web-console-static.js";
48
+ import {
49
+ ACCESS_QUERY_PARAM,
50
+ accessUrls,
51
+ authorise,
52
+ generateAccessToken,
53
+ localWebUrl,
54
+ ownInterfaceAddresses,
55
+ requiresAccessToken,
56
+ resolveWebBindHost,
57
+ type AccessDecision,
58
+ type AccessPolicy,
59
+ } from "./web-access.js";
60
+ import { startWebBeacon } from "./web-beacon.js";
61
+ import {
62
+ createFrontendAssetLoader,
63
+ FrontendNotBuiltError,
64
+ type FrontendAssets,
65
+ } from "./web-assets.js";
66
+
67
+ const HTTP_OK = 200;
68
+ const HTTP_BAD_REQUEST = 400;
69
+ const HTTP_UNAUTHORIZED = 401;
70
+ const HTTP_NOT_FOUND = 404;
71
+ const HTTP_INTERNAL_SERVER_ERROR = 500;
72
+ const HTTP_NOT_IMPLEMENTED = 501;
73
+ const HTTP_SERVICE_UNAVAILABLE = 503;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Static assets — read from the frontend build on first request
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const frontendAssets = createFrontendAssetLoader(
80
+ fileURLToPath(new URL("./dist", import.meta.url)),
81
+ );
82
+
83
+ /**
84
+ * The frontend build output, or undefined after answering 503 because the build is absent. The server module is imported by every bridge, so a checkout without the build must still start; only the routes that serve the frontend are unavailable.
85
+ */
86
+ function frontendOrUnavailable(
87
+ res: http.ServerResponse,
88
+ ): FrontendAssets | undefined {
89
+ try {
90
+ return frontendAssets();
91
+ } catch (error) {
92
+ if (!(error instanceof FrontendNotBuiltError)) throw error;
93
+ res.writeHead(HTTP_SERVICE_UNAVAILABLE, {
94
+ "Content-Type": "text/plain; charset=utf-8",
95
+ });
96
+ res.end(error.message);
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Active handles — needed so HTTP handlers can reach the PushManager
103
+ // ---------------------------------------------------------------------------
104
+
105
+ const activeHandles = new Set<WebServerHandle>();
106
+
107
+ function findHandle(controller: ChatController): WebServerHandle | undefined {
108
+ for (const handle of activeHandles) {
109
+ if (handle.controller === controller) return handle;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Types
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export interface WebServerHandle {
119
+ server: http.Server;
120
+ controller: ChatController;
121
+ wss: WebSocketServer;
122
+ pushManager: PushManager;
123
+ publisher: MeshEventPublisher;
124
+ /** The address the server is bound to: loopback unless the operator opted into LAN reachability (AGENT_COMMS_WEB_HOST). */
125
+ host: string;
126
+ /** The per-process secret every non-loopback request must present. Present only when the bind is beyond loopback; never advertised. */
127
+ accessToken: string | undefined;
128
+ }
129
+
130
+ /** Resolve the listening port from a running web server handle, or undefined if the OS hasn't assigned one yet. */
131
+ export function getWebPort(handle: WebServerHandle): number | undefined {
132
+ const addr = handle.server.address();
133
+ return typeof addr === "object" && addr ? addr.port : undefined;
134
+ }
135
+
136
+ /** Resolve a bridge's own web UI address for the CommsTool web_url action, given whatever handle (if any) that bridge's own tryStartBridgeWebServer() call produced. The single place this three-way distinction is computed, shared by every bridge's `tool.getWebUrlStatus` wiring instead of each bridge re-deriving it from a raw WebServerHandle. */
137
+ export function getWebUrlStatus(
138
+ handle: WebServerHandle | undefined,
139
+ ): WebUrlStatus {
140
+ if (!handle) return { kind: "not_running" };
141
+ const port = getWebPort(handle);
142
+ if (port === undefined || port === 0) return { kind: "pending" };
143
+ return { kind: "ready", url: localWebUrl(handle.host, port) };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Auto-start — called by every bridge after MeshStore.init()
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Starts the web UI for a bridge on top of the bridge's own store, so the process stays one mesh peer and one agent (the agent id is the store's peer id, before or after the agent registers). Every bridge entry point that owns a store uses this, and only the standalone dashboard command builds a controller, and so a store, of its own.
152
+ *
153
+ * The server listens on an OS-assigned free port (port 0) to avoid TOCTOU races when multiple bridges start web servers concurrently: the OS atomically allocates a unique free port for each. The PWA discovery path (probe from 19877) is only used when the page is served from a non-local host (e.g. GitHub Pages); when served locally, the browser connects via location.host directly, so the port number doesn't need to be predictable.
154
+ */
155
+ export async function tryStartBridgeWebServer(
156
+ store: MeshStore,
157
+ harness: string,
158
+ ): Promise<WebServerHandle | undefined> {
159
+ return createWebServer({
160
+ port: 0,
161
+ existingController: ChatController.fromExisting(store, {
162
+ agentId: store.peerId,
163
+ harness,
164
+ cwd: process.cwd(),
165
+ pid: process.pid,
166
+ }),
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Create and start the web server on a dynamic port.
172
+ *
173
+ * Accepts an optional ChatController for reuse — bridges that already
174
+ * have a MeshStore and agent identity pass theirs in so the web UI
175
+ * shares the same mesh peer instead of creating a redundant one.
176
+ * When no controller is provided (standalone runWeb mode), a fresh
177
+ * Dashboard controller is created.
178
+ */
179
+ export async function createWebServer(options?: {
180
+ port?: number;
181
+ /** The IP address to bind. Defaults to AGENT_COMMS_WEB_HOST, then loopback. A non-loopback address makes the server reachable from the LAN and turns on the access token (see web-access.ts). */
182
+ host?: string | undefined;
183
+ /** The addresses whose clients count as on this machine besides loopback. Defaults to every interface address of this machine; tests replace it to make a client on the machine's own address stand in for a remote device (see AccessPolicy.localClientAddresses). */
184
+ localClientAddresses?: (() => ReadonlySet<string>) | undefined;
185
+ /** The UDP port the LAN beacon advertises on when the bind is beyond loopback. Defaults to the first-contact port; tests pass an OS-assigned free port. */
186
+ beaconPort?: number | undefined;
187
+ existingController?: ChatController | undefined;
188
+ coordinatorPort?: number | undefined;
189
+ /** Overrides the first-contact UDP port a fresh controller's store runs presence on (ChatController's own firstContactPort); ignored when existingController is supplied. */
190
+ firstContactPort?: number | undefined;
191
+ /** Overrides the hub a fresh controller's own coordinator role dials on takeover -- ChatController's own hubUrl, ignored when existingController is supplied since that controller already made its own choice. See ChatController's own hubUrl doc comment. */
192
+ hubUrl?: string | undefined;
193
+ }): Promise<WebServerHandle> {
194
+ const {
195
+ port = 0,
196
+ host: requestedHost,
197
+ localClientAddresses = ownInterfaceAddresses,
198
+ beaconPort,
199
+ existingController,
200
+ coordinatorPort,
201
+ firstContactPort,
202
+ hubUrl,
203
+ } = options ?? {};
204
+ const controller =
205
+ existingController ??
206
+ new ChatController("Dashboard", {
207
+ coordinatorPort,
208
+ hubUrl,
209
+ firstContactPort,
210
+ });
211
+ if (!existingController) {
212
+ await controller.init();
213
+ }
214
+
215
+ const webConsoleDist = resolveWebConsoleDist();
216
+
217
+ const host = resolveWebBindHost(requestedHost, process.env);
218
+ const accessToken = requiresAccessToken(host)
219
+ ? generateAccessToken()
220
+ : undefined;
221
+
222
+ // The port is only known once the server listens (port 0 asks the OS), so the policy's port is filled in by the listen callback, before any request can arrive.
223
+ const accessPolicy: AccessPolicy = {
224
+ token: accessToken,
225
+ bindHost: host,
226
+ port,
227
+ localClientAddresses,
228
+ };
229
+
230
+ const server = http.createServer((req, res) => {
231
+ if (!admitRequest(accessPolicy, req, res)) return;
232
+ handleRequest(req, res, controller, webConsoleDist);
233
+ });
234
+
235
+ const pushManager = new PushManager();
236
+ const publisher = new MeshEventPublisher();
237
+ ensurePatchListener(controller.meshStore, publisher);
238
+ controller.on("message", (event: DeliveryEvent) => {
239
+ publisher.publish({ kind: "delivery", event });
240
+ });
241
+
242
+ const wss = new WebSocketServer({ noServer: true });
243
+ const rpcHandler = new RPCHandler(meshRouter);
244
+
245
+ server.on("upgrade", (req, socket, head) => {
246
+ const decision = authorise(accessPolicy, requestOf(req));
247
+ if (decision.kind !== "allow") {
248
+ const status =
249
+ decision.kind === "deny" ? decision.status : HTTP_UNAUTHORIZED;
250
+ socket.write(
251
+ `HTTP/1.1 ${String(status)} ${http.STATUS_CODES[status] ?? ""}\r\n\r\n`,
252
+ );
253
+ socket.destroy();
254
+ return;
255
+ }
256
+ if (req.url === "/ws/mesh") {
257
+ wss.handleUpgrade(req, socket, head, (ws) => {
258
+ wss.emit("connection", ws, req);
259
+ });
260
+ } else {
261
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
262
+ socket.destroy();
263
+ }
264
+ });
265
+
266
+ wss.on("connection", (ws) => {
267
+ // RPCHandler.upgrade() wants a DOM-shaped WebSocket (addEventListener's overload set), which "ws"'s own type declarations don't structurally satisfy (its addEventListener options param is narrower than DOM's) -- .message()/.close() accept the looser { send } shape "ws" does satisfy, and are the documented manual equivalent to .upgrade().
268
+ const context = { controller, publisher, pushManager };
269
+ ws.on("message", (data, isBinary) => {
270
+ // "ws" types incoming data as Buffer | ArrayBuffer | Buffer[] regardless of frame type; normalise to a real Buffer first so both branches below have a type they can trust rather than calling .toString()/Uint8Array.from() on a union that could be an ArrayBuffer.
271
+ const raw = Array.isArray(data) ? Buffer.concat(data) : data;
272
+ const bytes = raw instanceof ArrayBuffer ? Buffer.from(raw) : raw;
273
+ // Buffer's .buffer is typed ArrayBufferLike (Buffer can wrap a SharedArrayBuffer); RPCHandler.message() wants the narrower Uint8Array<ArrayBuffer> -- a fresh Uint8Array.from() copy is always backed by a plain ArrayBuffer, satisfying that exactly.
274
+ const payload = isBinary ? Uint8Array.from(bytes) : bytes.toString();
275
+ void rpcHandler.message(ws, payload, { context });
276
+ });
277
+ ws.on("close", () => {
278
+ void rpcHandler.close(ws);
279
+ });
280
+ });
281
+
282
+ server.listen(port, host, () => {
283
+ const addr = server.address();
284
+ accessPolicy.port = typeof addr === "object" && addr ? addr.port : port;
285
+ // stderr, not stdout: a stdio MCP bridge owns stdout for JSON-RPC, so any other line there is protocol noise to a strictly framed client.
286
+ console.error(
287
+ `Agent Comms web UI: ${localWebUrl(host, accessPolicy.port)}`,
288
+ );
289
+ if (accessToken !== undefined) {
290
+ announceLanAccess(
291
+ server,
292
+ { ...accessPolicy, token: accessToken },
293
+ controller.meshStore.peerId,
294
+ beaconPort,
295
+ );
296
+ }
297
+ });
298
+
299
+ return { server, controller, wss, pushManager, publisher, host, accessToken };
300
+ }
301
+
302
+ function requestOf(req: http.IncomingMessage): {
303
+ method: string;
304
+ url: string;
305
+ remoteAddress: string | undefined;
306
+ headers: http.IncomingHttpHeaders;
307
+ } {
308
+ return {
309
+ method: req.method ?? "GET",
310
+ url: req.url ?? "/",
311
+ remoteAddress: req.socket.remoteAddress,
312
+ headers: req.headers,
313
+ };
314
+ }
315
+
316
+ /** Applies the access policy to an HTTP request, answering it (401, 403, or the cookie-setting redirect of the one-time URL exchange) when it is not admitted. Returns true when the request should proceed to the normal handlers. */
317
+ function admitRequest(
318
+ policy: Readonly<AccessPolicy>,
319
+ req: http.IncomingMessage,
320
+ res: http.ServerResponse,
321
+ ): boolean {
322
+ const decision: AccessDecision = authorise(policy, requestOf(req));
323
+ switch (decision.kind) {
324
+ case "allow":
325
+ return true;
326
+ case "exchange":
327
+ res.writeHead(decision.status, {
328
+ "Set-Cookie": decision.cookie,
329
+ Location: decision.location,
330
+ "Cache-Control": "no-store",
331
+ });
332
+ res.end();
333
+ return false;
334
+ case "deny":
335
+ res.writeHead(decision.status, {
336
+ "Content-Type": "text/plain; charset=utf-8",
337
+ ...(decision.status === HTTP_UNAUTHORIZED
338
+ ? { "WWW-Authenticate": "Bearer" }
339
+ : {}),
340
+ });
341
+ res.end(decision.message);
342
+ return false;
343
+ default:
344
+ return decision satisfies never;
345
+ }
346
+ }
347
+
348
+ /** Tells the operator how to open the LAN-reachable UI, and advertises its host and port (never the token) over the beacon port. The token lines go to stderr: stdout can be an MCP channel, and the token is a credential. */
349
+ function announceLanAccess(
350
+ server: http.Server,
351
+ policy: Readonly<AccessPolicy> & { readonly token: string },
352
+ peerId: string,
353
+ beaconPort: number | undefined,
354
+ ): void {
355
+ console.error(
356
+ "Agent Comms web UI is reachable beyond loopback. Open one of these from a device on the network; the token is required for every non-loopback request:",
357
+ );
358
+ const urls = accessUrls(policy);
359
+ if (urls.length === 0) {
360
+ console.error(
361
+ `No external address of this machine was found. Reach it by a name or address the network knows, at port ${String(policy.port)}, and open /?${ACCESS_QUERY_PARAM}=${policy.token}`,
362
+ );
363
+ }
364
+ for (const url of urls) console.error(` ${url}`);
365
+ const beacon = startWebBeacon({
366
+ peerId,
367
+ webPort: policy.port,
368
+ port: beaconPort,
369
+ onError: (error) => {
370
+ console.error(`Agent Comms web UI beacon: ${error.message}`);
371
+ },
372
+ });
373
+ server.once("close", () => {
374
+ beacon.stop();
375
+ });
376
+ }
377
+
378
+ class HandleRef {
379
+ constructor(public readonly handle: WebServerHandle) {
380
+ activeHandles.add(handle);
381
+ }
382
+
383
+ dispose(): void {
384
+ activeHandles.delete(this.handle);
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Standalone mode — `npx agent-comms chat`
390
+ // ---------------------------------------------------------------------------
391
+
392
+ export async function runWeb(userName: string, port = 0): Promise<void> {
393
+ const handle = await createWebServer({ port });
394
+ // Keep handle alive for cleanup — variable is intentionally unused
395
+ new HandleRef(handle);
396
+
397
+ handle.server.on("listening", () => {
398
+ console.log(
399
+ `Connected as ${userName} (user) [${handle.controller.agentId}]`,
400
+ );
401
+ });
402
+
403
+ // Graceful shutdown
404
+ const cleanup = async (): Promise<void> => {
405
+ handle.wss.close();
406
+ handle.server.close();
407
+ await handle.controller.shutdown();
408
+ process.exit(0);
409
+ };
410
+
411
+ process.on("SIGINT", () => {
412
+ void cleanup();
413
+ });
414
+ process.on("SIGTERM", () => {
415
+ void cleanup();
416
+ });
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // HTTP handler
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function handleRequest(
424
+ req: http.IncomingMessage,
425
+ res: http.ServerResponse,
426
+ controller: ChatController,
427
+ webConsoleDist: string | undefined,
428
+ ): void {
429
+ const url = new URL(req.url ?? "/", `http://localhost`);
430
+
431
+ // wire-mesh's web-console — an alternate, generic reference-client UI, served only when AGENT_COMMS_WEB_CONSOLE_DIST resolves to a real build.
432
+ if (
433
+ webConsoleDist !== undefined &&
434
+ req.method === "GET" &&
435
+ serveWebConsole(webConsoleDist, url.pathname, res)
436
+ ) {
437
+ return;
438
+ }
439
+
440
+ // Frontend HTML -- also served at the literal /index.html path (not just
441
+ // /) because vite-plugin-pwa's injectManifest precache list references
442
+ // the build output's own filename, index.html, and precaches it by
443
+ // fetching that exact URL: a 404 there fails cache.addAll's whole batch,
444
+ // which fails the service worker's install step, so the browser discards
445
+ // the registration entirely.
446
+ if (
447
+ (url.pathname === "/" || url.pathname === "/index.html") &&
448
+ req.method === "GET"
449
+ ) {
450
+ const assets = frontendOrUnavailable(res);
451
+ if (assets === undefined) return;
452
+ res.writeHead(HTTP_OK, { "Content-Type": "text/html; charset=utf-8" });
453
+ res.end(assets.indexHtml);
454
+ return;
455
+ }
456
+
457
+ // Frontend JS bundle
458
+ if (url.pathname === "/bundle.js" && req.method === "GET") {
459
+ const assets = frontendOrUnavailable(res);
460
+ if (assets === undefined) return;
461
+ res.writeHead(HTTP_OK, {
462
+ "Content-Type": "application/javascript; charset=utf-8",
463
+ });
464
+ res.end(assets.bundleJs);
465
+ return;
466
+ }
467
+
468
+ // Mesh SharedWorker bundle
469
+ if (url.pathname === "/mesh-worker.js" && req.method === "GET") {
470
+ const assets = frontendOrUnavailable(res);
471
+ if (assets === undefined) return;
472
+ res.writeHead(HTTP_OK, {
473
+ "Content-Type": "application/javascript; charset=utf-8",
474
+ });
475
+ res.end(assets.meshWorkerJs);
476
+ return;
477
+ }
478
+
479
+ // Service worker bundle
480
+ if (url.pathname === "/sw.js" && req.method === "GET") {
481
+ const assets = frontendOrUnavailable(res);
482
+ if (assets === undefined) return;
483
+ res.writeHead(HTTP_OK, {
484
+ "Content-Type": "application/javascript; charset=utf-8",
485
+ "Service-Worker-Allowed": "/",
486
+ });
487
+ res.end(assets.swJs);
488
+ return;
489
+ }
490
+
491
+ // Web App Manifest
492
+ if (url.pathname === "/manifest.json" && req.method === "GET") {
493
+ const assets = frontendOrUnavailable(res);
494
+ if (assets === undefined) return;
495
+ res.writeHead(HTTP_OK, {
496
+ "Content-Type": "application/manifest+json; charset=utf-8",
497
+ });
498
+ res.end(assets.manifestJson);
499
+ return;
500
+ }
501
+
502
+ // PWA icons
503
+ if (url.pathname.startsWith("/icons/") && req.method === "GET") {
504
+ const filename = url.pathname.slice("/icons/".length);
505
+ const assets = frontendOrUnavailable(res);
506
+ if (assets === undefined) return;
507
+ const icon = assets.icons[filename];
508
+ if (icon) {
509
+ res.writeHead(HTTP_OK, {
510
+ "Content-Type": "image/svg+xml",
511
+ "Cache-Control": "public, max-age=604800",
512
+ });
513
+ res.end(icon);
514
+ return;
515
+ }
516
+ }
517
+
518
+ // VAPID public key for push subscription
519
+ if (url.pathname === "/api/push/vapid-key" && req.method === "GET") {
520
+ // Lazy-initialise PushManager on first request — this endpoint
521
+ // is only hit from the frontend when push is supported.
522
+ const handle = findHandle(controller);
523
+ const key = handle?.pushManager.getPublicKey() ?? "";
524
+ json(res, { publicKey: key });
525
+ return;
526
+ }
527
+
528
+ // API routes
529
+ if (url.pathname === "/api/agents" && req.method === "GET") {
530
+ void (async () => {
531
+ const agents = await controller.getAgents();
532
+ json(res, agents);
533
+ })();
534
+ return;
535
+ }
536
+
537
+ if (url.pathname === "/api/rooms" && req.method === "GET") {
538
+ void (async () => {
539
+ const rooms = await controller.getRooms();
540
+ json(res, rooms);
541
+ })();
542
+ return;
543
+ }
544
+
545
+ if (
546
+ url.pathname.startsWith("/api/rooms/") &&
547
+ url.pathname.endsWith("/messages") &&
548
+ req.method === "GET"
549
+ ) {
550
+ void (async () => {
551
+ const roomId = url.pathname.split("/")[3];
552
+ if (roomId === undefined || roomId === "") {
553
+ jsonError(res, "Room ID required", HTTP_BAD_REQUEST);
554
+ return;
555
+ }
556
+ const since = url.searchParams.get("since") ?? undefined;
557
+ const messages = await getRoomMessagesRead(controller, {
558
+ room: roomId,
559
+ since,
560
+ });
561
+ json(res, messages);
562
+ })();
563
+ return;
564
+ }
565
+
566
+ if (url.pathname === "/api/mesh/graph" && req.method === "GET") {
567
+ try {
568
+ json(res, getMeshGraphRead(controller));
569
+ } catch (err) {
570
+ jsonError(
571
+ res,
572
+ meshErrorMessage(err),
573
+ meshErrorStatus(err, HTTP_INTERNAL_SERVER_ERROR),
574
+ );
575
+ }
576
+ return;
577
+ }
578
+
579
+ if (url.pathname === "/api/mesh/trace" && req.method === "GET") {
580
+ void (async () => {
581
+ const target = url.searchParams.get("target");
582
+ if (target === null || target === "") {
583
+ jsonError(res, "target query parameter required", HTTP_BAD_REQUEST);
584
+ return;
585
+ }
586
+ const timeoutMsParam = url.searchParams.get("timeoutMs");
587
+ const timeoutMs =
588
+ timeoutMsParam !== null ? Number(timeoutMsParam) : undefined;
589
+ try {
590
+ const result = await getMeshTraceRead(controller, {
591
+ target,
592
+ timeoutMs,
593
+ });
594
+ json(res, result);
595
+ } catch (err) {
596
+ jsonError(
597
+ res,
598
+ meshErrorMessage(err),
599
+ meshErrorStatus(err, HTTP_BAD_REQUEST),
600
+ );
601
+ }
602
+ })();
603
+ return;
604
+ }
605
+
606
+ if (url.pathname === "/api/action" && req.method === "POST") {
607
+ void (async () => {
608
+ const body = await readBody(req);
609
+ const parsed: unknown = JSON.parse(body);
610
+ if (typeof parsed !== "object" || parsed === null) {
611
+ jsonError(res, "Invalid JSON", HTTP_BAD_REQUEST);
612
+ return;
613
+ }
614
+ const params = Object.fromEntries(Object.entries(parsed));
615
+
616
+ const result = await executeAction(controller, params);
617
+ json(res, result);
618
+ })();
619
+ return;
620
+ }
621
+
622
+ res.writeHead(HTTP_NOT_FOUND);
623
+ res.end("Not found");
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Mesh state patch fan-out
628
+ // ---------------------------------------------------------------------------
629
+
630
+ /**
631
+ * Stores this wiring has already been applied to. A WeakSet (not a single boolean) because a process can run multiple concurrent WebServerHandle instances, each with its own MeshStore (multiple bridges each starting their own web UI) -- a single once-ever flag would silently wire only the first store any handle in the process ever created, leaving every later handle's store.onPatch never set and its publisher never fed. Confirmed directly: this was the original bug behind the single boolean this replaces (frontend/mesh-worker.ts's own module-level meshPatchListenerActive), caught by a multi-test-in-one-process run where only the first test's server ever received state_patch events.
632
+ */
633
+ const patchListenerWiredStores = new WeakSet<MeshStore>();
634
+
635
+ /**
636
+ * Publishes every state patch the given store produces as a state_patch MeshEvent on the oRPC event stream. Called once per WebServerHandle at server-start time (createWebServer). Idempotent per store, not per process.
637
+ */
638
+ function ensurePatchListener(
639
+ store: MeshStore,
640
+ publisher: MeshEventPublisher,
641
+ ): void {
642
+ if (patchListenerWiredStores.has(store)) return;
643
+ patchListenerWiredStores.add(store);
644
+ store.addPatchListener((patch: MeshStatePatch): void => {
645
+ publisher.publish({ kind: "state_patch", patch });
646
+ });
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Action dispatcher
651
+ // ---------------------------------------------------------------------------
652
+
653
+ async function executeAction(
654
+ controller: ChatController,
655
+ params: Record<string, unknown>,
656
+ ): Promise<{ content: string; isError: boolean }> {
657
+ const action = params.action;
658
+
659
+ switch (action) {
660
+ case "send":
661
+ return sendAction(controller, {
662
+ target: getString(params, "target") ?? "",
663
+ content: getString(params, "content") ?? "",
664
+ });
665
+ case "dm":
666
+ return dmAction(controller, {
667
+ target: getString(params, "target") ?? "",
668
+ content: getString(params, "content") ?? "",
669
+ });
670
+ case "join_room":
671
+ return joinRoomAction(controller, {
672
+ room: getString(params, "room") ?? "",
673
+ });
674
+ case "leave_room":
675
+ return leaveRoomAction(controller, { room: getString(params, "room") });
676
+ case "create_room":
677
+ return createRoomAction(controller, {
678
+ name: getString(params, "name") ?? "",
679
+ type: getRoomType(params, "type") ?? "public",
680
+ description: getString(params, "description"),
681
+ });
682
+ case "list_rooms":
683
+ return listRoomsAction(controller);
684
+ case "list_agents":
685
+ return listAgentsAction(controller);
686
+ case "read_room":
687
+ return readRoomAction(controller, { room: getString(params, "room") });
688
+ case "destroy_room":
689
+ return destroyRoomAction(controller, {
690
+ room: getString(params, "room") ?? "",
691
+ });
692
+ case "invite":
693
+ return inviteAction(controller, {
694
+ room: getString(params, "room") ?? "",
695
+ agent: getString(params, "agent") ?? "",
696
+ });
697
+ case "decline_invite":
698
+ return declineInviteAction(controller, {
699
+ room: getString(params, "room") ?? "",
700
+ reason: getString(params, "reason") ?? "",
701
+ });
702
+ case "kick":
703
+ return kickAction(controller, {
704
+ room: getString(params, "room") ?? "",
705
+ agent: getString(params, "agent") ?? "",
706
+ });
707
+ case "rename_agent":
708
+ return renameAgentAction(controller, {
709
+ agent: getString(params, "agent") ?? "",
710
+ name: getString(params, "name") ?? "",
711
+ });
712
+ default:
713
+ return { content: `Unknown action: ${String(action)}`, isError: true };
714
+ }
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // Param extraction (no type assertions)
719
+ // ---------------------------------------------------------------------------
720
+
721
+ function getString(
722
+ params: Record<string, unknown>,
723
+ key: string,
724
+ ): string | undefined {
725
+ const value = params[key];
726
+ return typeof value === "string" ? value : undefined;
727
+ }
728
+
729
+ function getRoomType(
730
+ params: Record<string, unknown>,
731
+ key: string,
732
+ ): "public" | "private" | "secret" | undefined {
733
+ const value = params[key];
734
+ if (value === "public" || value === "private" || value === "secret") {
735
+ return value;
736
+ }
737
+ return undefined;
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Helpers
742
+ // ---------------------------------------------------------------------------
743
+
744
+ function json(res: http.ServerResponse, data: unknown): void {
745
+ res.writeHead(HTTP_OK, { "Content-Type": "application/json" });
746
+ res.end(JSON.stringify(data));
747
+ }
748
+
749
+ function jsonError(
750
+ res: http.ServerResponse,
751
+ message: string,
752
+ status: number,
753
+ ): void {
754
+ res.writeHead(status, { "Content-Type": "application/json" });
755
+ res.end(JSON.stringify({ error: message }));
756
+ }
757
+
758
+ /** Extracts a reportable message from whatever meshGraph()/meshTrace() threw -- a CommsError has a real message, anything else is reported generically rather than leaking an unexpected error shape to the client. */
759
+ function meshErrorMessage(err: unknown): string {
760
+ return err instanceof Error ? err.message : "Unknown error";
761
+ }
762
+
763
+ /** A transport that doesn't support mesh_graph/mesh_trace (CommsError code NOT_SUPPORTED, e.g. this bridge is running on FileStore rather than a real mesh) is a client-visible "this endpoint isn't available here" rather than a server fault. Any other error falls back to `otherwise` -- HTTP_BAD_REQUEST for mesh_trace, since its only other realistic failure is a malformed client-supplied target; HTTP_INTERNAL_SERVER_ERROR for mesh_graph, which takes no client input at all. */
764
+ function meshErrorStatus(err: unknown, otherwise: number): number {
765
+ if (err instanceof CommsError && err.code === "NOT_SUPPORTED") {
766
+ return HTTP_NOT_IMPLEMENTED;
767
+ }
768
+ return otherwise;
769
+ }
770
+
771
+ async function readBody(req: http.IncomingMessage): Promise<string> {
772
+ return new Promise((resolve, reject) => {
773
+ const chunks: Buffer[] = [];
774
+ req.on("data", (chunk: Buffer) => {
775
+ chunks.push(chunk);
776
+ });
777
+ req.on("end", () => {
778
+ resolve(Buffer.concat(chunks).toString());
779
+ });
780
+ req.on("error", reject);
781
+ });
782
+ }