Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

> **Note:** Versions 0.3.26 – 0.3.55 were released as git tags without changelog entries. Changelog resumes at 0.3.56 below.

## 0.5.4

### Fixed

- **Replacement transports never received a handshake; remote rejected the
next heartbeat-`ping` as a protocol violation.** Companion fix to v0.5.3.
When the dual-dial dedup or stale-prior swap path in `_createPeer` replaced
an existing transport, the new transport was registered in
`existingPeer.transports` but no handshake was sent on it — `_addPeer`
(which sends the handshake) is only called for brand-new peers, not
transport replacements. The remote (sym-swift) saw the new connection
reach `.ready`, sent its own handshake, and waited for ours. Ours never
arrived. ~10 seconds later the heartbeat tick fired `ping` on every
transport, the remote saw `ping` as the first frame, and disconnected
with `[SYM] session: expected handshake, got ping` — protocol violation.
Net result: a flap loop where every reconnect was killed within 10s by
the protocol-violation trip-wire.

Fix: extracted handshake-build into `_buildHandshake()` helper; the
existing-peer branch in `_createPeer` now sends the handshake on every
newly-registered transport. Idempotent — if the remote already sent
its handshake, it processes both fine.

Verified end-to-end on Mac Catalyst MeloMove ↔ claude-code-mac (Node)
on the same Mac. Connection stays stable, peers persist in the UI,
CMBs flow continuously without the periodic 10s drop.

## 0.5.3

### Fixed
Expand Down
34 changes: 28 additions & 6 deletions lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,17 @@ class SymNode extends EventEmitter {
existingPeer.transports.set(source, transport);
this._log(`Transport added for ${peerName}: ${source} (${existingPeer.transports.size} transports)`);

// Send handshake on the new transport. _addPeer is only called for
// brand-new peers; once a peer exists, additional/replacement
// transports route through this branch and would otherwise sit in
// the dict with no handshake ever sent on them. The next heartbeat
// tick fires `ping` on every transport, the remote sees `ping` as
// the first frame, and rejects the connection ("expected handshake,
// got ping" — a sym-swift-side disconnect on protocol violation).
// Idempotent: if the remote also sent a handshake from its side,
// it processes both fine.
try { transport.send(this._buildHandshake()); } catch {}

// Identity-aware close handler. A previously-closed transport for the
// same source has its own pending close handler that, if it fires after
// we replace the entry here, would `transports.delete(source)` away
Expand Down Expand Up @@ -1308,11 +1319,15 @@ class SymNode extends EventEmitter {
return null;
}

_addPeer(peer) {
this._peers.set(peer.peerId, peer);

// Handshake — per MMP Section 5.2 + §5.8 optional `group` field
peer.transport.send({
/// Builds a handshake frame addressed to the given peer. Per MMP §5.2 +
/// §5.8 optional `group` field. Extracted into a helper so the dedup
/// replace paths in `_createPeer` can also send a handshake on the new
/// transport — without it, a replacement transport sits in the dict with
/// no handshake ever sent, and the next heartbeat-tick `ping` becomes the
/// first frame the remote sees, tripping its "first frame must be
/// handshake" check.
_buildHandshake() {
return {
type: 'handshake',
nodeId: this._identity.nodeId,
name: this.name,
Expand All @@ -1322,7 +1337,14 @@ class SymNode extends EventEmitter {
publicKey: this._identity.publicKey,
e2ePublicKey: this._e2eKeyPair.publicKey.toString('base64'),
lifecycleRole: this._lifecycleRole,
});
};
}

_addPeer(peer) {
this._peers.set(peer.peerId, peer);

// Handshake — per MMP Section 5.2 + §5.8 optional `group` field
peer.transport.send(this._buildHandshake());

// MMP v0.2.2: no state-sync — hidden states never cross the wire under
// SVAF (Xu, 2026, arXiv:2604.03955, §3.4). Cognitive bootstrap to a
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sym-bot/sym",
"version": "0.5.3",
"version": "0.5.4",
"description": "Infrastructure and protocol for multi-agent collective intelligence",
"main": "lib/node.js",
"bin": {
Expand Down