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/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. */
@@ -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
- const acpAgents = config.acp?.enabled ? config.acp.agents : undefined;
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 the `ws` package for outbound connections.
647
- // Node.js 24+'s built-in WebSocket (undici) defaults binaryType to "blob",
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 WsWebSocket(url, ["graphql-transport-ws"]);
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 && !SKIP_DEDUP_TYPES.has(frame.type) && this.router.isDuplicate(frame.id)) return;
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
- 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/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
  }