clawmatrix 0.2.11 → 0.4.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/LICENSE +27 -0
- package/README.md +123 -12
- package/cli/bin/clawmatrix.mjs +1006 -0
- package/cli/package.json +27 -0
- package/cli/skills/clawmatrix/SKILL.md +104 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +3 -1
- package/src/acp-proxy.ts +820 -96
- package/src/cluster-service.ts +186 -16
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +40 -11
- package/src/index.ts +686 -14
- package/src/knowledge-sync.ts +62 -10
- package/src/model-proxy.ts +40 -10
- package/src/peer-manager.ts +114 -17
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +52 -6
- package/src/tools/cluster-diagnostic.ts +3 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-notify.ts +132 -0
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +103 -1
- package/src/web.ts +2 -10
- package/src/cli.ts +0 -243
- package/src/web-ui.ts +0 -1622
package/src/router.ts
CHANGED
|
@@ -38,6 +38,14 @@ export class Router {
|
|
|
38
38
|
/** Failed request IDs with expiry timestamps. Separate from dedup to support longer TTLs. */
|
|
39
39
|
private failedRequests = new Map<string, number>(); // requestId → expiresAt
|
|
40
40
|
|
|
41
|
+
// ── Indexes for O(1) lookups in hot paths ──────────────────────
|
|
42
|
+
/** agentId → Set of nodeIds that host this agent. */
|
|
43
|
+
private agentIndex = new Map<string, Set<string>>();
|
|
44
|
+
/** tag → Set of nodeIds (both node-level and agent-level tags). */
|
|
45
|
+
private tagIndex = new Map<string, Set<string>>();
|
|
46
|
+
/** modelId → Set of nodeIds that provide this model. */
|
|
47
|
+
private modelIndex = new Map<string, Set<string>>();
|
|
48
|
+
|
|
41
49
|
constructor(
|
|
42
50
|
nodeId: string,
|
|
43
51
|
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
|
|
@@ -53,11 +61,64 @@ export class Router {
|
|
|
53
61
|
this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/** Rebuild all indexes from scratch. Called after any route table mutation. */
|
|
65
|
+
private rebuildIndexes() {
|
|
66
|
+
this.agentIndex.clear();
|
|
67
|
+
this.tagIndex.clear();
|
|
68
|
+
this.modelIndex.clear();
|
|
69
|
+
for (const entry of this.routes.values()) {
|
|
70
|
+
this.indexEntry(entry);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Add a single entry to all indexes. */
|
|
75
|
+
private indexEntry(entry: RouteEntry) {
|
|
76
|
+
const nid = entry.nodeId;
|
|
77
|
+
for (const a of entry.agents) {
|
|
78
|
+
let set = this.agentIndex.get(a.id);
|
|
79
|
+
if (!set) { set = new Set(); this.agentIndex.set(a.id, set); }
|
|
80
|
+
set.add(nid);
|
|
81
|
+
for (const t of a.tags ?? []) {
|
|
82
|
+
let ts = this.tagIndex.get(t);
|
|
83
|
+
if (!ts) { ts = new Set(); this.tagIndex.set(t, ts); }
|
|
84
|
+
ts.add(nid);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const t of entry.tags ?? []) {
|
|
88
|
+
let set = this.tagIndex.get(t);
|
|
89
|
+
if (!set) { set = new Set(); this.tagIndex.set(t, set); }
|
|
90
|
+
set.add(nid);
|
|
91
|
+
}
|
|
92
|
+
for (const m of entry.models ?? []) {
|
|
93
|
+
let set = this.modelIndex.get(m.id);
|
|
94
|
+
if (!set) { set = new Set(); this.modelIndex.set(m.id, set); }
|
|
95
|
+
set.add(nid);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Remove a single entry from all indexes. */
|
|
100
|
+
private unindexEntry(entry: RouteEntry) {
|
|
101
|
+
const nid = entry.nodeId;
|
|
102
|
+
for (const a of entry.agents ?? []) {
|
|
103
|
+
this.agentIndex.get(a.id)?.delete(nid);
|
|
104
|
+
for (const t of a.tags ?? []) this.tagIndex.get(t)?.delete(nid);
|
|
105
|
+
}
|
|
106
|
+
for (const t of entry.tags ?? []) this.tagIndex.get(t)?.delete(nid);
|
|
107
|
+
for (const m of entry.models ?? []) this.modelIndex.get(m.id)?.delete(nid);
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
/** Update locally advertised ACP agents (used after auto-detection). */
|
|
57
111
|
updateLocalAcpAgents(agents: AcpAgentInfo[]) {
|
|
58
112
|
this.localAcpAgents = agents;
|
|
59
113
|
}
|
|
60
114
|
|
|
115
|
+
/** Update the local tool proxy catalog (descriptions + schemas for remote callers). */
|
|
116
|
+
updateLocalToolCatalog(catalog: ToolProxyInfo["catalog"]) {
|
|
117
|
+
if (this.localToolProxy) {
|
|
118
|
+
this.localToolProxy = { ...this.localToolProxy, catalog };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
61
122
|
/** Stop periodic cleanup. Call on shutdown. */
|
|
62
123
|
destroy() {
|
|
63
124
|
if (this.rotateTimer) {
|
|
@@ -75,8 +136,10 @@ export class Router {
|
|
|
75
136
|
connection: Connection,
|
|
76
137
|
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
|
|
77
138
|
) {
|
|
139
|
+
const old = this.routes.get(nodeId);
|
|
140
|
+
if (old) this.unindexEntry(old);
|
|
78
141
|
this.connections.set(nodeId, connection);
|
|
79
|
-
|
|
142
|
+
const entry: RouteEntry = {
|
|
80
143
|
nodeId,
|
|
81
144
|
agents: capabilities.agents,
|
|
82
145
|
models: capabilities.models,
|
|
@@ -89,7 +152,9 @@ export class Router {
|
|
|
89
152
|
deviceInfo: capabilities.deviceInfo,
|
|
90
153
|
toolProxy: capabilities.toolProxy,
|
|
91
154
|
acpAgents: capabilities.acpAgents,
|
|
92
|
-
}
|
|
155
|
+
};
|
|
156
|
+
this.routes.set(nodeId, entry);
|
|
157
|
+
this.indexEntry(entry);
|
|
93
158
|
}
|
|
94
159
|
|
|
95
160
|
addRelayPeer(peer: PeerInfo, viaNodeId: string) {
|
|
@@ -106,7 +171,8 @@ export class Router {
|
|
|
106
171
|
// Don't overwrite a better relay route with a worse one (allow equal for capability updates)
|
|
107
172
|
if (existing?.reachableVia && existing.latencyMs < estimatedLatency) return;
|
|
108
173
|
|
|
109
|
-
this.
|
|
174
|
+
if (existing) this.unindexEntry(existing);
|
|
175
|
+
const entry: RouteEntry = {
|
|
110
176
|
nodeId: peer.nodeId,
|
|
111
177
|
agents: peer.agents,
|
|
112
178
|
models: peer.models,
|
|
@@ -119,15 +185,22 @@ export class Router {
|
|
|
119
185
|
deviceInfo: peer.deviceInfo,
|
|
120
186
|
toolProxy: peer.toolProxy,
|
|
121
187
|
acpAgents: peer.acpAgents,
|
|
122
|
-
}
|
|
188
|
+
};
|
|
189
|
+
this.routes.set(peer.nodeId, entry);
|
|
190
|
+
this.indexEntry(entry);
|
|
123
191
|
}
|
|
124
192
|
|
|
125
193
|
removePeer(nodeId: string) {
|
|
126
194
|
this.connections.delete(nodeId);
|
|
127
|
-
this.routes.
|
|
195
|
+
const removed = this.routes.get(nodeId);
|
|
196
|
+
if (removed) {
|
|
197
|
+
this.unindexEntry(removed);
|
|
198
|
+
this.routes.delete(nodeId);
|
|
199
|
+
}
|
|
128
200
|
// Also remove routes that relied on this node as relay
|
|
129
201
|
for (const [id, entry] of this.routes) {
|
|
130
202
|
if (entry.reachableVia === nodeId) {
|
|
203
|
+
this.unindexEntry(entry);
|
|
131
204
|
this.routes.delete(id);
|
|
132
205
|
}
|
|
133
206
|
}
|
|
@@ -139,6 +212,7 @@ export class Router {
|
|
|
139
212
|
) {
|
|
140
213
|
const entry = this.routes.get(nodeId);
|
|
141
214
|
if (entry) {
|
|
215
|
+
this.unindexEntry(entry);
|
|
142
216
|
entry.agents = capabilities.agents;
|
|
143
217
|
entry.models = capabilities.models;
|
|
144
218
|
entry.tags = capabilities.tags;
|
|
@@ -151,6 +225,7 @@ export class Router {
|
|
|
151
225
|
entry.toolProxy = capabilities.toolProxy;
|
|
152
226
|
entry.acpAgents = capabilities.acpAgents;
|
|
153
227
|
entry.lastSeen = Date.now();
|
|
228
|
+
this.indexEntry(entry);
|
|
154
229
|
}
|
|
155
230
|
}
|
|
156
231
|
|
|
@@ -169,20 +244,20 @@ export class Router {
|
|
|
169
244
|
/** Resolve target agent to a specific nodeId. Supports agent ID or "tags:<tag>". */
|
|
170
245
|
resolveAgent(target: string): RouteEntry | undefined {
|
|
171
246
|
const isTagQuery = target.startsWith("tags:");
|
|
172
|
-
|
|
247
|
+
|
|
248
|
+
let nodeIds: Set<string> | undefined;
|
|
249
|
+
if (isTagQuery) {
|
|
250
|
+
nodeIds = this.tagIndex.get(target.slice(5));
|
|
251
|
+
} else {
|
|
252
|
+
nodeIds = this.agentIndex.get(target);
|
|
253
|
+
}
|
|
173
254
|
|
|
174
255
|
let candidates: RouteEntry[] = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (entry
|
|
180
|
-
candidates.push(entry);
|
|
181
|
-
}
|
|
182
|
-
} else {
|
|
183
|
-
if (entry.agents.some((a) => a.id === target)) {
|
|
184
|
-
candidates.push(entry);
|
|
185
|
-
}
|
|
256
|
+
if (nodeIds) {
|
|
257
|
+
for (const nid of nodeIds) {
|
|
258
|
+
if (nid === this.nodeId) continue;
|
|
259
|
+
const entry = this.routes.get(nid);
|
|
260
|
+
if (entry) candidates.push(entry);
|
|
186
261
|
}
|
|
187
262
|
}
|
|
188
263
|
|
|
@@ -193,6 +268,7 @@ export class Router {
|
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
if (candidates.length === 0) return undefined;
|
|
271
|
+
if (candidates.length === 1) return candidates[0];
|
|
196
272
|
|
|
197
273
|
// Sort: direct connections first, then by latency
|
|
198
274
|
candidates.sort((a, b) => {
|
|
@@ -210,15 +286,16 @@ export class Router {
|
|
|
210
286
|
resolveNode(target: string): RouteEntry | undefined {
|
|
211
287
|
if (target.startsWith("tags:")) {
|
|
212
288
|
const tag = target.slice(5);
|
|
289
|
+
const nodeIds = this.tagIndex.get(tag);
|
|
290
|
+
if (!nodeIds) return undefined;
|
|
213
291
|
const candidates: RouteEntry[] = [];
|
|
214
|
-
for (const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (entry.
|
|
218
|
-
candidates.push(entry);
|
|
219
|
-
}
|
|
292
|
+
for (const nid of nodeIds) {
|
|
293
|
+
if (nid === this.nodeId) continue;
|
|
294
|
+
const entry = this.routes.get(nid);
|
|
295
|
+
if (entry) candidates.push(entry);
|
|
220
296
|
}
|
|
221
297
|
if (candidates.length === 0) return undefined;
|
|
298
|
+
if (candidates.length === 1) return candidates[0];
|
|
222
299
|
// Sort: direct connections first, then by latency
|
|
223
300
|
candidates.sort((a, b) => {
|
|
224
301
|
const aDirect = a.connection ? 0 : 1;
|
|
@@ -236,10 +313,13 @@ export class Router {
|
|
|
236
313
|
/** Find reachable nodes that provide a specific model, sorted by latency.
|
|
237
314
|
* Excludes nodes in the `exclude` set. */
|
|
238
315
|
findNodesForModel(modelId: string, exclude?: Set<string>): RouteEntry[] {
|
|
316
|
+
const nodeIds = this.modelIndex.get(modelId);
|
|
317
|
+
if (!nodeIds) return [];
|
|
239
318
|
const candidates: RouteEntry[] = [];
|
|
240
|
-
for (const
|
|
241
|
-
if (exclude?.has(
|
|
242
|
-
|
|
319
|
+
for (const nid of nodeIds) {
|
|
320
|
+
if (exclude?.has(nid)) continue;
|
|
321
|
+
const entry = this.routes.get(nid);
|
|
322
|
+
if (!entry) continue;
|
|
243
323
|
// Check reachability
|
|
244
324
|
if (entry.connection?.isOpen) {
|
|
245
325
|
candidates.push(entry);
|
|
@@ -248,13 +328,15 @@ export class Router {
|
|
|
248
328
|
if (relay?.isOpen) candidates.push(entry);
|
|
249
329
|
}
|
|
250
330
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
331
|
+
if (candidates.length > 1) {
|
|
332
|
+
// Sort: direct first, then by latency
|
|
333
|
+
candidates.sort((a, b) => {
|
|
334
|
+
const aDirect = a.connection ? 0 : 1;
|
|
335
|
+
const bDirect = b.connection ? 0 : 1;
|
|
336
|
+
if (aDirect !== bDirect) return aDirect - bDirect;
|
|
337
|
+
return a.latencyMs - b.latencyMs;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
258
340
|
return candidates;
|
|
259
341
|
}
|
|
260
342
|
|
package/src/sentinel-manager.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { fork, type ChildProcess } from "node:child_process";
|
|
10
10
|
import { join, dirname } from "node:path";
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, openSync, closeSync } from "node:fs";
|
|
12
|
+
import { createConnection } from "node:net";
|
|
12
13
|
import { homedir, tmpdir } from "node:os";
|
|
13
14
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
14
15
|
|
|
@@ -86,6 +87,56 @@ export class SentinelManager {
|
|
|
86
87
|
}, 1000);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Kill the old sentinel and wait for the listen port to become free.
|
|
92
|
+
* Must be called BEFORE PeerManager.startListening() to avoid EADDRINUSE.
|
|
93
|
+
*/
|
|
94
|
+
async ensurePortFree() {
|
|
95
|
+
// Kill old sentinel process if alive
|
|
96
|
+
if (existsSync(this.pidFile)) {
|
|
97
|
+
try {
|
|
98
|
+
const pid = parseInt(readFileSync(this.pidFile, "utf-8").trim(), 10);
|
|
99
|
+
if (pid) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, "SIGTERM");
|
|
102
|
+
// Wait for the process to exit (up to 5s)
|
|
103
|
+
for (let i = 0; i < 100; i++) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
107
|
+
} catch {
|
|
108
|
+
break; // exited
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Already gone
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Malformed PID file
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Probe the port until it's free (up to 5s)
|
|
121
|
+
const port = this.config.sentinel?.listenPort
|
|
122
|
+
?? (this.config.listen ? this.config.listenPort : 0);
|
|
123
|
+
if (!port) return;
|
|
124
|
+
|
|
125
|
+
const host = this.config.sentinel?.listenHost ?? this.config.listenHost ?? "0.0.0.0";
|
|
126
|
+
for (let i = 0; i < 50; i++) {
|
|
127
|
+
const inUse = await new Promise<boolean>((resolve) => {
|
|
128
|
+
const sock = createConnection({ port, host }, () => {
|
|
129
|
+
sock.destroy();
|
|
130
|
+
resolve(true);
|
|
131
|
+
});
|
|
132
|
+
sock.on("error", () => resolve(false));
|
|
133
|
+
sock.setTimeout(200, () => { sock.destroy(); resolve(false); });
|
|
134
|
+
});
|
|
135
|
+
if (!inUse) return;
|
|
136
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
89
140
|
async stop() {
|
|
90
141
|
// IPC is disconnected shortly after start, so use PID file for shutdown
|
|
91
142
|
if (existsSync(this.pidFile)) {
|
package/src/sentinel.ts
CHANGED
|
@@ -78,6 +78,11 @@ let httpServer: Server | null = null;
|
|
|
78
78
|
let wss: WebSocketServer | null = null;
|
|
79
79
|
const inboundConnections = new Map<WsWebSocket, Connection>();
|
|
80
80
|
let listening = false;
|
|
81
|
+
/** Timestamp when sentinel voluntarily released the port. During the cooldown
|
|
82
|
+
* period (30s), sentinel will not re-listen even if gateway appears to be gone,
|
|
83
|
+
* giving the new gateway time to bind the port. */
|
|
84
|
+
let voluntaryReleaseAt = 0;
|
|
85
|
+
const PORT_RELEASE_COOLDOWN = 30_000;
|
|
81
86
|
|
|
82
87
|
// ── Rate limiting for diagnostic_exec ────────────────────────────
|
|
83
88
|
const EXEC_RATE_WINDOW = 60_000; // 1 minute
|
|
@@ -464,6 +469,9 @@ function stopListening() {
|
|
|
464
469
|
httpServer?.close();
|
|
465
470
|
httpServer = null;
|
|
466
471
|
listening = false;
|
|
472
|
+
// Mark voluntary release — sentinel will not re-listen during cooldown
|
|
473
|
+
// to give the gateway time to bind the port.
|
|
474
|
+
voluntaryReleaseAt = Date.now();
|
|
467
475
|
log("Port released — gateway is back");
|
|
468
476
|
}
|
|
469
477
|
|
|
@@ -612,12 +620,14 @@ function startGatewayHealthCheck() {
|
|
|
612
620
|
log(`Gateway process (pid ${gatewayPid}) gone — entering standalone mode`);
|
|
613
621
|
// Connect to peers now that gateway is down
|
|
614
622
|
connectAllPeers();
|
|
615
|
-
// Take over the gateway's listen port
|
|
623
|
+
// Take over the gateway's listen port — but respect cooldown after
|
|
624
|
+
// voluntary release so we don't compete with a restarting gateway.
|
|
616
625
|
if (config.listenPort) {
|
|
617
|
-
|
|
626
|
+
const cooldownRemaining = PORT_RELEASE_COOLDOWN - (Date.now() - voluntaryReleaseAt);
|
|
627
|
+
const delay = Math.max(2_000, cooldownRemaining);
|
|
618
628
|
setTimeout(() => {
|
|
619
629
|
if (!gatewayAlive && !isReplaced()) startListening();
|
|
620
|
-
},
|
|
630
|
+
}, delay);
|
|
621
631
|
}
|
|
622
632
|
}
|
|
623
633
|
}
|
package/src/tool-proxy.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type {
|
|
|
10
10
|
} from "./types.ts";
|
|
11
11
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
12
12
|
import { isLocalTool, executeLocally } from "./local-tools.ts";
|
|
13
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
13
16
|
|
|
14
17
|
const DEFAULT_TOOL_TIMEOUT = 30_000;
|
|
15
18
|
|
|
@@ -45,6 +48,10 @@ export class ToolProxy {
|
|
|
45
48
|
private logger: PluginLogger;
|
|
46
49
|
private satelliteHandler: SatelliteToolHandler | null = null;
|
|
47
50
|
private readonly toolTimeout: number;
|
|
51
|
+
// Pre-built Sets for O(1) allow/deny checks
|
|
52
|
+
private readonly allowSet: Set<string>;
|
|
53
|
+
private readonly denySet: Set<string>;
|
|
54
|
+
private readonly allowAll: boolean;
|
|
48
55
|
|
|
49
56
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, logger: PluginLogger) {
|
|
50
57
|
this.config = config;
|
|
@@ -52,6 +59,10 @@ export class ToolProxy {
|
|
|
52
59
|
this.gatewayInfo = gatewayInfo;
|
|
53
60
|
this.logger = logger;
|
|
54
61
|
this.toolTimeout = config.toolTimeout ?? DEFAULT_TOOL_TIMEOUT;
|
|
62
|
+
const tp = config.toolProxy;
|
|
63
|
+
this.denySet = new Set(tp?.deny ?? []);
|
|
64
|
+
this.allowSet = new Set(tp?.allow ?? []);
|
|
65
|
+
this.allowAll = this.allowSet.size === 0 || this.allowSet.has("*");
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
/** Set the satellite tool handler (called by ClusterRuntime after WebHandler is created). */
|
|
@@ -128,13 +139,41 @@ export class ToolProxy {
|
|
|
128
139
|
|
|
129
140
|
if (frame.payload.success && frame.payload.result) {
|
|
130
141
|
this.logger.info(`[clawmatrix] Tool response: id=${frame.id} from="${frame.from}" success`);
|
|
131
|
-
|
|
142
|
+
const result = this.extractInlineImage(frame.payload.result);
|
|
143
|
+
pending.resolve(result);
|
|
132
144
|
} else {
|
|
133
145
|
this.logger.warn(`[clawmatrix] Tool response: id=${frame.id} from="${frame.from}" failed: ${frame.payload.error}`);
|
|
134
146
|
pending.reject(new Error(frame.payload.error ?? "Remote tool execution failed"));
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
|
|
150
|
+
/**
|
|
151
|
+
* If the tool result contains inline base64 image data (mime: "image/*" + data),
|
|
152
|
+
* save it to a local temp file and replace `data` with `localPath`.
|
|
153
|
+
* This avoids flooding the LLM context with base64 text (saves ~tens of thousands of tokens).
|
|
154
|
+
*/
|
|
155
|
+
private extractInlineImage(result: Record<string, unknown>): Record<string, unknown> {
|
|
156
|
+
const mime = result.mime;
|
|
157
|
+
const data = result.data;
|
|
158
|
+
if (typeof mime !== "string" || !mime.startsWith("image/") || typeof data !== "string") {
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const ext = mime === "image/png" ? ".png" : mime === "image/webp" ? ".webp" : ".jpg";
|
|
164
|
+
const dir = join(tmpdir(), "clawmatrix-images");
|
|
165
|
+
mkdirSync(dir, { recursive: true });
|
|
166
|
+
const localPath = join(dir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
167
|
+
writeFileSync(localPath, Buffer.from(data, "base64"));
|
|
168
|
+
this.logger.info(`[clawmatrix] Saved inline image (${(data.length * 0.75 / 1024).toFixed(0)}KB) to ${localPath}`);
|
|
169
|
+
const { data: _stripped, ...rest } = result;
|
|
170
|
+
return { ...rest, localPath };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.logger.warn(`[clawmatrix] Failed to extract inline image: ${err}`);
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
138
177
|
// ── Incoming request: execute via local Gateway ────────────────
|
|
139
178
|
async handleRequest(frame: ToolProxyRequest): Promise<void> {
|
|
140
179
|
const { id, from, payload } = frame;
|
|
@@ -280,7 +319,14 @@ export class ToolProxy {
|
|
|
280
319
|
|
|
281
320
|
clearTimeout(pending.timer);
|
|
282
321
|
this.pendingBatch.delete(frame.id);
|
|
283
|
-
|
|
322
|
+
// Extract inline images from batch results
|
|
323
|
+
const results = frame.payload.results.map((item) => {
|
|
324
|
+
if (item.success && item.result) {
|
|
325
|
+
return { ...item, result: this.extractInlineImage(item.result) };
|
|
326
|
+
}
|
|
327
|
+
return item;
|
|
328
|
+
});
|
|
329
|
+
pending.resolve(results);
|
|
284
330
|
}
|
|
285
331
|
|
|
286
332
|
// ── Incoming batch request: execute sequentially via local Gateway ──
|
|
@@ -338,10 +384,10 @@ export class ToolProxy {
|
|
|
338
384
|
}
|
|
339
385
|
|
|
340
386
|
// ── Security ───────────────────────────────────────────────────
|
|
341
|
-
private isToolAllowed(tool: string,
|
|
342
|
-
if (
|
|
343
|
-
if (
|
|
344
|
-
return
|
|
387
|
+
private isToolAllowed(tool: string, _tpConfig: ToolProxyConfig): boolean {
|
|
388
|
+
if (this.denySet.has(tool)) return false;
|
|
389
|
+
if (this.allowAll) return true;
|
|
390
|
+
return this.allowSet.has(tool);
|
|
345
391
|
}
|
|
346
392
|
|
|
347
393
|
destroy() {
|
|
@@ -10,9 +10,10 @@ export function createClusterDiagnosticTool(): AnyAgentTool {
|
|
|
10
10
|
label: "Cluster Diagnostic",
|
|
11
11
|
description:
|
|
12
12
|
"Diagnose or execute commands on a remote node's sentinel process. " +
|
|
13
|
-
"Works even when the remote OpenClaw gateway is down. " +
|
|
13
|
+
"Works even when the remote OpenClaw gateway is down (sentinel is a lightweight always-on process). " +
|
|
14
14
|
"Use action 'status' to check if the remote gateway is alive, " +
|
|
15
|
-
"or 'exec' to run a shell command on the remote machine."
|
|
15
|
+
"or 'exec' to run a shell command on the remote machine. " +
|
|
16
|
+
"Note: exec runs commands without restrictions — only use in trusted networks.",
|
|
16
17
|
parameters: {
|
|
17
18
|
type: "object",
|
|
18
19
|
properties: {
|
|
@@ -7,7 +7,8 @@ export function createClusterEditTool(): AnyAgentTool {
|
|
|
7
7
|
label: "Cluster Edit",
|
|
8
8
|
description:
|
|
9
9
|
"Edit a file on a remote cluster node by replacing exact text. " +
|
|
10
|
-
"
|
|
10
|
+
"IMPORTANT: Use cluster_read first to get exact file content — oldText must match precisely (including whitespace and indentation). " +
|
|
11
|
+
"For creating new files use cluster_write instead.",
|
|
11
12
|
parameters: {
|
|
12
13
|
type: "object",
|
|
13
14
|
properties: {
|
|
@@ -6,7 +6,9 @@ export function createClusterEventsTool(): AnyAgentTool {
|
|
|
6
6
|
name: "cluster_events",
|
|
7
7
|
label: "Cluster Events",
|
|
8
8
|
description:
|
|
9
|
-
"Query and consume events from external sources (
|
|
9
|
+
"Query and consume events from external sources (iOS Shortcuts, webhooks, etc.). " +
|
|
10
|
+
"Common types: message_received, location_change, battery_status. " +
|
|
11
|
+
"Events are persistent until consumed. Requires web.enabled in config.",
|
|
10
12
|
parameters: {
|
|
11
13
|
type: "object",
|
|
12
14
|
properties: {
|
|
@@ -7,6 +7,8 @@ export function createClusterExecTool(): AnyAgentTool {
|
|
|
7
7
|
label: "Cluster Exec",
|
|
8
8
|
description:
|
|
9
9
|
"Execute a shell command on a remote cluster node. " +
|
|
10
|
+
"Use for file/shell operations on remote machines. " +
|
|
11
|
+
"Returns {stdout, stderr, exitCode}. " +
|
|
10
12
|
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
11
13
|
parameters: {
|
|
12
14
|
type: "object",
|
|
@@ -6,7 +6,9 @@ export function createClusterHandoffTool(): AnyAgentTool {
|
|
|
6
6
|
name: "cluster_handoff",
|
|
7
7
|
label: "Cluster Handoff",
|
|
8
8
|
description:
|
|
9
|
-
"Hand off a task to another agent in the cluster and wait for the result. " +
|
|
9
|
+
"Hand off a complex task to another agent in the cluster and wait for the result. " +
|
|
10
|
+
"Use for multi-step tasks that require the remote agent's full capabilities. " +
|
|
11
|
+
"If the remote agent needs more info, response includes inputRequired=true — use cluster_handoff_reply to continue. " +
|
|
10
12
|
"Use agent ID (e.g. 'coder') or tag query (e.g. 'tags:coding') as target.",
|
|
11
13
|
parameters: {
|
|
12
14
|
type: "object",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export function createClusterNotifyTool(): AnyAgentTool {
|
|
6
|
+
return {
|
|
7
|
+
name: "cluster_notify",
|
|
8
|
+
label: "Cluster Notify",
|
|
9
|
+
description:
|
|
10
|
+
"Push a notification to mobile devices in the cluster (triggers Dynamic Island / Live Activity on iOS). " +
|
|
11
|
+
"Use this when you are about to start a long-running task so the user can track progress on their phone. " +
|
|
12
|
+
'Actions: "start" begins a new activity, "update" changes detail/progress, "end" dismisses it.',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
action: {
|
|
17
|
+
type: "string",
|
|
18
|
+
enum: ["start", "update", "end"],
|
|
19
|
+
description: 'Action to perform. Default: "start"',
|
|
20
|
+
},
|
|
21
|
+
taskId: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Task ID for update/end actions. Returned by start action. Omit for start to auto-generate.",
|
|
24
|
+
},
|
|
25
|
+
title: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Activity title (shown in Dynamic Island and lock screen)",
|
|
28
|
+
},
|
|
29
|
+
detail: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Activity detail text (e.g. current step description)",
|
|
32
|
+
},
|
|
33
|
+
progress: {
|
|
34
|
+
type: "number",
|
|
35
|
+
description: "Progress value 0.0 to 1.0 (optional, shows progress bar when provided)",
|
|
36
|
+
},
|
|
37
|
+
tool: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Current tool being executed (shown as a tag in the activity)",
|
|
40
|
+
},
|
|
41
|
+
success: {
|
|
42
|
+
type: "boolean",
|
|
43
|
+
description: 'For "end" action: true for completed, false for failed. Default: true',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: [],
|
|
47
|
+
},
|
|
48
|
+
async execute(_toolCallId, params) {
|
|
49
|
+
const {
|
|
50
|
+
action = "start",
|
|
51
|
+
taskId: providedTaskId,
|
|
52
|
+
title,
|
|
53
|
+
detail,
|
|
54
|
+
progress,
|
|
55
|
+
tool,
|
|
56
|
+
success = true,
|
|
57
|
+
} = params as {
|
|
58
|
+
action?: "start" | "update" | "end";
|
|
59
|
+
taskId?: string;
|
|
60
|
+
title?: string;
|
|
61
|
+
detail?: string;
|
|
62
|
+
progress?: number;
|
|
63
|
+
tool?: string;
|
|
64
|
+
success?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const runtime = getClusterRuntime();
|
|
69
|
+
const peers = runtime.peerManager.router.getAllPeers();
|
|
70
|
+
const mobileTargets = peers.filter((p) =>
|
|
71
|
+
p.tags.some((t) => t === "mobile" || t === "ios" || t === "phone"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (mobileTargets.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text" as const, text: "No mobile peers connected" }],
|
|
77
|
+
details: { error: true },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const taskId = providedTaskId || randomUUID();
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
|
|
84
|
+
let status: string;
|
|
85
|
+
if (action === "start") status = "started";
|
|
86
|
+
else if (action === "end") status = success ? "completed" : "failed";
|
|
87
|
+
else status = "progress";
|
|
88
|
+
|
|
89
|
+
const frame = {
|
|
90
|
+
type: "task_activity" as const,
|
|
91
|
+
id: randomUUID(),
|
|
92
|
+
from: runtime.config.nodeId,
|
|
93
|
+
timestamp: now,
|
|
94
|
+
payload: {
|
|
95
|
+
taskId,
|
|
96
|
+
taskType: "notify" as const,
|
|
97
|
+
status,
|
|
98
|
+
agent: title || runtime.config.nodeId,
|
|
99
|
+
nodeId: runtime.config.nodeId,
|
|
100
|
+
title: title || runtime.config.nodeId,
|
|
101
|
+
detail: detail || (action === "end" ? (success ? "已完成" : "失败") : undefined),
|
|
102
|
+
startedAt: now,
|
|
103
|
+
elapsedMs: 0,
|
|
104
|
+
progress,
|
|
105
|
+
tool,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (const target of mobileTargets) {
|
|
110
|
+
runtime.peerManager.sendTo(target.nodeId, { ...frame, to: target.nodeId });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const targetNames = mobileTargets.map((t) => t.nodeId).join(", ");
|
|
114
|
+
return {
|
|
115
|
+
content: [{
|
|
116
|
+
type: "text" as const,
|
|
117
|
+
text: `Notification ${action}ed → ${targetNames} (taskId: ${taskId})`,
|
|
118
|
+
}],
|
|
119
|
+
details: { taskId, action, targets: mobileTargets.length },
|
|
120
|
+
};
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: "text" as const,
|
|
125
|
+
text: `Notify error: ${err instanceof Error ? err.message : String(err)}`,
|
|
126
|
+
}],
|
|
127
|
+
details: { error: true },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -6,7 +6,9 @@ export function createClusterPeersTool(): AnyAgentTool {
|
|
|
6
6
|
name: "cluster_peers",
|
|
7
7
|
label: "Cluster Peers",
|
|
8
8
|
description:
|
|
9
|
-
"List all
|
|
9
|
+
"List all peers in the cluster with their agents, models, tools, tags, and connection status. " +
|
|
10
|
+
"Call this first to discover what's available before using other cluster tools. " +
|
|
11
|
+
"Status: direct (connected), relay (via another node), sentinel-only (gateway down, sentinel up), satellite (HTTP polling device), unreachable.",
|
|
10
12
|
parameters: {
|
|
11
13
|
type: "object",
|
|
12
14
|
properties: {},
|