@sym-bot/sym 0.1.0 → 0.2.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.
package/lib/node.js CHANGED
@@ -9,6 +9,9 @@ const { nodeDir, loadOrCreateIdentity, ensureDir, log: logMsg } = require('./con
9
9
  const { MemoryStore } = require('./memory-store');
10
10
  const { FrameParser, sendFrame } = require('./frame-parser');
11
11
  const { encode, DIM } = require('./context-encoder');
12
+ const { TcpTransport, RelayPeerTransport } = require('./transport');
13
+ const fs = require('fs');
14
+ const { createSign } = require('crypto');
12
15
 
13
16
  /**
14
17
  * SymNode — a sovereign mesh node with cognitive coupling.
@@ -47,6 +50,24 @@ class SymNode extends EventEmitter {
47
50
  this._port = 0;
48
51
  this._running = false;
49
52
 
53
+ // Relay
54
+ this._relayUrl = opts.relay || null;
55
+ this._relayToken = opts.relayToken || null;
56
+ this._relayOnly = opts.relayOnly || false;
57
+ this._relayWs = null;
58
+ this._relayReconnectTimer = null;
59
+ this._relayReconnectDelay = 1000;
60
+ this._relayPeerTransports = new Map(); // nodeId → RelayPeerTransport
61
+
62
+ // Wake
63
+ this._wakeChannel = opts.wakeChannel || null; // { platform, token, environment }
64
+ this._peerWakeChannels = new Map(); // nodeId → { platform, token, environment }
65
+ this._wakeCooldownMs = opts.wakeCooldownMs || 5 * 60 * 1000; // 5 min default
66
+ this._peerLastWake = new Map(); // nodeId → timestamp
67
+ this._pendingFrames = new Map(); // nodeId → [frames] — queued for woken peers
68
+ this._wakeChannelsFile = path.join(this._dir, 'wake-channels.json');
69
+ this._loadWakeChannels();
70
+
50
71
  // Timers
51
72
  this._heartbeatInterval = opts.heartbeatInterval || 5000;
52
73
  this._heartbeatTimeout = opts.heartbeatTimeout || 15000;
@@ -77,6 +98,38 @@ class SymNode extends EventEmitter {
77
98
  return parts.join('\n');
78
99
  }
79
100
 
101
+ // ── Wake Channel Persistence ───────────────────────────────
102
+
103
+ /** Load persisted peer wake channels from disk. */
104
+ _loadWakeChannels() {
105
+ try {
106
+ if (fs.existsSync(this._wakeChannelsFile)) {
107
+ const data = JSON.parse(fs.readFileSync(this._wakeChannelsFile, 'utf8'));
108
+ for (const [id, ch] of Object.entries(data)) {
109
+ if (ch?.platform && ch?.token) {
110
+ this._peerWakeChannels.set(id, ch);
111
+ }
112
+ }
113
+ if (this._peerWakeChannels.size > 0) {
114
+ this._log(`Loaded ${this._peerWakeChannels.size} wake channel(s) from disk`);
115
+ }
116
+ }
117
+ } catch (err) {
118
+ this._log(`Failed to load wake channels: ${err.message}`);
119
+ }
120
+ }
121
+
122
+ /** Persist peer wake channels to disk. */
123
+ _saveWakeChannels() {
124
+ try {
125
+ ensureDir(path.dirname(this._wakeChannelsFile));
126
+ const data = Object.fromEntries(this._peerWakeChannels);
127
+ fs.writeFileSync(this._wakeChannelsFile, JSON.stringify(data, null, 2));
128
+ } catch (err) {
129
+ this._log(`Failed to save wake channels: ${err.message}`);
130
+ }
131
+ }
132
+
80
133
  _reencodeAndBroadcast() {
81
134
  const context = this._buildContext();
82
135
  if (context.length < 5) return;
@@ -122,7 +175,7 @@ class SymNode extends EventEmitter {
122
175
  this._log(`Not sharing with ${peer.name} — rejected (drift: ${d.drift.toFixed(3)})`);
123
176
  continue;
124
177
  }
125
- sendFrame(peer.socket, { type: 'memory-share', ...entry });
178
+ peer.transport.send({ type: 'memory-share', ...entry });
126
179
  shared++;
127
180
  if (d) {
128
181
  this._log(`Shared with ${peer.name} — ${d.decision} (drift: ${d.drift.toFixed(3)})`);
@@ -139,13 +192,19 @@ class SymNode extends EventEmitter {
139
192
  if (this._running) return;
140
193
  this._running = true;
141
194
 
142
- await this._startServer();
143
- this._startDiscovery();
195
+ if (!this._relayOnly) {
196
+ await this._startServer();
197
+ this._startDiscovery();
198
+ }
199
+
200
+ if (this._relayUrl) {
201
+ this._connectRelay();
202
+ }
144
203
 
145
204
  this._heartbeatTimer = setInterval(() => this._checkHeartbeats(), this._heartbeatInterval);
146
205
  this._encodeTimer = setInterval(() => this._reencodeAndBroadcast(), this._encodeInterval);
147
206
 
148
- this._log(`Started (port: ${this._port}, id: ${this._identity.nodeId.slice(0, 8)})`);
207
+ this._log(`Started (port: ${this._port}, id: ${this._identity.nodeId.slice(0, 8)}${this._relayUrl ? ', relay: ' + this._relayUrl : ''})`);
149
208
  }
150
209
 
151
210
  async stop() {
@@ -154,9 +213,21 @@ class SymNode extends EventEmitter {
154
213
 
155
214
  if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
156
215
  if (this._encodeTimer) clearInterval(this._encodeTimer);
216
+ if (this._relayReconnectTimer) clearTimeout(this._relayReconnectTimer);
217
+
218
+ // Close relay transports
219
+ for (const [, transport] of this._relayPeerTransports) {
220
+ transport.destroy();
221
+ }
222
+ this._relayPeerTransports.clear();
223
+
224
+ if (this._relayWs) {
225
+ try { this._relayWs.close(); } catch {}
226
+ this._relayWs = null;
227
+ }
157
228
 
158
229
  for (const [, peer] of this._peers) {
159
- try { peer.socket.destroy(); } catch {}
230
+ peer.transport.close();
160
231
  }
161
232
  this._peers.clear();
162
233
 
@@ -184,7 +255,6 @@ class SymNode extends EventEmitter {
184
255
  this._meshNode.updateLocalState(h1, h2, 0.8);
185
256
 
186
257
  // Evaluate coupling with each peer — only share with aligned/guarded
187
- // Trigger coupling evaluation
188
258
  this._meshNode.coupledState();
189
259
  const decisions = this._meshNode.couplingDecisions;
190
260
 
@@ -197,7 +267,7 @@ class SymNode extends EventEmitter {
197
267
  continue;
198
268
  }
199
269
 
200
- sendFrame(peer.socket, { type: 'memory-share', ...entry });
270
+ peer.transport.send({ type: 'memory-share', ...entry });
201
271
  shared++;
202
272
 
203
273
  if (d) {
@@ -231,6 +301,10 @@ class SymNode extends EventEmitter {
231
301
  timestamp: Date.now(),
232
302
  };
233
303
  this._broadcastToPeers(frame);
304
+
305
+ // Wake sleeping peers and queue the frame for delivery on reconnect
306
+ this._wakeSleepingPeers('mood', frame);
307
+
234
308
  this._log(`Mood broadcast: "${mood.slice(0, 50)}"`);
235
309
  }
236
310
 
@@ -246,12 +320,195 @@ class SymNode extends EventEmitter {
246
320
  };
247
321
  if (opts.to) {
248
322
  const peer = this._peers.get(opts.to);
249
- if (peer) sendFrame(peer.socket, frame);
323
+ if (peer) peer.transport.send(frame);
250
324
  } else {
251
325
  this._broadcastToPeers(frame);
252
326
  }
253
327
  }
254
328
 
329
+ // ── Wake ────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Wake a sleeping peer via push notification.
333
+ * Autonomous decision: checks transport, coupling, cooldown.
334
+ */
335
+ async wakeIfNeeded(peerId, reason = 'message') {
336
+ // 1. Is peer transport active?
337
+ const peer = this._peers.get(peerId);
338
+ if (peer?.transport) return false; // peer is connected, no wake needed
339
+
340
+ // 2. Does peer have a wake channel?
341
+ const wakeChannel = this._peerWakeChannels.get(peerId);
342
+ if (!wakeChannel || wakeChannel.platform === 'none') return false;
343
+
344
+ // 3. Is peer coupled? (autonomous decision)
345
+ const d = this._meshNode.couplingDecisions.get(peerId);
346
+ if (d && d.decision === 'rejected') return false;
347
+
348
+ // 4. Cooldown check
349
+ const lastWake = this._peerLastWake.get(peerId) || 0;
350
+ if (Date.now() - lastWake < this._wakeCooldownMs) return false;
351
+
352
+ // 5. Send wake
353
+ try {
354
+ await this._sendWake(wakeChannel, reason);
355
+ this._peerLastWake.set(peerId, Date.now());
356
+ this._log(`Wake sent to ${peerId}: ${reason} via ${wakeChannel.platform}`);
357
+ return true;
358
+ } catch (err) {
359
+ this._log(`Wake failed for ${peerId}: ${err.message}`);
360
+ return false;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Wake all sleeping coupled peers.
366
+ */
367
+ async wakeAllPeers(reason = 'message') {
368
+ const promises = [];
369
+ for (const [peerId] of this._peerWakeChannels) {
370
+ promises.push(this.wakeIfNeeded(peerId, reason));
371
+ }
372
+ const results = await Promise.allSettled(promises);
373
+ return results.filter(r => r.status === 'fulfilled' && r.value).length;
374
+ }
375
+
376
+ /**
377
+ * Attempt to wake all sleeping peers that have wake channels
378
+ * but no active transport. Queues the frame for delivery on reconnect.
379
+ */
380
+ _wakeSleepingPeers(reason, pendingFrame) {
381
+ for (const [peerId] of this._peerWakeChannels) {
382
+ if (!this._peers.has(peerId)) {
383
+ // Queue frame for delivery when this peer reconnects
384
+ if (pendingFrame) {
385
+ if (!this._pendingFrames.has(peerId)) {
386
+ this._pendingFrames.set(peerId, []);
387
+ }
388
+ this._pendingFrames.get(peerId).push(pendingFrame);
389
+ }
390
+
391
+ this.wakeIfNeeded(peerId, reason).catch(err => {
392
+ this._log(`Wake failed for ${peerId.slice(0, 8)}: ${err.message}`);
393
+ });
394
+ }
395
+ }
396
+ }
397
+
398
+ /** Route wake to the appropriate platform transport. */
399
+ async _sendWake(wakeChannel, reason) {
400
+ switch (wakeChannel.platform) {
401
+ case 'apns':
402
+ return this._sendAPNsWake(wakeChannel, reason);
403
+ default:
404
+ throw new Error(`Unsupported wake platform: ${wakeChannel.platform}`);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Send an APNs push notification to wake a sleeping iOS node.
410
+ *
411
+ * Uses JWT (ES256) authentication with a p8 key from ~/.sym/wake-keys/.
412
+ * Sends a visible alert (iOS throttles silent pushes making them unreliable).
413
+ * The content-available flag triggers background processing on delivery.
414
+ */
415
+ async _sendAPNsWake(wakeChannel, reason) {
416
+ const http2 = require('http2');
417
+ const keysDir = path.join(require('os').homedir(), '.sym', 'wake-keys');
418
+ const configPath = path.join(keysDir, 'apns-config.json');
419
+ const keyPath = path.join(keysDir, 'apns-key.p8');
420
+
421
+ // Load and cache APNs credentials on first use
422
+ if (!this._apnsConfig) {
423
+ if (!fs.existsSync(configPath) || !fs.existsSync(keyPath)) {
424
+ throw new Error('APNs keys not found at ~/.sym/wake-keys/ (need apns-key.p8 + apns-config.json)');
425
+ }
426
+ this._apnsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
427
+ this._apnsKey = fs.readFileSync(keyPath, 'utf8');
428
+
429
+ if (!this._apnsConfig.teamId || !this._apnsConfig.keyId || !this._apnsConfig.bundleId) {
430
+ this._apnsConfig = null;
431
+ throw new Error('apns-config.json must have teamId, keyId, and bundleId');
432
+ }
433
+ }
434
+
435
+ const { teamId, keyId, bundleId } = this._apnsConfig;
436
+
437
+ // Build JWT (ES256) — valid for up to 1 hour per Apple spec
438
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: keyId })).toString('base64url');
439
+ const iat = Math.floor(Date.now() / 1000);
440
+ const claims = Buffer.from(JSON.stringify({ iss: teamId, iat })).toString('base64url');
441
+ const signer = createSign('SHA256');
442
+ signer.update(`${header}.${claims}`);
443
+ const signature = signer.sign({ key: this._apnsKey, dsaEncoding: 'ieee-p1363' }, 'base64url');
444
+ const jwt = `${header}.${claims}.${signature}`;
445
+
446
+ // APNs requires HTTP/2 — Node.js fetch does not support it
447
+ const host = wakeChannel.environment === 'sandbox'
448
+ ? 'api.sandbox.push.apple.com'
449
+ : 'api.push.apple.com';
450
+
451
+ // Visible alert with content-available for background processing
452
+ const reasonText = {
453
+ mood: 'shared a mood signal',
454
+ message: 'sent a message',
455
+ memory: 'shared a memory',
456
+ }[reason] || 'wants to connect';
457
+
458
+ const payload = JSON.stringify({
459
+ aps: {
460
+ alert: { title: 'SYM Mesh', body: `${this.name}: ${reasonText}` },
461
+ 'content-available': 1,
462
+ sound: 'default',
463
+ },
464
+ mmp: {
465
+ type: 'wake',
466
+ from: this._identity.nodeId,
467
+ fromName: this.name,
468
+ reason,
469
+ },
470
+ });
471
+
472
+ return new Promise((resolve, reject) => {
473
+ const client = http2.connect(`https://${host}`);
474
+
475
+ client.on('error', (err) => {
476
+ client.close();
477
+ reject(new Error(`APNs connection failed: ${err.message}`));
478
+ });
479
+
480
+ const req = client.request({
481
+ ':method': 'POST',
482
+ ':path': `/3/device/${wakeChannel.token}`,
483
+ 'authorization': `bearer ${jwt}`,
484
+ 'apns-topic': bundleId,
485
+ 'apns-push-type': 'alert',
486
+ 'apns-priority': '10',
487
+ });
488
+
489
+ let status;
490
+ let body = '';
491
+
492
+ req.on('response', (headers) => { status = headers[':status']; });
493
+ req.on('data', (chunk) => { body += chunk; });
494
+ req.on('end', () => {
495
+ client.close();
496
+ if (status === 200) {
497
+ resolve();
498
+ } else {
499
+ reject(new Error(`APNs responded ${status}: ${body}`));
500
+ }
501
+ });
502
+ req.on('error', (err) => {
503
+ client.close();
504
+ reject(new Error(`APNs request failed: ${err.message}`));
505
+ });
506
+
507
+ req.write(payload);
508
+ req.end();
509
+ });
510
+ }
511
+
255
512
  // ── Monitoring ─────────────────────────────────────────────
256
513
 
257
514
  peers() {
@@ -266,6 +523,7 @@ class SymNode extends EventEmitter {
266
523
  lastSeen: peer.lastSeen,
267
524
  coupling: d ? d.decision : 'pending',
268
525
  drift: d ? parseFloat(d.drift.toFixed(3)) : null,
526
+ source: peer.source || 'bonjour',
269
527
  });
270
528
  }
271
529
  return result;
@@ -285,6 +543,8 @@ class SymNode extends EventEmitter {
285
543
  nodeId: this._identity.nodeId.slice(0, 8),
286
544
  running: this._running,
287
545
  port: this._port,
546
+ relay: this._relayUrl || null,
547
+ relayConnected: this._relayWs?.readyState === 1 || false,
288
548
  peers: this.peers(),
289
549
  peerCount: this._peers.size,
290
550
  memoryCount: this.memories(),
@@ -311,24 +571,30 @@ class SymNode extends EventEmitter {
311
571
  }
312
572
 
313
573
  _handleInboundConnection(socket) {
314
- const parser = new FrameParser();
574
+ const transport = new TcpTransport(socket);
315
575
  let identified = false;
316
- const timeout = setTimeout(() => { if (!identified) socket.destroy(); }, 10000);
576
+ const timeout = setTimeout(() => { if (!identified) transport.close(); }, 10000);
317
577
 
318
- socket.on('data', (chunk) => { if (!identified) parser.feed(chunk); });
319
-
320
- parser.on('message', (msg) => {
578
+ transport.on('message', (msg) => {
321
579
  if (identified) return;
322
- if (msg.type !== 'handshake') { socket.destroy(); return; }
580
+ if (msg.type !== 'handshake') { transport.close(); return; }
323
581
  identified = true;
324
582
  clearTimeout(timeout);
325
- if (this._peers.has(msg.nodeId)) { socket.destroy(); return; }
326
- const peer = this._createPeer(socket, msg.nodeId, msg.name, false);
327
- if (parser.buffer.length > 0) peer.parser.feed(parser.buffer);
583
+ if (this._peers.has(msg.nodeId)) { transport.close(); return; }
584
+
585
+ // Re-wire transport messages to peer handler
586
+ transport.removeAllListeners('message');
587
+ transport.on('message', (m) => {
588
+ const peer = this._peers.get(msg.nodeId);
589
+ if (peer) peer.lastSeen = Date.now();
590
+ this._handlePeerMessage(msg.nodeId, msg.name, m);
591
+ });
592
+
593
+ const peer = this._createPeer(transport, msg.nodeId, msg.name, false, 'bonjour');
328
594
  this._addPeer(peer);
329
595
  });
330
596
 
331
- socket.on('error', () => clearTimeout(timeout));
597
+ transport.on('error', () => clearTimeout(timeout));
332
598
  }
333
599
 
334
600
  // ── Bonjour Discovery ──────────────────────────────────────
@@ -362,46 +628,203 @@ class SymNode extends EventEmitter {
362
628
  _connectToPeer(address, port, peerId, peerName) {
363
629
  if (this._peers.has(peerId)) return;
364
630
  const socket = net.createConnection({ host: address, port }, () => {
365
- const peer = this._createPeer(socket, peerId, peerName, true);
631
+ const transport = new TcpTransport(socket);
632
+ transport.on('message', (msg) => {
633
+ const peer = this._peers.get(peerId);
634
+ if (peer) peer.lastSeen = Date.now();
635
+ this._handlePeerMessage(peerId, peerName, msg);
636
+ });
637
+ transport.on('error', () => {});
638
+ const peer = this._createPeer(transport, peerId, peerName, true, 'bonjour');
366
639
  this._addPeer(peer);
367
640
  });
368
641
  socket.on('error', (err) => this._log(`Connect failed to ${peerName}: ${err.message}`));
369
642
  socket.setTimeout(10000, () => socket.destroy());
370
643
  }
371
644
 
372
- // ── Peer Management ────────────────────────────────────────
645
+ // ── WebSocket Relay ─────────────────────────────────────────
646
+
647
+ _connectRelay() {
648
+ if (!this._running || !this._relayUrl) return;
649
+
650
+ let WebSocket;
651
+ try {
652
+ WebSocket = require('ws');
653
+ } catch {
654
+ this._log('Relay requires the "ws" package — npm install ws');
655
+ return;
656
+ }
657
+
658
+ const ws = new WebSocket(this._relayUrl);
659
+ this._relayWs = ws;
660
+
661
+ ws.on('open', () => {
662
+ this._relayReconnectDelay = 1000;
663
+ this._log(`Relay connected: ${this._relayUrl}`);
664
+
665
+ // Authenticate with the relay
666
+ const auth = {
667
+ type: 'relay-auth',
668
+ nodeId: this._identity.nodeId,
669
+ name: this.name,
670
+ wakeChannel: this._wakeChannel || undefined,
671
+ };
672
+ if (this._relayToken) auth.token = this._relayToken;
673
+ ws.send(JSON.stringify(auth));
674
+ });
675
+
676
+ ws.on('message', (data) => {
677
+ let msg;
678
+ try { msg = JSON.parse(data.toString()); } catch { return; }
679
+
680
+ if (msg.type === 'relay-peer-joined') {
681
+ this._handleRelayPeerJoined(msg.nodeId, msg.name);
682
+ } else if (msg.type === 'relay-peer-left') {
683
+ this._handleRelayPeerLeft(msg.nodeId, msg.name);
684
+ } else if (msg.type === 'relay-peers') {
685
+ // Initial peer list from relay — includes gossip (wake channels)
686
+ for (const p of (msg.peers || [])) {
687
+ // Store gossiped wake channels
688
+ if (p.wakeChannel && p.wakeChannel.platform !== 'none') {
689
+ this._peerWakeChannels.set(p.nodeId, p.wakeChannel);
690
+ this._log(`Gossip: learned wake channel for ${p.name} (${p.wakeChannel.platform})`);
691
+ }
692
+ // Only connect to online peers
693
+ if (!p.offline) {
694
+ this._handleRelayPeerJoined(p.nodeId, p.name);
695
+ }
696
+ }
697
+ this._saveWakeChannels();
698
+ } else if (msg.type === 'relay-ping') {
699
+ ws.send(JSON.stringify({ type: 'relay-pong' }));
700
+ } else if (msg.type === 'relay-error') {
701
+ this._log(`Relay error: ${msg.message}`);
702
+ } else if (msg.from && msg.payload) {
703
+ // Routed frame from a peer
704
+ const peer = this._peers.get(msg.from);
705
+ if (peer) peer.lastSeen = Date.now();
706
+ this._handlePeerMessage(msg.from, msg.fromName || 'unknown', msg.payload);
707
+ }
708
+ });
709
+
710
+ ws.on('close', () => {
711
+ this._log('Relay disconnected');
712
+ this._relayWs = null;
713
+
714
+ // Clean up relay peers
715
+ for (const [peerId, peer] of this._peers) {
716
+ if (peer.source === 'relay') {
717
+ this._peers.delete(peerId);
718
+ this._meshNode.removePeer(peerId);
719
+ this.emit('peer-left', { id: peerId, name: peer.name });
720
+ }
721
+ }
722
+ for (const [, transport] of this._relayPeerTransports) {
723
+ transport.destroy();
724
+ }
725
+ this._relayPeerTransports.clear();
373
726
 
374
- _createPeer(socket, peerId, peerName, isOutbound) {
375
- const parser = new FrameParser();
376
- socket.on('data', (chunk) => parser.feed(chunk));
377
- parser.on('message', (msg) => {
378
- const peer = this._peers.get(peerId);
379
- if (peer) peer.lastSeen = Date.now();
380
- this._handlePeerMessage(peerId, peerName, msg);
727
+ this._scheduleRelayReconnect();
381
728
  });
382
- parser.on('error', (err) => this._log(`Frame error from ${peerName}: ${err.message}`));
383
- socket.on('close', () => {
729
+
730
+ ws.on('error', (err) => {
731
+ this._log(`Relay error: ${err.message}`);
732
+ });
733
+ }
734
+
735
+ _handleRelayPeerJoined(peerId, peerName) {
736
+ if (!peerId || peerId === this._identity.nodeId) return;
737
+ if (this._peers.has(peerId)) return; // Already connected (via Bonjour or relay)
738
+
739
+ const transport = new RelayPeerTransport(this._relayWs, peerId);
740
+ this._relayPeerTransports.set(peerId, transport);
741
+
742
+ transport.on('close', () => {
743
+ this._relayPeerTransports.delete(peerId);
744
+ });
745
+
746
+ const peer = this._createPeer(transport, peerId, peerName, true, 'relay');
747
+ this._addPeer(peer);
748
+ }
749
+
750
+ _handleRelayPeerLeft(peerId, peerName) {
751
+ const peer = this._peers.get(peerId);
752
+ if (!peer || peer.source !== 'relay') return;
753
+
754
+ const transport = this._relayPeerTransports.get(peerId);
755
+ if (transport) transport.destroy();
756
+ this._relayPeerTransports.delete(peerId);
757
+
758
+ this._peers.delete(peerId);
759
+ this._meshNode.removePeer(peerId);
760
+ this._log(`Relay peer left: ${peerName || peerId}`);
761
+ this.emit('peer-left', { id: peerId, name: peerName || peer.name });
762
+ }
763
+
764
+ _scheduleRelayReconnect() {
765
+ if (!this._running || !this._relayUrl) return;
766
+
767
+ const jitter = this._relayReconnectDelay * 0.1 * Math.random();
768
+ const delay = this._relayReconnectDelay + jitter;
769
+
770
+ this._log(`Relay reconnecting in ${Math.round(delay / 1000)}s`);
771
+ this._relayReconnectTimer = setTimeout(() => this._connectRelay(), delay);
772
+
773
+ // Exponential backoff, capped at 30s
774
+ this._relayReconnectDelay = Math.min(this._relayReconnectDelay * 2, 30000);
775
+ }
776
+
777
+ // ── Peer Management ────────────────────────────────────────
778
+
779
+ _createPeer(transport, peerId, peerName, isOutbound, source) {
780
+ transport.on('close', () => {
384
781
  this._peers.delete(peerId);
385
782
  this._meshNode.removePeer(peerId);
386
783
  this._log(`Peer disconnected: ${peerName}`);
387
784
  this.emit('peer-left', { id: peerId, name: peerName });
388
785
  });
389
- socket.on('error', () => {});
390
- return { socket, parser, peerId, name: peerName, isOutbound, lastSeen: Date.now() };
786
+
787
+ return { transport, peerId, name: peerName, isOutbound, source, lastSeen: Date.now() };
391
788
  }
392
789
 
393
790
  _addPeer(peer) {
394
791
  this._peers.set(peer.peerId, peer);
395
792
 
396
793
  // Handshake
397
- sendFrame(peer.socket, { type: 'handshake', nodeId: this._identity.nodeId, name: this.name });
794
+ peer.transport.send({ type: 'handshake', nodeId: this._identity.nodeId, name: this.name });
398
795
 
399
796
  // Send cognitive state for coupling evaluation
400
797
  const [h1, h2] = this._meshNode.coupledState();
401
- sendFrame(peer.socket, { type: 'state-sync', h1, h2, confidence: 0.8 });
798
+ peer.transport.send({ type: 'state-sync', h1, h2, confidence: 0.8 });
799
+
800
+ // Send wake channel if configured (legacy, for backward compat)
801
+ if (this._wakeChannel) {
802
+ peer.transport.send({ type: 'wake-channel', ...this._wakeChannel });
803
+ }
402
804
 
403
- this._log(`Peer connected: ${peer.name} (${peer.isOutbound ? 'outbound' : 'inbound'})`);
805
+ // Send peer-info gossip share what we know about other peers
806
+ const knownPeers = [];
807
+ for (const [id, wc] of this._peerWakeChannels) {
808
+ if (id !== peer.peerId) {
809
+ knownPeers.push({ nodeId: id, wakeChannel: wc, lastSeen: Date.now() });
810
+ }
811
+ }
812
+ if (knownPeers.length > 0) {
813
+ peer.transport.send({ type: 'peer-info', peers: knownPeers });
814
+ }
815
+
816
+ this._log(`Peer connected: ${peer.name} (${peer.isOutbound ? 'outbound' : 'inbound'}, ${peer.source})`);
404
817
  this.emit('peer-joined', { id: peer.peerId, name: peer.name });
818
+
819
+ // Deliver any frames queued while this peer was sleeping
820
+ const pending = this._pendingFrames.get(peer.peerId);
821
+ if (pending && pending.length > 0) {
822
+ this._log(`Delivering ${pending.length} pending frame(s) to ${peer.name}`);
823
+ for (const frame of pending) {
824
+ peer.transport.send(frame);
825
+ }
826
+ this._pendingFrames.delete(peer.peerId);
827
+ }
405
828
  }
406
829
 
407
830
  _handlePeerMessage(peerId, peerName, msg) {
@@ -464,15 +887,41 @@ class SymNode extends EventEmitter {
464
887
  }
465
888
  break;
466
889
 
890
+ case 'wake-channel':
891
+ if (msg.platform) {
892
+ this._peerWakeChannels.set(peerId, {
893
+ platform: msg.platform,
894
+ token: msg.token,
895
+ environment: msg.environment,
896
+ });
897
+ this._saveWakeChannels();
898
+ this._log(`Wake channel from ${peerName}: ${msg.platform}`);
899
+ }
900
+ break;
901
+
902
+ case 'peer-info':
903
+ // Gossip: peer is sharing what it knows about other peers
904
+ if (Array.isArray(msg.peers)) {
905
+ for (const p of msg.peers) {
906
+ if (p.nodeId && p.wakeChannel && p.nodeId !== this._identity.nodeId) {
907
+ this._peerWakeChannels.set(p.nodeId, p.wakeChannel);
908
+ this._log(`Gossip from ${peerName}: learned wake channel for ${p.name}`);
909
+ }
910
+ }
911
+ this._saveWakeChannels();
912
+ }
913
+ break;
914
+
467
915
  case 'message':
468
916
  this._log(`Message from ${msg.fromName || peerName}: ${(msg.content || '').slice(0, 60)}`);
469
917
  this.emit('message', msg.fromName || peerName, msg.content, msg);
470
918
  break;
471
919
 
472
- case 'ping':
920
+ case 'ping': {
473
921
  const peer = this._peers.get(peerId);
474
- if (peer) sendFrame(peer.socket, { type: 'pong' });
922
+ if (peer) peer.transport.send({ type: 'pong' });
475
923
  break;
924
+ }
476
925
 
477
926
  case 'pong':
478
927
  break;
@@ -481,7 +930,7 @@ class SymNode extends EventEmitter {
481
930
 
482
931
  _broadcastToPeers(frame) {
483
932
  for (const [, peer] of this._peers) {
484
- sendFrame(peer.socket, frame);
933
+ peer.transport.send(frame);
485
934
  }
486
935
  }
487
936
 
@@ -490,11 +939,11 @@ class SymNode extends EventEmitter {
490
939
  for (const [id, peer] of this._peers) {
491
940
  if (now - peer.lastSeen > this._heartbeatTimeout) {
492
941
  this._log(`Heartbeat timeout: ${peer.name}`);
493
- peer.socket.destroy();
942
+ peer.transport.close();
494
943
  this._peers.delete(id);
495
944
  this._meshNode.removePeer(id);
496
945
  } else if (now - peer.lastSeen > this._heartbeatInterval) {
497
- sendFrame(peer.socket, { type: 'ping' });
946
+ peer.transport.send({ type: 'ping' });
498
947
  }
499
948
  }
500
949
  }