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/tool-proxy.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
2
|
import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ToolProxyRequest,
|
|
5
|
+
ToolProxyResponse,
|
|
6
|
+
ToolBatchRequest,
|
|
7
|
+
ToolBatchResponse,
|
|
8
|
+
ToolBatchItem,
|
|
9
|
+
ToolBatchResultItem,
|
|
10
|
+
} from "./types.ts";
|
|
4
11
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
5
12
|
import { isLocalTool, executeLocally } from "./local-tools.ts";
|
|
6
13
|
|
|
7
|
-
const
|
|
14
|
+
const DEFAULT_TOOL_TIMEOUT = 30_000;
|
|
8
15
|
|
|
9
16
|
interface PendingToolReq {
|
|
10
17
|
resolve: (result: Record<string, unknown>) => void;
|
|
@@ -12,6 +19,12 @@ interface PendingToolReq {
|
|
|
12
19
|
timer: ReturnType<typeof setTimeout>;
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
interface PendingBatchReq {
|
|
23
|
+
resolve: (results: ToolBatchResultItem[]) => void;
|
|
24
|
+
reject: (error: Error) => void;
|
|
25
|
+
timer: ReturnType<typeof setTimeout>;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export interface GatewayInfo {
|
|
16
29
|
port: number;
|
|
17
30
|
authHeader?: string;
|
|
@@ -27,15 +40,18 @@ export class ToolProxy {
|
|
|
27
40
|
private config: ClawMatrixConfig;
|
|
28
41
|
private peerManager: PeerManager;
|
|
29
42
|
private pending = new Map<string, PendingToolReq>();
|
|
43
|
+
private pendingBatch = new Map<string, PendingBatchReq>();
|
|
30
44
|
private gatewayInfo: GatewayInfo;
|
|
31
45
|
private logger: PluginLogger;
|
|
32
46
|
private satelliteHandler: SatelliteToolHandler | null = null;
|
|
47
|
+
private readonly toolTimeout: number;
|
|
33
48
|
|
|
34
49
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, logger: PluginLogger) {
|
|
35
50
|
this.config = config;
|
|
36
51
|
this.peerManager = peerManager;
|
|
37
52
|
this.gatewayInfo = gatewayInfo;
|
|
38
53
|
this.logger = logger;
|
|
54
|
+
this.toolTimeout = config.toolTimeout ?? DEFAULT_TOOL_TIMEOUT;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
/** Set the satellite tool handler (called by ClusterRuntime after WebHandler is created). */
|
|
@@ -83,7 +99,7 @@ export class ToolProxy {
|
|
|
83
99
|
};
|
|
84
100
|
|
|
85
101
|
return new Promise((resolve, reject) => {
|
|
86
|
-
const timeoutMs = timeout ??
|
|
102
|
+
const timeoutMs = timeout ?? this.toolTimeout;
|
|
87
103
|
const timer = setTimeout(() => {
|
|
88
104
|
this.pending.delete(id);
|
|
89
105
|
this.peerManager.router.markFailed(id);
|
|
@@ -187,6 +203,7 @@ export class ToolProxy {
|
|
|
187
203
|
method: "POST",
|
|
188
204
|
headers,
|
|
189
205
|
body: JSON.stringify({ tool, args: params }),
|
|
206
|
+
signal: AbortSignal.timeout(this.toolTimeout),
|
|
190
207
|
});
|
|
191
208
|
|
|
192
209
|
if (!res.ok) {
|
|
@@ -215,6 +232,108 @@ export class ToolProxy {
|
|
|
215
232
|
: { result: body.result };
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
// ── Batch: request remote batch execution ─────────────────────
|
|
236
|
+
async invokeBatch(
|
|
237
|
+
node: string,
|
|
238
|
+
items: ToolBatchItem[],
|
|
239
|
+
options?: { stopOnError?: boolean; timeout?: number },
|
|
240
|
+
): Promise<ToolBatchResultItem[]> {
|
|
241
|
+
const route = this.peerManager.router.resolveNode(node);
|
|
242
|
+
if (!route) throw new Error(`Node "${node}" not reachable`);
|
|
243
|
+
|
|
244
|
+
const id = crypto.randomUUID();
|
|
245
|
+
this.logger.info(`[clawmatrix] Tool batch: ${items.length} items -> node "${node}" (id=${id})`);
|
|
246
|
+
const frame: ToolBatchRequest = {
|
|
247
|
+
type: "tool_batch_req",
|
|
248
|
+
id,
|
|
249
|
+
from: this.config.nodeId,
|
|
250
|
+
to: route.nodeId,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
payload: { items, stopOnError: options?.stopOnError ?? true },
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const timeoutMs = options?.timeout ?? this.toolTimeout * items.length;
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
this.pendingBatch.delete(id);
|
|
259
|
+
reject(new Error(`Tool batch timed out on node "${node}"`));
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
|
|
262
|
+
this.pendingBatch.set(id, { resolve, reject, timer });
|
|
263
|
+
|
|
264
|
+
const sent = this.peerManager.sendTo(route.nodeId, frame);
|
|
265
|
+
if (!sent) {
|
|
266
|
+
this.pendingBatch.delete(id);
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
reject(new Error(`Cannot reach node "${route.nodeId}"`));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Incoming batch response ──────────────────────────────────
|
|
274
|
+
handleBatchResponse(frame: ToolBatchResponse) {
|
|
275
|
+
const pending = this.pendingBatch.get(frame.id);
|
|
276
|
+
if (!pending) return;
|
|
277
|
+
|
|
278
|
+
clearTimeout(pending.timer);
|
|
279
|
+
this.pendingBatch.delete(frame.id);
|
|
280
|
+
pending.resolve(frame.payload.results);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Incoming batch request: execute sequentially via local Gateway ──
|
|
284
|
+
async handleBatchRequest(frame: ToolBatchRequest): Promise<void> {
|
|
285
|
+
const { id, from, payload } = frame;
|
|
286
|
+
const { items, stopOnError = true } = payload;
|
|
287
|
+
const toolProxyConfig = this.config.toolProxy;
|
|
288
|
+
|
|
289
|
+
if (!toolProxyConfig?.enabled) {
|
|
290
|
+
this.sendBatchResponse(id, from, [{
|
|
291
|
+
tool: items[0]?.tool ?? "unknown",
|
|
292
|
+
success: false,
|
|
293
|
+
error: "Tool proxy not enabled on this node",
|
|
294
|
+
}]);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.logger.info(`[clawmatrix] Tool batch request: ${items.length} items from="${from}" (id=${id})`);
|
|
299
|
+
|
|
300
|
+
const results: ToolBatchResultItem[] = [];
|
|
301
|
+
for (const item of items) {
|
|
302
|
+
if (!this.isToolAllowed(item.tool, toolProxyConfig)) {
|
|
303
|
+
this.logger.warn(`[clawmatrix] Tool batch denied: "${item.tool}" from="${from}"`);
|
|
304
|
+
results.push({ tool: item.tool, success: false, error: `Tool "${item.tool}" not allowed on this node` });
|
|
305
|
+
if (stopOnError) break;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const result = isLocalTool(item.tool)
|
|
311
|
+
? await executeLocally(item.tool, item.params)
|
|
312
|
+
: await this.executeViaGateway(item.tool, item.params);
|
|
313
|
+
results.push({ tool: item.tool, success: true, result });
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
316
|
+
this.logger.error(`[clawmatrix] Tool batch item failed: "${item.tool}" id=${id}: ${error}`);
|
|
317
|
+
results.push({ tool: item.tool, success: false, error });
|
|
318
|
+
if (stopOnError) break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.logger.info(`[clawmatrix] Tool batch done: id=${id} ${results.length}/${items.length} executed`);
|
|
323
|
+
this.sendBatchResponse(id, from, results);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private sendBatchResponse(id: string, to: string, results: ToolBatchResultItem[]) {
|
|
327
|
+
this.peerManager.sendTo(to, {
|
|
328
|
+
type: "tool_batch_res",
|
|
329
|
+
id,
|
|
330
|
+
from: this.config.nodeId,
|
|
331
|
+
to,
|
|
332
|
+
timestamp: Date.now(),
|
|
333
|
+
payload: { results },
|
|
334
|
+
} as ToolBatchResponse);
|
|
335
|
+
}
|
|
336
|
+
|
|
218
337
|
// ── Security ───────────────────────────────────────────────────
|
|
219
338
|
private isToolAllowed(tool: string, tpConfig: ToolProxyConfig): boolean {
|
|
220
339
|
if (tpConfig.deny.includes(tool)) return false;
|
|
@@ -228,5 +347,10 @@ export class ToolProxy {
|
|
|
228
347
|
pending.reject(new Error("Shutting down"));
|
|
229
348
|
}
|
|
230
349
|
this.pending.clear();
|
|
350
|
+
for (const [, pending] of this.pendingBatch) {
|
|
351
|
+
clearTimeout(pending.timer);
|
|
352
|
+
pending.reject(new Error("Shutting down"));
|
|
353
|
+
}
|
|
354
|
+
this.pendingBatch.clear();
|
|
231
355
|
}
|
|
232
356
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
import type { AcpSessionMode } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export function createClusterAcpTool(): AnyAgentTool {
|
|
6
|
+
return {
|
|
7
|
+
name: "cluster_acp",
|
|
8
|
+
label: "Cluster ACP Agent",
|
|
9
|
+
description:
|
|
10
|
+
"Run a task on a remote coding agent (Claude Code, Codex, Gemini CLI, etc.) via ACP protocol. " +
|
|
11
|
+
"Actions: " +
|
|
12
|
+
'"prompt" — send a task (one-shot or persistent session). ' +
|
|
13
|
+
'"list" — list all sessions on a remote node. ' +
|
|
14
|
+
'"resume" — resume an existing ACP session for follow-up prompts. ' +
|
|
15
|
+
'"cancel" — cancel an in-flight prompt on a session. ' +
|
|
16
|
+
'"set_mode" — switch session mode (e.g. "code", "architect", "ask"). ' +
|
|
17
|
+
'"get_modes" — get available modes for a session. ' +
|
|
18
|
+
'"close" — terminate a persistent session.',
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
action: {
|
|
23
|
+
type: "string",
|
|
24
|
+
enum: ["prompt", "list", "resume", "cancel", "set_mode", "get_modes", "close"],
|
|
25
|
+
description: 'Action to perform. Default: "prompt"',
|
|
26
|
+
},
|
|
27
|
+
node: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: 'Target node ID or "tags:<tag>" expression',
|
|
30
|
+
},
|
|
31
|
+
agent: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: 'ACP agent name: "claude", "codex", "gemini", etc.',
|
|
34
|
+
},
|
|
35
|
+
task: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Task or prompt to send to the remote coding agent",
|
|
38
|
+
},
|
|
39
|
+
sessionId: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Session ID for follow-up prompts, cancel, set_mode, get_modes, or close",
|
|
42
|
+
},
|
|
43
|
+
acpSessionId: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "ACP protocol session ID to resume (from list results)",
|
|
46
|
+
},
|
|
47
|
+
modeId: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: 'Session mode ID to set (e.g. "code", "architect", "ask")',
|
|
50
|
+
},
|
|
51
|
+
mode: {
|
|
52
|
+
type: "string",
|
|
53
|
+
enum: ["oneshot", "persistent"],
|
|
54
|
+
description: 'Session mode. "oneshot" (default) runs once, "persistent" keeps the session alive for follow-ups',
|
|
55
|
+
},
|
|
56
|
+
cwd: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Working directory on the remote node (absolute path)",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ["node"],
|
|
62
|
+
},
|
|
63
|
+
async execute(_toolCallId, params) {
|
|
64
|
+
const {
|
|
65
|
+
action = "prompt",
|
|
66
|
+
node,
|
|
67
|
+
agent,
|
|
68
|
+
task,
|
|
69
|
+
sessionId,
|
|
70
|
+
acpSessionId,
|
|
71
|
+
modeId,
|
|
72
|
+
mode,
|
|
73
|
+
cwd,
|
|
74
|
+
} = params as {
|
|
75
|
+
action?: "prompt" | "list" | "resume" | "cancel" | "set_mode" | "get_modes" | "close";
|
|
76
|
+
node: string;
|
|
77
|
+
agent?: string;
|
|
78
|
+
task?: string;
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
acpSessionId?: string;
|
|
81
|
+
modeId?: string;
|
|
82
|
+
mode?: AcpSessionMode;
|
|
83
|
+
cwd?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const runtime = getClusterRuntime();
|
|
88
|
+
|
|
89
|
+
if (!runtime.acpProxy) {
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text" as const, text: "ACP proxy not available (acp.enabled is false in config)" }],
|
|
92
|
+
details: { error: true },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nodeId = resolveNodeId(runtime, node);
|
|
97
|
+
|
|
98
|
+
if (action === "list") {
|
|
99
|
+
const result = await runtime.acpProxy.listSessions(nodeId, { agent, cwd });
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
102
|
+
details: result,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (action === "resume") {
|
|
107
|
+
if (!acpSessionId) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text" as const, text: "acpSessionId is required for resume action (use list to find session IDs)" }],
|
|
110
|
+
details: { error: true },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (!agent) {
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text" as const, text: "agent is required for resume action" }],
|
|
116
|
+
details: { error: true },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const result = await runtime.acpProxy.resumeSession(
|
|
120
|
+
nodeId, agent, acpSessionId, cwd ?? process.cwd(),
|
|
121
|
+
);
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
124
|
+
details: result,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (action === "cancel") {
|
|
129
|
+
if (!sessionId) {
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text" as const, text: "sessionId is required for cancel action" }],
|
|
132
|
+
details: { error: true },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const result = await runtime.acpProxy.cancelSession(nodeId, sessionId);
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
138
|
+
details: result,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (action === "set_mode") {
|
|
143
|
+
if (!sessionId) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text" as const, text: "sessionId is required for set_mode action" }],
|
|
146
|
+
details: { error: true },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!modeId) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text" as const, text: "modeId is required for set_mode action" }],
|
|
152
|
+
details: { error: true },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const result = await runtime.acpProxy.setSessionMode(nodeId, sessionId, modeId);
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
158
|
+
details: result,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (action === "get_modes") {
|
|
163
|
+
if (!sessionId) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text" as const, text: "sessionId is required for get_modes action" }],
|
|
166
|
+
details: { error: true },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const result = await runtime.acpProxy.getSessionModes(nodeId, sessionId);
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
172
|
+
details: result,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (action === "close") {
|
|
177
|
+
if (!sessionId) {
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text" as const, text: "sessionId is required for close action" }],
|
|
180
|
+
details: { error: true },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const result = await runtime.acpProxy.closeSession(nodeId, sessionId);
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
186
|
+
details: result,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// action === "prompt"
|
|
191
|
+
if (!task && sessionId) {
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: "text" as const, text: "task is required for follow-up prompts" }],
|
|
194
|
+
details: { error: true },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!agent && !sessionId) {
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: "text" as const, text: "agent is required when starting a new session (no sessionId provided)" }],
|
|
200
|
+
details: { error: true },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result = await runtime.acpProxy.prompt(
|
|
205
|
+
node, agent ?? "", task ?? "", { sessionId, mode, cwd },
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
210
|
+
details: result,
|
|
211
|
+
};
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text" as const, text: `ACP error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
215
|
+
details: { error: true },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveNodeId(
|
|
223
|
+
runtime: ReturnType<typeof getClusterRuntime>,
|
|
224
|
+
node: string,
|
|
225
|
+
): string {
|
|
226
|
+
const peers = runtime.peerManager.router.getAllPeers();
|
|
227
|
+
const direct = peers.find((p) => p.nodeId === node);
|
|
228
|
+
if (direct) return direct.nodeId;
|
|
229
|
+
|
|
230
|
+
if (node.startsWith("tags:")) {
|
|
231
|
+
const tag = node.slice(5);
|
|
232
|
+
const match = peers.find((p) => p.tags.includes(tag));
|
|
233
|
+
if (match) return match.nodeId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return node;
|
|
237
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterBatchTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_batch",
|
|
7
|
+
label: "Cluster Batch",
|
|
8
|
+
description:
|
|
9
|
+
"Execute multiple tools on a remote node in a single round-trip. " +
|
|
10
|
+
"Tools run sequentially (supports dependencies like read→edit). " +
|
|
11
|
+
"By default stops on first error; set stopOnError=false to continue.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
node: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: 'Target nodeId or "tags:<tag>"',
|
|
18
|
+
},
|
|
19
|
+
items: {
|
|
20
|
+
type: "array",
|
|
21
|
+
description: "Array of tool invocations to execute in order",
|
|
22
|
+
items: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
tool: { type: "string", description: "Tool name" },
|
|
26
|
+
params: { type: "object", description: "Tool arguments" },
|
|
27
|
+
},
|
|
28
|
+
required: ["tool", "params"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
stopOnError: {
|
|
32
|
+
type: "boolean",
|
|
33
|
+
description: "Stop on first error (default true)",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: ["node", "items"],
|
|
37
|
+
},
|
|
38
|
+
async execute(_toolCallId, params) {
|
|
39
|
+
const { node, items, stopOnError } = params as {
|
|
40
|
+
node: string;
|
|
41
|
+
items: Array<{ tool: string; params: Record<string, unknown> }>;
|
|
42
|
+
stopOnError?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (!items || items.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text" as const, text: "Error: items array is empty" }],
|
|
48
|
+
details: { error: true },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const runtime = getClusterRuntime();
|
|
54
|
+
const results = await runtime.toolProxy.invokeBatch(node, items, { stopOnError });
|
|
55
|
+
|
|
56
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
57
|
+
const summary = results.map((r, i) => {
|
|
58
|
+
if (r.success) {
|
|
59
|
+
return `[${i + 1}] ${r.tool}: OK\n${JSON.stringify(r.result)}`;
|
|
60
|
+
}
|
|
61
|
+
return `[${i + 1}] ${r.tool}: FAILED\n${r.error}`;
|
|
62
|
+
}).join("\n\n");
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text" as const, text: `${succeeded}/${items.length} succeeded\n\n${summary}` }],
|
|
66
|
+
details: { results },
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text" as const, text: `Batch error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
71
|
+
details: { error: true },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
import type { ClusterRuntime } from "../cluster-service.ts";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import type { AnyClusterFrame, DiagnosticExecResponse, DiagnosticStatusResponse } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
export function createClusterDiagnosticTool(): AnyAgentTool {
|
|
8
|
+
return {
|
|
9
|
+
name: "cluster_diagnostic",
|
|
10
|
+
label: "Cluster Diagnostic",
|
|
11
|
+
description:
|
|
12
|
+
"Diagnose or execute commands on a remote node's sentinel process. " +
|
|
13
|
+
"Works even when the remote OpenClaw gateway is down. " +
|
|
14
|
+
"Use action 'status' to check if the remote gateway is alive, " +
|
|
15
|
+
"or 'exec' to run a shell command on the remote machine.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
node: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description:
|
|
22
|
+
'Target nodeId. The sentinel is reached as "<nodeId>:sentinel".',
|
|
23
|
+
},
|
|
24
|
+
action: {
|
|
25
|
+
type: "string",
|
|
26
|
+
enum: ["status", "exec"],
|
|
27
|
+
description: '"status" to check gateway health, "exec" to run a command',
|
|
28
|
+
},
|
|
29
|
+
command: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Shell command to execute (required when action is exec)",
|
|
32
|
+
},
|
|
33
|
+
timeout: {
|
|
34
|
+
type: "number",
|
|
35
|
+
description: "Timeout in seconds (default 30)",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["node", "action"],
|
|
39
|
+
},
|
|
40
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
41
|
+
const { node, action, command, timeout = 30 } = params as {
|
|
42
|
+
node: string;
|
|
43
|
+
action: "status" | "exec";
|
|
44
|
+
command?: string;
|
|
45
|
+
timeout?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sentinelNodeId = node.endsWith(":sentinel") ? node : `${node}:sentinel`;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const runtime = getClusterRuntime();
|
|
52
|
+
const router = runtime.peerManager.router;
|
|
53
|
+
|
|
54
|
+
// Check if sentinel is reachable
|
|
55
|
+
const route = router.getRoute(sentinelNodeId);
|
|
56
|
+
if (!route) {
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: "text" as const,
|
|
60
|
+
text: `Sentinel "${sentinelNodeId}" is not reachable. The remote machine may be completely offline.`,
|
|
61
|
+
}],
|
|
62
|
+
details: { error: true },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const id = randomUUID();
|
|
67
|
+
const timeoutMs = timeout * 1000;
|
|
68
|
+
|
|
69
|
+
if (action === "status") {
|
|
70
|
+
const result = await sendAndWait<DiagnosticStatusResponse>(
|
|
71
|
+
runtime, sentinelNodeId, id,
|
|
72
|
+
{
|
|
73
|
+
type: "diagnostic_status",
|
|
74
|
+
id,
|
|
75
|
+
from: runtime.config.nodeId,
|
|
76
|
+
to: sentinelNodeId,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
} as AnyClusterFrame,
|
|
79
|
+
"diagnostic_status_res",
|
|
80
|
+
timeoutMs,
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
content: [{
|
|
84
|
+
type: "text" as const,
|
|
85
|
+
text: JSON.stringify(result.payload, null, 2),
|
|
86
|
+
}],
|
|
87
|
+
details: result.payload,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (action === "exec") {
|
|
92
|
+
if (!command) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text" as const, text: "Missing 'command' parameter for exec action." }],
|
|
95
|
+
details: { error: true },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const result = await sendAndWait<DiagnosticExecResponse>(
|
|
99
|
+
runtime, sentinelNodeId, id,
|
|
100
|
+
{
|
|
101
|
+
type: "diagnostic_exec",
|
|
102
|
+
id,
|
|
103
|
+
from: runtime.config.nodeId,
|
|
104
|
+
to: sentinelNodeId,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
payload: { command, timeout },
|
|
107
|
+
} as AnyClusterFrame,
|
|
108
|
+
"diagnostic_exec_res",
|
|
109
|
+
timeoutMs + 5_000, // extra overhead
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
content: [{
|
|
113
|
+
type: "text" as const,
|
|
114
|
+
text: JSON.stringify(result.payload, null, 2),
|
|
115
|
+
}],
|
|
116
|
+
details: result.payload,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: `Unknown action: ${action}` }],
|
|
122
|
+
details: { error: true },
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return {
|
|
126
|
+
content: [{
|
|
127
|
+
type: "text" as const,
|
|
128
|
+
text: `Diagnostic error: ${err instanceof Error ? err.message : String(err)}`,
|
|
129
|
+
}],
|
|
130
|
+
details: { error: true },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Send a frame to the sentinel and wait for the correlated response. */
|
|
138
|
+
function sendAndWait<T>(
|
|
139
|
+
runtime: ClusterRuntime,
|
|
140
|
+
to: string,
|
|
141
|
+
id: string,
|
|
142
|
+
frame: AnyClusterFrame,
|
|
143
|
+
responseType: string,
|
|
144
|
+
timeoutMs: number,
|
|
145
|
+
): Promise<T> {
|
|
146
|
+
return new Promise<T>((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
cleanup();
|
|
149
|
+
reject(new Error(`Diagnostic request timed out after ${timeoutMs}ms`));
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
|
|
152
|
+
const handler = (incoming: AnyClusterFrame) => {
|
|
153
|
+
if (incoming.type === responseType && incoming.id === id) {
|
|
154
|
+
cleanup();
|
|
155
|
+
resolve(incoming as unknown as T);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const cleanup = () => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
runtime.peerManager.off("frame", handler);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Listen for response frames
|
|
165
|
+
runtime.peerManager.on("frame", handler);
|
|
166
|
+
|
|
167
|
+
// Route the frame to sentinel via router
|
|
168
|
+
const sent = runtime.peerManager.router.sendTo(to, frame);
|
|
169
|
+
if (!sent) {
|
|
170
|
+
cleanup();
|
|
171
|
+
reject(new Error(`No route to ${to}`));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|