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/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
2
  import { ClawMatrixConfigSchema, parseConfig, type ClawMatrixConfig } from "./config.ts";
3
+ import { debug, initDebugLogger } from "./debug.ts";
3
4
  import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
4
5
  import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
5
6
  import { createClusterHandoffReplyTool } from "./tools/cluster-handoff-reply.ts";
@@ -8,9 +9,15 @@ import { createClusterPeersTool } from "./tools/cluster-peers.ts";
8
9
  import { createClusterExecTool } from "./tools/cluster-exec.ts";
9
10
  import { createClusterReadTool } from "./tools/cluster-read.ts";
10
11
  import { createClusterWriteTool } from "./tools/cluster-write.ts";
11
- import { createClusterToolTool } from "./tools/cluster-tool.ts";
12
+ import { createClusterEditTool } from "./tools/cluster-edit.ts";
13
+ import { createClusterBatchTool } from "./tools/cluster-batch.ts";
12
14
  import { createClusterEventsTool } from "./tools/cluster-events.ts";
15
+ import { createClusterDiagnosticTool } from "./tools/cluster-diagnostic.ts";
16
+ import { createClusterAcpTool } from "./tools/cluster-acp.ts";
17
+ import { createClusterTerminalTool } from "./tools/cluster-terminal.ts";
18
+ import { createClusterToolInvokeTool } from "./tools/cluster-tool.ts";
13
19
  import { registerClusterCli } from "./cli.ts";
20
+ import { spawnProcess } from "./compat.ts";
14
21
 
