clawmatrix 0.1.14 → 0.1.16

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/BOOTSTRAP.md CHANGED
@@ -122,7 +122,8 @@ openclaw clawmatrix status
122
122
  ],
123
123
  "models": [],
124
124
  "proxyModels": [
125
- { "id": "claude-sonnet", "nodeId": "office-01" }
125
+ { "id": "gpt-5", "nodeId": "office-01", "description": "GPT-5", "input": ["text", "image"], "contextWindow": 400000, "maxTokens": 128000 },
126
+ { "id": "gemini-2.5-pro", "nodeId": "office-01", "description": "Gemini 2.5 Pro", "input": ["text", "image"], "contextWindow": 1048576, "maxTokens": 65535 }
126
127
  ],
127
128
  "tags": ["home"]
128
129
  }
@@ -165,9 +166,55 @@ openclaw clawmatrix status
165
166
  | `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
166
167
  | `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
167
168
 
168
- ## 安装后 Agent 获得的工具
169
+ ## 启用集群工具
169
170
 
170
- 安装后,本节点的 Agent 自动获得 7 个集群工具:
171
+ ClawMatrix Agent 工具注册为可选工具(optional),需要在 OpenClaw 配置中显式启用。
172
+
173
+ 在 `openclaw.json` 中添加:
174
+
175
+ ```json
176
+ {
177
+ "tools": {
178
+ "profile": "full",
179
+ "sessions": {
180
+ "visibility": "all"
181
+ },
182
+ "allow": [
183
+ "clawmatrix"
184
+ ]
185
+ }
186
+ }
187
+ ```
188
+
189
+ 配置说明:
190
+ - `profile: "full"` — 启用完整工具集(包括可选工具)
191
+ - `sessions.visibility: "all"` — 允许跨会话访问工具(集群工具需要此设置才能在所有会话中可用)
192
+ - `allow: ["clawmatrix"]` — 显式允许 ClawMatrix 插件注册的所有集群工具
193
+
194
+ 也可以按 Agent 粒度启用:
195
+
196
+ ```json
197
+ {
198
+ "agents": {
199
+ "list": [
200
+ {
201
+ "id": "main",
202
+ "tools": {
203
+ "profile": "full",
204
+ "sessions": {
205
+ "visibility": "all"
206
+ },
207
+ "allow": ["clawmatrix"]
208
+ }
209
+ }
210
+ ]
211
+ }
212
+ }
213
+ ```
214
+
215
+ > 如果不配置,Agent 的系统提示中会提到集群工具,但实际无法调用。
216
+
217
+ 启用后,本节点的 Agent 获得 7 个集群工具:
171
218
 
172
219
  | 工具 | 用途 | 关键参数 |
173
220
  |------|------|----------|
@@ -199,13 +246,13 @@ openclaw clawmatrix status
199
246
  "api": "openai-completions",
200
247
  "models": [
201
248
  {
202
- "id": "<模型ID>",
203
- "name": "<显示名>",
249
+ "id": "gpt-5",
250
+ "name": "GPT-5",
204
251
  "reasoning": false,
205
- "input": ["text"],
252
+ "input": ["text", "image"],
206
253
  "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
207
- "contextWindow": 200000,
208
- "maxTokens": 32000
254
+ "contextWindow": 400000,
255
+ "maxTokens": 128000
209
256
  }
210
257
  ]
211
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",
@@ -41,6 +41,8 @@
41
41
  "@types/ws": "^8.18.1"
42
42
  },
43
43
  "peerDependencies": {
44
+ "openclaw": ">=2026.3.7",
44
45
  "typescript": "^5"
45
- }
46
+ },
47
+ "overrides": {}
46
48
  }
package/src/auth.ts CHANGED
@@ -3,17 +3,40 @@ export function generateNonce(): string {
3
3
  return Buffer.from(bytes).toString("hex");
4
4
  }
5
5
 
6
- export async function computeHmac(
7
- nonce: string,
8
- secret: string,
9
- ): Promise<string> {
10
- const key = await crypto.subtle.importKey(
6
+ /** Cache imported CryptoKey keyed by a hash of the secret (avoid storing plaintext). Max 8 entries. */
7
+ const KEY_CACHE_MAX = 8;
8
+ const keyCache = new Map<string, CryptoKey>();
9
+
10
+ function cacheKeyFor(secret: string): string {
11
+ const { createHash } = require("node:crypto");
12
+ return createHash("sha256").update(secret).digest("hex");
13
+ }
14
+
15
+ async function getHmacKey(secret: string): Promise<CryptoKey> {
16
+ const cacheKey = cacheKeyFor(secret);
17
+ let key = keyCache.get(cacheKey);
18
+ if (key) return key;
19
+ key = await crypto.subtle.importKey(
11
20
  "raw",
12
21
  new TextEncoder().encode(secret),
13
22
  { name: "HMAC", hash: "SHA-256" },
14
23
  false,
15
24
  ["sign"],
16
25
  );
26
+ if (keyCache.size >= KEY_CACHE_MAX) {
27
+ // Evict oldest entry
28
+ const oldest = keyCache.keys().next().value!;
29
+ keyCache.delete(oldest);
30
+ }
31
+ keyCache.set(cacheKey, key);
32
+ return key;
33
+ }
34
+
35
+ export async function computeHmac(
36
+ nonce: string,
37
+ secret: string,
38
+ ): Promise<string> {
39
+ const key = await getHmacKey(secret);
17
40
  const sig = await crypto.subtle.sign(
18
41
  "HMAC",
19
42
  key,
@@ -22,14 +45,21 @@ export async function computeHmac(
22
45
  return Buffer.from(sig).toString("hex");
23
46
  }
24
47
 
25
- /** Constant-time string comparison. */
48
+ /** Constant-time string comparison. Uses native crypto.timingSafeEqual with
49
+ * SHA-256 pre-hash to normalize lengths (avoids length-leak from early return). */
26
50
  export function timingSafeEqual(a: string, b: string): boolean {
27
- if (a.length !== b.length) return false;
28
- let diff = 0;
29
- for (let i = 0; i < a.length; i++) {
30
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
51
+ const nodeTimingSafeEqual = require("node:crypto").timingSafeEqual;
52
+ const encoder = new TextEncoder();
53
+ const bufA = encoder.encode(a);
54
+ const bufB = encoder.encode(b);
55
+ if (bufA.byteLength !== bufB.byteLength) {
56
+ // Hash both to fixed length so we still compare in constant time
57
+ const { createHash } = require("node:crypto");
58
+ const hashA = createHash("sha256").update(bufA).digest();
59
+ const hashB = createHash("sha256").update(bufB).digest();
60
+ return nodeTimingSafeEqual(hashA, hashB);
31
61
  }
32
- return diff === 0;
62
+ return nodeTimingSafeEqual(Buffer.from(bufA), Buffer.from(bufB));
33
63
  }
34
64
 
35
65
  export async function verifyHmac(
@@ -38,5 +68,5 @@ export async function verifyHmac(
38
68
  sig: string,
39
69
  ): Promise<boolean> {
40
70
  const expected = await computeHmac(nonce, secret);
41
- return timingSafeEqual(expected, sig);
71
+ return timingSafeEqual(expected, sig); // timingSafeEqual is now sync
42
72
  }
@@ -17,6 +17,11 @@ import type {
17
17
  HandoffRequest,
18
18
  HandoffResponse,
19
19
  HandoffStreamChunk,
20
+ HandoffCancel,
21
+ HandoffStatusQuery,
22
+ HandoffStatusResponse,
23
+ HandoffInputRequired,
24
+ HandoffInput,
20
25
  ModelRequest,
21
26
  ModelResponse,
22
27
  ModelStreamChunk,
@@ -44,13 +49,14 @@ export class ClusterRuntime {
44
49
  readonly handoffManager: HandoffManager;
45
50
  readonly modelProxy: ModelProxy;
46
51
  readonly toolProxy: ToolProxy;
52
+ webHandler: WebHandler | null = null;
47
53
  private logger: PluginLogger;
48
54
 
49
- constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig) {
55
+ constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
50
56
  this.config = config;
51
57
  this.logger = logger;
52
58
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
53
- this.peerManager = new PeerManager(config);
59
+ this.peerManager = new PeerManager(config, openclawVersion);
54
60
  this.handoffManager = new HandoffManager(config, this.peerManager);
55
61
  this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
56
62
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
@@ -58,7 +64,7 @@ export class ClusterRuntime {
58
64
 
59
65
  start() {
60
66
  // Wire up frame dispatch
61
- this.peerManager.on("frame", (frame, conn) => {
67
+ this.peerManager.on("frame", (frame) => {
62
68
  this.dispatchFrame(frame);
63
69
  });
64
70
 
@@ -72,8 +78,10 @@ export class ClusterRuntime {
72
78
 
73
79
  // Web dashboard (must be set before peerManager.start() creates the HTTP server)
74
80
  if (this.config.web?.enabled) {
75
- const webHandler = new WebHandler(this.config, this.peerManager);
76
- this.peerManager.setHttpHandler((req, res) => webHandler.handle(req, res));
81
+ this.webHandler = new WebHandler(this.config, this.peerManager, this.handoffManager);
82
+ this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
83
+ // Enable satellite tool routing through WebHandler
84
+ this.toolProxy.setSatelliteHandler(this.webHandler);
77
85
  this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
78
86
  }
79
87
 
@@ -89,6 +97,7 @@ export class ClusterRuntime {
89
97
  }
90
98
 
91
99
  async stop() {
100
+ this.webHandler?.destroy();
92
101
  this.handoffManager.destroy();
93
102
  this.modelProxy.stop();
94
103
  this.toolProxy.destroy();
@@ -98,7 +107,7 @@ export class ClusterRuntime {
98
107
 
99
108
  private dispatchFrame(frame: AnyClusterFrame) {
100
109
  if (frame.type.startsWith("model_")) {
101
- debug("dispatch", `${frame.type} id=${frame.id} from=${(frame as ClusterFrame).from}`);
110
+ debug("dispatch", `${frame.type} id=${frame.id} from=${frame.from}`);
102
111
  }
103
112
  switch (frame.type) {
104
113
  case "handoff_req":
@@ -112,6 +121,24 @@ export class ClusterRuntime {
112
121
  case "handoff_res":
113
122
  this.handoffManager.handleResponse(frame as HandoffResponse);
114
123
  break;
124
+ case "handoff_cancel":
125
+ this.handoffManager.handleCancel(frame as HandoffCancel);
126
+ break;
127
+ case "handoff_status":
128
+ this.handoffManager.handleStatusQuery(frame as HandoffStatusQuery);
129
+ break;
130
+ case "handoff_input_required":
131
+ this.handoffManager.handleInputRequired(frame as HandoffInputRequired);
132
+ break;
133
+ case "handoff_input":
134
+ this.handoffManager.handleInput(frame as HandoffInput).catch((err) => {
135
+ this.logger.error(`[clawmatrix] Handoff input error: ${err}`);
136
+ });
137
+ break;
138
+ case "handoff_status_res":
139
+ // Status responses are handled by the requester via pending callbacks
140
+ // (currently informational — logged but not routed to pending map)
141
+ break;
115
142
  case "model_req":
116
143
  this.modelProxy.handleModelRequest(frame as ModelRequest).catch((err) => {
117
144
  this.logger.error(`[clawmatrix] Model request error: ${err}`);
@@ -178,11 +205,12 @@ export function getClusterRuntime(): ClusterRuntime {
178
205
  export function createClusterService(
179
206
  config: ClawMatrixConfig,
180
207
  openclawConfig: OpenClawConfig,
208
+ openclawVersion?: string,
181
209
  ): OpenClawPluginService {
182
210
  return {
183
211
  id: "clawmatrix",
184
212
  start(ctx: OpenClawPluginServiceContext) {
185
- clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig);
213
+ clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig, openclawVersion);
186
214
  clusterRuntime.start();
187
215
  },
188
216
  async stop() {
package/src/compat.ts CHANGED
@@ -40,6 +40,9 @@ export function spawnProcess(
40
40
  stream.on("end", () => controller.close());
41
41
  stream.on("error", (err) => controller.error(err));
42
42
  },
43
+ cancel() {
44
+ stream.destroy();
45
+ },
43
46
  });
44
47
  }
45
48
 
package/src/config.ts CHANGED
@@ -61,19 +61,28 @@ const ToolProxyConfigSchema = z.object({
61
61
  maxOutputBytes: z.number().default(1_048_576),
62
62
  });
63
63
 
64
- const ProxyModelSchema = z.object({
64
+ // Per-model entry inside a proxy group (minimal — inherits group-level fields)
65
+ const ProxyModelEntrySchema = z.object({
65
66
  id: z.string(),
66
- nodeId: z.string().optional(),
67
67
  description: z.string().optional(),
68
68
  ...ModelParamsSchema,
69
69
  });
70
70
 
71
+ // Grouped proxy models: shared nodeId/api/provider, list of models
72
+ const ProxyModelGroupSchema = z.object({
73
+ nodeId: z.string(),
74
+ provider: z.string().optional(),
75
+ description: z.string().optional(),
76
+ ...ModelParamsSchema,
77
+ models: z.array(ProxyModelEntrySchema),
78
+ });
79
+
71
80
  const WebConfigSchema = z.object({
72
81
  enabled: z.boolean().default(false),
73
82
  token: z.string().min(8, "web token must be at least 8 characters"),
74
83
  }).optional();
75
84
 
76
- export const ClawMatrixConfigSchema = z.object({
85
+ const RawClawMatrixConfigSchema = z.object({
77
86
  nodeId: z.string(),
78
87
  secret: z.string().min(16, "secret must be at least 16 characters"),
79
88
  listen: z.boolean().default(false),
@@ -82,7 +91,7 @@ export const ClawMatrixConfigSchema = z.object({
82
91
  peers: z.array(PeerConfigSchema).default([]),
83
92
  agents: z.array(AgentInfoSchema).default([]),
84
93
  models: z.array(ModelInfoSchema).default([]),
85
- proxyModels: z.array(ProxyModelSchema).default([]),
94
+ proxyModels: z.array(ProxyModelGroupSchema).default([]),
86
95
  tags: z.array(z.string()).default([]),
87
96
  proxyPort: z.number().default(19001),
88
97
  toolProxy: ToolProxyConfigSchema.optional(),
@@ -90,10 +99,52 @@ export const ClawMatrixConfigSchema = z.object({
90
99
  web: WebConfigSchema,
91
100
  });
92
101
 
93
- export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
102
+ /** Flat proxy model after group expansion (used internally). */
103
+ export interface ProxyModel {
104
+ id: string;
105
+ nodeId: string;
106
+ provider?: string;
107
+ description?: string;
108
+ api?: string;
109
+ contextWindow?: number;
110
+ maxTokens?: number;
111
+ reasoning?: boolean;
112
+ input?: ("text" | "image")[];
113
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
114
+ compat?: z.infer<typeof ModelCompatSchema>;
115
+ }
116
+
117
+ export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels"> & {
118
+ proxyModels: ProxyModel[];
119
+ };
120
+
121
+ export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
94
122
  export type PeerConfig = z.infer<typeof PeerConfigSchema>;
95
123
  export type ToolProxyConfig = z.infer<typeof ToolProxyConfigSchema>;
96
124
 
125
+ /** Parse and flatten grouped proxyModels into flat array. */
97
126
  export function parseConfig(raw: unknown): ClawMatrixConfig {
98
- return ClawMatrixConfigSchema.parse(raw);
127
+ const parsed = RawClawMatrixConfigSchema.parse(raw);
128
+
129
+ // Flatten proxy model groups
130
+ const proxyModels: ProxyModel[] = [];
131
+ for (const group of parsed.proxyModels) {
132
+ for (const m of group.models) {
133
+ proxyModels.push({
134
+ id: m.id,
135
+ nodeId: group.nodeId,
136
+ provider: group.provider,
137
+ description: m.description ?? group.description,
138
+ api: m.api ?? group.api,
139
+ contextWindow: m.contextWindow ?? group.contextWindow,
140
+ maxTokens: m.maxTokens ?? group.maxTokens,
141
+ reasoning: m.reasoning ?? group.reasoning,
142
+ input: m.input ?? group.input,
143
+ cost: m.cost ?? group.cost,
144
+ compat: m.compat ?? group.compat,
145
+ });
146
+ }
147
+ }
148
+
149
+ return { ...parsed, proxyModels };
99
150
  }
package/src/connection.ts CHANGED
@@ -6,8 +6,10 @@ import type {
6
6
  AuthOk,
7
7
  AuthFail,
8
8
  ClusterFrame,
9
+ DeviceInfo,
9
10
  ModelInfo,
10
11
  NodeCapabilities,
12
+ ToolProxyInfo,
11
13
  } from "./types.ts";
12
14
  import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
13
15
 
@@ -28,6 +30,7 @@ export interface WsTransport {
28
30
  export interface ConnectionEvents {
29
31
  message: [frame: AnyClusterFrame];
30
32
  authenticated: [capabilities: NodeCapabilities];
33
+ latency: [ms: number];
31
34
  close: [code: number, reason: string];
32
35
  error: [error: Error];
33
36
  }
@@ -48,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
48
51
  private missedPongs = 0;
49
52
  private pendingNonce: string | null = null;
50
53
  private closed = false;
54
+ private lastPingSentAt = 0;
55
+ /** Exponential moving average of heartbeat RTT in milliseconds. */
56
+ latencyMs = 0;
51
57
 
52
58
  constructor(
53
59
  transport: WsTransport,
@@ -63,13 +69,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
63
69
  this.secret = secret;
64
70
  this.localCapabilities = localCapabilities;
65
71
 
72
+ // Both inbound and outbound get an auth timeout to prevent hanging connections
73
+ this.authTimer = setTimeout(() => {
74
+ if (!this.authenticated) {
75
+ this.close(4003, "auth timeout");
76
+ }
77
+ }, AUTH_TIMEOUT);
78
+
66
79
  if (role === "inbound") {
67
80
  this.sendAuthChallenge();
68
- this.authTimer = setTimeout(() => {
69
- if (!this.authenticated) {
70
- this.close(4003, "auth timeout");
71
- }
72
- }, AUTH_TIMEOUT);
73
81
  }
74
82
  }
75
83
 
@@ -142,6 +150,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
142
150
  }
143
151
  if (frame.type === "pong") {
144
152
  this.missedPongs = 0;
153
+ if (this.lastPingSentAt > 0) {
154
+ const rtt = Date.now() - this.lastPingSentAt;
155
+ // Exponential moving average (α = 0.3)
156
+ this.latencyMs = this.latencyMs === 0 ? rtt : Math.round(this.latencyMs * 0.7 + rtt * 0.3);
157
+ this.emit("latency", this.latencyMs);
158
+ }
145
159
  return;
146
160
  }
147
161
 
@@ -151,12 +165,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
151
165
  private async handleAuthMessage(frame: AnyClusterFrame) {
152
166
  if (this.role === "inbound") {
153
167
  if (frame.type !== "auth") return;
154
- const { nodeId, sig, agents, models, tags } = frame.payload as {
168
+ const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy } = frame.payload as {
155
169
  nodeId: string;
156
170
  sig: string;
157
171
  agents?: AgentInfo[];
158
172
  models?: ModelInfo[];
159
173
  tags?: string[];
174
+ deviceInfo?: DeviceInfo;
175
+ toolProxy?: ToolProxyInfo;
160
176
  };
161
177
  if (!this.pendingNonce) return;
162
178
 
@@ -180,6 +196,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
180
196
  agents: agents ?? [],
181
197
  models: models ?? [],
182
198
  tags: tags ?? [],
199
+ deviceInfo,
200
+ toolProxy,
183
201
  };
184
202
  this.authenticated = true;
185
203
  this.clearAuthTimer();
@@ -193,8 +211,10 @@ export class Connection extends EventEmitter<ConnectionEvents> {
193
211
  agents: this.localCapabilities.agents,
194
212
  models: this.localCapabilities.models,
195
213
  tags: this.localCapabilities.tags,
214
+ deviceInfo: this.localCapabilities.deviceInfo,
215
+ toolProxy: this.localCapabilities.toolProxy,
196
216
  },
197
- } satisfies AuthOk);
217
+ } as AuthOk);
198
218
 
199
219
  this.startHeartbeat();
200
220
  this.emit("authenticated", this.remoteCapabilities);
@@ -212,6 +232,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
212
232
  agents: this.localCapabilities.agents,
213
233
  models: this.localCapabilities.models,
214
234
  tags: this.localCapabilities.tags,
235
+ deviceInfo: this.localCapabilities.deviceInfo,
236
+ toolProxy: this.localCapabilities.toolProxy,
215
237
  },
216
238
  });
217
239
  return;
@@ -225,8 +247,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
225
247
  agents: ok.payload.agents,
226
248
  models: ok.payload.models,
227
249
  tags: ok.payload.tags,
250
+ deviceInfo: ok.payload.deviceInfo,
251
+ toolProxy: ok.payload.toolProxy,
228
252
  };
229
253
  this.authenticated = true;
254
+ this.clearAuthTimer();
230
255
  this.startHeartbeat();
231
256
  this.emit("authenticated", this.remoteCapabilities);
232
257
  return;
@@ -258,10 +283,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
258
283
  this.close(4002, "heartbeat timeout");
259
284
  return;
260
285
  }
286
+ this.lastPingSentAt = Date.now();
261
287
  this.sendRaw({
262
288
  type: "ping",
263
289
  from: this.nodeId,
264
- timestamp: Date.now(),
290
+ timestamp: this.lastPingSentAt,
265
291
  });
266
292
  scheduleNext();
267
293
  }, interval);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Collect local device/system information using Node.js os module.
3
+ */
4
+ import { arch, cpus, hostname, totalmem, type, release } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import type { DeviceInfo } from "./types.ts";
8
+
9
+ function readVersionFromPkgJson(dir: string): string | undefined {
10
+ try {
11
+ const raw = readFileSync(join(dir, "package.json"), "utf-8");
12
+ const pkg = JSON.parse(raw) as { name?: string; version?: string };
13
+ if (pkg.name === "openclaw" && pkg.version) return pkg.version;
14
+ } catch { /* not found */ }
15
+ return undefined;
16
+ }
17
+
18
+ function resolveOpenclawVersion(hint?: string): string | undefined {
19
+ if (hint && hint !== "unknown") return hint;
20
+ // Walk up from the OpenClaw entry script (process.argv[1]) to find package.json
21
+ try {
22
+ const entry = process.argv[1];
23
+ if (!entry) return undefined;
24
+ let dir = dirname(entry);
25
+ for (let i = 0; i < 10; i++) {
26
+ const v = readVersionFromPkgJson(dir);
27
+ if (v) return v;
28
+ const parent = dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ } catch { /* best effort */ }
33
+ return undefined;
34
+ }
35
+
36
+ /** Collect device info once at startup. */
37
+ export function collectDeviceInfo(openclawVersion?: string): DeviceInfo {
38
+ const cpuList = cpus();
39
+ return {
40
+ os: `${type()} ${release()}`,
41
+ arch: arch(),
42
+ cpuModel: cpuList[0]?.model?.trim() ?? "unknown",
43
+ cpuCores: cpuList.length,
44
+ totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
45
+ hostname: hostname(),
46
+ openclawVersion: resolveOpenclawVersion(openclawVersion),
47
+ };
48
+ }