clawmatrix 0.5.0 → 0.6.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.
@@ -237,6 +237,10 @@ export class ClusterRuntime {
237
237
  });
238
238
  this.knowledgeSync.start().then(() => {
239
239
  this.logger.info(`[clawmatrix] Knowledge sync started: ${workspacePath}`);
240
+ // Sync with any peers that connected before start() completed
241
+ for (const peerId of this.peerManager.router.getDirectPeerIds()) {
242
+ this.knowledgeSync?.initPeerSync(peerId);
243
+ }
240
244
  }).catch((err) => {
241
245
  this.logger.error(`[clawmatrix] Knowledge sync failed to start: ${err}`);
242
246
  });
@@ -643,15 +647,26 @@ export class ClusterRuntime {
643
647
  case "availability_req": {
644
648
  const af = frame as AvailabilityRequest;
645
649
  const range = af.payload.range ?? "24h";
646
- const data = this.healthTracker.getAvailability(range);
647
- this.peerManager.sendTo(af.from, {
648
- type: "availability_res",
649
- id: af.id,
650
- from: this.config.nodeId,
651
- to: af.from,
652
- timestamp: Date.now(),
653
- payload: { success: true, data },
654
- } as AvailabilityResponse);
650
+ try {
651
+ const data = this.healthTracker.getAvailability(range);
652
+ this.peerManager.sendTo(af.from, {
653
+ type: "availability_res",
654
+ id: af.id,
655
+ from: this.config.nodeId,
656
+ to: af.from,
657
+ timestamp: Date.now(),
658
+ payload: { success: true, data },
659
+ } as AvailabilityResponse);
660
+ } catch (err) {
661
+ this.peerManager.sendTo(af.from, {
662
+ type: "availability_res",
663
+ id: af.id,
664
+ from: this.config.nodeId,
665
+ to: af.from,
666
+ timestamp: Date.now(),
667
+ payload: { success: false, error: String((err as Error)?.message ?? err) },
668
+ } as AvailabilityResponse);
669
+ }
655
670
  break;
656
671
  }
657
672
  case "acp_req":
package/src/config.ts CHANGED
@@ -133,23 +133,29 @@ const TerminalConfigSchema = z.object({
133
133
  allowFrom: z.array(z.string()).default([]),
134
134
  }).optional();
135
135
 
136
- const FileTransferConfigSchema = z.object({
137
- enabled: z.boolean().default(false),
138
- chunkSize: z.number().default(262_144), // 256KB
139
- maxFileSize: z.number().default(104_857_600), // 100MB
140
- timeout: z.number().default(300_000), // 5min per-chunk
141
- allowedPaths: z.array(z.string()).default([]), // empty = no restriction
142
- }).optional();
136
+ const FileTransferConfigSchema = z.preprocess(
137
+ (v) => v ?? {},
138
+ z.object({
139
+ enabled: z.boolean().default(true),
140
+ chunkSize: z.number().default(262_144), // 256KB
141
+ maxFileSize: z.number().default(104_857_600), // 100MB
142
+ timeout: z.number().default(300_000), // 5min per-chunk
143
+ allowedPaths: z.array(z.string()).default([]), // empty = no restriction
144
+ }),
145
+ ).default({});
143
146
 
144
- const KanbanConfigSchema = z.object({
145
- enabled: z.boolean().default(false),
146
- /** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
147
- prefix: z.string().default("CM"),
148
- /** Allow agents to auto-claim matching cards. */
149
- autoAssign: z.boolean().default(true),
150
- /** Archive cards older than this (ms). Default: 30 days. */
151
- archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
152
- }).optional();
147
+ const KanbanConfigSchema = z.preprocess(
148
+ (v) => v ?? {},
149
+ z.object({
150
+ enabled: z.boolean().default(true),
151
+ /** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
152
+ prefix: z.string().default("CM"),
153
+ /** Allow agents to auto-claim matching cards. */
154
+ autoAssign: z.boolean().default(true),
155
+ /** Archive cards older than this (ms). Default: 30 days. */
156
+ archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
157
+ }),
158
+ ).default({});
153
159
 
154
160
  const AcpConfigSchema = z.object({
155
161
  enabled: z.boolean().default(false),
package/src/connection.ts CHANGED
@@ -269,6 +269,25 @@ export class Connection extends EventEmitter<ConnectionEvents> {
269
269
  private async onRawMessage(data: unknown) {
270
270
  this.lastReceivedAt = Date.now();
271
271
 
272
+ // Debug: log data type for unauthenticated connections to diagnose auth issues
273
+ if (!this.authenticated && this.role === "outbound") {
274
+ const dtype = Buffer.isBuffer(data) ? `Buffer(${(data as Buffer).length})` :
275
+ data instanceof ArrayBuffer ? `ArrayBuffer(${data.byteLength})` :
276
+ typeof data === "string" ? `string(${data.length})` :
277
+ `${data?.constructor?.name ?? typeof data}`;
278
+ debug("auth", `onRawMessage[outbound] dataType=${dtype}`);
279
+ }
280
+
281
+ // Normalize non-Buffer binary types to Buffer.
282
+ // Node.js 24+'s built-in WebSocket (undici) delivers binary frames as Blob
283
+ // (binaryType "blob") or ArrayBuffer (binaryType "arraybuffer").
284
+ // The ws package delivers Buffer (binaryType "nodebuffer").
285
+ if (data instanceof ArrayBuffer) {
286
+ data = Buffer.from(data);
287
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
288
+ data = Buffer.from(await data.arrayBuffer());
289
+ }
290
+
272
291
  let frame: AnyClusterFrame | undefined;
273
292
 
274
293
  // Binary frame (Buffer) — decrypt directly without base64
@@ -560,6 +579,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
560
579
  }
561
580
 
562
581
  // auth_ok (decrypted from binary envelope, or plaintext legacy)
582
+ debug("auth", `Outbound received frame type=${frame.type} (authenticated=${this.authenticated})`);
563
583
  if (frame.type === "auth_ok") {
564
584
  const ok = frame as AuthOk;
565
585
  this.remoteNodeId = ok.payload.nodeId;
@@ -50,6 +50,15 @@ function resolveWorkspace(openclawConfig?: Record<string, unknown>): string | un
50
50
  return undefined;
51
51
  }
52
52
 
53
+ function resolveClawmatrixVersion(): string | undefined {
54
+ try {
55
+ const raw = readFileSync(join(dirname(import.meta.url.replace("file://", "")), "..", "package.json"), "utf-8");
56
+ const pkg = JSON.parse(raw) as { version?: string };
57
+ return pkg.version;
58
+ } catch { /* best effort */ }
59
+ return undefined;
60
+ }
61
+
53
62
  /** Collect device info once at startup. */
54
63
  export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Record<string, unknown>): DeviceInfo {
55
64
  const cpuList = cpus();
@@ -61,6 +70,7 @@ export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Rec
61
70
  totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
62
71
  hostname: hostname(),
63
72
  openclawVersion: resolveOpenclawVersion(openclawVersion),
73
+ clawmatrixVersion: resolveClawmatrixVersion(),
64
74
  cwd: process.cwd(),
65
75
  workspace: resolveWorkspace(openclawConfig),
66
76
  };
@@ -155,7 +155,10 @@ export class HealthTracker {
155
155
  if (ev.peer) everConnected.add(ev.peer);
156
156
  }
157
157
 
158
- const knownNodes = this.getKnownNodes();
158
+ const knownNodes = new Set(this.getKnownNodes());
159
+ // Also include nodes we've observed via peer_online events
160
+ for (const peerId of everConnected) knownNodes.add(peerId);
161
+
159
162
  for (const nodeId of knownNodes) {
160
163
  if (nodeId !== this.nodeId && !everConnected.has(nodeId)) continue;
161
164
 
@@ -181,12 +184,29 @@ export class HealthTracker {
181
184
  observerGaps: Array<[number, number]>,
182
185
  ): NodeTimeline | null {
183
186
  const sorted = [...events].sort((a, b) => a.ts - b.ts);
184
- if (sorted.length === 0) return null;
185
187
 
186
- const firstSeen = sorted[0]!.ts;
187
- const lastSeen = sorted[sorted.length - 1]!.ts;
188
+ // For remote nodes, also consider peer observation intervals
189
+ const peerIntervals = nodeId !== this.nodeId
190
+ ? this.buildPeerIntervals(nodeId, startTs, endTs)
191
+ : [];
192
+
193
+ if (sorted.length === 0 && peerIntervals.length === 0) return null;
194
+
195
+ // Determine firstSeen/lastSeen from both self-reported events and peer observations
196
+ let firstSeen = Infinity;
197
+ let lastSeen = -Infinity;
198
+ if (sorted.length > 0) {
199
+ firstSeen = sorted[0]!.ts;
200
+ lastSeen = sorted[sorted.length - 1]!.ts;
201
+ }
202
+ for (const [s, e] of peerIntervals) {
203
+ if (s < firstSeen) firstSeen = s;
204
+ if (e > lastSeen) lastSeen = e;
205
+ }
188
206
 
189
- const intervals = this.buildOnlineIntervals(nodeId, sorted, startTs, endTs);
207
+ const intervals = nodeId !== this.nodeId
208
+ ? this.mergeIntervals([...this.buildSelfIntervals(sorted, startTs, endTs), ...peerIntervals])
209
+ : this.buildOnlineIntervals(nodeId, sorted, startTs, endTs);
190
210
 
191
211
  const buckets: BucketState[] = [];
192
212
  let totalOnline = 0;
package/src/index.ts CHANGED
@@ -1537,6 +1537,291 @@ const plugin = {
1537
1537
  },
1538
1538
  );
1539
1539
 
1540
+ // ── Availability gateway method ──────────────────────────────────
1541
+
1542
+ api.registerGatewayMethod(
1543
+ "clawmatrix.availability",
1544
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1545
+ try {
1546
+ const runtime = getClusterRuntime();
1547
+ const { range } = (params ?? {}) as { range?: string };
1548
+ const validRanges = ["24h", "7d", "90d"];
1549
+ const r = validRanges.includes(range ?? "") ? (range as "24h" | "7d" | "90d") : "24h";
1550
+ const result = runtime.healthTracker.getAvailability(r);
1551
+ respond(true, result);
1552
+ } catch {
1553
+ respond(false, { error: "ClawMatrix service not running" });
1554
+ }
1555
+ },
1556
+ );
1557
+
1558
+ // ── Automation gateway methods ──────────────────────────────────
1559
+
1560
+ api.registerGatewayMethod(
1561
+ "clawmatrix.automations.rules",
1562
+ ({ respond }: GatewayRequestHandlerOptions) => {
1563
+ try {
1564
+ const runtime = getClusterRuntime();
1565
+ if (!runtime.automationManager) {
1566
+ respond(true, { rules: [] });
1567
+ return;
1568
+ }
1569
+ respond(true, { rules: runtime.automationManager.getRules() });
1570
+ } catch {
1571
+ respond(false, { error: "ClawMatrix service not running" });
1572
+ }
1573
+ },
1574
+ );
1575
+
1576
+ api.registerGatewayMethod(
1577
+ "clawmatrix.automations.save",
1578
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1579
+ try {
1580
+ const runtime = getClusterRuntime();
1581
+ if (!runtime.automationManager) {
1582
+ respond(false, { error: "Automations not available" });
1583
+ return;
1584
+ }
1585
+ const { rules } = (params ?? {}) as { rules?: unknown[] };
1586
+ if (!Array.isArray(rules)) {
1587
+ respond(false, { error: "Missing required param: rules (array)" });
1588
+ return;
1589
+ }
1590
+ await runtime.automationManager.saveRules(rules as Parameters<typeof runtime.automationManager.saveRules>[0]);
1591
+ respond(true, { ok: true, count: rules.length });
1592
+ } catch (err) {
1593
+ respond(false, { error: String(err) });
1594
+ }
1595
+ },
1596
+ );
1597
+
1598
+ api.registerGatewayMethod(
1599
+ "clawmatrix.automations.history",
1600
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1601
+ try {
1602
+ const runtime = getClusterRuntime();
1603
+ if (!runtime.automationManager) {
1604
+ respond(true, { executions: [] });
1605
+ return;
1606
+ }
1607
+ const { limit } = (params ?? {}) as { limit?: number };
1608
+ respond(true, { executions: runtime.automationManager.getExecutions(limit ?? 50) });
1609
+ } catch {
1610
+ respond(false, { error: "ClawMatrix service not running" });
1611
+ }
1612
+ },
1613
+ );
1614
+
1615
+ api.registerGatewayMethod(
1616
+ "clawmatrix.automations.run",
1617
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1618
+ try {
1619
+ const runtime = getClusterRuntime();
1620
+ if (!runtime.automationManager) {
1621
+ respond(false, { error: "Automations not available" });
1622
+ return;
1623
+ }
1624
+ const { ruleId, event } = (params ?? {}) as { ruleId?: string; event?: Record<string, unknown> };
1625
+ if (!ruleId) {
1626
+ respond(false, { error: "Missing required param: ruleId" });
1627
+ return;
1628
+ }
1629
+ const evt = event ? {
1630
+ id: nanoid(),
1631
+ source: String(event.source || "cli"),
1632
+ type: String(event.type || "manual"),
1633
+ data: (event.data ?? {}) as Record<string, unknown>,
1634
+ ts: typeof event.ts === "number" ? event.ts : Date.now(),
1635
+ consumed: false,
1636
+ } as import("./types.ts").IngestedEvent : undefined;
1637
+ const execution = await runtime.automationManager.runRuleById(ruleId, evt);
1638
+ respond(true, { ok: true, execution });
1639
+ } catch (err) {
1640
+ respond(false, { error: String(err) });
1641
+ }
1642
+ },
1643
+ );
1644
+
1645
+ api.registerGatewayMethod(
1646
+ "clawmatrix.automations.replay",
1647
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1648
+ try {
1649
+ const runtime = getClusterRuntime();
1650
+ if (!runtime.automationManager) {
1651
+ respond(false, { error: "Automations not available" });
1652
+ return;
1653
+ }
1654
+ const { executionId } = (params ?? {}) as { executionId?: string };
1655
+ if (!executionId) {
1656
+ respond(false, { error: "Missing required param: executionId" });
1657
+ return;
1658
+ }
1659
+ const execution = await runtime.automationManager.replayExecution(executionId);
1660
+ respond(true, { ok: true, execution });
1661
+ } catch (err) {
1662
+ respond(false, { error: String(err) });
1663
+ }
1664
+ },
1665
+ );
1666
+
1667
+ // ── Events ingest gateway method ──────────────────────────────────
1668
+
1669
+ api.registerGatewayMethod(
1670
+ "clawmatrix.events.ingest",
1671
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1672
+ try {
1673
+ const runtime = getClusterRuntime();
1674
+ if (!runtime.apiHandler) {
1675
+ respond(false, { error: "API handler not available (listen mode required)" });
1676
+ return;
1677
+ }
1678
+ const { events } = (params ?? {}) as { events?: unknown[] };
1679
+ if (!Array.isArray(events) || events.length === 0) {
1680
+ respond(false, { error: "Missing required param: events (non-empty array)" });
1681
+ return;
1682
+ }
1683
+ const result = runtime.apiHandler.ingestEvents(events as Array<Record<string, unknown>>);
1684
+ respond(true, { ok: true, ...result });
1685
+ } catch {
1686
+ respond(false, { error: "ClawMatrix service not running" });
1687
+ }
1688
+ },
1689
+ );
1690
+
1691
+ // ── Board update gateway method ──────────────────────────────────
1692
+
1693
+ api.registerGatewayMethod(
1694
+ "clawmatrix.board.update",
1695
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1696
+ try {
1697
+ const runtime = getClusterRuntime();
1698
+ if (!runtime.kanbanManager) {
1699
+ respond(false, { error: "Kanban not enabled" });
1700
+ return;
1701
+ }
1702
+ const { cardId, ...updates } = (params ?? {}) as {
1703
+ cardId?: string; title?: string; description?: string;
1704
+ priority?: string; targetNode?: string; targetAgent?: string;
1705
+ cwd?: string; labels?: string[];
1706
+ };
1707
+ if (!cardId) {
1708
+ respond(false, { error: "Missing required param: cardId" });
1709
+ return;
1710
+ }
1711
+ const card = runtime.kanbanManager.updateCard(cardId, updates as Parameters<typeof runtime.kanbanManager.updateCard>[1]);
1712
+ if (!card) {
1713
+ respond(false, { error: `Card not found: ${cardId}` });
1714
+ return;
1715
+ }
1716
+ respond(true, card);
1717
+ } catch {
1718
+ respond(false, { error: "ClawMatrix service not running" });
1719
+ }
1720
+ },
1721
+ );
1722
+
1723
+ // ── Config gateway method ──────────────────────────────────────────
1724
+
1725
+ api.registerGatewayMethod(
1726
+ "clawmatrix.config.get",
1727
+ ({ respond }: GatewayRequestHandlerOptions) => {
1728
+ try {
1729
+ const runtime = getClusterRuntime();
1730
+ const c = runtime.config;
1731
+ respond(true, {
1732
+ nodeId: c.nodeId,
1733
+ listen: c.listen,
1734
+ tags: c.tags,
1735
+ agents: c.agents.map((a) => ({ id: a.id, model: a.model })),
1736
+ models: c.models.map((m) => ({ id: m.id, provider: m.provider })),
1737
+ e2ee: c.e2ee,
1738
+ toolProxy: c.toolProxy ? { enabled: c.toolProxy.enabled, allow: c.toolProxy.allow, deny: c.toolProxy.deny } : undefined,
1739
+ terminal: c.terminal,
1740
+ acp: c.acp ? { enabled: c.acp.enabled } : undefined,
1741
+ knowledge: c.knowledge ? { enabled: c.knowledge.enabled } : undefined,
1742
+ proxyModels: c.proxyModels?.length ?? 0,
1743
+ peers: c.peers?.length ?? 0,
1744
+ });
1745
+ } catch {
1746
+ respond(false, { error: "ClawMatrix service not running" });
1747
+ }
1748
+ },
1749
+ );
1750
+
1751
+ // ── Config file read/write gateway methods ──────────────────────
1752
+
1753
+ api.registerGatewayMethod(
1754
+ "clawmatrix.config.read",
1755
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1756
+ try {
1757
+ const runtime = getClusterRuntime();
1758
+ if (!runtime.apiHandler) {
1759
+ respond(false, { error: "API handler not available" });
1760
+ return;
1761
+ }
1762
+ const { path: configPath } = (params ?? {}) as { path?: string };
1763
+ if (!configPath) {
1764
+ respond(false, { error: "Missing required param: path" });
1765
+ return;
1766
+ }
1767
+ const result = await runtime.apiHandler.readConfigFile(configPath);
1768
+ respond(result.success, result);
1769
+ } catch (err) {
1770
+ respond(false, { error: String(err) });
1771
+ }
1772
+ },
1773
+ );
1774
+
1775
+ api.registerGatewayMethod(
1776
+ "clawmatrix.config.write",
1777
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1778
+ try {
1779
+ const runtime = getClusterRuntime();
1780
+ if (!runtime.apiHandler) {
1781
+ respond(false, { error: "API handler not available" });
1782
+ return;
1783
+ }
1784
+ const { path: configPath, content } = (params ?? {}) as { path?: string; content?: string };
1785
+ if (!configPath || typeof content !== "string") {
1786
+ respond(false, { error: "Missing required params: path, content" });
1787
+ return;
1788
+ }
1789
+ const result = await runtime.apiHandler.writeConfigFile(configPath, content);
1790
+ respond(result.success, result);
1791
+ } catch (err) {
1792
+ respond(false, { error: String(err) });
1793
+ }
1794
+ },
1795
+ );
1796
+
1797
+ // ── Knowledge content gateway method ──────────────────────────────
1798
+
1799
+ api.registerGatewayMethod(
1800
+ "clawmatrix.kb.content",
1801
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1802
+ try {
1803
+ const runtime = getClusterRuntime();
1804
+ if (!runtime.knowledgeSync) {
1805
+ respond(false, { error: "Knowledge sync not enabled" });
1806
+ return;
1807
+ }
1808
+ const { path } = (params ?? {}) as { path?: string };
1809
+ if (!path) {
1810
+ respond(false, { error: "Missing required param: path" });
1811
+ return;
1812
+ }
1813
+ const content = runtime.knowledgeSync.getFileContent(path);
1814
+ if (content === null) {
1815
+ respond(false, { error: "File not found or content not yet synced" });
1816
+ return;
1817
+ }
1818
+ respond(true, { path, content });
1819
+ } catch {
1820
+ respond(false, { error: "ClawMatrix service not running" });
1821
+ }
1822
+ },
1823
+ );
1824
+
1540
1825
  // Log model selection on each LLM call (fire-and-forget)
