clawmatrix 0.1.23 → 0.2.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/router.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo } from "./types.ts";
1
+ import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo, AcpAgentInfo } from "./types.ts";
2
2
  import type { Connection } from "./connection.ts";
3
3
 
4
4
  const DEFAULT_TTL = 3;
@@ -17,6 +17,7 @@ export interface RouteEntry {
17
17
  directPeers: string[]; // nodeIds this node has direct connections to
18
18
  deviceInfo?: DeviceInfo;
19
19
  toolProxy?: ToolProxyInfo;
20
+ acpAgents?: AcpAgentInfo[];
20
21
  }
21
22
 
22
23
  export class Router {
@@ -26,6 +27,7 @@ export class Router {
26
27
  private localTags: string[];
27
28
  private localDeviceInfo?: DeviceInfo;
28
29
  private localToolProxy?: ToolProxyInfo;
30
+ private localAcpAgents?: AcpAgentInfo[];
29
31
  private routes = new Map<string, RouteEntry>();
30
32
  private connections = new Map<string, Connection>(); // nodeId → direct connection
31
33
  /** Double-map dedup: current window + previous window. Rotated periodically. */
@@ -37,7 +39,7 @@ export class Router {
37
39
 
38
40
  constructor(
39
41
  nodeId: string,
40
- localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
42
+ localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
41
43
  ) {
42
44
  this.nodeId = nodeId;
43
45
  this.localAgents = localCapabilities?.agents ?? [];
@@ -45,6 +47,7 @@ export class Router {
45
47
  this.localTags = localCapabilities?.tags ?? [];
46
48
  this.localDeviceInfo = localCapabilities?.deviceInfo;
47
49
  this.localToolProxy = localCapabilities?.toolProxy;
50
+ this.localAcpAgents = localCapabilities?.acpAgents;
48
51
 
49
52
  this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
50
53
  }
@@ -64,7 +67,7 @@ export class Router {
64
67
  addDirectPeer(
65
68
  nodeId: string,
66
69
  connection: Connection,
67
- capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
70
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
68
71
  ) {
69
72
  this.connections.set(nodeId, connection);
70
73
  this.routes.set(nodeId, {
@@ -79,6 +82,7 @@ export class Router {
79
82
  directPeers: [],
80
83
  deviceInfo: capabilities.deviceInfo,
81
84
  toolProxy: capabilities.toolProxy,
85
+ acpAgents: capabilities.acpAgents,
82
86
  });
83
87
  }
84
88
 
@@ -89,6 +93,10 @@ export class Router {
89
93
  const existing = this.routes.get(peer.nodeId);
90
94
  if (existing?.connection) return;
91
95
 
96
+ // Estimate relay latency: local→relay RTT + relay→target RTT (from peer_sync)
97
+ const relayRoute = this.routes.get(viaNodeId);
98
+ const estimatedLatency = (relayRoute?.latencyMs ?? 0) + (peer.latencyMs ?? 0);
99
+
92
100
  this.routes.set(peer.nodeId, {
93
101
  nodeId: peer.nodeId,
94
102
  agents: peer.agents,
@@ -97,10 +105,11 @@ export class Router {
97
105
  connection: null,
98
106
  reachableVia: viaNodeId,
99
107
  lastSeen: Date.now(),
100
- latencyMs: 0,
108
+ latencyMs: estimatedLatency,
101
109
  directPeers: peer.directPeers ?? [],
102
110
  deviceInfo: peer.deviceInfo,
103
111
  toolProxy: peer.toolProxy,
112
+ acpAgents: peer.acpAgents,
104
113
  });
105
114
  }
106
115
 
@@ -117,7 +126,7 @@ export class Router {
117
126
 
118
127
  updatePeerCapabilities(
119
128
  nodeId: string,
120
- capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
129
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
121
130
  ) {
122
131
  const entry = this.routes.get(nodeId);
123
132
  if (entry) {
@@ -131,6 +140,7 @@ export class Router {
131
140
  entry.deviceInfo = capabilities.deviceInfo;
132
141
  }
133
142
  entry.toolProxy = capabilities.toolProxy;
143
+ entry.acpAgents = capabilities.acpAgents;
134
144
  entry.lastSeen = Date.now();
135
145
  }
136
146
  }
@@ -189,15 +199,21 @@ export class Router {
189
199
  resolveNode(target: string): RouteEntry | undefined {
190
200
  if (target.startsWith("tags:")) {
191
201
  const tag = target.slice(5);
192
- let best: RouteEntry | undefined;
202
+ const candidates: RouteEntry[] = [];
193
203
  for (const entry of this.routes.values()) {
194
204
  if (entry.tags.includes(tag)) {
195
- if (!best || (entry.connection && !best.connection) || entry.latencyMs < best.latencyMs) {
196
- best = entry;
197
- }
205
+ candidates.push(entry);
198
206
  }
199
207
  }
200
- return best;
208
+ if (candidates.length === 0) return undefined;
209
+ // Sort: direct connections first, then by latency
210
+ candidates.sort((a, b) => {
211
+ const aDirect = a.connection ? 0 : 1;
212
+ const bDirect = b.connection ? 0 : 1;
213
+ if (aDirect !== bDirect) return aDirect - bDirect;
214
+ return a.latencyMs - b.latencyMs;
215
+ });
216
+ return candidates[0];
201
217
  }
202
218
  return this.routes.get(target);
203
219
  }
@@ -330,8 +346,12 @@ export class Router {
330
346
  directPeers: myDirectPeers,
331
347
  deviceInfo: this.localDeviceInfo,
332
348
  toolProxy: this.localToolProxy,
349
+ acpAgents: this.localAcpAgents,
333
350
  });
334
351
  for (const entry of this.routes.values()) {
352
+ // Same-nodeId 本地客户端(Mac/iOS)不出现在 peer_sync 中,
353
+ // 客户端通过 auth_ok 获取网关 capabilities,无需在此重复。
354
+ if (entry.nodeId === this.nodeId) continue;
335
355
  peers.push({
336
356
  nodeId: entry.nodeId,
337
357
  agents: entry.agents,
@@ -341,6 +361,8 @@ export class Router {
341
361
  directPeers: entry.directPeers.length > 0 ? entry.directPeers : undefined,
342
362
  deviceInfo: entry.deviceInfo,
343
363
  toolProxy: entry.toolProxy,
364
+ acpAgents: entry.acpAgents,
365
+ latencyMs: entry.latencyMs > 0 ? entry.latencyMs : undefined,
344
366
  });
345
367
  }
346
368
  return peers;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * SentinelManager — spawns and manages the sentinel subprocess from the
3
+ * main ClawMatrix process.
4
+ *
5
+ * The sentinel is a detached child that maintains WS connections independently,
6
+ * surviving OpenClaw crashes so remote nodes can still diagnose the machine.
7
+ */
8
+
9
+ import { fork, type ChildProcess } from "node:child_process";
10
+ import { join, dirname } from "node:path";
11
+ import { existsSync, readFileSync, mkdirSync, openSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import type { ClawMatrixConfig } from "./config.ts";
14
+
15
+ export class SentinelManager {
16
+ private config: ClawMatrixConfig;
17
+ private child: ChildProcess | null = null;
18
+ private pidFile: string;
19
+
20
+ constructor(config: ClawMatrixConfig) {
21
+ this.config = config;
22
+ // Use ~/.openclaw/ for PID file (process.cwd() may be read-only, e.g. "/")
23
+ const pidDir = join(homedir() || tmpdir(), ".openclaw");
24
+ try { mkdirSync(pidDir, { recursive: true }); } catch { /* exists */ }
25
+ this.pidFile = join(pidDir, `.clawmatrix-sentinel-${config.nodeId}.pid`);
26
+ }
27
+
28
+ start() {
29
+ // The sentinel script path — resolve relative to this file
30
+ const sentinelPath = join(dirname(new URL(import.meta.url).pathname), "sentinel.ts");
31
+
32
+ // Sentinel stderr goes to a log file instead of a pipe to the parent.
33
+ // A pipe would break when the parent exits, crashing the sentinel.
34
+ const logFile = join(dirname(this.pidFile), `sentinel-${this.config.nodeId}.log`);
35
+ const logFd = openSync(logFile, "a");
36
+
37
+ // Fork with detached + IPC channel
38
+ this.child = fork(sentinelPath, [], {
39
+ detached: true,
40
+ stdio: ["ignore", "ignore", logFd, "ipc"],
41
+ // Use the same Node.js execPath; tsx/ts-node loader from parent is inherited
42
+ execArgv: this.resolveExecArgv(),
43
+ });
44
+
45
+ // Send config to sentinel via IPC (includes gateway PID for health checks)
46
+ // If sentinel has no explicit listenPort but the gateway is a listener,
47
+ // inherit the gateway's port for automatic takeover when gateway dies.
48
+ const sentinelListenPort = this.config.sentinel?.listenPort
49
+ ?? (this.config.listen ? this.config.listenPort : 0);
50
+ this.child.send({
51
+ type: "init",
52
+ config: {
53
+ nodeId: this.config.nodeId,
54
+ secret: this.config.secret,
55
+ peers: this.config.peers,
56
+ agents: this.config.agents,
57
+ models: this.config.models,
58
+ tags: this.config.tags,
59
+ e2ee: this.config.e2ee,
60
+ compression: this.config.compression,
61
+ pidFile: this.pidFile,
62
+ gatewayPid: process.pid,
63
+ listenPort: sentinelListenPort,
64
+ listenHost: this.config.sentinel?.listenHost ?? this.config.listenHost,
65
+ peerApproval: this.config.peerApproval ? {
66
+ enabled: this.config.peerApproval.enabled,
67
+ allowList: this.config.peerApproval.allowList,
68
+ persistPath: this.config.peerApproval.persistPath,
69
+ } : undefined,
70
+ },
71
+ });
72
+
73
+ // Unref so the parent doesn't wait for the sentinel to exit
74
+ this.child.unref();
75
+ // Disconnect IPC so the parent can exit cleanly.
76
+ // The sentinel switches to PID-based health checks on disconnect.
77
+ setTimeout(() => {
78
+ try {
79
+ this.child?.disconnect();
80
+ } catch {
81
+ // Already disconnected
82
+ }
83
+ }, 1000);
84
+ }
85
+
86
+ stop() {
87
+ // IPC is disconnected shortly after start, so use PID file for shutdown
88
+ if (existsSync(this.pidFile)) {
89
+ try {
90
+ const pid = parseInt(readFileSync(this.pidFile, "utf-8").trim(), 10);
91
+ if (pid) {
92
+ process.kill(pid, "SIGTERM");
93
+ // Wait briefly for the process to exit so the next start()
94
+ // doesn't race with a still-dying sentinel
95
+ const deadline = Date.now() + 3_000;
96
+ while (Date.now() < deadline) {
97
+ try {
98
+ process.kill(pid, 0);
99
+ // Still alive — brief spin
100
+ const waitUntil = Date.now() + 50;
101
+ while (Date.now() < waitUntil) { /* spin */ }
102
+ } catch {
103
+ break; // exited
104
+ }
105
+ }
106
+ }
107
+ } catch {
108
+ // Already gone
109
+ }
110
+ }
111
+
112
+ this.child = null;
113
+ }
114
+
115
+ private resolveExecArgv(): string[] {
116
+ // Inherit TypeScript loaders from the parent process so sentinel.ts can be executed.
117
+ // Common patterns: --loader tsx, --import tsx/esm, --require ts-node/register, etc.
118
+ // We filter in pairs: flag args like "--loader tsx" come as two separate entries,
119
+ // so we keep both the flag and its value when matched.
120
+ const result: string[] = [];
121
+ const argv = process.execArgv;
122
+ for (let i = 0; i < argv.length; i++) {
123
+ const arg = argv[i]!;
124
+ const isLoaderFlag =
125
+ arg === "--loader" || arg.startsWith("--loader=") ||
126
+ arg === "--import" || arg.startsWith("--import=") ||
127
+ arg === "--require" || arg.startsWith("--require=");
128
+ const isTsArg = arg.includes("tsx") || arg.includes("ts-node");
129
+
130
+ if (isLoaderFlag) {
131
+ result.push(arg);
132
+ // If flag doesn't have = value, next arg is the value
133
+ if (!arg.includes("=") && i + 1 < argv.length) {
134
+ result.push(argv[++i]!);
135
+ }
136
+ } else if (isTsArg) {
137
+ result.push(arg);
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ }