clawmatrix 0.6.0 → 0.6.2

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.
@@ -650,6 +650,8 @@ function cmdHelpJson() {
650
650
  { name: "peers", args: "", options: [], description: "List known peers (JSON)" },
651
651
  { name: "check", args: "<nodeId>", options: [], description: "Check if a specific node is reachable" },
652
652
  { name: "config", args: "", options: ["--json"], description: "Show local node configuration" },
653
+ { name: "config read", args: "<path>", options: [], description: "Read a config file under ~/.openclaw/" },
654
+ { name: "config write", args: "<path> <content|->", options: [], description: "Write a config file under ~/.openclaw/" },
653
655
  { name: "availability", args: "[range]", options: ["--json"], description: "Show node uptime (24h|7d|90d)" },
654
656
  // Tools & Execution
655
657
  { name: "tools", args: "[node]", options: ["--json", "-v", "-f <keyword>", "-d <tool>"], description: "List available tools on remote nodes" },
@@ -677,6 +679,7 @@ function cmdHelpJson() {
677
679
  { name: "kb files", args: "", options: ["--json"], description: "List all synced knowledge files" },
678
680
  { name: "kb history", args: "<path>", options: ["--json"], description: "Show change history for a synced file" },
679
681
  { name: "kb blame", args: "<path>", options: ["--json"], description: "Show line-by-line attribution for a synced file" },
682
+ { name: "kb content", args: "<path>", options: [], description: "Read the content of a synced file" },
680
683
  { name: "board", args: "", options: ["--json"], description: "Kanban board summary" },
681
684
  { name: "board list", args: "", options: ["--json", "--stage <s>", "--label <l>", "--priority <p>", "--node <n>"], description: "List kanban cards" },
682
685
  { name: "board create", args: "<title>", options: ["--desc <text>", "--priority <p>", "--labels <l1,l2>", "--node <n>", "--agent <a>"], description: "Create a new kanban card" },
@@ -972,7 +975,8 @@ async function cmdKb(args) {
972
975
  Subcommands:
973
976
  files [--json] List all synced files with metadata
974
977
  history <path> [--json] Show change history for a synced file
975
- blame <path> [--json] Show line-by-line attribution for a synced file`);
978
+ blame <path> [--json] Show line-by-line attribution for a synced file
979
+ content <path> Read the content of a synced file`);
976
980
  return;
977
981
  }
978
982
 
@@ -1043,6 +1047,19 @@ Subcommands:
1043
1047
  return;
1044
1048
  }
1045
1049
 