15
22
  /**
16
23
  * Auto-discover models from OpenClaw's models.providers config.
@@ -25,41 +32,48 @@ function discoverModels(
25
32
  config: ClawMatrixConfig,
26
33
  ): ClawMatrixConfig["models"] {
27
34
  const cfg = openclawConfig as Record<string, unknown>;
28
- const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
29
- if (!providers || typeof providers !== "object") return [];
30
35
 
31
36
  // Collect proxyModel node IDs to exclude clawmatrix-registered providers
32
37
  const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
33
38
 
34
39
  const result: ClawMatrixConfig["models"] = [];
35
- for (const [providerId, providerConfig] of Object.entries(providers)) {
36
- // Skip clawmatrix proxy providers (remote models from peer nodes)
37
- if (proxyNodeIds.has(providerId)) continue;
38
-
39
- const models = providerConfig?.models;
40
- if (!Array.isArray(models)) continue;
41
-
42
- for (const m of models) {
43
- if (!m.id || typeof m.id !== "string") continue;
44
- result.push({
45
- id: m.id,
46
- provider: providerId,
47
- description: m.name as string | undefined,
48
- baseUrl: providerConfig?.baseUrl,
49
- apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
50
- api: (m.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
51
- contextWindow: m.contextWindow as number | undefined,
52
- maxTokens: m.maxTokens as number | undefined,
53
- reasoning: m.reasoning as boolean | undefined,
54
- input: m.input as ("text" | "image")[] | undefined,
55
- cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
56
- compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
57
- });
40
+ const seenIds = new Set<string>(); // "provider/modelId"
41
+
42
+ // Discover from models.providers (explicit provider configs with baseUrl + apiKey)
43
+ const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
44
+ if (providers && typeof providers === "object") {
45
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
46
+ // Skip clawmatrix proxy providers (remote models from peer nodes)
47
+ if (proxyNodeIds.has(providerId)) continue;
48
+
49
+ const models = providerConfig?.models;
50
+ if (!Array.isArray(models)) continue;
51
+
52
+ for (const m of models) {
53
+ if (!m.id || typeof m.id !== "string") continue;
54
+ const key = `${providerId}/${m.id}`;
55
+ if (seenIds.has(key)) continue;
56
+ seenIds.add(key);
57
+ result.push({
58
+ id: m.id,
59
+ provider: providerId,
60
+ description: m.name as string | undefined,
61
+ baseUrl: providerConfig?.baseUrl,
62
+ apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
63
+ api: (m.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
64
+ contextWindow: m.contextWindow as number | undefined,
65
+ maxTokens: m.maxTokens as number | undefined,
66
+ reasoning: m.reasoning as boolean | undefined,
67
+ input: m.input as ("text" | "image")[] | undefined,
68
+ cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
69
+ compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
70
+ });
71
+ }
58
72
  }
59
73
  }
60
74
 
61
75
  if (result.length > 0) {
62
- console.debug(`[clawmatrix] Auto-discovered ${result.length} model(s) from models.providers: ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
76
+ debug("models", `Auto-discovered ${result.length} model(s): ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
63
77
  }
64
78
 
65
79
  return result;
@@ -80,6 +94,8 @@ const plugin = {
80
94
  },
81
95
 
82
96
  register(api: OpenClawPluginApi) {
97
+ initDebugLogger(api.logger);
98
+
83
99
  let config: ReturnType<typeof parseConfig>;
84
100
  try {
85
101
  config = parseConfig(api.pluginConfig);
@@ -89,9 +105,34 @@ const plugin = {
89
105
  return;
90
106
  }
91
107
 
92
- // Auto-discover models from agents.defaults.models if no explicit models configured
93
- if (config.models.length === 0) {
94
- config = { ...config, models: discoverModels(api.config, config) };
108
+ // Auto-discover models from OpenClaw providers and merge with explicit config
109
+ {
110
+ const discovered = discoverModels(api.config, config);
111
+ if (discovered.length > 0) {
112
+ // Merge: add discovered models not already explicitly configured
113
+ const existingIds = new Set(config.models.map((m) => `${m.provider}/${m.id}`));
114
+ const newModels = discovered.filter((m) => !existingIds.has(`${m.provider}/${m.id}`));
115
+ if (newModels.length > 0) {
116
+ config = { ...config, models: [...config.models, ...newModels] };
117
+ }
118
+ }
119
+
120
+ // Filter out models that have no direct API endpoint with apiKey — these rely
121
+ // on OpenClaw's internal API key management and cannot be served to remote
122
+ // cluster nodes. Built-in providers (e.g. anthropic) may have a default baseUrl
123
+ // but no apiKey in the provider config, so we must check both.
124
+ const openclawProviders = ((api.config as Record<string, unknown>).models as { providers?: Record<string, { baseUrl?: string; apiKey?: string }> } | undefined)?.providers;
125
+ const servable = config.models.filter((m) => {
126
+ const providerCfg = openclawProviders?.[m.provider];
127
+ const baseUrl = m.baseUrl || providerCfg?.baseUrl;
128
+ const apiKey = m.apiKey || (typeof providerCfg?.apiKey === "string" ? providerCfg.apiKey : undefined);
129
+ return !!(baseUrl && apiKey);
130
+ });
131
+ if (servable.length < config.models.length) {
132
+ const dropped = config.models.length - servable.length;
133
+ debug("models", `Filtered out ${dropped} model(s) without direct API endpoint (not servable to cluster)`);
134
+ config = { ...config, models: servable };
135
+ }
95
136
  }
96
137
 
97
138
  // Background service: manages mesh connections, WS listener, heartbeat
@@ -135,14 +176,86 @@ const plugin = {
135
176
  // activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
136
177
  // so our injected providers get lost. There is no plugin-facing "config_reload"
137
178
  // event, so we re-patch periodically and skip when the reference hasn't changed.
179
+ /** Track which model IDs we injected per provider so we can remove stale ones. */
180
+ const injectedDiscoveredIds = new Map<string, Set<string>>(); // nodeId → Set<modelId>
181
+
182
+ /** Patch discovered (auto-discovered from peers) models into OpenClaw config.
183
+ * Full sync: adds new models, removes models whose peers went offline. */
184
+ const patchDiscoveredProviders = (cfg: Record<string, unknown>) => {
185
+ try {
186
+ const runtime = getClusterRuntime();
187
+ const discovered = runtime.modelProxy.allProxyModels;
188
+ const models = ((cfg).models ??= {}) as Record<string, unknown>;
189
+ const providers = (models.providers ??= {}) as Record<string, unknown>;
190
+
191
+ // Build desired state: group discovered models by nodeId (skip static ones)
192
+ const desiredByNode = new Map<string, ReturnType<typeof formatModel>[]>();
193
+ for (const m of discovered) {
194
+ if (modelsByNode[m.nodeId]?.some((sm) => sm.id === m.id)) continue;
195
+ const arr = desiredByNode.get(m.nodeId) ?? [];
196
+ arr.push(formatModel(m));
197
+ desiredByNode.set(m.nodeId, arr);
198
+ }
199
+
200
+ // Remove stale: models we previously injected but are no longer discovered
201
+ for (const [nodeId, prevIds] of injectedDiscoveredIds) {
202
+ const desiredModels = desiredByNode.get(nodeId);
203
+ const desiredIds = new Set(desiredModels?.map((m) => m.id) ?? []);
204
+ const staleIds = [...prevIds].filter((id) => !desiredIds.has(id));
205
+ if (staleIds.length === 0) continue;
206
+
207
+ const provider = providers[nodeId] as Record<string, unknown> | undefined;
208
+ if (!provider) continue;
209
+ const providerModels = provider.models as Array<{ id: string }> | undefined;
210
+ if (!providerModels) continue;
211
+ const staleSet = new Set(staleIds);
212
+ provider.models = providerModels.filter((m) => !staleSet.has(m.id));
213
+ // If provider has no models left and is purely discovered (not in static config), remove it
214
+ if ((provider.models as unknown[]).length === 0 && !modelsByNode[nodeId]) {
215
+ delete providers[nodeId];
216
+ }
217
+ }
218
+
219
+ // Add new discovered models
220
+ const nextInjected = new Map<string, Set<string>>();
221
+ for (const [nodeId, nodeModels] of desiredByNode) {
222
+ const ids = new Set(nodeModels.map((m) => m.id));
223
+ nextInjected.set(nodeId, ids);
224
+
225
+ const existing = providers[nodeId] as Record<string, unknown> | undefined;
226
+ if (existing) {
227
+ if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
228
+ if (!existing.baseUrl) existing.baseUrl = baseUrl;
229
+ const existingModels = (existing.models ?? []) as Array<{ id: string }>;
230
+ const existingIds = new Set(existingModels.map((m) => m.id));
231
+ for (const nm of nodeModels) {
232
+ if (!existingIds.has(nm.id)) existingModels.push(nm);
233
+ }
234
+ existing.models = existingModels;
235
+ } else {
236
+ const apiType = nodeApiType[nodeId] ?? "openai-completions";
237
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
238
+ }
239
+ }
240
+
241
+ // Update tracking state
242
+ injectedDiscoveredIds.clear();
243
+ for (const [k, v] of nextInjected) injectedDiscoveredIds.set(k, v);
244
+ } catch {
245
+ // Runtime not ready yet — will be patched on next cycle
246
+ }
247
+ };
248
+
138
249
  let lastSnapshotRef: unknown = null;
