agent-comms 8.15.8 → 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,704 @@
1
+ /**
2
+ * Filesystem layout for agent comms.
3
+ *
4
+ * All paths are relative to a configurable comms root (default: ~/.agents/comms/).
5
+ * Every operation is a file read/write — no server process needed.
6
+ * JSON.parse boundaries use Zod schemas for type-safe parsing.
7
+ */
8
+
9
+ import { MAX_DISPLAY_NAME_LENGTH, parseDisplayName } from "./display-name.js";
10
+ import * as fs from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { nanoid } from "./nanoid.js";
14
+
15
+ /** Swallow expected filesystem errors (e.g. file already deleted). */
16
+ function swallow(_error: unknown): void {
17
+ /* intentionally empty — expected failure */
18
+ }
19
+
20
+ import {
21
+ AgentIdentitySchema,
22
+ DeliveryEventSchema,
23
+ RoomMessageSchema,
24
+ RoomSchema,
25
+ } from "./types.js";
26
+ import type {
27
+ AgentIdentity,
28
+ DeliveryEvent,
29
+ DmMessage,
30
+ Room,
31
+ RoomMessage,
32
+ RoomType,
33
+ StreamingBehavior,
34
+ Visibility,
35
+ } from "./types.js";
36
+ import type {
37
+ CommsStore,
38
+ SendRoomMessageOptions,
39
+ SentDm,
40
+ SentRoomMessage,
41
+ } from "./comms-store.js";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // CommsError
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Length of the random suffix used for a freshly minted agent id. */
48
+ const AGENT_ID_LENGTH = 8;
49
+
50
+ /** Length of the random suffix appended to a `<timestamp>-<suffix>` message/event id, to disambiguate ids minted within the same millisecond. */
51
+ const MESSAGE_ID_SUFFIX_LENGTH = 6;
52
+
53
+ export class CommsError extends Error {
54
+ constructor(
55
+ message: string,
56
+ public readonly code: string,
57
+ ) {
58
+ super(message);
59
+ this.name = "CommsError";
60
+ }
61
+ }
62
+
63
+ /** raw as parseDisplayName (display-name.ts) stores it. Throws INVALID_NAME, naming what was being named (for example "A petname"), when parseDisplayName refuses it: every place that accepts a name from this end (a petname, this machine's name, an agent's registered name) refuses an unusable one the same way. Lives here beside CommsError because display-name.ts is also bundled for the browser and so depends on nothing. */
64
+ export function requireDisplayName(raw: string, what: string): string {
65
+ const name = parseDisplayName(raw);
66
+ if (name === undefined) {
67
+ throw new CommsError(
68
+ `${what} must be non-empty, at most ${String(MAX_DISPLAY_NAME_LENGTH)} characters, and free of control characters.`,
69
+ "INVALID_NAME",
70
+ );
71
+ }
72
+ return name;
73
+ }
74
+
75
+ /** An agent's registered name is its self-asserted name (display-name.ts), so it is held to the same rules as it is registered or changed: requireDisplayName for "An agent name". */
76
+ export function requireAgentName(raw: string): string {
77
+ return requireDisplayName(raw, "An agent name");
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // FileStore
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export class FileStore implements CommsStore {
85
+ constructor(
86
+ public readonly root: string = path.join(os.homedir(), ".agents", "comms"),
87
+ ) {}
88
+
89
+ // -------------------------------------------------------------------------
90
+ // Paths
91
+ // -------------------------------------------------------------------------
92
+
93
+ agentPath(id: string): string {
94
+ return path.join(this.root, "registry", "agents", `${id}.json`);
95
+ }
96
+
97
+ roomPath(id: string): string {
98
+ return path.join(this.root, "registry", "rooms", `${id}.json`);
99
+ }
100
+
101
+ roomMessagesDir(id: string): string {
102
+ return path.join(this.root, "rooms", id);
103
+ }
104
+
105
+ dmDir(a: string, b: string): string {
106
+ const sorted = [a, b].sort();
107
+ const first = sorted[0] ?? a;
108
+ const second = sorted[1] ?? b;
109
+ return path.join(this.root, "dms", `${first}--${second}`);
110
+ }
111
+
112
+ deliveryDir(id: string): string {
113
+ return path.join(this.root, "delivery", id);
114
+ }
115
+
116
+ identityPath(harness: string, cwd: string): string {
117
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "_");
118
+ return path.join(this.root, "identity", `${harness}--${slug}.json`);
119
+ }
120
+
121
+ // -------------------------------------------------------------------------
122
+ // Helpers
123
+ // -------------------------------------------------------------------------
124
+
125
+ private async readJsonFile(filePath: string): Promise<unknown> {
126
+ const raw = await fs.readFile(filePath, "utf-8");
127
+ return JSON.parse(raw);
128
+ }
129
+
130
+ private async writeJsonFile(filePath: string, data: unknown): Promise<void> {
131
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
132
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
133
+ }
134
+
135
+ // -------------------------------------------------------------------------
136
+ // Identity
137
+ // -------------------------------------------------------------------------
138
+
139
+ async readIdentity(
140
+ harness: string,
141
+ cwd: string,
142
+ ): Promise<{ id: string } | undefined> {
143
+ try {
144
+ const raw = await this.readJsonFile(this.identityPath(harness, cwd));
145
+ if (typeof raw === "object" && raw !== null && "id" in raw) {
146
+ const id = raw.id;
147
+ if (typeof id === "string") return { id };
148
+ }
149
+ return undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ async writeIdentity(harness: string, cwd: string, id: string): Promise<void> {
156
+ await this.writeJsonFile(this.identityPath(harness, cwd), { id });
157
+ }
158
+
159
+ // -------------------------------------------------------------------------
160
+ // Agent registry
161
+ // -------------------------------------------------------------------------
162
+
163
+ async registerAgent(opts: {
164
+ name: string;
165
+ harness: string;
166
+ cwd: string;
167
+ pid: number;
168
+ visibility: Visibility;
169
+ tags: string[];
170
+ }): Promise<AgentIdentity> {
171
+ const name = requireAgentName(opts.name);
172
+ const existing = await this.readIdentity(opts.harness, opts.cwd);
173
+ if (existing) {
174
+ return this.updateAgent(existing.id, {
175
+ name,
176
+ visibility: opts.visibility,
177
+ tags: opts.tags,
178
+ status: "active",
179
+ pid: opts.pid,
180
+ });
181
+ }
182
+
183
+ const id = nanoid(AGENT_ID_LENGTH);
184
+ const agent: AgentIdentity = {
185
+ id,
186
+ version: 1,
187
+ name,
188
+ harness: opts.harness,
189
+ cwd: opts.cwd,
190
+ pid: opts.pid,
191
+ startedAt: new Date().toISOString(),
192
+ visibility: opts.visibility,
193
+ status: "active",
194
+ tags: opts.tags,
195
+ subscribedRooms: [],
196
+ };
197
+
198
+ await this.writeJsonFile(this.agentPath(id), agent);
199
+ await this.writeIdentity(opts.harness, opts.cwd, id);
200
+ return agent;
201
+ }
202
+
203
+ async getAgent(id: string): Promise<AgentIdentity | undefined> {
204
+ try {
205
+ return AgentIdentitySchema.parse(
206
+ await this.readJsonFile(this.agentPath(id)),
207
+ );
208
+ } catch {
209
+ return undefined;
210
+ }
211
+ }
212
+
213
+ async updateAgent(
214
+ id: string,
215
+ patch: Partial<
216
+ Pick<AgentIdentity, "name" | "visibility" | "status" | "tags" | "pid">
217
+ >,
218
+ ): Promise<AgentIdentity> {
219
+ const agent = await this.getAgent(id);
220
+ if (!agent)
221
+ throw new CommsError(`Agent ${id} not found`, "AGENT_NOT_FOUND");
222
+
223
+ const updated: AgentIdentity = {
224
+ ...agent,
225
+ ...patch,
226
+ ...(patch.name === undefined
227
+ ? {}
228
+ : { name: requireAgentName(patch.name) }),
229
+ version: agent.version + 1,
230
+ };
231
+ await this.writeJsonFile(this.agentPath(id), updated);
232
+ return updated;
233
+ }
234
+
235
+ async listAgents(requesterId: string): Promise<AgentIdentity[]> {
236
+ const dir = path.join(this.root, "registry", "agents");
237
+ let files: string[];
238
+ try {
239
+ files = await fs.readdir(dir);
240
+ } catch (err) {
241
+ // A store with no registry yet legitimately lists no agents; a record
242
+ // that fails to parse is a real failure and must surface (#32).
243
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
244
+ return [];
245
+ }
246
+ throw err;
247
+ }
248
+ const agents: AgentIdentity[] = [];
249
+ for (const file of files) {
250
+ if (!file.endsWith(".json")) continue;
251
+ const agent = AgentIdentitySchema.parse(
252
+ await this.readJsonFile(path.join(dir, file)),
253
+ );
254
+ if (agent.visibility === "ghost" && agent.id !== requesterId) continue;
255
+ agents.push(agent);
256
+ }
257
+ return agents;
258
+ }
259
+
260
+ async setAgentOffline(id: string): Promise<void> {
261
+ const agent = await this.getAgent(id);
262
+ if (agent) {
263
+ agent.status = "offline";
264
+ agent.version += 1;
265
+ await this.writeJsonFile(this.agentPath(id), agent);
266
+ }
267
+ }
268
+
269
+ // -------------------------------------------------------------------------
270
+ // Rooms
271
+ // -------------------------------------------------------------------------
272
+
273
+ async createRoom(
274
+ opts: Readonly<{
275
+ name: string;
276
+ type: RoomType;
277
+ owner: string;
278
+ description: string;
279
+ }>,
280
+ ): Promise<Room> {
281
+ const id = opts.type === "secret" ? `_${opts.name}` : opts.name;
282
+ const existing = await this.getRoom(id);
283
+ if (existing)
284
+ throw new CommsError(`Room ${id} already exists`, "ROOM_EXISTS");
285
+
286
+ const room: Room = {
287
+ id,
288
+ version: 1,
289
+ name: opts.name,
290
+ type: opts.type,
291
+ owner: opts.owner,
292
+ createdAt: new Date().toISOString(),
293
+ description: opts.description,
294
+ members: [opts.owner],
295
+ invited: [],
296
+ memberJoins: { [opts.owner]: 1 },
297
+ memberLeaves: {},
298
+ invitedJoins: {},
299
+ invitedLeaves: {},
300
+ };
301
+
302
+ await this.writeJsonFile(this.roomPath(id), room);
303
+ await fs.mkdir(this.roomMessagesDir(id), { recursive: true });
304
+ return room;
305
+ }
306
+
307
+ async getRoom(id: string): Promise<Room | undefined> {
308
+ try {
309
+ return RoomSchema.parse(await this.readJsonFile(this.roomPath(id)));
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ }
314
+
315
+ async listRooms(requesterId: string): Promise<Room[]> {
316
+ const dir = path.join(this.root, "registry", "rooms");
317
+ try {
318
+ const files = await fs.readdir(dir);
319
+ const rooms: Room[] = [];
320
+ for (const file of files) {
321
+ if (!file.endsWith(".json")) continue;
322
+ const room = RoomSchema.parse(
323
+ await this.readJsonFile(path.join(dir, file)),
324
+ );
325
+ if (room.type === "secret" && !room.members.includes(requesterId))
326
+ continue;
327
+ rooms.push(room);
328
+ }
329
+ return rooms;
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ async joinRoom(roomId: string, agentId: string): Promise<Room> {
336
+ const room = await this.getRoom(roomId);
337
+ if (!room)
338
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
339
+
340
+ if (
341
+ room.type !== "public" &&
342
+ !room.invited.includes(agentId) &&
343
+ room.owner !== agentId &&
344
+ !room.members.includes(agentId)
345
+ ) {
346
+ throw new CommsError(`Not invited to room ${roomId}`, "NOT_INVITED");
347
+ }
348
+
349
+ room.version += 1;
350
+ room.memberJoins[agentId] = room.version;
351
+ room.invitedLeaves[agentId] = room.version;
352
+ room.members = Object.keys(room.memberJoins).filter(
353
+ (id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0),
354
+ );
355
+ room.invited = Object.keys(room.invitedJoins).filter(
356
+ (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0),
357
+ );
358
+ await this.writeJsonFile(this.roomPath(roomId), room);
359
+
360
+ const agent = await this.getAgent(agentId);
361
+ if (agent && !agent.subscribedRooms.includes(roomId)) {
362
+ agent.subscribedRooms.push(roomId);
363
+ await this.writeJsonFile(this.agentPath(agentId), agent);
364
+ }
365
+
366
+ await this.deliverToMembers(
367
+ roomId,
368
+ { type: "member_joined", room: roomId, agent: agentId },
369
+ agentId,
370
+ );
371
+
372
+ return room;
373
+ }
374
+
375
+ async leaveRoom(roomId: string, agentId: string): Promise<void> {
376
+ const room = await this.getRoom(roomId);
377
+ if (!room)
378
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
379
+
380
+ room.version += 1;
381
+ room.memberLeaves[agentId] = room.version;
382
+ room.members = Object.keys(room.memberJoins).filter(
383
+ (id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0),
384
+ );
385
+
386
+ await this.writeJsonFile(this.roomPath(roomId), room);
387
+
388
+ const agent = await this.getAgent(agentId);
389
+ if (agent) {
390
+ agent.subscribedRooms = agent.subscribedRooms.filter(
391
+ (id) => id !== roomId,
392
+ );
393
+ await this.writeJsonFile(this.agentPath(agentId), agent);
394
+ }
395
+
396
+ await this.deliverToMembers(
397
+ roomId,
398
+ { type: "member_left", room: roomId, agent: agentId },
399
+ agentId,
400
+ );
401
+
402
+ if (room.members.length === 0 && room.owner === agentId) {
403
+ await this.destroyRoom(roomId, agentId);
404
+ }
405
+ }
406
+
407
+ async inviteToRoom(
408
+ roomId: string,
409
+ targetId: string,
410
+ inviterId: string,
411
+ ): Promise<void> {
412
+ const room = await this.getRoom(roomId);
413
+ if (!room)
414
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
415
+ if (room.owner !== inviterId)
416
+ throw new CommsError("Only the room owner can invite", "NOT_OWNER");
417
+
418
+ if (!room.invited.includes(targetId) && !room.members.includes(targetId)) {
419
+ room.version += 1;
420
+ room.invitedJoins[targetId] = room.version;
421
+ room.invited = Object.keys(room.invitedJoins).filter(
422
+ (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0),
423
+ );
424
+ }
425
+ await this.writeJsonFile(this.roomPath(roomId), room);
426
+
427
+ const inviter = await this.getAgent(inviterId);
428
+ await this.deliver(targetId, {
429
+ type: "room_invite",
430
+ room: roomId,
431
+ roomDescription: room.description,
432
+ from: inviterId,
433
+ fromName: inviter?.name ?? inviterId,
434
+ fromCwd: inviter?.cwd ?? "",
435
+ });
436
+ }
437
+
438
+ async declineInvite(
439
+ roomId: string,
440
+ agentId: string,
441
+ reason: string,
442
+ ): Promise<void> {
443
+ const room = await this.getRoom(roomId);
444
+ if (!room)
445
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
446
+
447
+ if (!room.invited.includes(agentId))
448
+ throw new CommsError(
449
+ `Agent ${agentId} was not invited to ${roomId}`,
450
+ "NOT_INVITED",
451
+ );
452
+
453
+ room.version += 1;
454
+ room.invitedLeaves[agentId] = room.version;
455
+ room.invited = Object.keys(room.invitedJoins).filter(
456
+ (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0),
457
+ );
458
+ await this.writeJsonFile(this.roomPath(roomId), room);
459
+
460
+ const decliner = await this.getAgent(agentId);
461
+ await this.deliver(room.owner, {
462
+ type: "invite_declined",
463
+ room: roomId,
464
+ agent: agentId,
465
+ agentName: decliner?.name ?? agentId,
466
+ reason,
467
+ });
468
+ }
469
+
470
+ async kickFromRoom(
471
+ roomId: string,
472
+ targetId: string,
473
+ kickerId: string,
474
+ ): Promise<void> {
475
+ const room = await this.getRoom(roomId);
476
+ if (!room)
477
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
478
+ if (room.owner !== kickerId)
479
+ throw new CommsError("Only the room owner can kick", "NOT_OWNER");
480
+
481
+ room.version += 1;
482
+ room.memberLeaves[targetId] = room.version;
483
+ room.invitedLeaves[targetId] = room.version;
484
+ room.members = Object.keys(room.memberJoins).filter(
485
+ (id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0),
486
+ );
487
+ room.invited = Object.keys(room.invitedJoins).filter(
488
+ (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0),
489
+ );
490
+
491
+ await this.writeJsonFile(this.roomPath(roomId), room);
492
+ }
493
+
494
+ async destroyRoom(roomId: string, agentId: string): Promise<void> {
495
+ const room = await this.getRoom(roomId);
496
+ if (!room)
497
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
498
+ if (room.owner !== agentId)
499
+ throw new CommsError("Only the room owner can destroy", "NOT_OWNER");
500
+
501
+ for (const memberId of room.members) {
502
+ const member = await this.getAgent(memberId);
503
+ if (member) {
504
+ member.subscribedRooms = member.subscribedRooms.filter(
505
+ (id) => id !== roomId,
506
+ );
507
+ await this.writeJsonFile(this.agentPath(memberId), member);
508
+ }
509
+ }
510
+
511
+ await fs.unlink(this.roomPath(roomId)).catch(swallow);
512
+ await fs
513
+ .rm(this.roomMessagesDir(roomId), { recursive: true })
514
+ .catch(swallow);
515
+ }
516
+
517
+ // -------------------------------------------------------------------------
518
+ // Messages
519
+ // -------------------------------------------------------------------------
520
+
521
+ async sendRoomMessage(
522
+ roomId: string,
523
+ from: string,
524
+ content: string,
525
+ options?: SendRoomMessageOptions,
526
+ ): Promise<SentRoomMessage> {
527
+ const { replyTo, streamingBehavior } = options ?? {};
528
+ const room = await this.getRoom(roomId);
529
+ if (!room)
530
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
531
+ if (!room.members.includes(from))
532
+ throw new CommsError(`Not a member of ${roomId}`, "NOT_MEMBER");
533
+
534
+ const id = `${String(Date.now())}-${nanoid(MESSAGE_ID_SUFFIX_LENGTH)}`;
535
+ const message: RoomMessage = {
536
+ id,
537
+ from,
538
+ room: roomId,
539
+ content,
540
+ timestamp: new Date().toISOString(),
541
+ replyTo,
542
+ readBy: [from],
543
+ ...(streamingBehavior !== undefined && { streamingBehavior }),
544
+ };
545
+
546
+ await fs.mkdir(this.roomMessagesDir(roomId), { recursive: true });
547
+ await this.writeJsonFile(
548
+ path.join(this.roomMessagesDir(roomId), `${id}.json`),
549
+ message,
550
+ );
551
+
552
+ await this.deliverToMembers(
553
+ roomId,
554
+ { type: "room_message", message },
555
+ from,
556
+ );
557
+
558
+ // A file-backed store hands every member's copy straight to the filesystem, which either succeeds or throws -- there is no unreachable member and nothing to hold for retry, so every member's delivery is settled the moment this returns.
559
+ return {
560
+ message,
561
+ deliveries: room.members
562
+ .filter((member) => member !== from)
563
+ .map((member) => ({
564
+ member,
565
+ delivery: { status: "delivered" as const },
566
+ })),
567
+ };
568
+ }
569
+
570
+ async readRoomMessages(
571
+ roomId: string,
572
+ since?: string,
573
+ ): Promise<RoomMessage[]> {
574
+ const dir = this.roomMessagesDir(roomId);
575
+ try {
576
+ const files = await fs.readdir(dir);
577
+ const messages: RoomMessage[] = [];
578
+ for (const file of files) {
579
+ if (!file.endsWith(".json")) continue;
580
+ const msg = RoomMessageSchema.parse(
581
+ await this.readJsonFile(path.join(dir, file)),
582
+ );
583
+ if (since === undefined || msg.timestamp > since) {
584
+ messages.push(msg);
585
+ }
586
+ }
587
+ messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
588
+ return messages;
589
+ } catch {
590
+ return [];
591
+ }
592
+ }
593
+
594
+ // -------------------------------------------------------------------------
595
+ // DMs
596
+ // -------------------------------------------------------------------------
597
+
598
+ async sendDm(
599
+ from: string,
600
+ to: string,
601
+ content: string,
602
+ streamingBehavior?: StreamingBehavior,
603
+ ): Promise<SentDm> {
604
+ if (to !== from) {
605
+ const recipient = await this.getAgent(to);
606
+ if (!recipient)
607
+ throw new CommsError(`Agent ${to} not found`, "AGENT_NOT_FOUND");
608
+ if (recipient.visibility === "ghost")
609
+ throw new CommsError(`Cannot DM agent ${to}`, "AGENT_NOT_FOUND");
610
+ }
611
+
612
+ const id = `${String(Date.now())}-${nanoid(MESSAGE_ID_SUFFIX_LENGTH)}`;
613
+ const message: DmMessage = {
614
+ id,
615
+ from,
616
+ to,
617
+ content,
618
+ timestamp: new Date().toISOString(),
619
+ readBy: [from],
620
+ ...(streamingBehavior !== undefined && { streamingBehavior }),
621
+ };
622
+
623
+ const dir = this.dmDir(from, to);
624
+ await fs.mkdir(dir, { recursive: true });
625
+ await this.writeJsonFile(path.join(dir, `${id}.json`), message);
626
+
627
+ await this.deliver(to, { type: "dm", message });
628
+
629
+ return { message, delivery: { status: "delivered" } };
630
+ }
631
+
632
+ // -------------------------------------------------------------------------
633
+ // Delivery
634
+ // -------------------------------------------------------------------------
635
+
636
+ async deliver(agentId: string, event: DeliveryEvent): Promise<void> {
637
+ const dir = this.deliveryDir(agentId);
638
+ await fs.mkdir(dir, { recursive: true });
639
+ const id = `${String(Date.now())}-${nanoid(MESSAGE_ID_SUFFIX_LENGTH)}`;
640
+ await this.writeJsonFile(path.join(dir, `${id}.json`), event);
641
+ }
642
+
643
+ async drainDelivery(agentId: string): Promise<DeliveryEvent[]> {
644
+ const dir = this.deliveryDir(agentId);
645
+ try {
646
+ const files = await fs.readdir(dir);
647
+ const events: DeliveryEvent[] = [];
648
+ for (const file of files) {
649
+ if (!file.endsWith(".json")) continue;
650
+ const event = DeliveryEventSchema.parse(
651
+ await this.readJsonFile(path.join(dir, file)),
652
+ );
653
+ events.push(event);
654
+ await fs.unlink(path.join(dir, file));
655
+ }
656
+ events.sort((a, b) => {
657
+ const ta =
658
+ a.type === "room_message" || a.type === "dm"
659
+ ? a.message.timestamp
660
+ : "";
661
+ const tb =
662
+ b.type === "room_message" || b.type === "dm"
663
+ ? b.message.timestamp
664
+ : "";
665
+ return ta.localeCompare(tb);
666
+ });
667
+ return events;
668
+ } catch {
669
+ return [];
670
+ }
671
+ }
672
+
673
+ private async deliverToMembers(
674
+ roomId: string,
675
+ event: DeliveryEvent,
676
+ excludeAgent: string,
677
+ ): Promise<void> {
678
+ const room = await this.getRoom(roomId);
679
+ if (!room) return;
680
+
681
+ for (const memberId of room.members) {
682
+ if (memberId !== excludeAgent) {
683
+ await this.deliver(memberId, event);
684
+ }
685
+ }
686
+ }
687
+
688
+ // -------------------------------------------------------------------------
689
+ // Comms initialisation
690
+ // -------------------------------------------------------------------------
691
+
692
+ async init(): Promise<void> {
693
+ const dirs = [
694
+ path.join(this.root, "registry", "agents"),
695
+ path.join(this.root, "registry", "rooms"),
696
+ path.join(this.root, "rooms"),
697
+ path.join(this.root, "dms"),
698
+ path.join(this.root, "delivery"),
699
+ ];
700
+ for (const dir of dirs) {
701
+ await fs.mkdir(dir, { recursive: true });
702
+ }
703
+ }
704
+ }