1050
+ if (subCmd === "content" || subCmd === "read" || subCmd === "cat") {
1051
+ const filePath = subArgs.filter((a) => !a.startsWith("--"))[0];
1052
+ if (!filePath) { console.error("Usage: clawmatrix kb content <path>"); process.exit(1); }
1053
+ const result = await callGateway("clawmatrix.kb.content", { path: filePath });
1054
+ if (result.content !== undefined) {
1055
+ console.log(result.content);
1056
+ } else {
1057
+ console.error(`Error: ${result.error || "File not found"}`);
1058
+ process.exitCode = 1;
1059
+ }
1060
+ return;
1061
+ }
1062
+
1046
1063
  if (subCmd === "blame") {
1047
1064
  const filePath = subArgs.filter((a) => !a.startsWith("--"))[0];
1048
1065
  if (!filePath) { console.error("Usage: clawmatrix kb blame <path>"); process.exit(1); }
@@ -1513,6 +1530,65 @@ Subcommands:
1513
1530
 
1514
1531
  async function cmdConfig(args) {
1515
1532
  const isJson = args.includes("--json");
1533
+ const subCmd = args[0];
1534
+
1535
+ // config read <path> — read a config file under ~/.openclaw/
1536
+ if (subCmd === "read") {
1537
+ const configPath = args[1];
1538
+ if (!configPath) { console.error("Usage: clawmatrix config read <path> (path under ~/.openclaw/)"); process.exit(1); }
1539
+ try {
1540
+ const data = await callGateway("clawmatrix.config.read", { path: configPath });
1541
+ if (data.content !== undefined) {
1542
+ console.log(data.content);
1543
+ } else {
1544
+ console.error(`Error: ${data.error || "Failed to read config"}`);
1545
+ process.exitCode = 1;
1546
+ }
1547
+ } catch (err) {
1548
+ console.error(`Error: ${err.message || String(err)}`);
1549
+ process.exitCode = 1;
1550
+ }
1551
+ return;
1552
+ }
1553
+
1554
+ // config write <path> <content|-> — write a config file under ~/.openclaw/
1555
+ if (subCmd === "write") {
1556
+ const configPath = args[1];
1557
+ let content = args[2];
1558
+ if (!configPath) { console.error("Usage: clawmatrix config write <path> <content|->"); process.exit(1); }
1559
+ if (content === "-" || (!content && !process.stdin.isTTY)) {
1560
+ const chunks = [];
1561
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
1562
+ content = Buffer.concat(chunks).toString("utf-8");
1563
+ }
1564
+ if (!content) { console.error("Error: No content provided. Pass content as argument or pipe via stdin."); process.exit(1); }
1565
+ try {
1566
+ const data = await callGateway("clawmatrix.config.write", { path: configPath, content });
1567
+ if (data.success) {
1568
+ console.log(`Written: ${configPath}`);
1569
+ } else {
1570
+ console.error(`Error: ${data.error || "Failed to write config"}`);
1571
+ process.exitCode = 1;
1572
+ }
1573
+ } catch (err) {
1574
+ console.error(`Error: ${err.message || String(err)}`);
1575
+ process.exitCode = 1;
1576
+ }
1577
+ return;
1578
+ }
1579
+
1580
+ if (subCmd === "--help" || subCmd === "-h") {
1581
+ console.log(`Usage: clawmatrix config [subcommand]
1582
+
1583
+ Subcommands:
1584
+ (no args) Show local node configuration summary
1585
+ read <path> Read a config file under ~/.openclaw/
1586
+ write <path> <content|->
1587
+ Write a config file under ~/.openclaw/
1588
+ Options:
1589
+ --json Output as JSON`);
1590
+ return;
1591
+ }
1516
1592
 
1517
1593
  try {
1518
1594
  const data = await callGateway("clawmatrix.config.get");
@@ -1608,7 +1684,7 @@ Cluster:
1608
1684
  status Show cluster topology and peer status
1609
1685
  peers List known peers (JSON)
1610
1686
  check <nodeId> Check if a specific node is reachable
1611
- config Show local node configuration
1687
+ config [subcmd] Node config (read|write or show summary)
1612
1688
  availability [range] Show node uptime (24h|7d|90d)
1613
1689
 
1614
1690
  Tools & Execution:
@@ -18,10 +18,7 @@ All commands output LLM-friendly text (no ANSI colors) when stdout is not a TTY.
18
18
  ```bash
19
19
  clawmatrix status # Cluster topology: peers, agents, models, tags
20
20
  clawmatrix status --json # Structured JSON output
21
- clawmatrix peers # List known peers (JSON)
22
21
  clawmatrix check <nodeId> # Quick reachability check with latency
23
- clawmatrix config # Show local node configuration
24
- clawmatrix config --json # Config as JSON
25
22
  ```
26
23
 
27
24
  Always start with `clawmatrix status` to understand the current topology before performing other operations.
@@ -58,14 +55,6 @@ clawmatrix models # All cluster models
58
55
  clawmatrix models --node <nodeId> # Filter by node
59
56
  ```
60
57
 
61
- ## Availability
62
-
63
- ```bash
64
- clawmatrix availability # Node uptime (default: 24h)
65
- clawmatrix availability 7d # 7-day uptime
66
- clawmatrix availability 90d --json # 90-day as JSON
67
- ```
68
-
69
58
  ## Events
70
59
 
71
60
  ```bash
@@ -74,19 +63,6 @@ clawmatrix events --type <type> # Filter by type
74
63
  clawmatrix events --source <nodeId> # Filter by source node
75
64
  clawmatrix events --consume <id1,id2> # Mark events as consumed
76
65
  clawmatrix events --all # Include consumed events
77
- clawmatrix events ingest '[{"type":"x","source":"y","data":{}}]' # Ingest events
78
- ```
79
-
80
- ## Automations
81
-
82
- ```bash
83
- clawmatrix automations rules # List automation rules
84
- clawmatrix automations history # Execution history
85
- clawmatrix automations history --limit 5 # Limit results
86
- clawmatrix automations run <ruleId> # Manually trigger a rule
87
- clawmatrix automations replay <execId> # Re-run historical execution
88
- clawmatrix automations save rules.json # Save rules from JSON file
89
- cat rules.json | clawmatrix automations save - # Save from stdin
90
66
  ```
91
67
 
92
68
  ## Peer Approval
@@ -118,31 +94,6 @@ clawmatrix notify "iOS 构建" --action end --task-id <id>
118
94
 
119
95
  Options: `--detail <text>`, `--progress <0-100>`, `--action start|update|end`, `--task-id <id>`, `--tool <name>`
120
96
 
121
- ## Knowledge Sync
122
-
123
- ```bash
124
- clawmatrix kb files # List synced knowledge files
125
- clawmatrix kb history <path> # File change history
126
- clawmatrix kb blame <path> # Line-by-line attribution
127
- ```
128
-
129
- ## Kanban Board
130
-
131
- ```bash
132
- clawmatrix board # Board summary
133
- clawmatrix board list # List all cards
134
- clawmatrix board list --stage in_progress --priority urgent # Filter cards
135
- clawmatrix board create "Task title" --priority high --labels "bug,p0"
136
- clawmatrix board get <cardId> # Card details
137
- clawmatrix board update <cardId> --title "New title" --desc "Updated"
138
- clawmatrix board claim <cardId> # Claim a card
139
- clawmatrix board move <cardId> done # Move to stage
140
- clawmatrix board annotate <cardId> "Progress note" --type progress
141
- clawmatrix board delete <cardId> # Delete a card
142
- ```
143
-
144
- Stages: `backlog` → `claimed` → `in_progress` → `review` → `done` → `archived`
145
-
146
97
  ## Workflow
147
98
 
148
99
  1. Run `clawmatrix status` to see the cluster topology
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -733,32 +733,6 @@ export class ApiHandler {
733
733
  return resolved;
734
734
  }
735
735
 
736
- /** Read a config file (for gateway methods). */
737
- async readConfigFile(configPath: string): Promise<{ success: boolean; content?: string; error?: string }> {
738
- const resolved = this.resolveConfigPath(configPath);
739
- if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
740
- try {
741
- const content = await readFile(resolved, "utf-8");
742
- return { success: true, content };
743
- } catch (err: unknown) {
744
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return { success: true, content: "{}" };
745
- return { success: false, error: err instanceof Error ? err.message : "Failed to read config" };
746
- }
747
- }
748
-
749
- /** Write a config file (for gateway methods). */
750
- async writeConfigFile(configPath: string, content: string): Promise<{ success: boolean; error?: string }> {
751
- const resolved = this.resolveConfigPath(configPath);
752
- if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
753
- try {
754
- await mkdir(dirname(resolved), { recursive: true });
755
- await writeFile(resolved, content, "utf-8");
756
- return { success: true };
757
- } catch (err) {
758
- return { success: false, error: err instanceof Error ? err.message : "Failed to write config" };
759
- }
760
- }
761
-
762
736
  /** GET /api/config/read?path=<configPath> — read a local config file. */
763
737
  private async handleConfigRead(req: IncomingMessage, res: ServerResponse) {
764
738
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -1609,46 +1583,6 @@ export class ApiHandler {
1609
1583
  return events.slice(-(opts?.limit ?? 50)).reverse();
1610
1584
  }
1611
1585
 
1612
- /** Ingest events programmatically (from gateway methods, CLI, etc.). */
1613
- ingestEvents(items: Array<Record<string, unknown>>): { count: number; ids: string[] } {
1614
- const ingested: IngestedEvent[] = [];
1615
- for (const item of items) {
1616
- if (!item.type) continue;
1617
- const event: IngestedEvent = {
1618
- id: nanoid(),
1619
- source: String(item.source || "unknown"),
1620
- type: String(item.type),
1621
- data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
1622
- ts: typeof item.ts === "number" ? Math.min(item.ts as number, Date.now()) : Date.now(),
1623
- consumed: false,
1624
- };
1625
- this.ingestedEvents.push(event);
1626
- ingested.push(event);
1627
- try {
1628
- this.store?.insertIngestedEvent({
1629
- id: event.id, ts: event.ts, type: event.type, source: event.source,
1630
- data: JSON.stringify(event.data),
1631
- });
1632
- } catch { /* non-fatal */ }
1633
- }
1634
- if (this.ingestedEvents.length > MAX_INGESTED_EVENTS) {
1635
- this.ingestedEvents.splice(0, this.ingestedEvents.length - MAX_INGESTED_EVENTS);
1636
- }
1637
- for (const event of ingested) {
1638
- this.pushSatelliteEvent({
1639
- ts: event.ts,
1640
- type: "event_ingested" as SatelliteEvent["type"],
1641
- data: { id: event.id, source: event.source, type: event.type },
1642
- });
1643
- }
1644
- if (this.automationManager) {
1645
- for (const event of ingested) {
1646
- this.automationManager.onEventIngested(event);
1647
- }
1648
- }
1649
- return { count: ingested.length, ids: ingested.map(e => e.id) };
1650
- }
1651
-
1652
1586
  /** Mark events as consumed by id(s). */
1653
1587
  consumeEvents(ids: string[]): number {
1654
1588
  if (this.store) {
package/src/connection.ts CHANGED
@@ -147,7 +147,17 @@ export class Connection extends EventEmitter<ConnectionEvents> {
147
147
 
148
148
  /** Bind standard WebSocket event listeners. Call this for outbound connections. */
149
149
  bindWebSocket(ws: WebSocket) {
150
- ws.addEventListener("message", (ev) => this.onRawMessage(ev.data));
150
+ // Node.js 22+ built-in WebSocket defaults binaryType to "blob", which
151
+ // onRawMessage cannot handle (Blob is async-only). Set "arraybuffer"
152
+ // so binary frames arrive as ArrayBuffer, then normalize to Buffer below.
153
+ if ("binaryType" in ws) {
154
+ (ws as any).binaryType = "arraybuffer";
155
+ }
156
+ ws.addEventListener("message", (ev) => {
157
+ // Normalize ArrayBuffer → Buffer for consistent handling across runtimes
158
+ const data = ev.data instanceof ArrayBuffer ? Buffer.from(ev.data) : ev.data;
159
+ this.onRawMessage(data);
160
+ });
151
161
  ws.addEventListener("close", (ev) => {
152
162
  this.close(ev.code, ev.reason);
153
163
  });
@@ -208,11 +218,19 @@ export class Connection extends EventEmitter<ConnectionEvents> {
208
218
 
209
219
  /** Send raw data. Buffers sent as binary frames; strings as-is; objects JSON-encoded. */
210
220
  private sendRaw(data: unknown) {
211
- if (this.transport.readyState === WebSocket.OPEN) {
212
- if (Buffer.isBuffer(data)) {
213
- this.transport.send(data);
214
- } else {
215
- this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
221
+ try {
222
+ if (this.transport.readyState === WebSocket.OPEN) {
223
+ if (Buffer.isBuffer(data)) {
224
+ this.transport.send(data);
225
+ } else {
226
+ this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
227
+ }
228
+ }
229
+ } catch (err) {
230
+ debug("send", `sendRaw failed for ${this.remoteNodeId ?? "unknown"}: ${err}`);
231
+ // Transport is broken — schedule close on next tick to avoid re-entrancy
232
+ if (!this.closed) {
233
+ queueMicrotask(() => this.close(4002, "send failed"));
216
234
  }
217
235
  }
218
236
  }
@@ -269,25 +287,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
269
287
  private async onRawMessage(data: unknown) {
270
288
  this.lastReceivedAt = Date.now();
271
289
 
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
-
291
290
  let frame: AnyClusterFrame | undefined;
292
291
 
293
292
  // Binary frame (Buffer) — decrypt directly without base64
@@ -403,7 +402,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
403
402
  }
404
403
 
405
404
  if (frame.type === "ping") {
406
- this.send({ type: "pong", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
405
+ this.sendDirect({ type: "pong", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
407
406
  return;
408
407
  }
409
408
  if (frame.type === "pong") {
@@ -579,7 +578,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
579
578
  }
580
579
 
581
580
  // auth_ok (decrypted from binary envelope, or plaintext legacy)
582
- debug("auth", `Outbound received frame type=${frame.type} (authenticated=${this.authenticated})`);
583
581
  if (frame.type === "auth_ok") {
584
582
  const ok = frame as AuthOk;
585
583
  this.remoteNodeId = ok.payload.nodeId;
@@ -718,7 +716,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
718
716
  return;
719
717
  }
720
718
  this.lastPingSentAt = Date.now();
721
- this.send({
719
+ this.sendDirect({
722
720
  type: "ping",
723
721
  from: this.nodeId,
724
722
  timestamp: this.lastPingSentAt,
@@ -755,7 +753,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
755
753
  close(code = 1000, reason = "normal") {
756
754
  if (this.closed) return;
757
755
  // Flush any pending batch before closing
758
- this.flushBatch();
756
+ try { this.flushBatch(); } catch { /* transport may already be dead */ }
759
757
  this.closed = true;
760
758
  this.clearAuthTimer();
761
759
  if (this.heartbeatTimer) {
@@ -55,6 +55,10 @@ export class HealthTracker {
55
55
  private readonly nodeId: string;
56
56
  private store: Store | null;
57
57
  private logReplicator: LogReplicator | null;
58
+ /** In-memory timestamp of when start() was called (survives store failures). */
59
+ private startedAt: number | null = null;
60
+ /** In-memory tracking of currently connected peers (timestamp of connection). */
61
+ private readonly connectedPeers = new Map<string, number>();
58
62
 
59
63
  constructor(opts: HealthTrackerOptions) {
60
64
  this.nodeId = opts.nodeId;
@@ -66,14 +70,23 @@ export class HealthTracker {
66
70
  setStore(store: Store, logReplicator?: LogReplicator) {
67
71
  this.store = store;
68
72
  this.logReplicator = logReplicator ?? this.logReplicator;
73
+ // Flush deferred events that occurred before store was available
74
+ if (this.startedAt !== null) {
75
+ this.recordEvent({ ts: this.startedAt, type: "start" });
76
+ }
77
+ for (const [peerId, ts] of this.connectedPeers) {
78
+ this.recordEvent({ ts, type: "peer_online", peer: peerId, via: "direct" });
79
+ }
69
80
  }
70
81
 
71
82
  async start() {
72
- this.recordEvent({ ts: Date.now(), type: "start" });
83
+ this.startedAt = Date.now();
84
+ this.recordEvent({ ts: this.startedAt, type: "start" });
73
85
  debug(TAG, `health tracker started for node "${this.nodeId}"`);
74
86
  }
75
87
 
76
88
  async stop() {
89
+ this.startedAt = null;
77
90
  this.recordEvent({ ts: Date.now(), type: "stop" });
78
91
  debug(TAG, "health tracker stopped");
79
92
  }
@@ -98,10 +111,12 @@ export class HealthTracker {
98
111
  }
99
112
 
100
113
  recordPeerOnline(peerId: string, via: "direct" | "relay") {
114
+ this.connectedPeers.set(peerId, Date.now());
101
115
  this.recordEvent({ ts: Date.now(), type: "peer_online", peer: peerId, via });
102
116
  }
103
117
 
104
118
  recordPeerOffline(peerId: string, reason?: string) {
119
+ this.connectedPeers.delete(peerId);
105
120
  this.recordEvent({ ts: Date.now(), type: "peer_offline", peer: peerId, reason });
106
121
  }
107
122
 
@@ -143,6 +158,18 @@ export class HealthTracker {
143
158
 
144
159
  // Find observation gaps (periods where THIS node was down)
145
160
  const selfEvents = this.getEventsForNode(this.nodeId);
161
+ // Inject current in-memory session if not reflected in persisted events.
162
+ // This handles the case where the store wasn't ready when start() was called,
163
+ // or the start event failed to persist for any reason.
164
+ if (this.startedAt !== null) {
165
+ const hasCurrentStart = selfEvents.some(
166
+ (e) => e.type === "start" && e.ts === this.startedAt,
167
+ );
168
+ if (!hasCurrentStart) {
169
+ selfEvents.push({ ts: this.startedAt, type: "start" });
170
+ selfEvents.sort((a, b) => a.ts - b.ts);
171
+ }
172
+ }
146
173
  const gaps = this.getObservationGaps(selfEvents, startTs, endTs);
147
174
 
148
175
  // Build timeline for each node (including self)
@@ -154,15 +181,22 @@ export class HealthTracker {
154
181
  for (const ev of allEvents) {
155
182
  if (ev.peer) everConnected.add(ev.peer);
156
183
  }
184
+ // Include currently connected peers (in-memory, survives store failures)
185
+ for (const peerId of this.connectedPeers.keys()) {
186
+ everConnected.add(peerId);
187
+ }
157
188
 
158
189
  const knownNodes = new Set(this.getKnownNodes());
190
+ // Always include self node
191
+ knownNodes.add(this.nodeId);
159
192
  // Also include nodes we've observed via peer_online events
160
193
  for (const peerId of everConnected) knownNodes.add(peerId);
161
194
 
162
195
  for (const nodeId of knownNodes) {
163
196
  if (nodeId !== this.nodeId && !everConnected.has(nodeId)) continue;
164
197
 
165
- const events = this.getEventsForNode(nodeId);
198
+ // Reuse selfEvents (with injected in-memory start) for the self node
199
+ const events = nodeId === this.nodeId ? selfEvents : this.getEventsForNode(nodeId);
166
200
  const timeline = this.buildNodeTimeline(
167
201
  nodeId, events, startTs, endTs, bucketMs, bucketCount, gaps,
168
202
  );
@@ -190,7 +224,15 @@ export class HealthTracker {
190
224
  ? this.buildPeerIntervals(nodeId, startTs, endTs)
191
225
  : [];
192
226
 
193
- if (sorted.length === 0 && peerIntervals.length === 0) return null;
227
+ if (sorted.length === 0 && peerIntervals.length === 0) {
228
+ // For self node with no events (store not initialized), treat as currently online
229
+ if (nodeId === this.nodeId) {
230
+ const now = Date.now();
231
+ const buckets: BucketState[] = Array(bucketCount).fill("up");
232
+ return { nodeId, firstSeen: now, lastSeen: now, buckets, uptimeRatio: 1 };
233
+ }
234
+ return null;
235
+ }
194
236
 
195
237
  // Determine firstSeen/lastSeen from both self-reported events and peer observations
196
238
  let firstSeen = Infinity;
@@ -300,13 +342,29 @@ export class HealthTracker {
300
342
  startTs: number,
301
343
  endTs: number,
302
344
  ): Array<[number, number]> {
303
- if (!this.store) return [];
345
+ const relevantEvents: HealthEvent[] = [];
346
+
347
+ if (this.store) {
348
+ // Collect peer_online/peer_offline events about this node from ALL observers
349
+ const relevantRows = this.store.queryHealth({ peer: targetNodeId });
350
+ for (const r of relevantRows) {
351
+ if (r.type === "peer_online" || r.type === "peer_offline") {
352
+ relevantEvents.push({ ts: r.ts, type: r.type as HealthEvent["type"], peer: r.peer ?? undefined });
353
+ }
354
+ }
355
+ }
356
+
357
+ // Inject in-memory connected peer if not reflected in persisted events
358
+ const connectedAt = this.connectedPeers.get(targetNodeId);
359
+ if (connectedAt !== undefined) {
360
+ const hasRecentOnline = relevantEvents.some(
361
+ (e) => e.type === "peer_online" && e.ts === connectedAt,
362
+ );
363
+ if (!hasRecentOnline) {
364
+ relevantEvents.push({ ts: connectedAt, type: "peer_online", peer: targetNodeId });
365
+ }
366
+ }
304
367
 
305
- // Collect peer_online/peer_offline events about this node from ALL observers
306
- const relevantRows = this.store.queryHealth({ peer: targetNodeId });
307
- const relevantEvents: HealthEvent[] = relevantRows
308
- .filter(r => r.type === "peer_online" || r.type === "peer_offline")
309
- .map(r => ({ ts: r.ts, type: r.type as HealthEvent["type"], peer: r.peer ?? undefined }));
310
368
  relevantEvents.sort((a, b) => a.ts - b.ts);
311
369
 
312
370
  const intervals: Array<[number, number]> = [];
package/src/index.ts CHANGED
@@ -1537,291 +1537,6 @@ 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
-
1825
1540
  // Log model selection on each LLM call (fire-and-forget)
1826
1541
  api.on("llm_input", (event) => {
1827
1542
  api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
@@ -191,6 +191,11 @@ export class KnowledgeSync {
191
191
 
192
192
  // ── Public API ─────────────────────────────────────────────────
193
193
 
194
+ /** The resolved workspace directory that knowledge files are synced under. */
195
+ get workspacePath(): string {
196
+ return this.opts.workspacePath;
197
+ }
198
+
194
199
  async start() {
195
200
  debug(TAG, `starting knowledge sync: workspace=${this.opts.workspacePath}`);
196
201
 
@@ -536,7 +541,7 @@ export class KnowledgeSync {
536
541
  }
537
542
 
538
543
  /** List all synced files with metadata. */
539
- listSyncedFiles(): Array<{ path: string; version: number; updatedAt: number; deleted: boolean }> {
544
+ listSyncedFiles(): Array<{ path: string; version: number; updatedAt: number; deleted: boolean; synced: boolean }> {
540
545
  const files = this.registry.files;
541
546
  if (!files) return [];
542
547
  return Object.entries(files).map(([path, meta]) => ({
@@ -544,6 +549,7 @@ export class KnowledgeSync {
544
549
  version: meta.version,
545
550
  updatedAt: meta.updatedAt,
546
551
  deleted: meta.deleted,
552
+ synced: this.fileDocs.has(path) && this.fileDocs.get(path)!.content !== undefined,
547
553
  })).filter(f => !f.deleted);
548
554
  }
549
555
 
@@ -140,7 +140,19 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
140
140
  super();
141
141
  this.config = config;
142
142
  this.localDeviceInfo = collectDeviceInfo(openclawVersion, openclawConfig);
143
- 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
+ }
144
156
  this.localCapabilities = {
145
157
  nodeId: config.nodeId,
146
158
  agents: config.agents,
@@ -707,9 +719,15 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
707
719
  debug("peer", `connectToChannel(${nodeId}): self-connection, will not reconnect`);
708
720
  return;
709
721
  }
710
- // Don't reconnect warm-up pruned channels
711
- if (ev.reason === "warm-up pruned") {
712
- debug("peer", `connectToChannel(${nodeId}): warm-up pruned, will not reconnect`);
722
+ // Don't reconnect deliberate closures that shouldn't trigger reconnection
723
+ const skipReasons = ["warm-up pruned", "route switch", "replaced by new connection"];
724
+ if (skipReasons.includes(ev.reason)) {
725
+ debug("peer", `connectToChannel(${nodeId}): ${ev.reason}, will not reconnect`);
726
+ return;
727
+ }
728
+ // Don't reconnect when peer approval was denied — retrying is futile
729
+ if (ev.code === 4005) {
730
+ debug("peer", `connectToChannel(${nodeId}): approval denied, will not reconnect`);
713
731
  return;
714
732
  }
715
733
  // Record close code for adaptive backoff
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 WsWebSocket(peer.url, ["graphql-transport-ws"]);
180
+ const ws = new WebSocket(peer.url, ["graphql-transport-ws"]);
181
181
  const e2eeOpts: ConnectionE2eeOptions = {
182
182
  e2ee: config.e2ee,
183
183
  compression: config.compression,