1541
1826
  api.on("llm_input", (event) => {
1542
1827
  api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
@@ -547,6 +547,13 @@ export class KnowledgeSync {
547
547
  })).filter(f => !f.deleted);
548
548
  }
549
549
 
550
+ /** Get the CRDT content of a synced file, or null if not found. */
551
+ getFileContent(relPath: string): string | null {
552
+ const doc = this.fileDocs.get(relPath);
553
+ if (!doc || doc.content === undefined) return null;
554
+ return doc.content;
555
+ }
556
+
550
557
  /**
551
558
  * Set pending attribution for a file path.
552
559
  * Called by tool hooks (after_tool_call, ACP stream) before fsWatcher picks up the change.
@@ -30,28 +30,32 @@ import type { KeyPair } from "./crypto.ts";
30
30
  const RECONNECT_MAX = 60_000;
31
31
 
32
32
  /** Frame types that bypass dedup (streams share one id across chunks; responses share id with request). */
33
- const SKIP_DEDUP_TYPES = new Set([
33
+ /**
34
+ * Frame types that bypass dedup. Response frames share the same `id` as their
35
+ * request, so the relay node would otherwise treat the response as a duplicate.
36
+ * Streaming chunks reuse a single `id` across many frames.
37
+ *
38
+ * Rather than maintaining a fragile manual list, we match by convention:
39
+ * - Any type ending in `_res` is a response frame
40
+ * - Explicit set covers streams, control frames, and file-transfer chunks
41
+ */
42
+ const SKIP_DEDUP_EXPLICIT = new Set([
34
43
  // Streaming
35
44
  "model_stream", "handoff_stream", "acp_stream",
36
- // Response frames (share id with their request)
37
- "model_res", "tool_res", "tool_batch_res",
38
- "handoff_res", "handoff_status_res", "handoff_input_required",
39
45
  // Handoff control (reuse original handoff_req id)
40
46
  "handoff_input", "handoff_cancel", "handoff_status",
41
- // Diagnostics & approval
42
- "diagnostic_exec_res", "diagnostic_status_res", "peer_approval_res",
43
- // ACP responses
44
- "acp_res", "acp_close_res", "acp_list_res", "acp_resume_res",
45
- "acp_cancel_res", "acp_set_mode_res", "acp_get_modes_res",
46
- "chat_history_res",
47
- // Terminal
48
- "terminal_open_res", "terminal_data", "terminal_resize",
49
- "terminal_close", "terminal_close_res",
47
+ "handoff_input_required",
48
+ // Terminal data/control
49
+ "terminal_data", "terminal_resize", "terminal_close",
50
50
  // File transfer
51
51
  "file_transfer_chunk", "file_transfer_chunk_ack",
52
52
  "file_transfer_ack", "file_transfer_complete",
53
53
  ]);
54
54
 
55
+ function skipDedup(type: string): boolean {
56
+ return type.endsWith("_res") || SKIP_DEDUP_EXPLICIT.has(type);
57
+ }
58
+
55
59
  // Reconnect backoff params are now provided by retry.ts → getReconnectBackoff()
56
60
 
57
61
  /** Classify WebSocket close code into a human-readable reason. */
@@ -111,6 +115,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
111
115
  private probeTimer: ReturnType<typeof setInterval> | null = null;
112
116
  /** EMA latency baseline per peer (for spike detection). */
113
117
  private latencyBaselines = new Map<string, number>();
118
+ /** Throttle timer for latency-triggered peer_sync broadcasts. */
119
+ private latencySyncTimer: ReturnType<typeof setTimeout> | null = null;
114
120
  /** Last time a latency-triggered probe was fired per peer (debounce). */
115
121
  private lastProbeTime = new Map<string, number>();
116
122
  /** Deferred disconnect timers — grace period before broadcasting peer_leave. */
@@ -225,6 +231,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
225
231
  clearTimeout(this.gossipDebounceTimer);
226
232
  this.gossipDebounceTimer = null;
227
233
  }
234
+ if (this.latencySyncTimer) {
235
+ clearTimeout(this.latencySyncTimer);
236
+ this.latencySyncTimer = null;
237
+ }
228
238
  for (const timer of this.reconnectTimers.values()) {
229
239
  clearTimeout(timer);
230
240
  }
@@ -973,7 +983,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
973
983
 
974
984
  conn.on("message", (frame) => this.onFrame(frame, conn));
975
985
  conn.on("latency", (latencyMs) => {
976
- this.router.updateActiveChannel(nodeId);
986
+ const changed = this.router.updateActiveChannel(nodeId);
987
+ if (changed) this.scheduleLatencySync();
977
988
  // Update baseline and check for spike
978
989
  const baseline = this.latencyBaselines.get(nodeId) ?? latencyMs;
979
990
  const newBaseline = baseline * 0.8 + latencyMs * 0.2;
@@ -1146,7 +1157,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
1146
1157
  // Skip dedup for streaming chunks (same id across many chunks) and response
1147
1158
  // frames (share id with their request — relay would otherwise drop the reply).
1148
1159
  // handoff_input/cancel/status reuse the original handoff_req id.
1149
- if (frame.id && !SKIP_DEDUP_TYPES.has(frame.type) && this.router.isDuplicate(frame.id)) return;
1160
+ if (frame.id && !skipDedup(frame.type) && this.router.isDuplicate(frame.id)) return;
1150
1161
 
1151
1162
  // Handle peer approval responses locally (don't emit to cluster-service)
1152
1163
  if (frame.type === "peer_approval_res") {
@@ -1240,6 +1251,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
1240
1251
  /** Last sync version sent to each direct peer. */
1241
1252
  private peerSyncVersions = new Map<string, number>();
1242
1253
 
1254
+ /** Throttled broadcast of peer_sync to all connections when latency changes.
1255
+ * Coalesces rapid updates into a single sync (max once per 5s). */
1256
+ private scheduleLatencySync() {
1257
+ if (this.latencySyncTimer) return; // already scheduled
1258
+ this.latencySyncTimer = setTimeout(() => {
1259
+ this.latencySyncTimer = null;
1260
+ for (const conn of this.router.getDirectConnections()) {
1261
+ this.sendPeerSync(conn);
1262
+ }
1263
+ }, 5_000);
1264
+ }
1265
+
1243
1266
  private sendPeerSync(conn: Connection) {
1244
1267
  const remoteNodeId = conn.remoteNodeId ?? "";
1245
1268
  const sinceVersion = this.peerSyncVersions.get(remoteNodeId) ?? 0;
package/src/router.ts CHANGED
@@ -192,10 +192,11 @@ export class Router {
192
192
  return false;
193
193
  }
194
194
 
195
- /** Re-evaluate the active (best) channel for a peer based on latency. */
196
- updateActiveChannel(nodeId: string) {
195
+ /** Re-evaluate the active (best) channel for a peer based on latency.
196
+ * Returns true if latency changed significantly (sync version bumped). */
197
+ updateActiveChannel(nodeId: string): boolean {
197
198
  const channelSet = this.channels.get(nodeId);
198
- if (!channelSet || channelSet.size === 0) return;
199
+ if (!channelSet || channelSet.size === 0) return false;
199
200
 
200
201
  let best: Connection | null = null;
201
202
  let bestLatency = Infinity;
@@ -213,10 +214,22 @@ export class Router {
213
214
  this.connections.set(nodeId, best);
214
215
  const route = this.routes.get(nodeId);
215
216
  if (route) {
217
+ const prevLatency = route.latencyMs;
216
218
  route.connection = best;
217
219
  route.latencyMs = best.latencyMs;
220
+ // Bump sync version when latency changes significantly (>10% or crosses 0)
221
+ // so delta peer_sync propagates the update to satellite clients.
222
+ if (prevLatency !== route.latencyMs && (
223
+ prevLatency === 0 || route.latencyMs === 0 ||
224
+ Math.abs(route.latencyMs - prevLatency) / Math.max(prevLatency, 1) > 0.1
225
+ )) {
226
+ this.syncVersion++;
227
+ this.peerVersions.set(nodeId, this.syncVersion);
228
+ return true;
229
+ }
218
230
  }
219
231
  }
232
+ return false;
220
233
  }
221
234
 
222
235
  /** Get the number of live channels for a peer. */
@@ -537,6 +550,11 @@ export class Router {
537
550
  return [...this.connections.values()];
538
551
  }
539
552
 
553
+ /** Get nodeIds of all directly connected peers. */
554
+ getDirectPeerIds(): string[] {
555
+ return [...this.connections.keys()];
556
+ }
557
+
540
558
  /** Current sync version for delta protocol. */
541
559
  get currentSyncVersion(): number {
542
560
  return this.syncVersion;
package/src/sentinel.ts CHANGED
@@ -177,7 +177,7 @@ function buildCapabilities(): NodeCapabilities {
177
177
  }
178
178
 
179
179
  function connectToPeer(peer: { nodeId: string; url: string }) {
180
- const ws = new WebSocket(peer.url, ["graphql-transport-ws"]);
180
+ const ws = new WsWebSocket(peer.url, ["graphql-transport-ws"]);
181
181
  const e2eeOpts: ConnectionE2eeOptions = {
182
182
  e2ee: config.e2ee,
183
183
  compression: config.compression,