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/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2183 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +171 -92
- package/src/identity.ts +95 -0
- package/src/index.ts +433 -58
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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(
|
|
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
|
|
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;
|