clawmatrix 0.2.7 → 0.2.9

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.
@@ -16,6 +16,7 @@ import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
16
16
  import { TerminalManager } from "./terminal.ts";
17
17
  import { WebHandler } from "./web.ts";
18
18
  import { KnowledgeSync } from "./knowledge-sync.ts";
19
+ import { HealthTracker } from "./health-tracker.ts";
19
20
  import { SentinelManager } from "./sentinel-manager.ts";
20
21
  import type {
21
22
  AnyClusterFrame,
@@ -28,6 +29,7 @@ import type {
28
29
  HandoffInputRequired,
29
30
  HandoffInput,
30
31
  KnowledgeSyncFrame,
32
+ HealthSyncFrame,
31
33
  ModelRequest,
32
34
  ModelResponse,
33
35
  ModelStreamChunk,
@@ -85,6 +87,7 @@ export class ClusterRuntime {
85
87
  readonly acpProxy: AcpProxy | null;
86
88
  readonly terminalManager: TerminalManager;
87
89
  knowledgeSync: KnowledgeSync | null = null;
90
+ healthTracker: HealthTracker;
88
91
  webHandler: WebHandler | null = null;
89
92
  private sentinelManager: SentinelManager | null = null;
90
93
  private logger: PluginLogger;
@@ -104,6 +107,10 @@ export class ClusterRuntime {
104
107
  const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
105
108
  this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
106
109
  this.terminalManager = new TerminalManager(config, this.peerManager);
110
+ this.healthTracker = new HealthTracker({
111
+ nodeId: config.nodeId,
112
+ peerManager: this.peerManager,
113
+ });
107
114
  }
108
115
 
109
116
  start() {
@@ -115,11 +122,15 @@ export class ClusterRuntime {
115
122
  this.peerManager.on("peerConnected", (nodeId) => {
116
123
  this.logger.info(`[clawmatrix] Peer connected: ${nodeId}`);
117
124
  this.refreshDiscoveredModels();
125
+ this.healthTracker.recordPeerOnline(nodeId, "direct");
126
+ this.healthTracker.initPeerSync(nodeId);
118
127
  });
119
128
 
120
129
  this.peerManager.on("peerDisconnected", (nodeId) => {
121
130
  this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
122
131
  this.refreshDiscoveredModels();
132
+ this.healthTracker.recordPeerOffline(nodeId);
133
+ this.healthTracker.removePeerSync(nodeId);
123
134
  });
124
135
 
125
136
  this.peerManager.on("peerCapabilitiesChanged", () => {
@@ -132,6 +143,7 @@ export class ClusterRuntime {
132
143
  this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
133
144
  // Enable satellite tool routing through WebHandler
134
145
  this.toolProxy.setSatelliteHandler(this.webHandler);
146
+ this.webHandler.setHealthTracker(this.healthTracker);
135
147
  this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
136
148
  }
137
149
 
@@ -166,8 +178,9 @@ export class ClusterRuntime {
166
178
  }
167
179
 
168
180
  // Auto-detect ACP agents if ACP is enabled but no agents are explicitly configured
169
- if (this.acpProxy && this.config.acp?.enabled && (!this.config.acp.agents || this.config.acp.agents.length === 0)) {
170
- AcpProxy.detectAvailableAgents(this.config.acp.commands).then((detected) => {
181
+ // Check both ClawMatrix and OpenClaw configs (consistent with acpProxy creation above)
182
+ if (this.acpProxy && (!this.config.acp?.agents || this.config.acp.agents.length === 0)) {
183
+ AcpProxy.detectAvailableAgents(this.config.acp?.commands).then((detected) => {
171
184
  if (detected.length > 0) {
172
185
  this.logger.info(`[clawmatrix] Auto-detected ACP agents: ${detected.map((a) => a.id).join(", ")}`);
173
186
  this.peerManager.updateAcpAgents(detected);
@@ -177,6 +190,11 @@ export class ClusterRuntime {
177
190
  });
178
191
  }
179
192
 
193
+ // Health tracker (always enabled, no config gate)
194
+ this.healthTracker.start().catch((err) => {
195
+ this.logger.error(`[clawmatrix] Health tracker failed to start: ${err}`);
196
+ });
197
+
180
198
  // Start subsystems
181
199
  this.peerManager.start();
182
200
  this.modelProxy.start();
@@ -213,6 +231,7 @@ export class ClusterRuntime {
213
231
  // NOTE: intentionally do NOT stop sentinel here.
214
232
  // Sentinel must survive gateway shutdown — that's its entire purpose.
215
233
  // It will be replaced by killOldSentinel() on next gateway start.
234
+ await this.healthTracker.stop();
216
235
  await this.knowledgeSync?.stop();
217
236
  this.webHandler?.destroy();
218
237
  this.handoffManager.destroy();
@@ -316,6 +335,9 @@ export class ClusterRuntime {
316
335
  this.logger.error(`[clawmatrix] Knowledge sync error: ${err}`);
317
336
  });
318
337
  break;
338
+ case "health_sync":
339
+ this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
340
+ break;
319
341
  case "acp_req":
320
342
  if (this.acpProxy) {
321
343
  this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
package/src/compat.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { spawn as cpSpawn } from "node:child_process";
8
- import { readFile, writeFile } from "node:fs/promises";
8
+ import { open, readFile, stat, writeFile } from "node:fs/promises";
9
9
  import { createRequire } from "node:module";
10
10
 
11
11
  export interface SpawnResult {
@@ -167,6 +167,41 @@ export async function readFileText(path: string): Promise<string> {
167
167
  return readFile(path, "utf-8");
168
168
  }
169
169
 
170
+ /** Read at most `bytes` bytes from the beginning of a file as text. */
171
+ export async function readFileHead(path: string, bytes: number): Promise<string> {
172
+ const fh = await open(path, "r");
173
+ try {
174
+ const buf = Buffer.alloc(bytes);
175
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
176
+ return buf.toString("utf-8", 0, bytesRead);
177
+ } finally {
178
+ await fh.close();
179
+ }
180
+ }
181
+
182
+ /** Read at most `bytes` bytes from the end of a file as text. */
183
+ export async function readFileTail(path: string, bytes: number): Promise<string> {
184
+ const info = await stat(path);
185
+ const size = info.size;
186
+ if (size === 0) return "";
187
+ const readBytes = Math.min(bytes, size);
188
+ const offset = size - readBytes;
189
+ const fh = await open(path, "r");
190
+ try {
191
+ const buf = Buffer.alloc(readBytes);
192
+ const { bytesRead } = await fh.read(buf, 0, readBytes, offset);
193
+ const text = buf.toString("utf-8", 0, bytesRead);
194
+ // Drop the first (possibly partial) line if we didn't read from start
195
+ if (offset > 0) {
196
+ const nl = text.indexOf("\n");
197
+ return nl >= 0 ? text.slice(nl + 1) : text;
198
+ }
199
+ return text;
200
+ } finally {
201
+ await fh.close();
202
+ }
203
+ }
204
+
170
205
  /** Write text to a file (replaces Bun.write()). */
171
206
  export async function writeFileText(path: string, content: string): Promise<void> {
172
207
  await writeFile(path, content, "utf-8");
package/src/handoff.ts CHANGED
@@ -91,6 +91,7 @@ export class HandoffManager {
91
91
  for (const [id, entry] of this.active) {
92
92
  if (entry.status === "input_required" && entry.inputRequiredAt && now - entry.inputRequiredAt > INPUT_REQUIRED_TTL) {
93
93
  this.active.delete(id);
94
+ this.sessionWatchers.delete(entry.sessionId);
94
95
  // Notify the requester that the handoff timed out
95
96
  this.peerManager.sendTo(entry.from, {
96
97
  type: "handoff_res",
@@ -110,6 +111,7 @@ export class HandoffManager {
110
111
  // (e.g. cancel arrived during input_required when no process was running)
111
112
  if (entry.status === "canceled") {
112
113
  this.active.delete(id);
114
+ this.sessionWatchers.delete(entry.sessionId);
113
115
  }
114
116
  }
115
117
  // Clean stale inputRequiredTargets (requester side)
@@ -355,6 +357,7 @@ export class HandoffManager {
355
357
 
356
358
  if (activeEntry.status === "canceled") {
357
359
  this.active.delete(id);
360
+ this.sessionWatchers.delete(activeEntry.sessionId);
358
361
  return;
359
362
  }
360
363
 
@@ -401,11 +404,13 @@ export class HandoffManager {
401
404
 
402
405
  if (activeEntry.status === "canceled") {
403
406
  this.active.delete(id);
407
+ this.sessionWatchers.delete(activeEntry.sessionId);
404
408
  return;
405
409
  }
406
410
 
407
411
  activeEntry.status = "completed";
408
412
  this.active.delete(id);
413
+ this.sessionWatchers.delete(activeEntry.sessionId);
409
414
 
410
415
  // Broadcast task completed
411
416
  this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
@@ -429,10 +434,12 @@ export class HandoffManager {
429
434
  } catch (err) {
430
435
  if (activeEntry.status === "canceled") {
431
436
  this.active.delete(id);
437
+ this.sessionWatchers.delete(activeEntry.sessionId);
432
438
  return;
433
439
  }
434
440
  activeEntry.status = "failed";
435
441
  this.active.delete(id);
442
+ this.sessionWatchers.delete(activeEntry.sessionId);
436
443
 
437
444
  // Broadcast task failed
438
445
  this.taskActivity.broadcast(
@@ -468,7 +475,7 @@ export class HandoffManager {
468
475
 
469
476
  const reader = body.getReader();
470
477
  const decoder = new TextDecoder();
471
- let full = "";
478
+ const chunks: string[] = [];
472
479
  let buffer = "";
473
480
 
474
481
  try {
@@ -489,7 +496,7 @@ export class HandoffManager {
489
496
  const parsed = JSON.parse(data);
490
497
  const delta = parsed.choices?.[0]?.delta?.content;
491
498
  if (delta) {
492
- full += delta;
499
+ chunks.push(delta);
493
500
  const streamFrame: HandoffStreamChunk = {
494
501
  type: "handoff_stream",
495
502
  id: handoffId,
@@ -531,7 +538,7 @@ export class HandoffManager {
531
538
  this.peerManager.sendTo(to, doneFrame);
532
539
  if (sessionId) this.sendToOtherWatchers(sessionId, to, doneFrame);
533
540
 
534
- return full;
541
+ return chunks.join("");
535
542
  }
536
543
 
537
544
  /** Handle incoming input_required from remote (requester side). */