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/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
- this.routes.set(nodeId, {
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.routes.set(peer.nodeId, {
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.delete(nodeId);
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
- const tag = isTagQuery ? target.slice(5) : null;
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
- for (const entry of this.routes.values()) {
176
- // Skip self never resolve to our own node
177
- if (entry.nodeId === this.nodeId) continue;
178
- if (isTagQuery) {
179
- if (entry.agents.some((a) => a.tags.includes(tag!)) || entry.tags.includes(tag!)) {
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 entry of this.routes.values()) {
215
- // Skip self never resolve to our own node
216
- if (entry.nodeId === this.nodeId) continue;
217
- if (entry.tags.includes(tag)) {
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 entry of this.routes.values()) {
241
- if (exclude?.has(entry.nodeId)) continue;
242
- if (!entry.models.some((m) => m.id === modelId)) continue;
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
- // Sort: direct first, then by latency
252
- candidates.sort((a, b) => {
253
- const aDirect = a.connection ? 0 : 1;
254
- const bDirect = b.connection ? 0 : 1;
255
- if (aDirect !== bDirect) return aDirect - bDirect;
256
- return a.latencyMs - b.latencyMs;
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
 
@@ -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
- // Small delay to let the OS release the port from the dead process
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
- }, 2_000);
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
- pending.resolve(frame.payload.result);
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
- pending.resolve(frame.payload.results);
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, tpConfig: ToolProxyConfig): boolean {
342
- if (tpConfig.deny.includes(tool)) return false;
343
- if (tpConfig.allow.length === 0 || tpConfig.allow.includes("*")) return true;
344
- return tpConfig.allow.includes(tool);
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
- "The oldText must match exactly (including whitespace).",
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 (messages, calls, location changes).",
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 reachable peers in the cluster, their agents, models, tools, and connection status.",
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: {},