agent-comms 8.15.9 → 8.15.11

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 (189) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/server.d.ts.map +1 -1
  5. package/dist/bridges/user/web/server.js +44 -24
  6. package/dist/bridges/user/web/server.js.map +1 -1
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  10. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  14. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  15. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  16. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  17. package/dist/bridges/user/web/web-assets.js +67 -0
  18. package/dist/bridges/user/web/web-assets.js.map +1 -0
  19. package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
  20. package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
  21. package/dist/test/pi-manifest-packed.integration.test.js +68 -0
  22. package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
  23. package/package.json +8 -2
  24. package/src/account-cli.ts +195 -0
  25. package/src/account-join-cli.ts +191 -0
  26. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  27. package/src/bridges/cc-peer/bridge.ts +138 -0
  28. package/src/bridges/cc-peer/default-front.ts +58 -0
  29. package/src/bridges/cc-peer/front-controller.ts +160 -0
  30. package/src/bridges/cc-peer/front-relay.ts +191 -0
  31. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  32. package/src/bridges/cc-peer/front.ts +59 -0
  33. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  34. package/src/bridges/cc-peer/run.ts +100 -0
  35. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  36. package/src/bridges/claude-code/channel.ts +318 -0
  37. package/src/bridges/codex/tool.ts +132 -0
  38. package/src/bridges/mcp/server.ts +137 -0
  39. package/src/bridges/opencode/plugin.ts +130 -0
  40. package/src/bridges/pi/extension.ts +330 -0
  41. package/src/bridges/registry.ts +17 -0
  42. package/src/bridges/user/cli.ts +87 -0
  43. package/src/bridges/user/controller.ts +345 -0
  44. package/src/bridges/user/tui.ts +300 -0
  45. package/src/bridges/user/web/actions.ts +173 -0
  46. package/src/bridges/user/web/contract.ts +222 -0
  47. package/src/bridges/user/web/dist/bundle.js +49 -0
  48. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  49. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  50. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  51. package/src/bridges/user/web/dist/index.html +21 -0
  52. package/src/bridges/user/web/dist/manifest.json +26 -0
  53. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  54. package/src/bridges/user/web/dist/sw.js +1 -0
  55. package/src/bridges/user/web/event-publisher.ts +114 -0
  56. package/src/bridges/user/web/port-discovery.ts +49 -0
  57. package/src/bridges/user/web/reads.ts +35 -0
  58. package/src/bridges/user/web/router.ts +158 -0
  59. package/src/bridges/user/web/server.ts +782 -0
  60. package/src/bridges/user/web/vite.config.ts +71 -0
  61. package/src/bridges/user/web/web-access.ts +404 -0
  62. package/src/bridges/user/web/web-assets.ts +90 -0
  63. package/src/bridges/user/web/web-beacon.ts +69 -0
  64. package/src/bridges/user/web/web-console-static.ts +90 -0
  65. package/src/cli-main.ts +635 -0
  66. package/src/cli.ts +13 -0
  67. package/src/core/account-bundle.ts +264 -0
  68. package/src/core/account-join.ts +289 -0
  69. package/src/core/account-ledger-crypto.ts +154 -0
  70. package/src/core/account-ledger-store.ts +66 -0
  71. package/src/core/account-ledger.ts +385 -0
  72. package/src/core/agent-details.ts +216 -0
  73. package/src/core/agent-list-format.ts +97 -0
  74. package/src/core/agent-registry.ts +282 -0
  75. package/src/core/agent-table.ts +58 -0
  76. package/src/core/atomic-file.ts +46 -0
  77. package/src/core/bind-retry.ts +38 -0
  78. package/src/core/bridge-mesh.ts +176 -0
  79. package/src/core/bridge.ts +718 -0
  80. package/src/core/capability-ask.ts +162 -0
  81. package/src/core/capability-bubble-up.ts +237 -0
  82. package/src/core/comms-store.ts +130 -0
  83. package/src/core/connection-approval.ts +134 -0
  84. package/src/core/connection-code-tool.ts +92 -0
  85. package/src/core/connection-code.ts +296 -0
  86. package/src/core/coordinator-role.ts +224 -0
  87. package/src/core/data-directory.ts +32 -0
  88. package/src/core/data-frame-routing.ts +309 -0
  89. package/src/core/default-command.ts +12 -0
  90. package/src/core/delegation-policy.ts +41 -0
  91. package/src/core/delivery-engine.ts +649 -0
  92. package/src/core/device-membership-verification.ts +125 -0
  93. package/src/core/device-membership.ts +130 -0
  94. package/src/core/directory-admission.ts +155 -0
  95. package/src/core/discovery-mdns.ts +247 -0
  96. package/src/core/discovery-tailscale.ts +224 -0
  97. package/src/core/discovery.ts +303 -0
  98. package/src/core/display-name.ts +73 -0
  99. package/src/core/dm-grant-actions.ts +72 -0
  100. package/src/core/dm-send-delegation.ts +73 -0
  101. package/src/core/dm-token-verification.ts +76 -0
  102. package/src/core/election-sessions.ts +135 -0
  103. package/src/core/error-reporting.ts +85 -0
  104. package/src/core/first-contact.ts +242 -0
  105. package/src/core/gateway-trust-actions.ts +184 -0
  106. package/src/core/gateway-trust.ts +305 -0
  107. package/src/core/gossip-directory.ts +276 -0
  108. package/src/core/gossip-extensions.ts +123 -0
  109. package/src/core/group-proofs.ts +205 -0
  110. package/src/core/handshake.ts +354 -0
  111. package/src/core/hub-forwarding.ts +24 -0
  112. package/src/core/hub-link.ts +176 -0
  113. package/src/core/hub-session.ts +374 -0
  114. package/src/core/identity-store.ts +715 -0
  115. package/src/core/identity.ts +489 -0
  116. package/src/core/index.ts +65 -0
  117. package/src/core/issuer-grouping.ts +165 -0
  118. package/src/core/issuer-identity-file.ts +187 -0
  119. package/src/core/lan-web-actions.ts +34 -0
  120. package/src/core/lan-web-uis.ts +55 -0
  121. package/src/core/ledger-lock.ts +81 -0
  122. package/src/core/listener-registry.ts +133 -0
  123. package/src/core/loopback-address.ts +22 -0
  124. package/src/core/machine-identity.ts +77 -0
  125. package/src/core/machine-list-groups.ts +53 -0
  126. package/src/core/mcp-registry-retry.ts +69 -0
  127. package/src/core/mcp-registry-version.ts +18 -0
  128. package/src/core/membership-proof.ts +139 -0
  129. package/src/core/membership-proofs.ts +149 -0
  130. package/src/core/mesh-graph-trace-tool.ts +81 -0
  131. package/src/core/mesh-graph.ts +89 -0
  132. package/src/core/mesh-network-actions.ts +183 -0
  133. package/src/core/mesh-store-options.ts +23 -0
  134. package/src/core/mesh-store-shared.ts +134 -0
  135. package/src/core/mesh-store.ts +1114 -0
  136. package/src/core/name-claim.ts +145 -0
  137. package/src/core/naming-actions.ts +90 -0
  138. package/src/core/naming.ts +138 -0
  139. package/src/core/nanoid.ts +20 -0
  140. package/src/core/network-interfaces.ts +24 -0
  141. package/src/core/package-version.ts +34 -0
  142. package/src/core/peer-lifecycle.ts +377 -0
  143. package/src/core/peer-link-error.ts +41 -0
  144. package/src/core/peer-versions.ts +98 -0
  145. package/src/core/pending-connection.ts +53 -0
  146. package/src/core/petnames.ts +121 -0
  147. package/src/core/pgp-keyserver.ts +32 -0
  148. package/src/core/pid-lock-file.ts +122 -0
  149. package/src/core/push-manager.ts +76 -0
  150. package/src/core/random-id.ts +9 -0
  151. package/src/core/receiver-set.ts +42 -0
  152. package/src/core/relay-role.ts +182 -0
  153. package/src/core/relay-selection.ts +120 -0
  154. package/src/core/relay-server.ts +198 -0
  155. package/src/core/relay-uplink.ts +78 -0
  156. package/src/core/request-timeouts.ts +37 -0
  157. package/src/core/room-discovery.ts +48 -0
  158. package/src/core/room-lifecycle.ts +838 -0
  159. package/src/core/room-lookup.ts +41 -0
  160. package/src/core/room-messaging.ts +247 -0
  161. package/src/core/room-notice-log.ts +61 -0
  162. package/src/core/room-path.ts +84 -0
  163. package/src/core/room-protocol.ts +893 -0
  164. package/src/core/room-router.ts +180 -0
  165. package/src/core/room-token-verification.ts +85 -0
  166. package/src/core/room-wire-extensions.ts +60 -0
  167. package/src/core/self-name-claim.ts +67 -0
  168. package/src/core/send-outcome.ts +69 -0
  169. package/src/core/shutdown-signals.ts +57 -0
  170. package/src/core/stale-agent-checker.ts +108 -0
  171. package/src/core/store.ts +704 -0
  172. package/src/core/token-text.ts +40 -0
  173. package/src/core/tool.ts +853 -0
  174. package/src/core/transport-events.ts +73 -0
  175. package/src/core/transport-queries.ts +54 -0
  176. package/src/core/transport.ts +367 -0
  177. package/src/core/types.ts +550 -0
  178. package/src/core/user-identity.ts +290 -0
  179. package/src/core/vapid.ts +81 -0
  180. package/src/core/version-check.ts +147 -0
  181. package/src/core/version-report-actions.ts +79 -0
  182. package/src/core/web-beacon.ts +31 -0
  183. package/src/core/web-push.ts +322 -0
  184. package/src/core/wire-mesh-core-version.ts +25 -0
  185. package/src/core/wire-mesh-identity.ts +36 -0
  186. package/src/core/wire-mesh-transport-options.ts +82 -0
  187. package/src/core/wire-mesh-transport.ts +1007 -0
  188. package/src/core/wire-protocol.ts +153 -0
  189. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * DeliveryEngine — the busiest hub in MeshStore: owns queued/local delivery, room-membership CRDT merge (mergeRoom/refreshMembership/recordMemberOp), patch application and broadcast, read receipts, and the two gossip callbacks (presence-advert, revocation-announce) that mutate this state directly. Split out of mesh-store.ts to reduce it under the repo's max-lines cap. Every core Map it touches (agents/rooms/messages/dms/deliveryQueues/localDeliveryKeys/pendingMarkReadTimers) is a direct reference into MeshStore's own fields, shared by construction rather than copied, since AgentRegistry, RoomLifecycle, RoomProtocol, RoomMessaging, FederationBridge, and StaleAgentChecker all read or mutate the same underlying state.
