agent-comms 1.27.3 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@
15
15
  "url": "https://github.com/ExaDev/agent-comms.git"
16
16
  },
17
17
  "description": "Cross-harness LLM agent communication mesh — rooms, DMs, and presence",
18
- "version": "1.27.3"
18
+ "version": "1.28.0"
19
19
  }
20
20
  ]
21
21
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-comms",
3
3
  "description": "Cross-harness LLM agent communication mesh — rooms, DMs, and presence",
4
- "version": "1.27.3",
4
+ "version": "1.28.0",
5
5
  "author": {
6
6
  "name": "ExaDev"
7
7
  },
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/agent-comms)
4
4
  [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/agent-comms)
5
- [![version](https://img.shields.io/badge/version-1.27.3-blue)](https://github.com/ExaDev/agent-comms/releases/tag/v1.27.3)
5
+ [![version](https://img.shields.io/badge/version-1.28.0-blue)](https://github.com/ExaDev/agent-comms/releases/tag/v1.28.0)
6
6
  [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/agent-comms/ci.yml?branch=main)](https://github.com/ExaDev/agent-comms/actions)
7
7
 
8
8
  Cross-harness communication mesh for LLM agents: rooms, DMs, presence, and visibility over TCP with zero filesystem dependencies.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Protocol handshake — version negotiation for the mesh wire format, fixing #31: a mixed fleet of old and new peers negotiates down to what both actually support (or refuses loudly) instead of one side silently misinterpreting the other's state sync.
3
+ *
4
+ * The frame is wire-mesh's handshake-frame (spec/handshake.cddl in ExaDev/wire-mesh), CBOR-encoded, negotiated by wire-mesh-core's `negotiate()` — the same mechanism every wire-mesh consumer speaks. The `version` field carries agent-comms' own wire-format version (not wire-mesh's protocol version): version 1 is the current format, the one with entity revision fields (#29) and deliveryQueues (#30). A peer that never sends a handshake frame is a legacy peer (version 0, unversioned) and the connection proceeds exactly as before — mixed-fleet tolerance during rollout, the scenario #31 describes.
5
+ *
6
+ * Wire order: a client sends its handshake frame as the very first bytes on a connection and does not wait — the rest of its traffic is ordinary newline-delimited JSON. A server sends its frame only in reply to a received one, so legacy clients never see binary bytes at all. The one unavoidable cross-build artefact is a legacy server receiving a new client's CBOR frame into its line buffer, where it lands without a newline and is flushed as a single malformed (skipped) line when the first JSON message arrives — the pre-existing malformed-line behaviour.
7
+ */
8
+ import { type NegotiationResult } from "@exadev/wire-mesh-core/domain/handshake";
9
+ /** agent-comms' own wire-format version. 1 = the current format (entity revision fields, deliveryQueues). */
10
+ export declare const MESH_PROTOCOL_VERSION = 1;
11
+ /**
12
+ * The capability domain this mesh negotiates under — a wire-mesh namespaced-domain-id (registrant-owned, no allocator): ExaDev's agent-comms mesh semantics. Peers that do not share it are not this protocol.
13
+ */
14
+ export declare const AGENT_COMMS_DOMAIN = "dev.exadev.agent-comms/mesh";
15
+ interface HandshakeShape {
16
+ type: "handshake";
17
+ version: number;
18
+ domains: string[];
19
+ }
20
+ /** The handshake frame this build sends, CBOR-encoded (canonical), ready to write as a connection's first bytes. */
21
+ export declare function encodeHandshakeFrame(): Uint8Array;
22
+ /** Negotiates this build's protocol against a received handshake frame — core's negotiation over agent-comms' versions. */
23
+ export declare function negotiateMeshProtocol(remote: HandshakeShape): NegotiationResult;
24
+ export type HandshakeOutcome = {
25
+ kind: "pending";
26
+ } | {
27
+ kind: "legacy";
28
+ rest: Buffer;
29
+ reason: "json-first-byte";
30
+ } | {
31
+ kind: "negotiated";
32
+ rest: Buffer;
33
+ result: NegotiationResult;
34
+ } | {
35
+ kind: "refused";
36
+ reason: string;
37
+ };
38
+ /**
39
+ * Per-connection gate fed the incoming byte stream. Consumes the (optional) leading handshake frame and classifies the connection: negotiated (a version was agreed — for a server, the caller replies with `encodeHandshakeFrame()`), legacy (first byte was '{' — a pre-handshake peer, proceed exactly as before), or refused (a handshake we cannot speak: destroy the connection loudly rather than desync — the #31 enforcement point). After the first classification every subsequent feed passes the bytes through unchanged.
40
+ */
41
+ export declare class ConnectionHandshake {
42
+ private readonly role;
43
+ private decided;
44
+ private pending;
45
+ private pendingLength;
46
+ constructor(role: "client" | "server");
47
+ feed(data: Buffer): HandshakeOutcome;
48
+ private lastResult;
49
+ private negotiatedResult;
50
+ /** True once this connection was classified (legacy or negotiated) — further feed() calls pass through. */
51
+ get settled(): boolean;
52
+ }
53
+ /** The slice of the Node socket surface the handshake needs — satisfied by net.Socket and tls.TLSSocket alike. */
54
+ export interface HandshakeSocket {
55
+ write(data: Uint8Array | string): unknown;
56
+ destroy(): void;
57
+ on(event: "data", listener: (data: Buffer) => void): unknown;
58
+ }
59
+ /**
60
+ * Wires a connection's handshake: a client sends its frame immediately (and
61
+ * never waits — the rest of its traffic is JSON either way); a server sends
62
+ * its frame only in reply to a received one, so legacy clients never see
63
+ * binary bytes. Payload bytes after classification (and everything on a
64
+ * legacy connection) flow to `onPayload` unchanged. A refused handshake
65
+ * destroys the connection and reports the reason — the loud #31 refusal
66
+ * replacing silent desync.
67
+ */
68
+ export declare function attachSocketHandshake(socket: HandshakeSocket, role: "client" | "server", onPayload: (data: Buffer) => void, onError?: (error: Error) => void): void;
69
+ /**
70
+ * Gate for a WebSocket connection, where every message is already framed: a
71
+ * binary first message is the peer's handshake frame (reply in kind via
72
+ * `sendBinary` when serving), a text first message is a legacy peer's JSON.
73
+ * Binary messages after the first, or a non-handshake binary first message,
74
+ * are refused.
75
+ */
76
+ export declare class WsHandshakeGate {
77
+ private readonly role;
78
+ private readonly sendBinary;
79
+ private settled;
80
+ constructor(role: "client" | "server", sendBinary: (data: Uint8Array) => void);
81
+ /**
82
+ * Classifies one incoming message. `isBinary` is the WS library's own frame-type flag (`ws`'s `message` event passes `(data, isBinary)`), not `typeof raw === "string"`: in Node, `ws` always delivers `data` as a Buffer regardless of whether the frame was sent as text or binary, so a `typeof` check can never see a text frame as a string here and would misclassify every legacy JSON message as an unexpected second handshake. `"payload"` means deliver it to the existing JSON message path (text only); `"consumed"` means it was the handshake and nothing downstream should see it; a throw is the refused case — the caller closes the socket.
83
+ */
84
+ feed(raw: unknown, isBinary: boolean): "payload" | "consumed";
85
+ }
86
+ /** The slice of the WebSocket surface the handshake needs. */
87
+ export interface HandshakeWs {
88
+ send(data: string | Uint8Array): unknown;
89
+ terminate(): void;
90
+ on(event: "message", listener: (raw: unknown, isBinary: boolean) => void): unknown;
91
+ }
92
+ /**
93
+ * Wires a WebSocket connection's handshake: a client sends its frame as a
94
+ * binary message immediately (before any JSON); a server replies in kind only
95
+ * on receiving one, so legacy clients never see a binary message. Text
96
+ * messages flow to `onText` unchanged; a refused handshake terminates the
97
+ * connection and reports the reason.
98
+ */
99
+ export declare function attachWsHandshake(ws: HandshakeWs, role: "client" | "server", onText: (raw: unknown) => void, onError?: (error: Error) => void): void;
100
+ export {};
101
+ //# sourceMappingURL=handshake.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handshake.d.ts","sourceRoot":"","sources":["../../src/core/handshake.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Protocol handshake — version negotiation for the mesh wire format, fixing #31: a mixed fleet of old and new peers negotiates down to what both actually support (or refuses loudly) instead of one side silently misinterpreting the other's state sync.
3
+ *
4
+ * The frame is wire-mesh's handshake-frame (spec/handshake.cddl in ExaDev/wire-mesh), CBOR-encoded, negotiated by wire-mesh-core's `negotiate()` — the same mechanism every wire-mesh consumer speaks. The `version` field carries agent-comms' own wire-format version (not wire-mesh's protocol version): version 1 is the current format, the one with entity revision fields (#29) and deliveryQueues (#30). A peer that never sends a handshake frame is a legacy peer (version 0, unversioned) and the connection proceeds exactly as before — mixed-fleet tolerance during rollout, the scenario #31 describes.
5
+ *
6
+ * Wire order: a client sends its handshake frame as the very first bytes on a connection and does not wait — the rest of its traffic is ordinary newline-delimited JSON. A server sends its frame only in reply to a received one, so legacy clients never see binary bytes at all. The one unavoidable cross-build artefact is a legacy server receiving a new client's CBOR frame into its line buffer, where it lands without a newline and is flushed as a single malformed (skipped) line when the first JSON message arrives — the pre-existing malformed-line behaviour.
7
+ */
8
+ import { decodeSequence, encode as cborEncode, cdeDecodeOptions, cdeEncodeOptions, } from "cbor2";
9
+ import { negotiate, } from "@exadev/wire-mesh-core/domain/handshake";
10
+ /** agent-comms' own wire-format version. 1 = the current format (entity revision fields, deliveryQueues). */
11
+ export const MESH_PROTOCOL_VERSION = 1;
12
+ /**
13
+ * The capability domain this mesh negotiates under — a wire-mesh namespaced-domain-id (registrant-owned, no allocator): ExaDev's agent-comms mesh semantics. Peers that do not share it are not this protocol.
14
+ */
15
+ export const AGENT_COMMS_DOMAIN = "dev.exadev.agent-comms/mesh";
16
+ /** Handshake frames are tiny; anything larger than this is not a frame, it is garbage. */
17
+ const MAX_HANDSHAKE_BYTES = 1024;
18
+ /** CBOR map head byte range (0xa0–0xbf); JSON always starts with '{' (0x7b). */
19
+ function isCborMapHead(byte) {
20
+ return byte >= 0xa0 && byte <= 0xbf;
21
+ }
22
+ const JSON_OBJECT_START = 0x7b; // '{'
23
+ /**
24
+ * Narrow structural check for a received handshake frame.
25
+ *
26
+ * Deliberately NOT the generated handshakeFrameSchema: its domain union's regexp branches are emitted double-escaped by cddl.js (ExaDev/cddl.js#10), so schema-validating any namespaced domain — including ours — rejects valid frames until that fix lands. This guard checks exactly the shape `negotiate()` consumes.
27
+ */
28
+ function isHandshakeShape(value) {
29
+ if (typeof value !== "object" || value === null)
30
+ return false;
31
+ if (!("type" in value) || value.type !== "handshake")
32
+ return false;
33
+ if (!("version" in value) || typeof value.version !== "number")
34
+ return false;
35
+ if (!("domains" in value) || !Array.isArray(value.domains))
36
+ return false;
37
+ return value.domains.every((d) => typeof d === "string");
38
+ }
39
+ function localFrame() {
40
+ return {
41
+ type: "handshake",
42
+ version: MESH_PROTOCOL_VERSION,
43
+ domains: [AGENT_COMMS_DOMAIN],
44
+ };
45
+ }
46
+ /** The handshake frame this build sends, CBOR-encoded (canonical), ready to write as a connection's first bytes. */
47
+ export function encodeHandshakeFrame() {
48
+ return cborEncode(localFrame(), cdeEncodeOptions);
49
+ }
50
+ /** Negotiates this build's protocol against a received handshake frame — core's negotiation over agent-comms' versions. */
51
+ export function negotiateMeshProtocol(remote) {
52
+ return negotiate(localFrame(), remote);
53
+ }
54
+ /**
55
+ * Per-connection gate fed the incoming byte stream. Consumes the (optional) leading handshake frame and classifies the connection: negotiated (a version was agreed — for a server, the caller replies with `encodeHandshakeFrame()`), legacy (first byte was '{' — a pre-handshake peer, proceed exactly as before), or refused (a handshake we cannot speak: destroy the connection loudly rather than desync — the #31 enforcement point). After the first classification every subsequent feed passes the bytes through unchanged.
56
+ */
57
+ export class ConnectionHandshake {
58
+ role;
59
+ decided = null;
60
+ pending = [];
61
+ pendingLength = 0;
62
+ constructor(role) {
63
+ this.role = role;
64
+ }
65
+ feed(data) {
66
+ if (this.decided !== null) {
67
+ if (this.decided === "negotiated") {
68
+ return {
69
+ kind: "negotiated",
70
+ rest: data,
71
+ result: this.negotiatedResult(),
72
+ };
73
+ }
74
+ return { kind: "legacy", rest: data, reason: "json-first-byte" };
75
+ }
76
+ // The connection's very first byte only classifies legacy vs. CBOR. A later chunk (the second half of a split frame) starts mid-item, and its own leading byte is not a fresh frame head -- re-checking it against isCborMapHead on every chunk was the bug a reassembly test caught.
77
+ if (this.pending.length === 0) {
78
+ const first = data[0];
79
+ if (first === undefined) {
80
+ return { kind: "pending" };
81
+ }
82
+ if (first === JSON_OBJECT_START) {
83
+ this.decided = "legacy";
84
+ return { kind: "legacy", rest: data, reason: "json-first-byte" };
85
+ }
86
+ if (!isCborMapHead(first)) {
87
+ return {
88
+ kind: "refused",
89
+ reason: `unexpected first byte 0x${first.toString(16)} — not a handshake frame or JSON message`,
90
+ };
91
+ }
92
+ }
93
+ // A CBOR item: accumulate until it decodes (frames are tiny; TCP may split them).
94
+ this.pending.push(data);
95
+ this.pendingLength += data.length;
96
+ if (this.pendingLength > MAX_HANDSHAKE_BYTES) {
97
+ return {
98
+ kind: "refused",
99
+ reason: `handshake frame exceeds ${String(MAX_HANDSHAKE_BYTES)} bytes`,
100
+ };
101
+ }
102
+ const joined = Buffer.concat(this.pending);
103
+ let value;
104
+ try {
105
+ // decodeSequence yields lazily: its first item resolves as soon as enough bytes exist for it, tolerating (rather than choking on) non-CBOR bytes that follow in the same buffer -- the ordinary case once a client's JSON traffic lands in the same TCP read as the frame. Plain decode() throws "Extra data in input" the instant anything trails the item, which would misclassify every such read as still-pending forever.
106
+ const item = decodeSequence(joined, cdeDecodeOptions).next();
107
+ if (item.done !== false) {
108
+ return { kind: "pending" };
109
+ }
110
+ value = item.value;
111
+ }
112
+ catch {
113
+ return { kind: "pending" };
114
+ }
115
+ if (!isHandshakeShape(value)) {
116
+ return {
117
+ kind: "refused",
118
+ reason: "CBOR item on a new connection is not a handshake frame",
119
+ };
120
+ }
121
+ // The frame consumed only its own bytes; anything after it is the stream's JSON traffic.
122
+ const encoded = cborEncode(value, cdeEncodeOptions);
123
+ const rest = joined.subarray(encoded.length);
124
+ const result = negotiateMeshProtocol(value);
125
+ if (!result.ok) {
126
+ return {
127
+ kind: "refused",
128
+ reason: `handshake refused (${this.role}): peer protocol version ${String(value.version)}, no shared domain`,
129
+ };
130
+ }
131
+ this.decided = "negotiated";
132
+ this.lastResult = result;
133
+ return { kind: "negotiated", rest, result };
134
+ }
135
+ lastResult;
136
+ negotiatedResult() {
137
+ if (this.lastResult === undefined) {
138
+ throw new Error("negotiated connection has no negotiation result");
139
+ }
140
+ return this.lastResult;
141
+ }
142
+ /** True once this connection was classified (legacy or negotiated) — further feed() calls pass through. */
143
+ get settled() {
144
+ return this.decided !== null;
145
+ }
146
+ }
147
+ /**
148
+ * Wires a connection's handshake: a client sends its frame immediately (and
149
+ * never waits — the rest of its traffic is JSON either way); a server sends
150
+ * its frame only in reply to a received one, so legacy clients never see
151
+ * binary bytes. Payload bytes after classification (and everything on a
152
+ * legacy connection) flow to `onPayload` unchanged. A refused handshake
153
+ * destroys the connection and reports the reason — the loud #31 refusal
154
+ * replacing silent desync.
155
+ */
156
+ export function attachSocketHandshake(socket, role, onPayload, onError) {
157
+ const gate = new ConnectionHandshake(role);
158
+ if (role === "client") {
159
+ socket.write(encodeHandshakeFrame());
160
+ }
161
+ // A settled connection reports "negotiated" (or "legacy") on every subsequent feed, not just the classifying one -- feed() has no separate signal for "just decided" versus "already decided, passing through". Without this guard the server branch below wrote a fresh reply frame on every single data event for the rest of the connection's life, corrupting the peer's JSON-line buffer with stray CBOR bytes mid-stream.
162
+ let serverReplySent = false;
163
+ socket.on("data", (data) => {
164
+ const outcome = gate.feed(data);
165
+ switch (outcome.kind) {
166
+ case "pending":
167
+ return;
168
+ case "legacy":
169
+ onPayload(outcome.rest);
170
+ return;
171
+ case "negotiated":
172
+ if (role === "server" && !serverReplySent) {
173
+ // Reply only once, on evidence the peer speaks the handshake — a legacy client must never receive binary bytes.
174
+ serverReplySent = true;
175
+ socket.write(encodeHandshakeFrame());
176
+ }
177
+ onPayload(outcome.rest);
178
+ return;
179
+ case "refused":
180
+ onError?.(new Error(outcome.reason));
181
+ socket.destroy();
182
+ return;
183
+ }
184
+ });
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // WebSocket attachment — binary first message is the handshake
188
+ // ---------------------------------------------------------------------------
189
+ /**
190
+ * Gate for a WebSocket connection, where every message is already framed: a
191
+ * binary first message is the peer's handshake frame (reply in kind via
192
+ * `sendBinary` when serving), a text first message is a legacy peer's JSON.
193
+ * Binary messages after the first, or a non-handshake binary first message,
194
+ * are refused.
195
+ */
196
+ export class WsHandshakeGate {
197
+ role;
198
+ sendBinary;
199
+ settled = false;
200
+ constructor(role, sendBinary) {
201
+ this.role = role;
202
+ this.sendBinary = sendBinary;
203
+ }
204
+ /**
205
+ * Classifies one incoming message. `isBinary` is the WS library's own frame-type flag (`ws`'s `message` event passes `(data, isBinary)`), not `typeof raw === "string"`: in Node, `ws` always delivers `data` as a Buffer regardless of whether the frame was sent as text or binary, so a `typeof` check can never see a text frame as a string here and would misclassify every legacy JSON message as an unexpected second handshake. `"payload"` means deliver it to the existing JSON message path (text only); `"consumed"` means it was the handshake and nothing downstream should see it; a throw is the refused case — the caller closes the socket.
206
+ */
207
+ feed(raw, isBinary) {
208
+ if (this.settled) {
209
+ if (!isBinary)
210
+ return "payload";
211
+ throw new Error("binary message after connection start on a WebSocket mesh connection");
212
+ }
213
+ if (!isBinary) {
214
+ this.settled = true;
215
+ return "payload";
216
+ }
217
+ const bytes = raw instanceof Buffer
218
+ ? raw
219
+ : raw instanceof ArrayBuffer
220
+ ? new Uint8Array(raw)
221
+ : ArrayBuffer.isView(raw)
222
+ ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
223
+ : undefined;
224
+ if (bytes === undefined) {
225
+ throw new Error("unsupported WebSocket message type");
226
+ }
227
+ const gate = new ConnectionHandshake(this.role);
228
+ const outcome = gate.feed(Buffer.from(bytes));
229
+ if (outcome.kind === "pending") {
230
+ throw new Error("handshake frame did not arrive as one WebSocket message");
231
+ }
232
+ if (outcome.kind === "refused") {
233
+ throw new Error(outcome.reason);
234
+ }
235
+ if (outcome.kind === "legacy") {
236
+ // A text-shaped payload cannot reach here (handled above); binary that
237
+ // is not a handshake frame is a refusal in ConnectionHandshake.
238
+ throw new Error("unexpected legacy classification for a binary WebSocket message");
239
+ }
240
+ if (this.role === "server") {
241
+ this.sendBinary(encodeHandshakeFrame());
242
+ }
243
+ if (outcome.rest.length > 0) {
244
+ throw new Error("handshake frame carried trailing bytes in a WebSocket message");
245
+ }
246
+ this.settled = true;
247
+ return "consumed";
248
+ }
249
+ }
250
+ /**
251
+ * Wires a WebSocket connection's handshake: a client sends its frame as a
252
+ * binary message immediately (before any JSON); a server replies in kind only
253
+ * on receiving one, so legacy clients never see a binary message. Text
254
+ * messages flow to `onText` unchanged; a refused handshake terminates the
255
+ * connection and reports the reason.
256
+ */
257
+ export function attachWsHandshake(ws, role, onText, onError) {
258
+ const gate = new WsHandshakeGate(role, (data) => {
259
+ ws.send(data);
260
+ });
261
+ if (role === "client") {
262
+ ws.send(encodeHandshakeFrame());
263
+ }
264
+ ws.on("message", (raw, isBinary) => {
265
+ try {
266
+ if (gate.feed(raw, isBinary) === "payload") {
267
+ onText(raw);
268
+ }
269
+ }
270
+ catch (error) {
271
+ onError?.(error instanceof Error ? error : new Error(String(error)));
272
+ ws.terminate();
273
+ }
274
+ });
275
+ }
276
+ //# sourceMappingURL=handshake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handshake.js","sourceRoot":"","sources":["../../src/core/handshake.ts"],"names":[],"mappings":"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"}
@@ -14,7 +14,7 @@ import * as os from "node:os";
14
14
  import { nanoid } from "./nanoid.js";
15
15
  import { CommsError } from "./store.js";
16
16
  import { TcpTransport } from "./tcp-transport.js";
17
- import { dmKey } from "./wire-protocol.js";
17
+ import { dmKey, normaliseWireState } from "./wire-protocol.js";
18
18
  import { DiscoveryManager } from "./discovery.js";
19
19
  import { MdnsDiscoveryBackend } from "./discovery-mdns.js";
20
20
  import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js";
@@ -296,7 +296,7 @@ export class MeshStore {
296
296
  }
297
297
  async handleDataMessage(handle, msg) {
298
298
  if (msg.method === "state_sync") {
299
- this.applyStateSync(msg.state);
299
+ this.applyStateSync(normaliseWireState(msg.state));
300
300
  }
301
301
  else if (msg.method === "state_update") {
302
302
  await this.applyPatch(msg.patch);