clawmatrix 0.5.1 → 0.6.1
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/cli/bin/clawmatrix.mjs +487 -12
- package/cli/skills/clawmatrix/SKILL.md +42 -86
- package/package.json +1 -1
- package/src/api.ts +410 -3
- package/src/automation.ts +90 -1
- package/src/cluster-service.ts +24 -9
- package/src/config.ts +22 -16
- package/src/connection.ts +10 -0
- package/src/device-info.ts +10 -0
- package/src/health-tracker.ts +91 -13
- package/src/index.ts +285 -0
- package/src/knowledge-sync.ts +7 -0
- package/src/peer-manager.ts +54 -23
- package/src/router.ts +21 -3
- package/src/types.ts +1 -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}`);
|
package/src/knowledge-sync.ts
CHANGED
|
@@ -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.
|
package/src/peer-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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. */
|
|
@@ -134,7 +140,19 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
134
140
|
super();
|
|
135
141
|
this.config = config;
|
|
136
142
|
this.localDeviceInfo = collectDeviceInfo(openclawVersion, openclawConfig);
|
|
137
|
-
|
|
143
|
+
// Determine initial ACP agents: ClawMatrix config → OpenClaw config fallback.
|
|
144
|
+
// When ACP is enabled via OpenClaw but ClawMatrix has no explicit agents list,
|
|
145
|
+
// use OpenClaw's allowedAgents so auth_ok advertises them before async detection.
|
|
146
|
+
const ocAcp = (openclawConfig as Record<string, any>)?.acp;
|
|
147
|
+
const acpEnabled = config.acp?.enabled || ocAcp?.enabled;
|
|
148
|
+
let acpAgents: { id: string; description: string }[] | undefined;
|
|
149
|
+
if (acpEnabled) {
|
|
150
|
+
if (config.acp?.agents?.length) {
|
|
151
|
+
acpAgents = config.acp.agents;
|
|
152
|
+
} else if (Array.isArray(ocAcp?.allowedAgents) && ocAcp.allowedAgents.length > 0) {
|
|
153
|
+
acpAgents = ocAcp.allowedAgents.map((id: string) => ({ id, description: "" }));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
138
156
|
this.localCapabilities = {
|
|
139
157
|
nodeId: config.nodeId,
|
|
140
158
|
agents: config.agents,
|
|
@@ -225,6 +243,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
225
243
|
clearTimeout(this.gossipDebounceTimer);
|
|
226
244
|
this.gossipDebounceTimer = null;
|
|
227
245
|
}
|
|
246
|
+
if (this.latencySyncTimer) {
|
|
247
|
+
clearTimeout(this.latencySyncTimer);
|
|
248
|
+
this.latencySyncTimer = null;
|
|
249
|
+
}
|
|
228
250
|
for (const timer of this.reconnectTimers.values()) {
|
|
229
251
|
clearTimeout(timer);
|
|
230
252
|
}
|
|
@@ -643,14 +665,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
643
665
|
const attempt = this.reconnectAttempts.get(channelKey) ?? 0;
|
|
644
666
|
debug("peer", `connectToChannel(${nodeId}): attempt=${attempt} url=${url}`);
|
|
645
667
|
|
|
646
|
-
// Use
|
|
647
|
-
|
|
648
|
-
// causing binary frames to arrive as Blob instead of Buffer — which
|
|
649
|
-
// onRawMessage cannot handle, silently dropping encrypted frames (including auth_ok).
|
|
650
|
-
// The `ws` package defaults to binaryType "nodebuffer", avoiding this issue.
|
|
651
|
-
let ws: InstanceType<typeof WsWebSocket>;
|
|
668
|
+
// Use a common WS subprotocol for traffic disguise
|
|
669
|
+
let ws: WebSocket;
|
|
652
670
|
try {
|
|
653
|
-
ws = new
|
|
671
|
+
ws = new WebSocket(url, ["graphql-transport-ws"]);
|
|
654
672
|
} catch (err) {
|
|
655
673
|
debug("peer", `connectToChannel(${nodeId}): WebSocket constructor threw: ${err}`);
|
|
656
674
|
this.scheduleChannelReconnect(nodeId, url);
|
|
@@ -977,7 +995,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
977
995
|
|
|
978
996
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
979
997
|
conn.on("latency", (latencyMs) => {
|
|
980
|
-
this.router.updateActiveChannel(nodeId);
|
|
998
|
+
const changed = this.router.updateActiveChannel(nodeId);
|
|
999
|
+
if (changed) this.scheduleLatencySync();
|
|
981
1000
|
// Update baseline and check for spike
|
|
982
1001
|
const baseline = this.latencyBaselines.get(nodeId) ?? latencyMs;
|
|
983
1002
|
const newBaseline = baseline * 0.8 + latencyMs * 0.2;
|
|
@@ -1150,7 +1169,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1150
1169
|
// Skip dedup for streaming chunks (same id across many chunks) and response
|
|
1151
1170
|
// frames (share id with their request — relay would otherwise drop the reply).
|
|
1152
1171
|
// handoff_input/cancel/status reuse the original handoff_req id.
|
|
1153
|
-
if (frame.id && !
|
|
1172
|
+
if (frame.id && !skipDedup(frame.type) && this.router.isDuplicate(frame.id)) return;
|
|
1154
1173
|
|
|
1155
1174
|
// Handle peer approval responses locally (don't emit to cluster-service)
|
|
1156
1175
|
if (frame.type === "peer_approval_res") {
|
|
@@ -1244,6 +1263,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1244
1263
|
/** Last sync version sent to each direct peer. */
|
|
1245
1264
|
private peerSyncVersions = new Map<string, number>();
|
|
1246
1265
|
|
|
1266
|
+
/** Throttled broadcast of peer_sync to all connections when latency changes.
|
|
1267
|
+
* Coalesces rapid updates into a single sync (max once per 5s). */
|
|
1268
|
+
private scheduleLatencySync() {
|
|
1269
|
+
if (this.latencySyncTimer) return; // already scheduled
|
|
1270
|
+
this.latencySyncTimer = setTimeout(() => {
|
|
1271
|
+
this.latencySyncTimer = null;
|
|
1272
|
+
for (const conn of this.router.getDirectConnections()) {
|
|
1273
|
+
this.sendPeerSync(conn);
|
|
1274
|
+
}
|
|
1275
|
+
}, 5_000);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1247
1278
|
private sendPeerSync(conn: Connection) {
|
|
1248
1279
|
const remoteNodeId = conn.remoteNodeId ?? "";
|
|
1249
1280
|
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
|
-
|
|
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/types.ts
CHANGED
|
@@ -388,6 +388,7 @@ export interface DeviceInfo {
|
|
|
388
388
|
totalMemoryMB: number; // total system memory in MB
|
|
389
389
|
hostname: string; // machine hostname
|
|
390
390
|
openclawVersion?: string; // e.g. "2026.3.7"
|
|
391
|
+
clawmatrixVersion?: string; // e.g. "0.5.0"
|
|
391
392
|
cwd?: string; // process.cwd() at gateway startup
|
|
392
393
|
workspace?: string; // OpenClaw workspace dir (agents.defaults.workspace)
|
|
393
394
|
}
|