3
+ */
4
+
5
+ import { parseDisplayName } from "./display-name.js";
6
+ import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
7
+ import type { RevocationEntry } from "wire-mesh-core/generated/protocol";
8
+ import { bytesFromHex } from "wire-mesh-core/domain/device-id";
9
+ import { loadRoomTokens } from "./identity-store.js";
10
+ import { dmRoomPath } from "./room-path.js";
11
+ import {
12
+ MAX_QUEUED_DELIVERIES_PER_AGENT,
13
+ mergeMessageHistories,
14
+ } from "./mesh-store-shared.js";
15
+ import type { MeshStoreIdentity } from "./mesh-store-shared.js";
16
+ import type { MeshTransport } from "./transport.js";
17
+ import type { RoomRequestOutcome } from "./send-outcome.js";
18
+ import type { MeshStatePatch, SerialisedState } from "./wire-protocol.js";
19
+ import type {
20
+ AgentIdentity,
21
+ AgentStatus,
22
+ DeliveryEvent,
23
+ DmMessage,
24
+ MessageDelivery,
25
+ Room,
26
+ RoomMessage,
27
+ } from "./types.js";
28
+
29
+ /**
30
+ * The state and collaborators DeliveryEngine needs from MeshStore. The Maps/Set/array are direct references into MeshStore's own fields (shared by construction, not copied); the closures read MeshStore's own current values or call through to a not-yet-constructed collaborator at the time DeliveryEngine itself is built (see sendRoomRequestToMember below).
31
+ */
32
+ export interface DeliveryEngineDeps {
33
+ agents: Map<string, AgentIdentity>;
34
+ rooms: Map<string, Room>;
35
+ messages: Map<string, RoomMessage[]>;
36
+ dms: Map<string, DmMessage[]>;
37
+ deliveryQueues: Map<string, DeliveryEvent[]>;
38
+ localDeliveryKeys: Set<string>;
39
+ pendingMarkReadTimers: ReturnType<typeof setTimeout>[];
40
+ getPeerId: () => string;
41
+ requireIdentity: () => MeshStoreIdentity;
42
+ requireTransport: () => MeshTransport;
43
+ getOnDelivery: () =>
44
+ | ((agentId: string, event: DeliveryEvent) => void | Promise<void>)
45
+ | undefined;
46
+ getOnPatch: () =>
47
+ ((patch: MeshStatePatch) => void | Promise<void>) | undefined;
48
+ isShutDown: () => boolean;
49
+ /**
50
+ * Sends one directed room-domain request to a single member, reporting what became of it and queuing it for retry when the member never answered -- RoomProtocol's own method. Deferred: RoomProtocol doesn't exist yet when DeliveryEngine is constructed (construction order: discovery -\> deliveryEngine -\> ... -\> roomProtocol), so MeshStore wires this as `(...) => this.roomProtocol.sendRoomRequestToMember(...)`, a closure over `this` that only resolves `this.roomProtocol` when markRead actually calls it at runtime, well after the constructor has finished. Every call here carries a read receipt or an informational notice rather than a message of this device's own, so the outcome is deliberately not acted on: both are best-effort by design, and a recipient that refuses one has nothing a sender needs to be told about.
51
+ */
52
+ sendRoomRequestToMember: (
53
+ memberId: string,
54
+ roomPath: string,
55
+ token: CapabilityToken,
56
+ params: Record<string, unknown>,
57
+ ) => Promise<RoomRequestOutcome>;
58
+ }
59
+
60
+ /** Cap on `localDeliveryKeys`, the dedup set of already-delivered event keys kept per process; oldest entries are evicted once this is exceeded. */
61
+ const MAX_LOCAL_DELIVERY_DEDUP_KEYS = 50;
62
+
63
+ export class DeliveryEngine {
64
+ constructor(private readonly deps: DeliveryEngineDeps) {}
65
+
66
+ // -----------------------------------------------------------------------
67
+ // Gossip callbacks
68
+ // -----------------------------------------------------------------------
69
+
70
+ /** Applies a peer's gossiped presence status to this store's own local record, without broadcasting it back onto the mesh -- gossip itself is already how this fact propagates (each side's WireMeshTransport re-advertises its own status periodically), so re-broadcasting a received one would just bounce it around indefinitely. Silently ignored for a device-id this store doesn't yet have an agent record for: presence gossip is a transport-layer fact about an already-known peer's current status, not itself a source of truth for who that peer is -- that still comes only from agent_upsert/registerAgent, which carries the name/harness/cwd/tags a fabricated partial record here never could. */
71
+ handlePresenceAdvert(agentId: string, status: AgentStatus): void {
72
+ const agent = this.deps.agents.get(agentId);
73
+ if (agent === undefined || agent.status === status) return;
74
+ agent.status = status;
75
+ this.bump(agent);
76
+ void this.notifyRoomsOfStatus(agentId, status);
77
+ }
78
+
79
+ /** Verifies a gossiped revocation-entry and, if it verifies, records it in this store's own RevocationView -- future verifyRoomToken calls against this token's (token-id, issuer) pair fail with "revoked" from this point on. A failing entry is dropped silently: the same "hostile input produces a verdict, never a throw" contract verifyRevocationEntry itself already guarantees, so there is nothing further for a caller to react to. */
80
+ async handleRevocationAnnounce(entry: RevocationEntry): Promise<void> {
81
+ const { identity, revocation } = this.deps.requireIdentity();
82
+ await revocation.record(entry, { identity });
83
+ }
84
+
85
+ // -----------------------------------------------------------------------
86
+ // Queueing and membership merge
87
+ // -----------------------------------------------------------------------
88
+
89
+ /** Append to a target agent's delivery queue, bounded oldest-first (#28). */
90
+ queueDelivery(agentId: string, event: DeliveryEvent): void {
91
+ const arr = this.deps.deliveryQueues.get(agentId) ?? [];
92
+ arr.push(event);
93
+ if (arr.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
94
+ arr.splice(0, arr.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
95
+ }
96
+ this.deps.deliveryQueues.set(agentId, arr);
97
+ }
98
+
99
+ /**
100
+ * Merge an incoming room record into the local one. Scalar fields follow the version gate the caller already applied (an equal or higher version reaches here), while membership is always element-merged per agent by highest operation revision, so concurrent joins of different agents survive and a kick racing a join converges with the kick honoured (#27). The members and invited views are re-derived from the merged operations.
101
+ */
102
+ private mergeRoom(incoming: Room): void {
103
+ const existing = this.deps.rooms.get(incoming.id);
104
+ if (existing === undefined) {
105
+ this.refreshMembership(incoming);
106
+ this.deps.rooms.set(incoming.id, incoming);
107
+ return;
108
+ }
109
+ existing.version = Math.max(existing.version, incoming.version);
110
+ existing.name = incoming.name;
111
+ existing.type = incoming.type;
112
+ existing.owner = incoming.owner;
113
+ existing.createdAt = incoming.createdAt;
114
+ existing.description = incoming.description;
115
+ existing.memberJoins = DeliveryEngine.mergeMemberOps(
116
+ existing.memberJoins,
117
+ incoming.memberJoins,
118
+ );
119
+ existing.memberLeaves = DeliveryEngine.mergeMemberOps(
120
+ existing.memberLeaves,
121
+ incoming.memberLeaves,
122
+ );
123
+ existing.invitedJoins = DeliveryEngine.mergeMemberOps(
124
+ existing.invitedJoins,
125
+ incoming.invitedJoins,
126
+ );
127
+ existing.invitedLeaves = DeliveryEngine.mergeMemberOps(
128
+ existing.invitedLeaves,
129
+ incoming.invitedLeaves,
130
+ );
131
+ this.refreshMembership(existing);
132
+ }
133
+
134
+ /**
135
+ * Derive the members and invited views from the per-agent operation maps. An agent is in the list when their latest join strictly outranks their latest leave; equal revisions mean the leave wins, so a kick racing a concurrent join converges with the kick honoured (#27).
136
+ */
137
+ refreshMembership(room: Room): void {
138
+ room.members = Object.keys(room.memberJoins).filter(
139
+ (id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0),
140
+ );
141
+ room.invited = Object.keys(room.invitedJoins).filter(
142
+ (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0),
143
+ );
144
+ }
145
+
146
+ /** Record a membership operation at the room's current revision. */
147
+ recordMemberOp(
148
+ room: Room,
149
+ list: "member" | "invited",
150
+ op: "join" | "leave",
151
+ agentId: string,
152
+ ): void {
153
+ const joins = list === "member" ? room.memberJoins : room.invitedJoins;
154
+ const leaves = list === "member" ? room.memberLeaves : room.invitedLeaves;
155
+ const stamp = room.version;
156
+ if (op === "join") joins[agentId] = stamp;
157
+ else leaves[agentId] = stamp;
158
+ }
159
+
160
+ /** Merge per-agent operation maps by highest revision per agent. */
161
+ private static mergeMemberOps(
162
+ local: Readonly<Record<string, number>>,
163
+ incoming: Readonly<Record<string, number>>,
164
+ ): Record<string, number> {
165
+ const merged: Record<string, number> = { ...local };
166
+ for (const [id, stamp] of Object.entries(incoming)) {
167
+ if (stamp > (merged[id] ?? 0)) merged[id] = stamp;
168
+ }
169
+ return merged;
170
+ }
171
+
172
+ /** Bump an entity's sync revision; call before broadcasting a local mutation. */
173
+ bump<T extends { version: number }>(entity: T): T {
174
+ entity.version += 1;
175
+ return entity;
176
+ }
177
+
178
+ // -----------------------------------------------------------------------
179
+ // State sync and patch application
180
+ // -----------------------------------------------------------------------
181
+
182
+ /** Whether `agent` is this store's own agent and is running, that is, not already set offline by this store itself. An agent's id is its own store's peer id, so this store is the authority on whether it is running. */
183
+ private isOwnRunningAgent(
184
+ agentId: string,
185
+ agent: Readonly<AgentIdentity>,
186
+ ): boolean {
187
+ return agentId === this.deps.getPeerId() && agent.status !== "offline";
188
+ }
189
+
190
+ /** Contradicts a stale offline report about this store's own running agent: raises its revision above the one the report carried, so every peer accepts the correction, and re-announces it. */
191
+ private async reassertOwnAgent(
192
+ agent: AgentIdentity,
193
+ reportedVersion: number,
194
+ ): Promise<void> {
195
+ agent.version = Math.max(agent.version, reportedVersion);
196
+ this.bump(agent);
197
+ await this.broadcastPatch({ type: "agent_upsert", agent });
198
+ }
199
+
200
+ /**
201
+ * Merge a peer's state snapshot into the local state. Agents and rooms accept the incoming copy when it carries a revision at least as high as the local one, so a peer holding stale entities converges when it receives a fresher snapshot, while its own stale copies are rejected by peers that stayed current (#27). Message and DM histories are append-only: add unseen entries and union read receipts.
202
+ */
203
+ applyStateSync(state: SerialisedState): void {
204
+ const incoming = {
205
+ agents: new Map(Object.entries(state.agents)),
206
+ rooms: new Map(Object.entries(state.rooms)),
207
+ messages: new Map(Object.entries(state.messages)),
208
+ dms: new Map(Object.entries(state.dms)),
209
+ };
210
+ for (const [id, agent] of incoming.agents) {
211
+ const existingVersion = this.deps.agents.get(id)?.version;
212
+ if (existingVersion !== undefined && agent.version < existingVersion) {
213
+ continue;
214
+ }
215
+ const own = this.deps.agents.get(id);
216
+ if (
217
+ own !== undefined &&
218
+ this.isOwnRunningAgent(id, own) &&
219
+ agent.status === "offline"
220
+ ) {
221
+ // The same authority rule as an agent_offline patch: a snapshot from a peer that still holds this store's agent as offline (the previous coordinator retired it) must not overwrite a running agent, or the store would gossip itself offline for good.
222
+ void this.reassertOwnAgent(own, agent.version);
223
+ continue;
224
+ }
225
+ if (existingVersion === agent.version) {
226
+ const existing = this.deps.agents.get(id);
227
+ if (existing) {
228
+ for (const r of existing.subscribedRooms) {
229
+ if (!agent.subscribedRooms.includes(r))
230
+ agent.subscribedRooms.push(r);
231
+ }
232
+ }
233
+ }
234
+ this.deps.agents.set(id, agent);
235
+ }
236
+ for (const [id, room] of incoming.rooms) {
237
+ const existingVersion = this.deps.rooms.get(id)?.version;
238
+ if (existingVersion !== undefined && room.version < existingVersion) {
239
+ continue;
240
+ }
241
+ this.mergeRoom(room);
242
+ }
243
+ for (const [id, msgs] of incoming.messages) {
244
+ const existing = this.deps.messages.get(id);
245
+ if (existing === undefined) {
246
+ this.deps.messages.set(id, msgs);
247
+ continue;
248
+ }
249
+ this.deps.messages.set(id, mergeMessageHistories(existing, msgs));
250
+ }
251
+ for (const [id, dmMsgs] of incoming.dms) {
252
+ const existing = this.deps.dms.get(id);
253
+ if (existing === undefined) {
254
+ this.deps.dms.set(id, dmMsgs);
255
+ continue;
256
+ }
257
+ this.deps.dms.set(id, mergeMessageHistories(existing, dmMsgs));
258
+ }
259
+ for (const [agentId, events] of Object.entries(state.deliveryQueues)) {
260
+ const seen = new Set(
261
+ (this.deps.deliveryQueues.get(agentId) ?? []).map((e) =>
262
+ JSON.stringify(e),
263
+ ),
264
+ );
265
+ for (const event of events) {
266
+ if (seen.has(JSON.stringify(event))) continue;
267
+ this.queueDelivery(agentId, event);
268
+ // Replay fires only for events with consumption evidence (#28): messages are consumed by reading (readBy), invites by acceptance or decline (no longer in the invited list). Transient notifications (member_joined, room_members, connection_request, delivery status) carry no consumption evidence, so replaying them could only ever duplicate-notify; the state they describe arrives via the synced room and agent records instead. They still merge into the queue, so drain bridges see them.
269
+ if (event.type === "room_message" || event.type === "dm") {
270
+ this.fireLocalDelivery(agentId, event);
271
+ } else if (event.type === "room_invite") {
272
+ const stillInvited = this.deps.rooms
273
+ .get(event.room)
274
+ ?.invited.includes(agentId);
275
+ if (stillInvited === true) {
276
+ this.fireLocalDelivery(agentId, event);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ async applyPatch(patch: MeshStatePatch): Promise<void> {
284
+ switch (patch.type) {
285
+ case "agent_upsert": {
286
+ // A registered name is checked as it is registered (requireAgentName), so a record whose name parseDisplayName would not keep as it is did not come from a conforming peer, and its name could carry a terminal control sequence into every surface that prints it: the record is not applied.
287
+ if (parseDisplayName(patch.agent.name) !== patch.agent.name) break;
288
+ const existingAgent = this.deps.agents.get(patch.agent.id);
289
+ if (
290
+ existingAgent !== undefined &&
291
+ patch.agent.version < existingAgent.version
292
+ ) {
293
+ // Stale copy from a peer that missed updates (#27).
294
+ break;
295
+ }
296
+ const merged = patch.agent;
297
+ if (existingAgent?.version === patch.agent.version) {
298
+ // Concurrent mutations from the same base: keep subscriptions gained locally. A strictly higher version replaces the record.
299
+ for (const r of existingAgent.subscribedRooms) {
300
+ if (!merged.subscribedRooms.includes(r))
301
+ merged.subscribedRooms.push(r);
302
+ }
303
+ }
304
+ this.deps.agents.set(merged.id, merged);
305
+ break;
306
+ }
307
+ case "agent_offline": {
308
+ const agent = this.deps.agents.get(patch.agentId);
309
+ if (agent === undefined) break;
310
+ // An agent is its own store's peer, so this store is the authority on whether it is running. A report that it is offline while it is (a previous coordinator retiring the session it fronted, or a stale process probe) is stale by definition, and applying it would stick: this store gossips its own status, so it would go on advertising itself offline. It is contradicted instead, at a higher revision so every peer accepts the correction. A store that has itself set its agent offline is not contradicted.
311
+ if (this.isOwnRunningAgent(patch.agentId, agent)) {
312
+ await this.reassertOwnAgent(agent, agent.version);
313
+ break;
314
+ }
315
+ // Bumped, as every store that sets an agent offline bumps it: left at the revision it had while running, the record would lose to any snapshot of that same revision still showing it running, and a peer that had not yet heard of the departure would turn it back on.
316
+ agent.status = "offline";
317
+ this.bump(agent);
318
+ this.deps.agents.set(patch.agentId, agent);
319
+ break;
320
+ }
321
+ case "room_upsert": {
322
+ const existing = this.deps.rooms.get(patch.room.id);
323
+ if (existing !== undefined && patch.room.version < existing.version) {
324
+ // Stale copy from a peer that missed updates (#27).
325
+ break;
326
+ }
327
+ this.mergeRoom(patch.room);
328
+ break;
329
+ }
330
+ case "room_delete":
331
+ this.deps.rooms.delete(patch.roomId);
332
+ break;
333
+ case "message_add": {
334
+ const arr = this.deps.messages.get(patch.roomId) ?? [];
335
+ arr.push(patch.message);
336
+ this.deps.messages.set(patch.roomId, arr);
337
+ break;
338
+ }
339
+ case "dm_add": {
340
+ const arr = this.deps.dms.get(patch.key) ?? [];
341
+ arr.push(patch.message);
342
+ this.deps.dms.set(patch.key, arr);
343
+ break;
344
+ }
345
+ case "delivery": {
346
+ this.queueDelivery(patch.agentId, patch.event);
347
+ const onDelivery = this.deps.getOnDelivery();
348
+ if (patch.agentId === this.deps.getPeerId() && onDelivery) {
349
+ // Deduplicate against local deliveries
350
+ const eventKey = JSON.stringify(patch.event);
351
+ if (this.deps.localDeliveryKeys.has(eventKey)) break;
352
+ this.deps.localDeliveryKeys.add(eventKey);
353
+ if (
354
+ this.deps.localDeliveryKeys.size > MAX_LOCAL_DELIVERY_DEDUP_KEYS
355
+ ) {
356
+ const oldest = this.deps.localDeliveryKeys.values().next().value;
357
+ if (oldest !== undefined)
358
+ this.deps.localDeliveryKeys.delete(oldest);
359
+ }
360
+ void onDelivery(patch.agentId, patch.event);
361
+ // Auto-mark read — scheduled as a macrotask to yield to the event loop. Without this yield, the delivery → markRead → broadcast → peer receives → handleDataMessage chain monopolises the microtask queue and starves macrotasks (timers, new connections, sendRoomMessage return values).
362
+ const evt = patch.event;
363
+ const timer = setTimeout(() => {
364
+ if (this.deps.isShutDown()) return;
365
+ if (evt.type === "room_message") {
366
+ void this.markRead(
367
+ evt.message.id,
368
+ this.deps.getPeerId(),
369
+ evt.message.room,
370
+ );
371
+ } else if (evt.type === "dm") {
372
+ void this.markRead(evt.message.id, this.deps.getPeerId());
373
+ }
374
+ }, 0);
375
+ if (!this.deps.isShutDown())
376
+ this.deps.pendingMarkReadTimers.push(timer);
377
+ }
378
+ break;
379
+ }
380
+ }
381
+
382
+ const onPatch = this.deps.getOnPatch();
383
+ if (onPatch) {
384
+ await onPatch(patch);
385
+ }
386
+ }
387
+
388
+ // -----------------------------------------------------------------------
389
+ // Broadcast and local delivery
390
+ // -----------------------------------------------------------------------
391
+
392
+ async broadcastPatch(patch: MeshStatePatch): Promise<void> {
393
+ await this.deps
394
+ .requireTransport()
395
+ .broadcast({ method: "state_update", patch });
396
+ const onPatch = this.deps.getOnPatch();
397
+ if (onPatch) {
398
+ await onPatch(patch);
399
+ }
400
+ }
401
+
402
+ async deliverLocallyAndBroadcast(
403
+ agentId: string,
404
+ event: DeliveryEvent,
405
+ ): Promise<void> {
406
+ // Local delivery
407
+ this.queueDelivery(agentId, event);
408
+
409
+ // Auto-emit delivered status for messages
410
+ if (event.type === "room_message") {
411
+ await this.emitDeliveryStatus(
412
+ event.message.id,
413
+ agentId,
414
+ { status: "delivered" },
415
+ event.message.room,
416
+ );
417
+ } else if (event.type === "dm") {
418
+ await this.emitDeliveryStatus(event.message.id, agentId, {
419
+ status: "delivered",
420
+ });
421
+ }
422
+
423
+ this.fireLocalDelivery(agentId, event);
424
+
425
+ // Remote delivery
426
+ const patch: MeshStatePatch = { type: "delivery", agentId, event };
427
+ await this.broadcastPatch(patch);
428
+ }
429
+
430
+ /**
431
+ * Fire onDelivery for an event targeting this peer's own agent, deduped against events already delivered in this process. Used both for live deliveries and for replays of events that accumulated while this process was down (#28): the dedup set is per-process, so a replayed event this process never saw fires, and one it already handled does not.
432
+ */
433
+ fireLocalDelivery(agentId: string, event: DeliveryEvent): void {
434
+ const onDelivery = this.deps.getOnDelivery();
435
+ if (agentId !== this.deps.getPeerId() || !onDelivery) return;
436
+ // A room message or DM this agent has already read was already pushed and consumed: read receipts mutate the event between snapshots, so a plain structural key would miss and re-fire on the next sync (#28).
437
+ if (
438
+ (event.type === "room_message" || event.type === "dm") &&
439
+ event.message.readBy.includes(agentId)
440
+ ) {
441
+ return;
442
+ }
443
+ const eventKey = JSON.stringify(event);
444
+ if (this.deps.localDeliveryKeys.has(eventKey)) return;
445
+ this.deps.localDeliveryKeys.add(eventKey);
446
+ // Prevent unbounded growth — evict oldest when cap reached
447
+ if (this.deps.localDeliveryKeys.size > MAX_LOCAL_DELIVERY_DEDUP_KEYS) {
448
+ const oldest = this.deps.localDeliveryKeys.values().next().value;
449
+ if (oldest !== undefined) this.deps.localDeliveryKeys.delete(oldest);
450
+ }
451
+ void onDelivery(agentId, event);
452
+ // Delivered to this process, so no longer pending for it. Peers that never fired the event keep their copies, which is what a restart replays from (#28). Key-based match: replayed events are JSON clones.
453
+ const queued = this.deps.deliveryQueues.get(agentId);
454
+ if (queued !== undefined) {
455
+ const idx = queued.findIndex((e) => JSON.stringify(e) === eventKey);
456
+ if (idx !== -1) queued.splice(idx, 1);
457
+ }
458
+ // Auto-mark read — scheduled as a macrotask to yield to the event loop.
459
+ const timer = setTimeout(() => {
460
+ if (this.deps.isShutDown()) return;
461
+ if (event.type === "room_message") {
462
+ void this.markRead(event.message.id, agentId, event.message.room);
463
+ } else if (event.type === "dm") {
464
+ void this.markRead(event.message.id, agentId);
465
+ }
466
+ }, 0);
467
+ if (!this.deps.isShutDown()) this.deps.pendingMarkReadTimers.push(timer);
468
+ }
469
+
470
+ /**
471
+ * Delivers a single informational event to one specific agent over a given room-path: queues it locally (matching every other queueDelivery caller's own "hold it for whoever reads it next" contract), then either fires local delivery directly for this store's own agent or sends a real, wire-authenticated room.notify -- replacing the legacy mesh-wide broadcastPatch every one of this method's callers used to ride via deliverLocallyAndBroadcast, per P3.8's own directed-delivery retirement (agent-comms#48). Silently does nothing beyond the local queue when this store holds no current room:member token for roomPath, the same best-effort-by-design choice markRead's own directed room.read already makes for an unreachable read receipt. Public: used both internally (deliverToRoom, notifyRoomsOfNameChange, emitDeliveryStatus) and by other collaborators (RoomLifecycle's own post-join member-list delivery) that already know the exact single member and room-path to address.
472
+ */
473
+ async deliverToMember(
474
+ memberId: string,
475
+ roomPath: string,
476
+ event: DeliveryEvent,
477
+ ): Promise<void> {
478
+ this.queueDelivery(memberId, event);
479
+ if (memberId === this.deps.getPeerId()) {
480
+ this.fireLocalDelivery(memberId, event);
481
+ return;
482
+ }
483
+ const { slot } = this.deps.requireIdentity();
484
+ const token = loadRoomTokens(slot)[roomPath];
485
+ if (token === undefined) return;
486
+ await this.deps.sendRoomRequestToMember(memberId, roomPath, token, {
487
+ verb: "room.notify",
488
+ event,
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Delivers an informational event (member_status, member_joined, name_changed, and the like -- never room_message/dm, which already ride handleRoomSend's own directed path) to every current member of a room, via deliverToMember for each.
494
+ */
495
+ async deliverToRoom(
496
+ roomId: string,
497
+ event: DeliveryEvent,
498
+ excludeAgent?: string,
499
+ ): Promise<void> {
500
+ const room = this.deps.rooms.get(roomId);
501
+ if (!room) return;
502
+ for (const memberId of room.members) {
503
+ if (memberId === excludeAgent) continue;
504
+ await this.deliverToMember(memberId, roomId, event);
505
+ }
506
+ }
507
+
508
+ async notifyRoomsOfStatus(
509
+ agentId: string,
510
+ status: AgentStatus,
511
+ ): Promise<void> {
512
+ const agent = this.deps.agents.get(agentId);
513
+ if (!agent) return;
514
+ for (const roomId of agent.subscribedRooms) {
515
+ await this.deliverToRoom(roomId, {
516
+ type: "member_status",
517
+ room: roomId,
518
+ agent: agentId,
519
+ status,
520
+ });
521
+ }
522
+ }
523
+
524
+ async notifyRoomsOfNameChange(
525
+ agentId: string,
526
+ oldName: string,
527
+ newName: string,
528
+ ): Promise<void> {
529
+ const agent = this.deps.agents.get(agentId);
530
+ if (!agent) return;
531
+ const event: DeliveryEvent = {
532
+ type: "name_changed",
533
+ agent: agentId,
534
+ oldName,
535
+ newName,
536
+ };
537
+ for (const roomId of agent.subscribedRooms) {
538
+ await this.deliverToRoom(roomId, event, agentId);
539
+ }
540
+ // Also deliver to the agent itself so it sees confirmation -- addressed via the implicit DM path when the renamed agent is a remote peer (e.g. renamed through the web console's own directory, which places no local-only restriction on which agent it targets), since a name change has no room context of its own to ride.
541
+ const peerId = this.deps.getPeerId();
542
+ if (agentId === peerId) {
543
+ this.queueDelivery(agentId, event);
544
+ this.fireLocalDelivery(agentId, event);
545
+ return;
546
+ }
547
+ await this.deliverToMember(agentId, dmRoomPath(peerId, agentId), event);
548
+ }
549
+
550
+ private async emitDeliveryStatus(
551
+ messageId: string,
552
+ agentId: string,
553
+ delivery: MessageDelivery,
554
+ room?: string,
555
+ ): Promise<void> {
556
+ const location = this.findMessageLocation(messageId, room);
557
+ if (location === undefined) return;
558
+ const { roomPath, from: senderId } = location;
559
+ const event: DeliveryEvent = {
560
+ type: "delivery_status",
561
+ messageId,
562
+ agent: agentId,
563
+ delivery,
564
+ room,
565
+ };
566
+ if (senderId === this.deps.getPeerId()) {
567
+ this.queueDelivery(senderId, event);
568
+ this.fireLocalDelivery(senderId, event);
569
+ return;
570
+ }
571
+ await this.deliverToMember(senderId, roomPath, event);
572
+ }
573
+
574
+ /** Returns the room-path a directed room.notify/room.read needs to address for a given message: room itself for a room message, or the specific DM key (this.dms is keyed by dmRoomPath/"self:...", not the bare pair) the message was actually found under. */
575
+ private findMessageLocation(
576
+ messageId: string,
577
+ room?: string,
578
+ ): { roomPath: string; from: string } | undefined {
579
+ if (room !== undefined) {
580
+ const msg = this.deps.messages.get(room)?.find((m) => m.id === messageId);
581
+ return msg === undefined ? undefined : { roomPath: room, from: msg.from };
582
+ }
583
+ for (const [key, msgs] of this.deps.dms) {
584
+ const msg = msgs.find((m) => m.id === messageId);
585
+ if (msg) return { roomPath: key, from: msg.from };
586
+ }
587
+ return undefined;
588
+ }
589
+
590
+ /**
591
+ * Marks a message read by readBy (always this store's own identity -- see the auto-mark-read timers in fireLocalDelivery and drainDelivery, its only callers) and notifies the message's own author via a directed, wire-authenticated room.read (P3.5), replacing the legacy message_read patch's mesh-wide broadcast: only the author is a genuine audience for "did you read my message" per core/room's own always-voluntary read-receipt design, and a direct request to them is strictly more than the old broadcast actually needed. Silently does nothing beyond the local readBy update when the author is this store itself (a self-DM, or an already-own message) or when this store holds no room:member token for the message's own room-path -- read receipts stay best-effort by design, never a reason to throw.
592
+ */
593
+ private async markRead(
594
+ messageId: string,
595
+ readBy: string,
596
+ room?: string,
597
+ ): Promise<void> {
598
+ const location = this.findMessageLocation(messageId, room);
599
+ if (location === undefined) return;
600
+ const { roomPath, from } = location;
601
+
602
+ const history =
603
+ room !== undefined
604
+ ? this.deps.messages.get(room)
605
+ : this.deps.dms.get(roomPath);
606
+ const message = history?.find((m) => m.id === messageId);
607
+ if (message && !message.readBy.includes(readBy)) {
608
+ message.readBy.push(readBy);
609
+ }
610
+
611
+ if (from === readBy) return;
612
+
613
+ const { slot, clock } = this.deps.requireIdentity();
614
+ const token = loadRoomTokens(slot)[roomPath];
615
+ if (token === undefined) return;
616
+
617
+ const params: Record<string, unknown> = {
618
+ verb: "room.read",
619
+ messages: [bytesFromHex(messageId)],
620
+ at: clock.now(),
621
+ };
622
+ await this.deps.sendRoomRequestToMember(from, roomPath, token, params);
623
+ }
624
+
625
+ // -----------------------------------------------------------------------
626
+ // CommsStore — Delivery
627
+ // -----------------------------------------------------------------------
628
+
629
+ async deliver(agentId: string, event: DeliveryEvent): Promise<void> {
630
+ await this.deliverLocallyAndBroadcast(agentId, event);
631
+ }
632
+
633
+ async drainDelivery(agentId: string): Promise<DeliveryEvent[]> {
634
+ await Promise.resolve();
635
+ const events = this.deps.deliveryQueues.get(agentId) ?? [];
636
+ this.deps.deliveryQueues.set(agentId, []);
637
+
638
+ // Auto-mark messages as read — drain bridges consume on tool call
639
+ for (const event of events) {
640
+ if (event.type === "room_message") {
641
+ await this.markRead(event.message.id, agentId, event.message.room);
642
+ } else if (event.type === "dm") {
643
+ await this.markRead(event.message.id, agentId);
644
+ }
645
+ }
646
+
647
+ return events;
648
+ }
649
+ }