clawmatrix 0.1.22 → 0.2.0

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
- import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
- import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
1
+ import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
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,116 @@ 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";
21
+
22
+ /**
23
+ * Auto-discover models from OpenClaw's models.providers config.
24
+ * Iterates all configured providers and their models, building the
25
+ * full model list with endpoint info (baseUrl, apiKey, api type, etc.).
26
+ * Excludes clawmatrix proxy providers (remote models from peer nodes).
27
+ */
28
+ type ProviderEntry = { baseUrl?: string; apiKey?: string; api?: string; models?: Array<Record<string, unknown>> };
29
+
30
+ function discoverModels(
31
+ openclawConfig: OpenClawConfig,
32
+ config: ClawMatrixConfig,
33
+ ): ClawMatrixConfig["models"] {
34
+ const cfg = openclawConfig as Record<string, unknown>;
35
+
36
+ // Collect proxyModel node IDs to exclude clawmatrix-registered providers
37
+ const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
38
+
39
+ const result: ClawMatrixConfig["models"] = [];
40
+ const seenIds = new Set<string>(); // "provider/modelId"
41
+
42
+ // 1. Discover from models.providers (explicit provider configs)
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
+ }
72
+ }
73
+ }
74
+
75
+ // 2. Discover from agents.defaults.model (primary + fallbacks) — the user's
76
+ // configured model list, which includes pi-ai built-in models like
77
+ // anthropic/claude-opus-4-6 that aren't in models.providers.
78
+ const agentDefaults = (cfg.agents as { defaults?: { model?: { primary?: string; fallbacks?: string[] } } } | undefined)?.defaults;
79
+ const modelConfig = agentDefaults?.model;
80
+ if (modelConfig) {
81
+ const refs: string[] = [];
82
+ if (modelConfig.primary) refs.push(modelConfig.primary);
83
+ if (Array.isArray(modelConfig.fallbacks)) refs.push(...modelConfig.fallbacks);
84
+
85
+ for (const modelRef of refs) {
86
+ if (typeof modelRef !== "string") continue;
87
+ const slashIdx = modelRef.indexOf("/");
88
+ if (slashIdx <= 0) continue;
89
+ const provider = modelRef.slice(0, slashIdx);
90
+ const modelId = modelRef.slice(slashIdx + 1);
91
+ if (!modelId) continue;
92
+
93
+ const key = `${provider}/${modelId}`;
94
+ if (seenIds.has(key)) continue;
95
+ if (proxyNodeIds.has(provider)) continue;
96
+ seenIds.add(key);
97
+
98
+ // Look up provider config for richer model info if available
99
+ const providerConfig = providers?.[provider];
100
+ const providerModel = providerConfig?.models?.find((m: Record<string, unknown>) => m.id === modelId);
101
+ result.push({
102
+ id: modelId,
103
+ provider,
104
+ description: providerModel?.name as string | undefined,
105
+ baseUrl: providerConfig?.baseUrl,
106
+ apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
107
+ api: (providerModel?.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
108
+ contextWindow: providerModel?.contextWindow as number | undefined,
109
+ maxTokens: providerModel?.maxTokens as number | undefined,
110
+ reasoning: providerModel?.reasoning as boolean | undefined,
111
+ input: providerModel?.input as ("text" | "image")[] | undefined,
112
+ });
113
+ }
114
+ }
115
+
116
+ if (result.length > 0) {
117
+ debug("models", `Auto-discovered ${result.length} model(s): ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
118
+ }
119
+
120
+ return result;
121
+ }
14
122
 
15
123
  const plugin = {
16
124
  id: "clawmatrix",
@@ -27,6 +135,8 @@ const plugin = {
27
135
  },
28
136
 
29
137
  register(api: OpenClawPluginApi) {
138
+ initDebugLogger(api.logger);
139
+
30
140
  let config: ReturnType<typeof parseConfig>;
31
141
  try {
32
142
  config = parseConfig(api.pluginConfig);
@@ -36,6 +146,35 @@ const plugin = {
36
146
  return;
37
147
  }
38
148
 
149
+ // Auto-discover models from OpenClaw providers and merge with explicit config
150
+ {
151
+ const discovered = discoverModels(api.config, config);
152
+ if (discovered.length > 0) {
153
+ // Merge: add discovered models not already explicitly configured
154
+ const existingIds = new Set(config.models.map((m) => `${m.provider}/${m.id}`));
155
+ const newModels = discovered.filter((m) => !existingIds.has(`${m.provider}/${m.id}`));
156
+ if (newModels.length > 0) {
157
+ config = { ...config, models: [...config.models, ...newModels] };
158
+ }
159
+ }
160
+
161
+ // Filter out models that have no direct API endpoint — these rely on OpenClaw's
162
+ // internal API key management and cannot be served to remote cluster nodes.
163
+ // Without this filter, remote nodes discover and try to use these models via
164
+ // model_req, only to get "No direct API endpoint configured" errors.
165
+ const openclawProviders = ((api.config as Record<string, unknown>).models as { providers?: Record<string, { baseUrl?: string }> } | undefined)?.providers;
166
+ const servable = config.models.filter((m) => {
167
+ if (m.baseUrl) return true;
168
+ if (openclawProviders?.[m.provider]?.baseUrl) return true;
169
+ return false;
170
+ });
171
+ if (servable.length < config.models.length) {
172
+ const dropped = config.models.length - servable.length;
173
+ debug("models", `Filtered out ${dropped} model(s) without direct API endpoint (not servable to cluster)`);
174
+ config = { ...config, models: servable };
175
+ }
176
+ }
177
+
39
178
  // Background service: manages mesh connections, WS listener, heartbeat
40
179
  api.registerService(createClusterService(config, api.config, api.runtime.version));
41
180
 
@@ -60,26 +199,114 @@ const plugin = {
60
199
  const models = ((cfg).models ??= {}) as Record<string, unknown>;
61
200
  const providers = (models.providers ??= {}) as Record<string, unknown>;
62
201
  for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
63
- if (!providers[nodeId]) {
64
- const api = nodeApiType[nodeId] ?? "openai-completions";
65
- providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api, models: nodeModels };
202
+ const apiType = nodeApiType[nodeId] ?? "openai-completions";
203
+ const existing = providers[nodeId] as Record<string, unknown> | undefined;
204
+ if (existing) {
205
+ // Provider already exists (e.g. from models.json or config reload) —
206
+ // ensure the dummy apiKey is always present so auth resolution succeeds.
207
+ if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
208
+ if (!existing.baseUrl) existing.baseUrl = baseUrl;
209
+ } else {
210
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
66
211
  }
67
212
  }
68
213
  };
69
214
 
70
- patchProviders(api.config as Record<string, unknown>);
215
+ // Patch all known config objects.
216
+ // activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
217
+ // so our injected providers get lost. There is no plugin-facing "config_reload"
218
+ // event, so we re-patch periodically and skip when the reference hasn't changed.
219
+ /** Track which model IDs we injected per provider so we can remove stale ones. */
220
+ const injectedDiscoveredIds = new Map<string, Set<string>>(); // nodeId → Set<modelId>
71
221
 
72
- // Also patch the runtime config snapshot (loadConfig returns it by reference).
73
- // activateSecretsRuntimeSnapshot clones the config, so api.config and the
74
- // snapshot returned by loadConfig() are separate objects — patch both.
75
- try {
76
- const snapshot = api.runtime.config.loadConfig();
77
- if (snapshot && snapshot !== api.config) {
78
- patchProviders(snapshot as Record<string, unknown>);
222
+ /** Patch discovered (auto-discovered from peers) models into OpenClaw config.
223
+ * Full sync: adds new models, removes models whose peers went offline. */
224
+ const patchDiscoveredProviders = (cfg: Record<string, unknown>) => {
225
+ try {
226
+ const runtime = getClusterRuntime();
227
+ const discovered = runtime.modelProxy.allProxyModels;
228
+ const models = ((cfg).models ??= {}) as Record<string, unknown>;
229
+ const providers = (models.providers ??= {}) as Record<string, unknown>;
230
+
231
+ // Build desired state: group discovered models by nodeId (skip static ones)
232
+ const desiredByNode = new Map<string, ReturnType<typeof formatModel>[]>();
233
+ for (const m of discovered) {
234
+ if (modelsByNode[m.nodeId]?.some((sm) => sm.id === m.id)) continue;
235
+ const arr = desiredByNode.get(m.nodeId) ?? [];
236
+ arr.push(formatModel(m));
237
+ desiredByNode.set(m.nodeId, arr);
238
+ }
239
+
240
+ // Remove stale: models we previously injected but are no longer discovered
241
+ for (const [nodeId, prevIds] of injectedDiscoveredIds) {
242
+ const desiredModels = desiredByNode.get(nodeId);
243
+ const desiredIds = new Set(desiredModels?.map((m) => m.id) ?? []);
244
+ const staleIds = [...prevIds].filter((id) => !desiredIds.has(id));
245
+ if (staleIds.length === 0) continue;
246
+
247
+ const provider = providers[nodeId] as Record<string, unknown> | undefined;
248
+ if (!provider) continue;
249
+ const providerModels = provider.models as Array<{ id: string }> | undefined;
250
+ if (!providerModels) continue;
251
+ const staleSet = new Set(staleIds);
252
+ provider.models = providerModels.filter((m) => !staleSet.has(m.id));
253
+ // If provider has no models left and is purely discovered (not in static config), remove it
254
+ if ((provider.models as unknown[]).length === 0 && !modelsByNode[nodeId]) {
255
+ delete providers[nodeId];
256
+ }
257
+ }
258
+
259
+ // Add new discovered models
260
+ const nextInjected = new Map<string, Set<string>>();
261
+ for (const [nodeId, nodeModels] of desiredByNode) {
262
+ const ids = new Set(nodeModels.map((m) => m.id));
263
+ nextInjected.set(nodeId, ids);
264
+
265
+ const existing = providers[nodeId] as Record<string, unknown> | undefined;
266
+ if (existing) {
267
+ if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
268
+ if (!existing.baseUrl) existing.baseUrl = baseUrl;
269
+ const existingModels = (existing.models ?? []) as Array<{ id: string }>;
270
+ const existingIds = new Set(existingModels.map((m) => m.id));
271
+ for (const nm of nodeModels) {
272
+ if (!existingIds.has(nm.id)) existingModels.push(nm);
273
+ }
274
+ existing.models = existingModels;
275
+ } else {
276
+ const apiType = nodeApiType[nodeId] ?? "openai-completions";
277
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
278
+ }
279
+ }
280
+
281
+ // Update tracking state
282
+ injectedDiscoveredIds.clear();
283
+ for (const [k, v] of nextInjected) injectedDiscoveredIds.set(k, v);
284
+ } catch {
285
+ // Runtime not ready yet — will be patched on next cycle
79
286
  }
80
- } catch {
81
- // Best-effort; api.config patch is the fallback
82
- }
287
+ };
288
+
289
+ let lastSnapshotRef: unknown = null;
290
+ const patchAllConfigs = () => {
291
+ patchProviders(api.config as Record<string, unknown>);
292
+ patchDiscoveredProviders(api.config as Record<string, unknown>);
293
+ try {
294
+ const snapshot = api.runtime.config.loadConfig();
295
+ if (snapshot && snapshot !== lastSnapshotRef) {
296
+ lastSnapshotRef = snapshot;
297
+ patchProviders(snapshot as Record<string, unknown>);
298
+ patchDiscoveredProviders(snapshot as Record<string, unknown>);
299
+ }
300
+ } catch {
301
+ // Best-effort
302
+ }
303
+ };
304
+
305
+ patchAllConfigs();
306
+
307
+ const repatchTimer = setInterval(patchAllConfigs, 10_000);
308
+ repatchTimer.unref?.();
309
+ api.on("dispose", () => clearInterval(repatchTimer));
83
310
 
84
311
  for (const [nodeId, models] of Object.entries(modelsByNode)) {
85
312
  api.registerProvider({
@@ -103,8 +330,107 @@ const plugin = {
103
330
  api.registerTool(createClusterExecTool(), { optional: true });
104
331
  api.registerTool(createClusterReadTool(), { optional: true });
105
332
  api.registerTool(createClusterWriteTool(), { optional: true });
106
- api.registerTool(createClusterToolTool(), { optional: true });
333
+ api.registerTool(createClusterEditTool(), { optional: true });
334
+ api.registerTool(createClusterBatchTool(), { optional: true });
107
335
  api.registerTool(createClusterEventsTool(), { optional: true });
336
+ api.registerTool(createClusterDiagnosticTool(), { optional: true });
337
+ api.registerTool(createClusterAcpTool(), { optional: true });
338
+ api.registerTool(createClusterTerminalTool(), { optional: true });
339
+ api.registerTool(createClusterToolInvokeTool(), { optional: true });
340
+
341
+ // Wire up peer approval with OpenClaw channel API
342
+ if (config.peerApproval.enabled) {
343
+ const setupApproval = () => {
344
+ try {
345
+ const runtime = getClusterRuntime();
346
+ const mgr = runtime.peerManager.approvalManager;
347
+
348
+ // Set channel API from OpenClaw runtime
349
+ const channelApi = (api.runtime as Record<string, unknown>)?.channel;
350
+ if (channelApi && typeof channelApi === "object") {
351
+ mgr.setChannelApi(channelApi as import("./peer-approval.ts").ChannelApi);
352
+ }
353
+
354
+ // Set gateway send function for plugin channels (e.g. feishu)
355
+ // Uses OpenClaw's `send` gateway method via subprocess — works for all channels
356
+ mgr.setGatewaySend(async (params) => {
357
+ const sendParams: Record<string, unknown> = {
358
+ to: params.to,
359
+ message: params.message,
360
+ channel: params.channel,
361
+ idempotencyKey: crypto.randomUUID(),
362
+ };
363
+ if (params.accountId) sendParams.accountId = params.accountId;
364
+ if (params.threadId) sendParams.threadId = params.threadId;
365
+
366
+ const proc = spawnProcess(
367
+ ["openclaw", "gateway", "call", "send", "--json", "--params", JSON.stringify(sendParams)],
368
+ { stdout: "pipe", stderr: "pipe" },
369
+ );
370
+ const code = await proc.exited;
371
+ if (code !== 0) {
372
+ const stderrChunks: Uint8Array[] = [];
373
+ if (proc.stderr) {
374
+ const reader = proc.stderr.getReader();
375
+ while (true) {
376
+ const { done, value } = await reader.read();
377
+ if (done) break;
378
+ stderrChunks.push(value);
379
+ }
380
+ }
381
+ const errMsg = Buffer.concat(stderrChunks).toString("utf-8").trim();
382
+ throw new Error(`gateway send failed (exit ${code}): ${errMsg}`);
383
+ }
384
+ });
385
+
386
+ // Resolve notification targets:
387
+ // 1. Explicit ClawMatrix notifyTargets config
388
+ // 2. Fallback: OpenClaw exec approval forwarding config
389
+ // 3. Auto-detect: scan OpenClaw channels config for configured groups
390
+ const targets: import("./peer-approval.ts").NotifyTarget[] = [];
391
+ if (config.peerApproval.notifyTargets.length > 0) {
392
+ for (const t of config.peerApproval.notifyTargets) {
393
+ targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
394
+ }
395
+ } else {
396
+ // Fallback: borrow from OpenClaw exec approval forwarding config
397
+ const approvalsConfig = (api.config as Record<string, unknown>).approvals as
398
+ | { exec?: { targets?: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }> } }
399
+ | undefined;
400
+ if (approvalsConfig?.exec?.targets) {
401
+ for (const t of approvalsConfig.exec.targets) {
402
+ targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
403
+ }
404
+ }
405
+ }
406
+ // Auto-detect: scan OpenClaw channels config for enabled channels with groups
407
+ if (targets.length === 0) {
408
+ const channelsConfig = (api.config as Record<string, unknown>).channels as
409
+ Record<string, { enabled?: boolean; groups?: Record<string, unknown> }> | undefined;
410
+ if (channelsConfig) {
411
+ for (const [channelId, chConf] of Object.entries(channelsConfig)) {
412
+ if (!chConf || chConf.enabled === false) continue;
413
+ if (chConf.groups && typeof chConf.groups === "object") {
414
+ // Use the first configured group as notification target
415
+ const firstGroupId = Object.keys(chConf.groups)[0];
416
+ if (firstGroupId) {
417
+ targets.push({ channel: channelId, to: firstGroupId });
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+ if (targets.length > 0) {
424
+ mgr.setNotifyTargets(targets);
425
+ }
426
+ } catch (err) {
427
+ debug("approval", `setupApproval failed: ${err}`);
428
+ }
429
+ };
430
+
431
+ // Delay setup until service has started
432
+ setTimeout(setupApproval, 1000);
433
+ }
108
434
 
109
435
  // Gateway methods (queried by CLI via `openclaw gateway call`)
110
436
  api.registerGatewayMethod(
@@ -120,19 +446,7 @@ const plugin = {
120
446
  agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
121
447
  models: config.models.map((m) => ({ id: m.id })),
122
448
  tags: config.tags,
123
- peers: peers.map((p) => {
124
- const status = runtime.peerManager.router.getPeerStatus(p);
125
- return {
126
- nodeId: p.nodeId,
127
- agents: p.agents,
128
- models: p.models,
129
- tags: p.tags,
130
- connected: status !== "unreachable",
131
- status,
132
- reachableVia: p.reachableVia,
133
- latencyMs: p.latencyMs,
134
- };
135
- }),
449
+ peers: mergeSentinelPeers(peers, runtime),
136
450
  });
137
451
  } catch {
138
452
  respond(false, { error: "ClawMatrix service not running" });
@@ -145,29 +459,156 @@ const plugin = {
145
459
  ({ respond }: GatewayRequestHandlerOptions) => {
146
460
  try {
147
461
  const runtime = getClusterRuntime();
148
- const peers = runtime.peerManager.router.getAllPeers().map((p) => {
149
- const status = runtime.peerManager.router.getPeerStatus(p);
150
- return {
462
+ const allPeers = runtime.peerManager.router.getAllPeers();
463
+ respond(true, mergeSentinelPeers(allPeers, runtime));
464
+ } catch {
465
+ respond(true, []);
466
+ }
467
+ },
468
+ );
469
+
470
+ // Peer approval gateway methods
471
+ api.registerGatewayMethod(
472
+ "clawmatrix.approval.resolve",
473
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
474
+ try {
475
+ const runtime = getClusterRuntime();
476
+ const { approvalId, decision, source } = (params ?? {}) as {
477
+ approvalId?: string; decision?: string; source?: string;
478
+ };
479
+ if (!approvalId || !decision || (decision !== "approve" && decision !== "deny")) {
480
+ respond(false, { error: "Invalid params: need approvalId and decision (approve|deny)" });
481
+ return;
482
+ }
483
+ const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
484
+ const ok = runtime.peerManager.approvalManager.handleResponse(approvalId, decision, resolvedBy);
485
+ if (!ok) {
486
+ // Not a local pending approval — broadcast response to mesh so the
487
+ // originating node can resolve it (e.g. user approved from a remote Telegram)
488
+ runtime.peerManager.router.broadcast({
489
+ type: "peer_approval_res",
490
+ id: approvalId,
491
+ from: config.nodeId,
492
+ timestamp: Date.now(),
493
+ payload: { approvalId, decision },
494
+ } as import("./types.ts").PeerApprovalResponse);
495
+ }
496
+ respond(true, { ok: true, approvalId, decision });
497
+ } catch {
498
+ respond(false, { error: "ClawMatrix service not running" });
499
+ }
500
+ },
501
+ );
502
+
503
+ api.registerGatewayMethod(
504
+ "clawmatrix.approval.list",
505
+ ({ respond }: GatewayRequestHandlerOptions) => {
506
+ try {
507
+ const runtime = getClusterRuntime();
508
+ const mgr = runtime.peerManager.approvalManager;
509
+ respond(true, {
510
+ approved: mgr.getApprovedPeers(),
511
+ pending: mgr.getPendingApprovals().map((p) => ({
512
+ approvalId: p.approvalId,
151
513
  nodeId: p.nodeId,
152
- agents: p.agents,
153
- models: p.models,
154
- tags: p.tags,
155
- connected: status !== "unreachable",
156
- status,
157
- reachableVia: p.reachableVia,
158
- latencyMs: p.latencyMs,
159
- };
514
+ deviceInfo: p.deviceInfo,
515
+ createdAt: p.createdAt,
516
+ })),
517
+ denied: mgr.getDeniedPeers(),
160
518
  });
161
- respond(true, peers);
162
519
  } catch {
163
- respond(true, []);
520
+ respond(false, { error: "ClawMatrix service not running" });
164
521
  }
165
522
  },
166
523
  );
167
524
 
525
+ api.registerGatewayMethod(
526
+ "clawmatrix.approval.revoke",
527
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
528
+ try {
529
+ const runtime = getClusterRuntime();
530
+ const { nodeId, source } = (params ?? {}) as { nodeId?: string; source?: string };
531
+ if (!nodeId) {
532
+ respond(false, { error: "Missing nodeId" });
533
+ return;
534
+ }
535
+ const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
536
+ const ok = runtime.peerManager.approvalManager.revoke(nodeId, resolvedBy);
537
+ respond(true, { ok, nodeId });
538
+ } catch {
539
+ respond(false, { error: "ClawMatrix service not running" });
540
+ }
541
+ },
542
+ );
543
+
544
+ // Log model selection on each LLM call (fire-and-forget)
545
+ api.on("llm_input", (event) => {
546
+ api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
547
+ });
548
+
168
549
  // CLI subcommand
169
550
  api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
170
551
 
552
+ // Plugin command: /clawmatrix approve|deny|revoke
553
+ // Handles Telegram callback buttons and other chat surfaces.
554
+ // Plugin commands are processed before the agent, so they bypass the LLM.
555
+ api.registerCommand({
556
+ name: "clawmatrix",
557
+ description: "ClawMatrix cluster management (approve/deny peer requests)",
558
+ acceptsArgs: true,
559
+ requireAuth: true,
560
+ handler: (ctx) => {
561
+ const args = ctx.args?.trim();
562
+ if (!args) {
563
+ return { text: "Usage: /clawmatrix approve|deny <id> or /clawmatrix revoke <nodeId>" };
564
+ }
565
+
566
+ const parts = args.split(/\s+/);
567
+ const subCmd = parts[0]?.toLowerCase();
568
+ const target = parts[1];
569
+
570
+ if (!target) {
571
+ return { text: "Usage: /clawmatrix approve|deny <approvalId> or /clawmatrix revoke <nodeId>" };
572
+ }
573
+
574
+ try {
575
+ const runtime = getClusterRuntime();
576
+ const mgr = runtime.peerManager.approvalManager;
577
+ const resolvedBy = { source: `command:${ctx.channel}:${ctx.senderId || "unknown"}`, at: Date.now() };
578
+
579
+ if (subCmd === "approve" || subCmd === "deny") {
580
+ const decision = subCmd as "approve" | "deny";
581
+ const ok = mgr.handleResponse(target, decision, resolvedBy);
582
+ if (!ok) {
583
+ // Not local — broadcast to mesh
584
+ runtime.peerManager.router.broadcast({
585
+ type: "peer_approval_res",
586
+ id: target,
587
+ from: config.nodeId,
588
+ timestamp: Date.now(),
589
+ payload: { approvalId: target, decision },
590
+ } as import("./types.ts").PeerApprovalResponse);
591
+ }
592
+ return { text: `${decision === "approve" ? "\u2705" : "\u274c"} ${decision === "approve" ? "Approved" : "Denied"}: ${target}` };
593
+ }
594
+
595
+ if (subCmd === "approval" && parts[1] === "revoke" && parts[2]) {
596
+ const ok = mgr.revoke(parts[2], resolvedBy);
597
+ return { text: ok ? `Revoked: ${parts[2]}` : `Node not found: ${parts[2]}` };
598
+ }
599
+
600
+ if (subCmd === "revoke") {
601
+ const ok = mgr.revoke(target, resolvedBy);
602
+ return { text: ok ? `Revoked: ${target}` : `Node not found: ${target}` };
603
+ }
604
+
605
+ return { text: "Unknown subcommand. Use: approve, deny, or revoke" };
606
+ } catch {
607
+ return { text: "ClawMatrix service not running" };
608
+ }
609
+ },
610
+ });
611
+
171
612
  // Inject cluster context into agent prompts.
172
613
  //
173
614
  // Minimal-injection strategy:
@@ -198,7 +639,7 @@ const plugin = {
198
639
  `[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
199
640
  ...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
200
641
  `${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
201
- "Prefer cluster_exec/read/write for simple ops; cluster_handoff for complex multi-step tasks.",
642
+ "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.",
202
643
  "IMPORTANT: Always tell user which remote node you're targeting before calling cluster tools.",
203
644
  );
204
645
  }
@@ -265,4 +706,57 @@ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<strin
265
706
  return result;
266
707
  }
267
708
 
709
+ /** Merge sentinel peers into their main node entry (hides `:sentinel` suffix). */
710
+ function mergeSentinelPeers(
711
+ allPeers: import("./router.ts").RouteEntry[],
712
+ runtime: import("./cluster-service.ts").ClusterRuntime,
713
+ ) {
714
+ const result: Record<string, unknown>[] = [];
715
+ const sentinelMap = new Map<string, typeof allPeers[number]>();
716
+
717
+ for (const p of allPeers) {
718
+ if (p.nodeId.endsWith(":sentinel")) {
719
+ sentinelMap.set(p.nodeId.replace(/:sentinel$/, ""), p);
720
+ }
721
+ }
722
+
723
+ const seen = new Set<string>();
724
+ for (const p of allPeers) {
725
+ if (p.nodeId.endsWith(":sentinel")) continue;
726
+ seen.add(p.nodeId);
727
+ const status = runtime.peerManager.router.getPeerStatus(p);
728
+ const sentinel = sentinelMap.get(p.nodeId);
729
+ const sentinelStatus = sentinel ? runtime.peerManager.router.getPeerStatus(sentinel) : undefined;
730
+ result.push({
731
+ nodeId: p.nodeId,
732
+ agents: p.agents,
733
+ models: p.models,
734
+ tags: p.tags,
735
+ connected: status !== "unreachable",
736
+ status,
737
+ reachableVia: p.reachableVia,
738
+ latencyMs: p.latencyMs,
739
+ ...(sentinel ? { sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline" } : {}),
740
+ });
741
+ }
742
+
743
+ // Sentinel-only: main node gone but sentinel alive
744
+ for (const [mainId, sentinel] of sentinelMap) {
745
+ if (seen.has(mainId)) continue;
746
+ const sentinelStatus = runtime.peerManager.router.getPeerStatus(sentinel);
747
+ result.push({
748
+ nodeId: mainId,
749
+ agents: [],
750
+ models: [],
751
+ tags: [],
752
+ connected: false,
753
+ status: "unreachable",
754
+ latencyMs: sentinel.latencyMs,
755
+ sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline",
756
+ });
757
+ }
758
+
759
+ return result;
760
+ }
761
+
268
762
  export default plugin;