clawmatrix 0.2.11 → 0.3.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/LICENSE +27 -0
- package/README.md +123 -12
- package/package.json +2 -1
- package/src/acp-proxy.ts +407 -68
- package/src/cli.ts +478 -10
- package/src/cluster-service.ts +114 -14
- 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/index.ts +234 -5
- package/src/knowledge-sync.ts +44 -6
- package/src/model-proxy.ts +35 -10
- package/src/peer-manager.ts +81 -13
- 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 +12 -4
- 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-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/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
|
@@ -45,6 +45,10 @@ export class ToolProxy {
|
|
|
45
45
|
private logger: PluginLogger;
|
|
46
46
|
private satelliteHandler: SatelliteToolHandler | null = null;
|
|
47
47
|
private readonly toolTimeout: number;
|
|
48
|
+
// Pre-built Sets for O(1) allow/deny checks
|
|
49
|
+
private readonly allowSet: Set<string>;
|
|
50
|
+
private readonly denySet: Set<string>;
|
|
51
|
+
private readonly allowAll: boolean;
|
|
48
52
|
|
|
49
53
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, logger: PluginLogger) {
|
|
50
54
|
this.config = config;
|
|
@@ -52,6 +56,10 @@ export class ToolProxy {
|
|
|
52
56
|
this.gatewayInfo = gatewayInfo;
|
|
53
57
|
this.logger = logger;
|
|
54
58
|
this.toolTimeout = config.toolTimeout ?? DEFAULT_TOOL_TIMEOUT;
|
|
59
|
+
const tp = config.toolProxy;
|
|
60
|
+
this.denySet = new Set(tp?.deny ?? []);
|
|
61
|
+
this.allowSet = new Set(tp?.allow ?? []);
|
|
62
|
+
this.allowAll = this.allowSet.size === 0 || this.allowSet.has("*");
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Set the satellite tool handler (called by ClusterRuntime after WebHandler is created). */
|
|
@@ -338,10 +346,10 @@ export class ToolProxy {
|
|
|
338
346
|
}
|
|
339
347
|
|
|
340
348
|
// ── Security ───────────────────────────────────────────────────
|
|
341
|
-
private isToolAllowed(tool: string,
|
|
342
|
-
if (
|
|
343
|
-
if (
|
|
344
|
-
return
|
|
349
|
+
private isToolAllowed(tool: string, _tpConfig: ToolProxyConfig): boolean {
|
|
350
|
+
if (this.denySet.has(tool)) return false;
|
|
351
|
+
if (this.allowAll) return true;
|
|
352
|
+
return this.allowSet.has(tool);
|
|
345
353
|
}
|
|
346
354
|
|
|
347
355
|
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",
|
|
@@ -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: {},
|
|
@@ -5,7 +5,10 @@ export function createClusterReadTool(): AnyAgentTool {
|
|
|
5
5
|
return {
|
|
6
6
|
name: "cluster_read",
|
|
7
7
|
label: "Cluster Read",
|
|
8
|
-
description:
|
|
8
|
+
description:
|
|
9
|
+
"Read a file from a remote cluster node. " +
|
|
10
|
+
"Returns {content} with the file text. " +
|
|
11
|
+
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
9
12
|
parameters: {
|
|
10
13
|
type: "object",
|
|
11
14
|
properties: {
|
|
@@ -6,7 +6,8 @@ export function createClusterSendTool(): AnyAgentTool {
|
|
|
6
6
|
name: "cluster_send",
|
|
7
7
|
label: "Cluster Send",
|
|
8
8
|
description:
|
|
9
|
-
"Send a one-way
|
|
9
|
+
"Send a one-way notification to a remote agent. Fire-and-forget: does not wait for a response. " +
|
|
10
|
+
"Use cluster_handoff instead when you need the remote agent to process a task and return a result.",
|
|
10
11
|
parameters: {
|
|
11
12
|
type: "object",
|
|
12
13
|
properties: {
|
|
@@ -7,13 +7,10 @@ export function createClusterTerminalTool(): AnyAgentTool {
|
|
|
7
7
|
label: "Cluster Terminal",
|
|
8
8
|
description:
|
|
9
9
|
"Interactive terminal (PTY) on a remote cluster node. " +
|
|
10
|
-
"
|
|
11
|
-
'"open"
|
|
12
|
-
'"
|
|
13
|
-
|
|
14
|
-
'"resize" — resize the terminal. ' +
|
|
15
|
-
'"list" — list active terminal sessions. ' +
|
|
16
|
-
'"close" — close a terminal session.',
|
|
10
|
+
"Workflow: open → input/read (repeat) → close. " +
|
|
11
|
+
'Actions: "open" (requires node), "input" (requires sessionId + data), "read" (requires sessionId), ' +
|
|
12
|
+
'"resize" (requires sessionId), "list" (no params), "close" (requires sessionId). ' +
|
|
13
|
+
"Prefer cluster_exec for one-shot commands; use this for interactive/stateful sessions.",
|
|
17
14
|
parameters: {
|
|
18
15
|
type: "object",
|
|
19
16
|
properties: {
|
|
@@ -7,8 +7,8 @@ export function createClusterToolInvokeTool(): AnyAgentTool {
|
|
|
7
7
|
label: "Cluster Tool Invoke",
|
|
8
8
|
description:
|
|
9
9
|
"Invoke any tool on a remote cluster node by name. " +
|
|
10
|
-
"
|
|
11
|
-
"
|
|
10
|
+
"Preferred for device-specific tools (screenshot, battery, location, clipboard, etc.). " +
|
|
11
|
+
"Run cluster_peers first to discover available tools, or use CLI: clawmatrix tools --describe <tool> for params. " +
|
|
12
12
|
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
13
13
|
parameters: {
|
|
14
14
|
type: "object",
|
|
@@ -5,7 +5,9 @@ export function createClusterWriteTool(): AnyAgentTool {
|
|
|
5
5
|
return {
|
|
6
6
|
name: "cluster_write",
|
|
7
7
|
label: "Cluster Write",
|
|
8
|
-
description:
|
|
8
|
+
description:
|
|
9
|
+
"Write content to a file on a remote cluster node (creates or overwrites). " +
|
|
10
|
+
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
9
11
|
parameters: {
|
|
10
12
|
type: "object",
|
|
11
13
|
properties: {
|