clawmatrix 0.1.14 → 0.1.16

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.
@@ -5,9 +5,11 @@ import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
5
5
  import { Connection } from "./connection.ts";
6
6
  import type { WsTransport } from "./connection.ts";
7
7
  import { Router } from "./router.ts";
8
+ import { collectDeviceInfo } from "./device-info.ts";
8
9
  import type {
9
10
  AnyClusterFrame,
10
11
  ClusterFrame,
12
+ DeviceInfo,
11
13
  NodeCapabilities,
12
14
  PeerInfo,
13
15
  PeerSync,
@@ -24,6 +26,8 @@ export interface PeerManagerEvents {
24
26
 
25
27
  export class PeerManager extends EventEmitter<PeerManagerEvents> {
26
28
  readonly router: Router;
29
+ readonly localDeviceInfo: DeviceInfo;
30
+ satelliteContexts: import("./types.ts").SatelliteContext[] = [];
27
31
  private config: ClawMatrixConfig;
28
32
  private localCapabilities: NodeCapabilities;
29
33
  private httpServer: Server | null = null;
@@ -35,19 +39,28 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
35
39
  private inboundConnections = new Map<WsWebSocket, Connection>();
36
40
  private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
37
41
 
38
- constructor(config: ClawMatrixConfig) {
42
+ constructor(config: ClawMatrixConfig, openclawVersion?: string) {
39
43
  super();
40
44
  this.config = config;
45
+ this.localDeviceInfo = collectDeviceInfo(openclawVersion);
41
46
  this.localCapabilities = {
42
47
  nodeId: config.nodeId,
43
48
  agents: config.agents,
44
49
  models: config.models,
45
50
  tags: config.tags,
51
+ deviceInfo: this.localDeviceInfo,
52
+ toolProxy: config.toolProxy ? {
53
+ enabled: config.toolProxy.enabled,
54
+ allow: config.toolProxy.allow,
55
+ deny: config.toolProxy.deny,
56
+ } : undefined,
46
57
  };
47
58
  this.router = new Router(config.nodeId, {
48
59
  agents: config.agents,
49
60
  models: config.models,
50
61
  tags: config.tags,
62
+ deviceInfo: this.localDeviceInfo,
63
+ toolProxy: this.localCapabilities.toolProxy,
51
64
  });
52
65
  }
53
66
 
@@ -230,6 +243,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
230
243
  this.router.addDirectPeer(nodeId, conn, caps);
231
244
 
232
245
  conn.on("message", (frame) => this.onFrame(frame, conn));
246
+ conn.on("latency", (ms) => this.router.updateLatency(nodeId, ms));
233
247
  conn.on("close", () => this.onPeerDisconnected(conn));
234
248
 
235
249
  this.sendPeerSync(conn);
@@ -244,6 +258,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
244
258
  agents: caps.agents,
245
259
  models: caps.models,
246
260
  tags: caps.tags,
261
+ deviceInfo: caps.deviceInfo,
262
+ toolProxy: caps.toolProxy,
247
263
  },
248
264
  } as AnyClusterFrame);
249
265
 
@@ -263,6 +279,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
263
279
 
264
280
  this.router.removePeer(nodeId);
265
281
 
282
+ // Remove satellite contexts that were only reachable via this peer
283
+ this.satelliteContexts = this.satelliteContexts.filter(s => {
284
+ // Keep satellites that are not associated with the disconnected peer
285
+ // (satellite nodeIds typically differ from mesh peer nodeIds)
286
+ return s.nodeId !== nodeId;
287
+ });
288
+
266
289
  this.router.broadcast({
267
290
  type: "peer_leave",
268
291
  from: this.config.nodeId,
@@ -278,9 +301,22 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
278
301
  // Validate from field: must be the direct peer or a known node (relayed)
279
302
  if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) return;
280
303
 
281
- // Skip dedup for streaming frame types — they share one id across many chunks
282
- const isStreamFrame = frame.type === "model_stream" || frame.type === "handoff_stream";
283
- if (frame.id && !isStreamFrame && this.router.isDuplicate(frame.id)) return;
304
+ // Skip dedup for streaming and response frame types.
305
+ // Stream frames share one id across many chunks.
306
+ // Response frames (model_res, tool_res, handoff_res, etc.) share the same id
307
+ // as their request — without this exemption, a relay node that forwarded
308
+ // the request would mark the id as seen and then drop the returning response.
309
+ // Skip dedup for streaming chunks (same id across many chunks) and response
310
+ // frames (share id with their request — relay would otherwise drop the reply).
311
+ // handoff_input, handoff_cancel, and handoff_status reuse the original handoff_req id,
312
+ // so relay nodes that already forwarded the request would drop them as duplicates.
313
+ const skipDedup = frame.type === "model_stream" || frame.type === "handoff_stream"
314
+ || frame.type === "model_res" || frame.type === "tool_res"
315
+ || frame.type === "handoff_res" || frame.type === "handoff_status_res"
316
+ || frame.type === "handoff_input_required"
317
+ || frame.type === "handoff_input" || frame.type === "handoff_cancel"
318
+ || frame.type === "handoff_status";
319
+ if (frame.id && !skipDedup && this.router.isDuplicate(frame.id)) return;
284
320
 
285
321
  if (frame.type === "peer_sync") {
286
322
  this.handlePeerSync(frame as PeerSync, from);
@@ -314,23 +350,46 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
314
350
 
315
351
  private sendPeerSync(conn: Connection) {
316
352
  const peers = this.router.buildPeerSyncPayload();
353
+ const payload: Record<string, unknown> = { peers };
354
+ if (this.satelliteContexts.length > 0) {
355
+ payload.satellites = this.satelliteContexts;
356
+ }
317
357
  conn.send({
318
358
  type: "peer_sync",
319
359
  from: this.config.nodeId,
320
360
  timestamp: Date.now(),
321
- payload: { peers },
361
+ payload,
322
362
  } as AnyClusterFrame);
323
363
  }
324
364
 
325
365
  private handlePeerSync(frame: PeerSync, from: Connection) {
366
+ // Merge satellite contexts from relay node (keep newest per nodeId)
367
+ if (frame.payload.satellites) {
368
+ const byNodeId = new Map<string, import("./types.ts").SatelliteContext>();
369
+ for (const s of this.satelliteContexts) byNodeId.set(s.nodeId, s);
370
+ for (const s of frame.payload.satellites) {
371
+ const existing = byNodeId.get(s.nodeId);
372
+ if (!existing || s.ts > existing.ts) {
373
+ byNodeId.set(s.nodeId, s);
374
+ }
375
+ }
376
+ this.satelliteContexts = [...byNodeId.values()];
377
+ }
378
+
326
379
  let changed = false;
327
380
  for (const peer of frame.payload.peers) {
328
381
  if (peer.nodeId === this.config.nodeId) continue;
329
382
  if (peer.nodeId === from.remoteNodeId) {
330
383
  const prev = this.router.getRoute(peer.nodeId);
331
384
  const hadAgents = prev?.agents.length ?? 0;
385
+ const hadDirectPeers = prev?.directPeers.length ?? 0;
386
+ const hadToolProxy = JSON.stringify(prev?.toolProxy);
387
+ const hadDeviceInfo = prev?.deviceInfo?.hostname;
332
388
  this.router.updatePeerCapabilities(peer.nodeId, peer);
333
- if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)) {
389
+ if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
390
+ || (peer.directPeers?.length ?? 0) !== hadDirectPeers
391
+ || JSON.stringify(peer.toolProxy) !== hadToolProxy
392
+ || peer.deviceInfo?.hostname !== hadDeviceInfo) {
334
393
  changed = true;
335
394
  }
336
395
  } else {
package/src/router.ts CHANGED
@@ -1,10 +1,9 @@
1
- import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo } from "./types.ts";
1
+ import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo } from "./types.ts";
2
2
  import type { Connection } from "./connection.ts";
3
3
 
4
4
  const DEFAULT_TTL = 3;
5
- const SEEN_FRAME_TTL = 60_000; // 60s dedup window
6
5
  const MAX_SEEN_FRAMES = 10_000;
7
- const PRUNE_INTERVAL = 30_000; // periodic cleanup every 30s
6
+ const ROTATE_INTERVAL = 60_000; // rotate dedup maps every 60s
8
7
 
9
8
  export interface RouteEntry {
10
9
  nodeId: string;
@@ -15,6 +14,9 @@ export interface RouteEntry {
15
14
  reachableVia: string | null; // relay nodeId, null = direct
16
15
  lastSeen: number;
17
16
  latencyMs: number;
17
+ directPeers: string[]; // nodeIds this node has direct connections to
18
+ deviceInfo?: DeviceInfo;
19
+ toolProxy?: ToolProxyInfo;
18
20
  }
19
21
 
20
22
  export class Router {
@@ -22,37 +24,47 @@ export class Router {
22
24
  private localAgents: AgentInfo[];
23
25
  private localModels: ModelInfo[];
24
26
  private localTags: string[];
27
+ private localDeviceInfo?: DeviceInfo;
28
+ private localToolProxy?: ToolProxyInfo;
25
29
  private routes = new Map<string, RouteEntry>();
26
30
  private connections = new Map<string, Connection>(); // nodeId → direct connection
27
- private seenFrames = new Map<string, number>(); // frameId timestamp
28
- private pruneTimer: ReturnType<typeof setInterval> | null = null;
31
+ /** Double-map dedup: current window + previous window. Rotated periodically. */
32
+ private seenCurrent = new Map<string, true>();
33
+ private seenPrevious = new Map<string, true>();
34
+ private rotateTimer: ReturnType<typeof setInterval> | null = null;
35
+ /** Failed request IDs with expiry timestamps. Separate from dedup to support longer TTLs. */
36
+ private failedRequests = new Map<string, number>(); // requestId → expiresAt
29
37
 
30
38
  constructor(
31
39
  nodeId: string,
32
- localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
40
+ localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
33
41
  ) {
34
42
  this.nodeId = nodeId;
35
43
  this.localAgents = localCapabilities?.agents ?? [];
36
44
  this.localModels = localCapabilities?.models ?? [];
37
45
  this.localTags = localCapabilities?.tags ?? [];
46
+ this.localDeviceInfo = localCapabilities?.deviceInfo;
47
+ this.localToolProxy = localCapabilities?.toolProxy;
38
48
 
39
- this.pruneTimer = setInterval(() => this.pruneSeenFrames(true), PRUNE_INTERVAL);
49
+ this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
40
50
  }
41
51
 
42
52
  /** Stop periodic cleanup. Call on shutdown. */
43
53
  destroy() {
44
- if (this.pruneTimer) {
45
- clearInterval(this.pruneTimer);
46
- this.pruneTimer = null;
54
+ if (this.rotateTimer) {
55
+ clearInterval(this.rotateTimer);
56
+ this.rotateTimer = null;
47
57
  }
48
- this.seenFrames.clear();
58
+ this.seenCurrent.clear();
59
+ this.seenPrevious.clear();
60
+ this.failedRequests.clear();
49
61
  }
50
62
 
51
63
  // ── Route table management ─────────────────────────────────────
52
64
  addDirectPeer(
53
65
  nodeId: string,
54
66
  connection: Connection,
55
- capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
67
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
56
68
  ) {
57
69
  this.connections.set(nodeId, connection);
58
70
  this.routes.set(nodeId, {
@@ -64,6 +76,9 @@ export class Router {
64
76
  reachableVia: null,
65
77
  lastSeen: Date.now(),
66
78
  latencyMs: 0,
79
+ directPeers: [],
80
+ deviceInfo: capabilities.deviceInfo,
81
+ toolProxy: capabilities.toolProxy,
67
82
  });
68
83
  }
69
84
 
@@ -83,6 +98,9 @@ export class Router {
83
98
  reachableVia: viaNodeId,
84
99
  lastSeen: Date.now(),
85
100
  latencyMs: 0,
101
+ directPeers: peer.directPeers ?? [],
102
+ deviceInfo: peer.deviceInfo,
103
+ toolProxy: peer.toolProxy,
86
104
  });
87
105
  }
88
106
 
@@ -99,17 +117,31 @@ export class Router {
99
117
 
100
118
  updatePeerCapabilities(
101
119
  nodeId: string,
102
- capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
120
+ capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
103
121
  ) {
104
122
  const entry = this.routes.get(nodeId);
105
123
  if (entry) {
106
124
  entry.agents = capabilities.agents;
107
125
  entry.models = capabilities.models;
108
126
  entry.tags = capabilities.tags;
127
+ if (capabilities.directPeers) {
128
+ entry.directPeers = capabilities.directPeers;
129
+ }
130
+ if (capabilities.deviceInfo) {
131
+ entry.deviceInfo = capabilities.deviceInfo;
132
+ }
133
+ entry.toolProxy = capabilities.toolProxy;
109
134
  entry.lastSeen = Date.now();
110
135
  }
111
136
  }
112
137
 
138
+ updateLatency(nodeId: string, latencyMs: number) {
139
+ const entry = this.routes.get(nodeId);
140
+ if (entry) {
141
+ entry.latencyMs = latencyMs;
142
+ }
143
+ }
144
+
113
145
  // ── Routing ────────────────────────────────────────────────────
114
146
  getRoute(nodeId: string): RouteEntry | undefined {
115
147
  return this.routes.get(nodeId);
@@ -152,24 +184,6 @@ export class Router {
152
184
  return candidates[0];
153
185
  }
154
186
 
155
- /** Find node that has a specific model. */
156
- resolveModel(modelId: string): RouteEntry | undefined {
157
- let candidates: RouteEntry[] = [];
158
- for (const entry of this.routes.values()) {
159
- if (entry.models.some((m) => m.id === modelId)) {
160
- candidates.push(entry);
161
- }
162
- }
163
- if (candidates.length === 0) return undefined;
164
-
165
- candidates.sort((a, b) => {
166
- const aDirect = a.connection ? 0 : 1;
167
- const bDirect = b.connection ? 0 : 1;
168
- if (aDirect !== bDirect) return aDirect - bDirect;
169
- return a.latencyMs - b.latencyMs;
170
- });
171
- return candidates[0];
172
- }
173
187
 
174
188
  /** Resolve a node target (nodeId or "tags:<tag>"). */
175
189
  resolveNode(target: string): RouteEntry | undefined {
@@ -233,33 +247,54 @@ export class Router {
233
247
  return relayed;
234
248
  }
235
249
 
236
- // ── Deduplication ──────────────────────────────────────────────
237
- /** Returns true if the frame has been seen before (duplicate). */
250
+ // ── Deduplication (double-map rotation) ────────────────────────
251
+ /**
252
+ * Returns true if the frame has been seen before (duplicate).
253
+ * Uses a double-map strategy: entries live in `seenCurrent` and are
254
+ * promoted from `seenPrevious` on access. Every ROTATE_INTERVAL the
255
+ * previous map is discarded and current becomes previous — O(1) cleanup.
256
+ */
238
257
  isDuplicate(frameId: string): boolean {
239
258
  if (!frameId) return false;
240
- if (this.seenFrames.has(frameId)) return true;
259
+ if (this.seenCurrent.has(frameId)) return true;
260
+ if (this.seenPrevious.has(frameId)) {
261
+ // Promote to current so it survives the next rotation
262
+ this.seenCurrent.set(frameId, true);
263
+ return true;
264
+ }
241
265
 
242
- this.seenFrames.set(frameId, Date.now());
243
- this.pruneSeenFrames();
266
+ this.seenCurrent.set(frameId, true);
267
+ // Safety valve: if current map grows too large, force a rotation
268
+ if (this.seenCurrent.size > MAX_SEEN_FRAMES) {
269
+ this.rotateSeenFrames();
270
+ }
244
271
  return false;
245
272
  }
246
273
 
247
- /** Mark a request ID as failed so late responses are ignored. */
248
- markFailed(requestId: string) {
249
- this.seenFrames.set(`failed:${requestId}`, Date.now());
274
+ /** Mark a request ID as failed so late responses are ignored.
275
+ * TTL defaults to 15 minutes — long enough for handoff timeouts. */
276
+ markFailed(requestId: string, ttlMs = 900_000) {
277
+ this.failedRequests.set(requestId, Date.now() + ttlMs);
250
278
  }
251
279
 
252
280
  isFailed(requestId: string): boolean {
253
- return this.seenFrames.has(`failed:${requestId}`);
281
+ const expiresAt = this.failedRequests.get(requestId);
282
+ if (expiresAt === undefined) return false;
283
+ if (Date.now() > expiresAt) {
284
+ this.failedRequests.delete(requestId);
285
+ return false;
286
+ }
287
+ return true;
254
288
  }
255
289
 
256
- private pruneSeenFrames(force = false) {
257
- if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
290
+ private rotateSeenFrames() {
291
+ this.seenPrevious = this.seenCurrent;
292
+ this.seenCurrent = new Map();
293
+
294
+ // Prune expired failed requests
258
295
  const now = Date.now();
259
- for (const [id, ts] of this.seenFrames) {
260
- if (now - ts > SEEN_FRAME_TTL) {
261
- this.seenFrames.delete(id);
262
- }
296
+ for (const [id, expiresAt] of this.failedRequests) {
297
+ if (now > expiresAt) this.failedRequests.delete(id);
263
298
  }
264
299
  }
265
300
 
@@ -275,12 +310,16 @@ export class Router {
275
310
  /** Build PeerInfo list for peer_sync. */
276
311
  buildPeerSyncPayload(): PeerInfo[] {
277
312
  const peers: PeerInfo[] = [];
278
- // Include ourselves
313
+ // Include ourselves with our direct peer list
314
+ const myDirectPeers = [...this.connections.keys()];
279
315
  peers.push({
280
316
  nodeId: this.nodeId,
281
317
  agents: this.localAgents,
282
318
  models: this.localModels,
283
319
  tags: this.localTags,
320
+ directPeers: myDirectPeers,
321
+ deviceInfo: this.localDeviceInfo,
322
+ toolProxy: this.localToolProxy,
284
323
  });
285
324
  for (const entry of this.routes.values()) {
286
325
  peers.push({
@@ -289,6 +328,9 @@ export class Router {
289
328
  models: entry.models,
290
329
  tags: entry.tags,
291
330
  reachableVia: entry.reachableVia ?? undefined,
331
+ directPeers: entry.directPeers.length > 0 ? entry.directPeers : undefined,
332
+ deviceInfo: entry.deviceInfo,
333
+ toolProxy: entry.toolProxy,
292
334
  });
293
335
  }
294
336
  return peers;
package/src/tool-proxy.ts CHANGED
@@ -16,11 +16,18 @@ export interface GatewayInfo {
16
16
  authHeader?: string;
17
17
  }
18
18
 
19
+ /** Interface for satellite tool routing (implemented by WebHandler). */
20
+ export interface SatelliteToolHandler {
21
+ isSatelliteNode(nodeId: string): boolean;
22
+ queueToolForSatellite(nodeId: string, id: string, tool: string, params: Record<string, unknown>, timeout?: number): Promise<Record<string, unknown>>;
23
+ }
24
+
19
25
  export class ToolProxy {
20
26
  private config: ClawMatrixConfig;
21
27
  private peerManager: PeerManager;
22
28
  private pending = new Map<string, PendingToolReq>();
23
29
  private gatewayInfo: GatewayInfo;
30
+ private satelliteHandler: SatelliteToolHandler | null = null;
24
31
 
25
32
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
26
33
  this.config = config;
@@ -28,6 +35,11 @@ export class ToolProxy {
28
35
  this.gatewayInfo = gatewayInfo;
29
36
  }
30
37
 
38
+ /** Set the satellite tool handler (called by ClusterRuntime after WebHandler is created). */
39
+ setSatelliteHandler(handler: SatelliteToolHandler) {
40
+ this.satelliteHandler = handler;
41
+ }
42
+
31
43
  // ── Outbound: request remote execution ─────────────────────────
32
44
  async invoke(
33
45
  node: string,
@@ -45,7 +57,15 @@ export class ToolProxy {
45
57
  timeout?: number,
46
58
  ): Promise<Record<string, unknown>> {
47
59
  const route = this.peerManager.router.resolveNode(node);
48
- if (!route) throw new Error(`Node "${node}" not reachable`);
60
+
61
+ // If no WS route, try satellite fallback
62
+ if (!route) {
63
+ if (this.satelliteHandler?.isSatelliteNode(node)) {
64
+ const id = crypto.randomUUID();
65
+ return this.satelliteHandler.queueToolForSatellite(node, id, tool, params, timeout);
66
+ }
67
+ throw new Error(`Node "${node}" not reachable`);
68
+ }
49
69
 
50
70
  const id = crypto.randomUUID();
51
71
  const frame: ToolProxyRequest = {
@@ -114,17 +134,12 @@ export class ToolProxy {
114
134
  }
115
135
 
116
136
  try {
117
- console.log(`Received tool request for: ${payload.tool}`);
118
- console.log(`Is local tool? ${isLocalTool(payload.tool)}`);
119
-
120
137
  const result = isLocalTool(payload.tool)
121
138
  ? await executeLocally(payload.tool, payload.params)
122
139
  : await this.executeViaGateway(payload.tool, payload.params);
123
-
124
- console.log(`Tool execution result: ${JSON.stringify(result)}`);
140
+
125
141
  this.sendResponse(id, from, { success: true, result });
126
142
  } catch (err) {
127
- console.error(`Tool execution error: ${err}`);
128
143
  this.sendResponse(id, from, {
129
144
  success: false,
130
145
  error: err instanceof Error ? err.message : String(err),
@@ -0,0 +1,119 @@
1
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
2
+ import { getClusterRuntime } from "../cluster-service.ts";
3
+
4
+ export function createClusterEventsTool(): AnyAgentTool {
5
+ return {
6
+ name: "cluster_events",
7
+ label: "Cluster Events",
8
+ description:
9
+ "Query and consume events from external sources (messages, calls, location changes).",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ action: {
14
+ type: "string",
15
+ enum: ["query", "consume"],
16
+ description: '"query" to list events, "consume" to mark events as processed',
17
+ },
18
+ type: {
19
+ type: "string",
20
+ description: 'Filter by event type (e.g. "message_received", "call_missed"). Only for action=query.',
21
+ },
22
+ source: {
23
+ type: "string",
24
+ description: 'Filter by source (e.g. "shortcuts", "iphone"). Only for action=query.',
25
+ },
26
+ unconsumed: {
27
+ type: "boolean",
28
+ description: "Only return unconsumed events. Default true. Only for action=query.",
29
+ },
30
+ since: {
31
+ type: "number",
32
+ description: "Only return events after this unix timestamp (ms). Only for action=query.",
33
+ },
34
+ limit: {
35
+ type: "number",
36
+ description: "Max events to return. Default 20. Only for action=query.",
37
+ },
38
+ ids: {
39
+ type: "array",
40
+ items: { type: "string" },
41
+ description: "Event IDs to mark as consumed. Required for action=consume.",
42
+ },
43
+ },
44
+ required: ["action"],
45
+ },
46
+ async execute(_toolCallId, params) {
47
+ const { action, type, source, unconsumed, since, limit, ids } = params as {
48
+ action: "query" | "consume";
49
+ type?: string;
50
+ source?: string;
51
+ unconsumed?: boolean;
52
+ since?: number;
53
+ limit?: number;
54
+ ids?: string[];
55
+ };
56
+
57
+ try {
58
+ const runtime = getClusterRuntime();
59
+ const webHandler = runtime.webHandler;
60
+ if (!webHandler) {
61
+ return {
62
+ content: [{ type: "text" as const, text: "Event ingestion requires web dashboard to be enabled (web.enabled = true)" }],
63
+ details: { error: true },
64
+ };
65
+ }
66
+
67
+ if (action === "consume") {
68
+ if (!ids || ids.length === 0) {
69
+ return {
70
+ content: [{ type: "text" as const, text: "Error: ids array required for consume action" }],
71
+ details: { error: true },
72
+ };
73
+ }
74
+ const consumed = webHandler.consumeEvents(ids);
75
+ return {
76
+ content: [{ type: "text" as const, text: `Consumed ${consumed} event(s)` }],
77
+ details: { consumed },
78
+ };
79
+ }
80
+
81
+ // action === "query"
82
+ const events = webHandler.queryEvents({
83
+ type,
84
+ source,
85
+ since,
86
+ unconsumed: unconsumed !== false, // default true
87
+ limit: limit ?? 20,
88
+ });
89
+
90
+ if (events.length === 0) {
91
+ return {
92
+ content: [{ type: "text" as const, text: "No matching events found." }],
93
+ details: { events: [] },
94
+ };
95
+ }
96
+
97
+ const summary = events.map(e => ({
98
+ id: e.id,
99
+ type: e.type,
100
+ source: e.source,
101
+ ts: e.ts,
102
+ time: new Date(e.ts).toISOString(),
103
+ consumed: e.consumed,
104
+ data: e.data,
105
+ }));
106
+
107
+ return {
108
+ content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
109
+ details: { events: summary },
110
+ };
111
+ } catch (err) {
112
+ return {
113
+ content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
114
+ details: { error: true },
115
+ };
116
+ }
117
+ },
118
+ };
119
+ }
@@ -40,10 +40,14 @@ export function createClusterExecTool(): AnyAgentTool {
40
40
 
41
41
  try {
42
42
  const runtime = getClusterRuntime();
43
+ // Network timeout must cover the exec timeout plus overhead
44
+ const execTimeoutSec = timeout ?? 1800;
45
+ const networkTimeout = (execTimeoutSec + 30) * 1000;
43
46
  const result = await runtime.toolProxy.invoke(
44
47
  node,
45
48
  "exec",
46
49
  { command, workdir, timeout },
50
+ networkTimeout,
47
51
  );
48
52
  return {
49
53
  content: [
@@ -0,0 +1,77 @@
1
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
2
+ import { getClusterRuntime } from "../cluster-service.ts";
3
+
4
+ export function createClusterHandoffReplyTool(): AnyAgentTool {
5
+ return {
6
+ name: "cluster_handoff_reply",
7
+ label: "Cluster Handoff Reply",
8
+ description:
9
+ "Reply to a remote agent that requested more input during a handoff. " +
10
+ "Use the handoff_id returned by cluster_handoff.",
11
+ parameters: {
12
+ type: "object",
13
+ properties: {
14
+ handoff_id: {
15
+ type: "string",
16
+ description: "The handoff ID from cluster_handoff's response",
17
+ },
18
+ message: {
19
+ type: "string",
20
+ description: "Your reply to the remote agent's question",
21
+ },
22
+ },
23
+ required: ["handoff_id", "message"],
24
+ },
25
+ async execute(_toolCallId, params) {
26
+ const { handoff_id, message } = params as { handoff_id: string; message: string };
27
+
28
+ try {
29
+ const runtime = getClusterRuntime();
30
+ const result = await runtime.handoffManager.sendHandoffInput(handoff_id, message);
31
+
32
+ if (result.inputRequired) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: "text" as const,
37
+ text: `Remote agent needs more information.\n\nHandoff ID: ${result.handoffId}\nQuestion: ${result.result}\n\nUse cluster_handoff_reply again to respond.`,
38
+ },
39
+ ],
40
+ details: result,
41
+ };
42
+ }
43
+
44
+ if (!result.success) {
45
+ return {
46
+ content: [{ type: "text" as const, text: `Handoff failed: ${result.error}` }],
47
+ details: result,
48
+ };
49
+ }
50
+
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text" as const,
55
+ text: JSON.stringify(
56
+ { nodeId: result.nodeId, agent: result.agent, result: result.result },
57
+ null,
58
+ 2,
59
+ ),
60
+ },
61
+ ],
62
+ details: result,
63
+ };
64
+ } catch (err) {
65
+ return {
66
+ content: [
67
+ {
68
+ type: "text" as const,
69
+ text: `Handoff reply error: ${err instanceof Error ? err.message : String(err)}`,
70
+ },
71
+ ],
72
+ details: { error: true },
73
+ };
74
+ }
75
+ },
76
+ };
77
+ }