Releases: sym-bot/sym
v0.5.6 — align _createPeer stale threshold with inbound handler (1s)
What's new
Aligned both stale-detection sites in lib/node.js on the 1s threshold introduced by v0.5.5.
Why
v0.5.5 lowered staleByLastSeen to 1s in the inbound-connection handler but left the _createPeer path on the old _heartbeatInterval (10s). When both sides dialled each other in rapid succession, the two paths could pick opposite winners — Mac-side and Node-side then kept different connections, each killing the other's choice. Field testing showed a continuous ~6s join → disconnect cycle persisting after v0.5.5.
Aligning both sites on 1s closes the asymmetry.
Pairs with
- sym-swift v0.3.83 (already correct — only one stale-check site, already at 1s)
v0.5.5 — lower stale-prior threshold from 10s to 1s
What's new
Lower staleAfterSeconds from 10s to 1s in the addPeer dedup path.
Why
When a peer process is killed and quickly relaunched, the old run had typically sent a CMB seconds before death, so lastSeen was still within the 10s window. The dedup logic then rejected the legitimate redial as same-direction-duplicate, producing connection ready → immediate disconnect with no handshake-complete on the dialing side.
A 1-second threshold preserves the loop-prevention purpose (a real same-direction-duplicate fires <100ms apart) while letting any process restart that takes >1s to come back up.
Pairs with
- sym-swift v0.3.83 (mirror change)
v0.5.4 — handshake on replacement transports
Fixed
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.
Field evidence
```
[SYM] session: expected handshake, got ping
[SYM] session: disconnected: Connection closed
```
That log line on Mac Catalyst MeloMove was the closing-disconnect every ~10 seconds in the flap loop. Every reconnect after a dedup-replace was killed 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 from its side, it processes both fine.
150/150 unit tests pass.
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.
See PR #23 for the full diff and CHANGELOG.md for the long-form entry.
v0.5.3 — loopback peer-restart fix (TCP keepalive + lastSeen-aware dedup)
Fixed
Companion fix to v0.5.2's same-host dedup. v0.5.2 added stale-prior detection via the `_closed` flag — set when `transport.close()` had been called explicitly. But the common dead-but-ESTABLISHED case (peer process killed; OS doesn't deliver FIN to the survivor before keepalive reaps it) leaves `_closed=false` forever.
On loopback this is a hard block — macOS default `TCP_KEEPALIVE = 7200s` (2 hours) before the first probe. Survivor sees dead socket as alive, dedup against the zombie rejects every redial.
Field evidence
Mac Catalyst MeloMove ↔ claude-code-mac (Node) on the same Mac. Each Mac MeloMove rebuild → claude-code-mac retains a dead ESTABLISHED socket → new Mac MeloMove dial rejected for 2h. iPhone ↔ claude-code-mac on Wi-Fi works because Wi-Fi noise reaps stale sockets within seconds — same logical bug, much less visible.
Three-part fix
-
TCP keepalive on every TcpTransport socket — `socket.setKeepAlive(true, 1000)`. macOS detects dead remote in ~10s instead of ~2h.
-
lastSeen-aware stale detection in both `inbound-connection` handler and `_createPeer`. A peer entry with `lastSeen` older than `_heartbeatInterval` (default 10s) is treated as stale regardless of the `_closed` flag. The remote re-dialling is itself evidence its prior is dead.
-
Identity-aware close handlers. When a stale prior is closed and replaced, its eventual close handler must NOT clobber the new transport entry. Both close handlers in `_createPeer` now guard with `transports.get(source) === transport` before mutating.
150/150 unit tests pass.
Affects all peers running on the same host as another sym instance, and any peer-restart scenario where the peer's TCP socket on the survivor side stays in ESTABLISHED state past the OS-level FIN.
See PR #22 for the full diff and CHANGELOG.md for the long-form entry.
v0.5.2 — same-host Bonjour dedup fix
Fixed
Same-host Bonjour peers permanently rejected each other. When two @sym-bot/sym (or sym-swift) processes ran on the same host and Bonjour-discovered each other, neither could maintain a peer relationship. The `inbound-connection` handler and `_createPeer` short-circuited the moment a same-source transport key was present in `peer.transports`, regardless of whether that prior was actually alive or what direction it was in.
Three real failure modes collapsed into the same bug:
- Stale prior — the previous transport's `_closed` flag was set but its close handler hadn't fired yet. Apple's Network framework doesn't always deliver FIN promptly when a peer process exits abruptly. Any reconnect attempt was permanently rejected until the OS reaped the dead entry.
- Same-direction duplicate — listener fires `newConnectionHandler` twice for the same advertised service (TCP retry, multipath race). Silently replacing the established healthy inbound with the duplicate tore down the wire pair on the remote side and triggered peer-left storms.
- Dual-dial collision — both peers Bonjour-discovered each other within ~50ms and both initiated outbound TCP. The unconditional reject killed one side's view of the connection, leaving asymmetric peer state.
Fix in both `inbound-connection` handler and `_createPeer`:
- Stale-aware dedup — short-circuit only when the prior transport is alive (`!_closed`). A stale entry is treated as no prior.
- Direction-aware dedup with deterministic tie-break:
- Same-direction duplicate → keep prior, drop new.
- Dual-dial → nodeId-based tie-break. Lower nodeId acts as client and keeps outbound; higher keeps inbound. Both peers compute the same physical-socket winner without exchanging coordination frames.
Mirrors the equivalent fix shipped in `@sym-bot/sym-swift` v0.3.79 + v0.3.80 so cross-runtime peers (sym-swift ↔ sym Node) now agree on the same dedup convention.
150/150 unit tests pass.
Affects all peers running on the same host as another sym instance, and any deployment where Bonjour discovery races finish within ~50ms of each other.
See PR #21 for the full diff and CHANGELOG.md for the long-form entry.
v0.5.0 — buildStartupPrimer
Added
node.buildStartupPrimer({ maxCount, maxAgeMs }) — reconstitute an agent's remix memory as a human-readable primer, suitable for injection into the LLM context at session start. Operationalises MMP §4.2 O2 (rejoin-without-replay). A fresh agent session wakes with its prior cognitive state already loaded — zero first-turn sym_recall overhead.
Returns { text, count, dropped, totalInStore }. Defaults: maxCount=20, maxAgeMs=86_400_000 (24h). Recency window applied first, then count cap. Empty store yields an empty primer.
Intended use — call as the final step of plugin initialisation, before constructing the MCP Server (the SDK stores instructions in a private field at construction):
const node = new SymNode({ name, ... });
const primer = node.buildStartupPrimer();
const mcpServer = new Server(
{ name, version },
{ capabilities, instructions: baseInstructions + '\n\n' + primer.text }
);
await node.start();Inherits to every plugin that depends on @sym-bot/sym. Lockstep consumers: @sym-bot/mesh-channel v0.3.0, @sym-bot/melotune-plugin v0.1.7.
Test coverage: empty-store path, 3-CMB primer text shape, maxCount cap with dropped-count reporting, maxAgeMs recency cap. 17 of 17 tests pass including the new 4 primer assertions in tests/node.test.js.