139
250
  const patchAllConfigs = () => {
140
251
  patchProviders(api.config as Record<string, unknown>);
252
+ patchDiscoveredProviders(api.config as Record<string, unknown>);
141
253
  try {
142
254
  const snapshot = api.runtime.config.loadConfig();
143
255
  if (snapshot && snapshot !== lastSnapshotRef) {
144
256
  lastSnapshotRef = snapshot;
145
257
  patchProviders(snapshot as Record<string, unknown>);
258
+ patchDiscoveredProviders(snapshot as Record<string, unknown>);
146
259
  }
147
260
  } catch {
148
261
  // Best-effort
@@ -177,8 +290,107 @@ const plugin = {
177
290
  api.registerTool(createClusterExecTool(), { optional: true });
178
291
  api.registerTool(createClusterReadTool(), { optional: true });
179
292
  api.registerTool(createClusterWriteTool(), { optional: true });
180
- api.registerTool(createClusterToolTool(), { optional: true });
293
+ api.registerTool(createClusterEditTool(), { optional: true });
294
+ api.registerTool(createClusterBatchTool(), { optional: true });
181
295
  api.registerTool(createClusterEventsTool(), { optional: true });
296
+ api.registerTool(createClusterDiagnosticTool(), { optional: true });
297
+ api.registerTool(createClusterAcpTool(), { optional: true });
298
+ api.registerTool(createClusterTerminalTool(), { optional: true });
299
+ api.registerTool(createClusterToolInvokeTool(), { optional: true });
300
+
301
+ // Wire up peer approval with OpenClaw channel API
302
+ if (config.peerApproval.enabled) {
303
+ const setupApproval = () => {
304
+ try {
305
+ const runtime = getClusterRuntime();
306
+ const mgr = runtime.peerManager.approvalManager;
307
+
308
+ // Set channel API from OpenClaw runtime
309
+ const channelApi = (api.runtime as Record<string, unknown>)?.channel;
310
+ if (channelApi && typeof channelApi === "object") {
311
+ mgr.setChannelApi(channelApi as import("./peer-approval.ts").ChannelApi);
312
+ }
313
+
314
+ // Set gateway send function for plugin channels (e.g. feishu)
315
+ // Uses OpenClaw's `send` gateway method via subprocess — works for all channels
316
+ mgr.setGatewaySend(async (params) => {
317
+ const sendParams: Record<string, unknown> = {
318
+ to: params.to,
319
+ message: params.message,
320
+ channel: params.channel,
321
+ idempotencyKey: crypto.randomUUID(),
322
+ };
323
+ if (params.accountId) sendParams.accountId = params.accountId;
324
+ if (params.threadId) sendParams.threadId = params.threadId;
325
+
326
+ const proc = spawnProcess(
327
+ ["openclaw", "gateway", "call", "send", "--json", "--params", JSON.stringify(sendParams)],
328
+ { stdout: "pipe", stderr: "pipe" },
329
+ );
330
+ const code = await proc.exited;
331
+ if (code !== 0) {
332
+ const stderrChunks: Uint8Array[] = [];
333
+ if (proc.stderr) {
334
+ const reader = proc.stderr.getReader();
335
+ while (true) {
336
+ const { done, value } = await reader.read();
337
+ if (done) break;
338
+ stderrChunks.push(value);
339
+ }
340
+ }
341
+ const errMsg = Buffer.concat(stderrChunks).toString("utf-8").trim();
342
+ throw new Error(`gateway send failed (exit ${code}): ${errMsg}`);
343
+ }
344
+ });
345
+
346
+ // Resolve notification targets:
347
+ // 1. Explicit ClawMatrix notifyTargets config
348
+ // 2. Fallback: OpenClaw exec approval forwarding config
349
+ // 3. Auto-detect: scan OpenClaw channels config for configured groups
350
+ const targets: import("./peer-approval.ts").NotifyTarget[] = [];
351
+ if (config.peerApproval.notifyTargets.length > 0) {
352
+ for (const t of config.peerApproval.notifyTargets) {
353
+ targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
354
+ }
355
+ } else {
356
+ // Fallback: borrow from OpenClaw exec approval forwarding config
357
+ const approvalsConfig = (api.config as Record<string, unknown>).approvals as
358
+ | { exec?: { targets?: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }> } }
359
+ | undefined;
360
+ if (approvalsConfig?.exec?.targets) {
361
+ for (const t of approvalsConfig.exec.targets) {
362
+ targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
363
+ }
364
+ }
365
+ }
366
+ // Auto-detect: scan OpenClaw channels config for enabled channels with groups
367
+ if (targets.length === 0) {
368
+ const channelsConfig = (api.config as Record<string, unknown>).channels as
369
+ Record<string, { enabled?: boolean; groups?: Record<string, unknown> }> | undefined;
370
+ if (channelsConfig) {
371
+ for (const [channelId, chConf] of Object.entries(channelsConfig)) {
372
+ if (!chConf || chConf.enabled === false) continue;
373
+ if (chConf.groups && typeof chConf.groups === "object") {
374
+ // Use the first configured group as notification target
375
+ const firstGroupId = Object.keys(chConf.groups)[0];
376
+ if (firstGroupId) {
377
+ targets.push({ channel: channelId, to: firstGroupId });
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ if (targets.length > 0) {
384
+ mgr.setNotifyTargets(targets);
385
+ }
386
+ } catch (err) {
387
+ debug("approval", `setupApproval failed: ${err}`);
388
+ }
389
+ };
390
+
391
+ // Delay setup until service has started
392
+ setTimeout(setupApproval, 1000);
393
+ }
182
394
 
183
395
  // Gateway methods (queried by CLI via `openclaw gateway call`)
184
396
  api.registerGatewayMethod(
@@ -194,19 +406,7 @@ const plugin = {
194
406
  agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
195
407
  models: config.models.map((m) => ({ id: m.id })),
196
408
  tags: config.tags,
197
- peers: peers.map((p) => {
198
- const status = runtime.peerManager.router.getPeerStatus(p);
199
- return {
200
- nodeId: p.nodeId,
201
- agents: p.agents,
202
- models: p.models,
203
- tags: p.tags,
204
- connected: status !== "unreachable",
205
- status,
206
- reachableVia: p.reachableVia,
207
- latencyMs: p.latencyMs,
208
- };
209
- }),
409
+ peers: mergeSentinelPeers(peers, runtime),
210
410
  });
211
411
  } catch {
212
412
  respond(false, { error: "ClawMatrix service not running" });
@@ -219,22 +419,84 @@ const plugin = {
219
419
  ({ respond }: GatewayRequestHandlerOptions) => {
220
420
  try {
221
421
  const runtime = getClusterRuntime();
222
- const peers = runtime.peerManager.router.getAllPeers().map((p) => {
223
- const status = runtime.peerManager.router.getPeerStatus(p);
224
- return {
422
+ const allPeers = runtime.peerManager.router.getAllPeers();
423
+ respond(true, mergeSentinelPeers(allPeers, runtime));
424
+ } catch {
425
+ respond(true, []);
426
+ }
427
+ },
428
+ );
429
+
430
+ // Peer approval gateway methods
431
+ api.registerGatewayMethod(
432
+ "clawmatrix.approval.resolve",
433
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
434
+ try {
435
+ const runtime = getClusterRuntime();
436
+ const { approvalId, decision, source } = (params ?? {}) as {
437
+ approvalId?: string; decision?: string; source?: string;
438
+ };
439
+ if (!approvalId || !decision || (decision !== "approve" && decision !== "deny")) {
440
+ respond(false, { error: "Invalid params: need approvalId and decision (approve|deny)" });
441
+ return;
442
+ }
443
+ const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
444
+ const ok = runtime.peerManager.approvalManager.handleResponse(approvalId, decision, resolvedBy);
445
+ if (!ok) {
446
+ // Not a local pending approval — broadcast response to mesh so the
447
+ // originating node can resolve it (e.g. user approved from a remote Telegram)
448
+ runtime.peerManager.router.broadcast({
449
+ type: "peer_approval_res",
450
+ id: approvalId,
451
+ from: config.nodeId,
452
+ timestamp: Date.now(),
453
+ payload: { approvalId, decision },
454
+ } as import("./types.ts").PeerApprovalResponse);
455
+ }
456
+ respond(true, { ok: true, approvalId, decision });
457
+ } catch {
458
+ respond(false, { error: "ClawMatrix service not running" });
459
+ }
460
+ },
461
+ );
462
+
463
+ api.registerGatewayMethod(
464
+ "clawmatrix.approval.list",
465
+ ({ respond }: GatewayRequestHandlerOptions) => {
466
+ try {
467
+ const runtime = getClusterRuntime();
468
+ const mgr = runtime.peerManager.approvalManager;
469
+ respond(true, {
470
+ approved: mgr.getApprovedPeers(),
471
+ pending: mgr.getPendingApprovals().map((p) => ({
472
+ approvalId: p.approvalId,
225
473
  nodeId: p.nodeId,
226
- agents: p.agents,
227
- models: p.models,
228
- tags: p.tags,
229
- connected: status !== "unreachable",
230
- status,
231
- reachableVia: p.reachableVia,
232
- latencyMs: p.latencyMs,
233
- };
474
+ deviceInfo: p.deviceInfo,
475
+ createdAt: p.createdAt,
476
+ })),
477
+ denied: mgr.getDeniedPeers(),
234
478
  });
235
- respond(true, peers);
236
479
  } catch {
237
- respond(true, []);
480
+ respond(false, { error: "ClawMatrix service not running" });
481
+ }
482
+ },
483
+ );
484
+
485
+ api.registerGatewayMethod(
486
+ "clawmatrix.approval.revoke",
487
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
488
+ try {
489
+ const runtime = getClusterRuntime();
490
+ const { nodeId, source } = (params ?? {}) as { nodeId?: string; source?: string };
491
+ if (!nodeId) {
492
+ respond(false, { error: "Missing nodeId" });
493
+ return;
494
+ }
495
+ const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
496
+ const ok = runtime.peerManager.approvalManager.revoke(nodeId, resolvedBy);
497
+ respond(true, { ok, nodeId });
498
+ } catch {
499
+ respond(false, { error: "ClawMatrix service not running" });
238
500
  }
239
501
  },
240
502
  );
@@ -247,6 +509,66 @@ const plugin = {
247
509
  // CLI subcommand
248
510
  api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
249
511
 
512
+ // Plugin command: /clawmatrix approve|deny|revoke
513
+ // Handles Telegram callback buttons and other chat surfaces.
514
+ // Plugin commands are processed before the agent, so they bypass the LLM.
515
+ api.registerCommand({
516
+ name: "clawmatrix",
517
+ description: "ClawMatrix cluster management (approve/deny peer requests)",
518
+ acceptsArgs: true,
519
+ requireAuth: true,
520
+ handler: (ctx) => {
521
+ const args = ctx.args?.trim();
522
+ if (!args) {
523
+ return { text: "Usage: /clawmatrix approve|deny <id> or /clawmatrix revoke <nodeId>" };
524
+ }
525
+
526
+ const parts = args.split(/\s+/);
527
+ const subCmd = parts[0]?.toLowerCase();
528
+ const target = parts[1];
529
+
530
+ if (!target) {
531
+ return { text: "Usage: /clawmatrix approve|deny <approvalId> or /clawmatrix revoke <nodeId>" };
532
+ }
533
+
534
+ try {
535
+ const runtime = getClusterRuntime();
536
+ const mgr = runtime.peerManager.approvalManager;
537
+ const resolvedBy = { source: `command:${ctx.channel}:${ctx.senderId || "unknown"}`, at: Date.now() };
538
+
539
+ if (subCmd === "approve" || subCmd === "deny") {
540
+ const decision = subCmd as "approve" | "deny";
541
+ const ok = mgr.handleResponse(target, decision, resolvedBy);
542
+ if (!ok) {
543
+ // Not local — broadcast to mesh
544
+ runtime.peerManager.router.broadcast({
545
+ type: "peer_approval_res",
546
+ id: target,
547
+ from: config.nodeId,
548
+ timestamp: Date.now(),
549
+ payload: { approvalId: target, decision },
550
+ } as import("./types.ts").PeerApprovalResponse);
551
+ }
552
+ return { text: `${decision === "approve" ? "\u2705" : "\u274c"} ${decision === "approve" ? "Approved" : "Denied"}: ${target}` };
553
+ }
554
+
555
+ if (subCmd === "approval" && parts[1] === "revoke" && parts[2]) {
556
+ const ok = mgr.revoke(parts[2], resolvedBy);
557
+ return { text: ok ? `Revoked: ${parts[2]}` : `Node not found: ${parts[2]}` };
558
+ }
559
+
560
+ if (subCmd === "revoke") {
561
+ const ok = mgr.revoke(target, resolvedBy);
562
+ return { text: ok ? `Revoked: ${target}` : `Node not found: ${target}` };
563
+ }
564
+
565
+ return { text: "Unknown subcommand. Use: approve, deny, or revoke" };
566
+ } catch {
567
+ return { text: "ClawMatrix service not running" };
568
+ }
569
+ },
570
+ });
571
+
250
572
  // Inject cluster context into agent prompts.
251
573
  //
252
574
  // Minimal-injection strategy:
@@ -277,7 +599,7 @@ const plugin = {
277
599
  `[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
278
600
  ...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
279
601
  `${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
280
- "Prefer cluster_exec/read/write for simple ops; cluster_handoff for complex multi-step tasks.",
602
+ "Prefer cluster_tool for device-specific tools (screenshot, battery, etc.); cluster_exec/read/write for file/shell ops; cluster_handoff for complex multi-step tasks.",
281
603
  "IMPORTANT: Always tell user which remote node you're targeting before calling cluster tools.",
282
604
  );
283
605
  }
@@ -344,4 +666,57 @@ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<strin
344
666
  return result;
345
667
  }
346
668
 
669
+ /** Merge sentinel peers into their main node entry (hides `:sentinel` suffix). */
670
+ function mergeSentinelPeers(
671
+ allPeers: import("./router.ts").RouteEntry[],
672
+ runtime: import("./cluster-service.ts").ClusterRuntime,
673
+ ) {
674
+ const result: Record<string, unknown>[] = [];
675
+ const sentinelMap = new Map<string, typeof allPeers[number]>();
676
+
677
+ for (const p of allPeers) {
678
+ if (p.nodeId.endsWith(":sentinel")) {
679
+ sentinelMap.set(p.nodeId.replace(/:sentinel$/, ""), p);
680
+ }
681
+ }
682
+
683
+ const seen = new Set<string>();
684
+ for (const p of allPeers) {
685
+ if (p.nodeId.endsWith(":sentinel")) continue;
686
+ seen.add(p.nodeId);
687
+ const status = runtime.peerManager.router.getPeerStatus(p);
688
+ const sentinel = sentinelMap.get(p.nodeId);
689
+ const sentinelStatus = sentinel ? runtime.peerManager.router.getPeerStatus(sentinel) : undefined;
690
+ result.push({
691
+ nodeId: p.nodeId,
692
+ agents: p.agents,
693
+ models: p.models,
694
+ tags: p.tags,
695
+ connected: status !== "unreachable",
696
+ status,
697
+ reachableVia: p.reachableVia,
698
+ latencyMs: p.latencyMs,
699
+ ...(sentinel ? { sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline" } : {}),
700
+ });
701
+ }
702
+
703
+ // Sentinel-only: main node gone but sentinel alive
704
+ for (const [mainId, sentinel] of sentinelMap) {
705
+ if (seen.has(mainId)) continue;
706
+ const sentinelStatus = runtime.peerManager.router.getPeerStatus(sentinel);
707
+ result.push({
708
+ nodeId: mainId,
709
+ agents: [],
710
+ models: [],
711
+ tags: [],
712
+ connected: false,
713
+ status: "unreachable",
714
+ latencyMs: sentinel.latencyMs,
715
+ sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline",
716
+ });
717
+ }
718
+
719
+ return result;
720
+ }
721
+
347
722
  export default plugin;