clawmatrix 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp-proxy.ts CHANGED
@@ -38,6 +38,20 @@ import type {
38
38
  } from "./types.ts";
39
39
  import { TaskActivityBroadcaster } from "./task-activity.ts";
40
40
 
41
+ /** Extract a human-readable message from any thrown value (Error, JSON-RPC error object, etc.) */
42
+ function errorMessage(err: unknown): string {
43
+ if (err instanceof Error) return err.message;
44
+ if (typeof err === "object" && err !== null) {
45
+ const obj = err as Record<string, unknown>;
46
+ if (typeof obj.message === "string") return obj.message;
47
+ // JSON-RPC error: { code: number, message: string, data?: ... }
48
+ if (typeof obj.code === "number" && typeof obj.message === "string") return obj.message;
49
+ // Fallback: try JSON.stringify to avoid [object Object]
50
+ try { return JSON.stringify(err); } catch { /* fall through */ }
51
+ }
52
+ return String(err);
53
+ }
54
+
41
55
  const DEFAULT_TIMEOUT = 600_000;
42
56
  const DEFAULT_SESSION_TTL = 1_800_000;
43
57
  const DEFAULT_MAX_SESSIONS = 5;
@@ -108,6 +122,8 @@ export class AcpProxy {
108
122
  private pendingResumes = new Set<string>();
109
123
  private pendingChatHistory = new Set<string>();
110
124
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
125
+ // Multi-device sync: track which nodes are watching each session
126
+ private sessionWatchers = new Map<string, Set<string>>();
111
127
 
112
128
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
113
129
  this.config = config;
@@ -128,6 +144,44 @@ export class AcpProxy {
128
144
  this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), CLEANUP_INTERVAL);
129
145
  }
130
146
 
147
+ // ── Multi-device sync helpers ──────────────────────────────────
148
+
149
+ private addSessionWatcher(sessionId: string, nodeId: string) {
150
+ let watchers = this.sessionWatchers.get(sessionId);
151
+ if (!watchers) {
152
+ watchers = new Set();
153
+ this.sessionWatchers.set(sessionId, watchers);
154
+ }
155
+ watchers.add(nodeId);
156
+ }
157
+
158
+ /** Send a frame to all session watchers except the specified node (usually the original `from`). */
159
+ private sendToOtherWatchers(sessionId: string, exclude: string, frame: AcpStreamChunk | AcpTaskResponse) {
160
+ const watchers = this.sessionWatchers.get(sessionId);
161
+ if (!watchers) return;
162
+ for (const nodeId of watchers) {
163
+ if (nodeId === exclude) continue;
164
+ this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
165
+ }
166
+ }
167
+
168
+ /** Send an acp_res to all session watchers except the original requester. */
169
+ private sendResponseToOtherWatchers(requestId: string, sessionId: string, exclude: string, payload: AcpTaskResponse["payload"]) {
170
+ const watchers = this.sessionWatchers.get(sessionId);
171
+ if (!watchers) return;
172
+ for (const nodeId of watchers) {
173
+ if (nodeId === exclude) continue;
174
+ this.peerManager.sendTo(nodeId, {
175
+ type: "acp_res",
176
+ id: requestId,
177
+ from: this.config.nodeId,
178
+ to: nodeId,
179
+ timestamp: Date.now(),
180
+ payload,
181
+ } satisfies AcpTaskResponse);
182
+ }
183
+ }
184
+
131
185
  // ── Requester side: send prompt ────────────────────────────────
132
186
 
133
187
  async prompt(
@@ -393,7 +447,7 @@ export class AcpProxy {
393
447
  if (frame.payload.event !== "agent_thought_chunk") {
394
448
  pending.accumulated += frame.payload.delta;
395
449
  }
396
- if (pending.onStream && frame.payload.delta) {
450
+ if (pending.onStream) {
397
451
  pending.onStream(frame.payload.delta);
398
452
  }
399
453
 
@@ -500,7 +554,7 @@ export class AcpProxy {
500
554
  try {
501
555
  newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
502
556
  } catch (resumeErr) {
503
- debug("acp", `Resume failed (${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}), falling back to new session`);
557
+ debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
504
558
  newSession = await this.createSession(agent, cwd, from);
505
559
  }
506
560
  } else {
@@ -509,6 +563,7 @@ export class AcpProxy {
509
563
  }
510
564
  if (mode === "persistent") {
511
565
  this.sessions.set(newSession.sessionId, newSession);
566
+ this.addSessionWatcher(newSession.sessionId, from);
512
567
  }
513
568
  if (task) {
514
569
  await this.runPrompt(id, from, newSession, task, images);
@@ -535,6 +590,7 @@ export class AcpProxy {
535
590
  };
536
591
  if (mode === "persistent") {
537
592
  this.sessions.set(newId, nativeSession);
593
+ this.addSessionWatcher(newId, from);
538
594
  }
539
595
  if (task) {
540
596
  await this.runNativePrompt(id, from, nativeSession, task);
@@ -544,10 +600,8 @@ export class AcpProxy {
544
600
  }
545
601
  return;
546
602
  }
547
- if (session.from !== from) {
548
- this.sendResponse(id, from, { success: false, error: "Not the session owner" });
549
- return;
550
- }
603
+ // Track this node as a session watcher for multi-device sync
604
+ this.addSessionWatcher(sessionId, from);
551
605
  session.lastActiveAt = Date.now();
552
606
  if (session.kind === "native") {
553
607
  await this.runNativePrompt(id, from, session, task);
@@ -569,6 +623,7 @@ export class AcpProxy {
569
623
  const session = await this.createSession(agent, cwd, from);
570
624
  if (mode === "persistent") {
571
625
  this.sessions.set(session.sessionId, session);
626
+ this.addSessionWatcher(session.sessionId, from);
572
627
  }
573
628
  if (task) {
574
629
  await this.runPrompt(id, from, session, task, images);
@@ -598,6 +653,7 @@ export class AcpProxy {
598
653
  };
599
654
  if (mode === "persistent") {
600
655
  this.sessions.set(newId, nativeSession);
656
+ this.addSessionWatcher(newId, from);
601
657
  }
602
658
  if (task) {
603
659
  await this.runNativePrompt(id, from, nativeSession, task);
@@ -609,7 +665,7 @@ export class AcpProxy {
609
665
  } catch (err) {
610
666
  this.sendResponse(id, from, {
611
667
  success: false,
612
- error: err instanceof Error ? err.message : String(err),
668
+ error: errorMessage(err),
613
669
  });
614
670
  }
615
671
  }
@@ -680,7 +736,7 @@ export class AcpProxy {
680
736
  this.peerManager.sendTo(from, {
681
737
  type: "acp_list_res", id, from: this.config.nodeId, to: from,
682
738
  timestamp: Date.now(),
683
- payload: { success: false, error: err instanceof Error ? err.message : String(err) },
739
+ payload: { success: false, error: errorMessage(err) },
684
740
  } satisfies AcpListResponse);
685
741
  }
686
742
  }
@@ -707,10 +763,11 @@ export class AcpProxy {
707
763
  try {
708
764
  session = await this.createSessionWithResume(agent, acpSessionId, cwd, from);
709
765
  } catch (resumeErr) {
710
- debug("acp", `Resume failed (${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}), falling back to new session`);
766
+ debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
711
767
  session = await this.createSession(agent, cwd, from);
712
768
  }
713
769
  this.sessions.set(session.sessionId, session);
770
+ this.addSessionWatcher(session.sessionId, from);
714
771
 
715
772
  this.peerManager.sendTo(from, {
716
773
  type: "acp_resume_res", id, from: this.config.nodeId, to: from,
@@ -731,6 +788,7 @@ export class AcpProxy {
731
788
  abortController: null,
732
789
  };
733
790
  this.sessions.set(sessionId, nativeSession);
791
+ this.addSessionWatcher(sessionId, from);
734
792
 
735
793
  this.peerManager.sendTo(from, {
736
794
  type: "acp_resume_res", id, from: this.config.nodeId, to: from,
@@ -742,7 +800,7 @@ export class AcpProxy {
742
800
  this.peerManager.sendTo(from, {
743
801
  type: "acp_resume_res", id, from: this.config.nodeId, to: from,
744
802
  timestamp: Date.now(),
745
- payload: { success: false, error: err instanceof Error ? err.message : String(err) },
803
+ payload: { success: false, error: errorMessage(err) },
746
804
  } satisfies AcpResumeResponse);
747
805
  }
748
806
  }
@@ -785,7 +843,7 @@ export class AcpProxy {
785
843
  this.peerManager.sendTo(from, {
786
844
  type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
787
845
  timestamp: Date.now(),
788
- payload: { success: false, error: err instanceof Error ? err.message : String(err) },
846
+ payload: { success: false, error: errorMessage(err) },
789
847
  } satisfies AcpCancelResponse);
790
848
  }
791
849
  }
@@ -828,7 +886,7 @@ export class AcpProxy {
828
886
  this.peerManager.sendTo(from, {
829
887
  type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
830
888
  timestamp: Date.now(),
831
- payload: { success: false, error: err instanceof Error ? err.message : String(err) },
889
+ payload: { success: false, error: errorMessage(err) },
832
890
  } satisfies AcpSetModeResponse);
833
891
  }
834
892
  }
@@ -889,7 +947,7 @@ export class AcpProxy {
889
947
  this.peerManager.sendTo(from, {
890
948
  type: "chat_history_res", id, from: this.config.nodeId, to: from,
891
949
  timestamp: Date.now(),
892
- payload: { success: false, error: err instanceof Error ? err.message : String(err) },
950
+ payload: { success: false, error: errorMessage(err) },
893
951
  } satisfies ChatHistoryResponse);
894
952
  }
895
953
  }
@@ -1056,8 +1114,16 @@ export class AcpProxy {
1056
1114
  streamCallback?.(content.text, updateType);
1057
1115
  }
1058
1116
  } else if (updateType === "tool_call") {
1059
- const toolName = (update as { name?: string }).name ?? "unknown";
1117
+ const toolName = (update as { title?: string }).title
1118
+ || (update as { toolCallId?: string }).toolCallId
1119
+ || "unknown";
1120
+ debug("acp", `tool_call update: title=${JSON.stringify((update as any).title)} toolCallId=${JSON.stringify((update as any).toolCallId)} keys=${Object.keys(update).join(",")}`);
1060
1121
  streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1122
+ } else if (updateType === "tool_call_update") {
1123
+ const status = (update as { status?: string }).status;
1124
+ if (status === "completed" || status === "error") {
1125
+ streamCallback?.("", "tool_result");
1126
+ }
1061
1127
  }
1062
1128
  },
1063
1129
  }),
@@ -1154,16 +1220,18 @@ export class AcpProxy {
1154
1220
  // Broadcast task started to mobile nodes
1155
1221
  this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
1156
1222
 
1157
- // Wire streaming to send acp_stream frames
1223
+ // Wire streaming to send acp_stream frames (to requester + all session watchers)
1158
1224
  session.setStreamCallback((delta, event) => {
1159
- this.peerManager.sendTo(from, {
1225
+ const streamFrame: AcpStreamChunk = {
1160
1226
  type: "acp_stream",
1161
1227
  id: requestId,
1162
1228
  from: this.config.nodeId,
1163
1229
  to: from,
1164
1230
  timestamp: Date.now(),
1165
- payload: { delta, event, done: false },
1166
- } satisfies AcpStreamChunk);
1231
+ payload: { delta, event, done: false, sessionId: session.sessionId },
1232
+ };
1233
+ this.peerManager.sendTo(from, streamFrame);
1234
+ this.sendToOtherWatchers(session.sessionId, from, streamFrame);
1167
1235
 
1168
1236
  // Broadcast progress to mobile nodes (only on meaningful events, not every text chunk)
1169
1237
  if (event === "tool_call") {
@@ -1184,37 +1252,41 @@ export class AcpProxy {
1184
1252
  });
1185
1253
 
1186
1254
  try {
1187
- const promptParts: Array<{ type: string; text?: string; data?: string; media_type?: string }> = [
1255
+ const promptParts: Array<Record<string, unknown>> = [
1188
1256
  { type: "text", text: task },
1189
1257
  ];
1190
1258
  if (images && images.length > 0) {
1191
1259
  for (const img of images) {
1192
- promptParts.push({ type: "image", data: img.data, media_type: img.mediaType });
1260
+ promptParts.push({ type: "image", data: img.data, mimeType: img.mediaType });
1193
1261
  }
1194
1262
  }
1195
1263
  const promptResponse = await session.conn.prompt({
1196
1264
  sessionId: session.acpSessionId,
1197
- prompt: promptParts,
1265
+ prompt: promptParts as any,
1198
1266
  });
1199
1267
 
1200
- // Send done marker
1201
- this.peerManager.sendTo(from, {
1268
+ // Send done marker to requester + watchers
1269
+ const doneFrame: AcpStreamChunk = {
1202
1270
  type: "acp_stream",
1203
1271
  id: requestId,
1204
1272
  from: this.config.nodeId,
1205
1273
  to: from,
1206
1274
  timestamp: Date.now(),
1207
- payload: { delta: "", done: true },
1208
- } satisfies AcpStreamChunk);
1275
+ payload: { delta: "", done: true, sessionId: session.sessionId },
1276
+ };
1277
+ this.peerManager.sendTo(from, doneFrame);
1278
+ this.sendToOtherWatchers(session.sessionId, from, doneFrame);
1209
1279
 
1210
- this.sendResponse(requestId, from, {
1280
+ const responsePayload: AcpTaskResponse["payload"] = {
1211
1281
  success: true,
1212
1282
  nodeId: this.config.nodeId,
1213
1283
  agent: session.agent,
1214
1284
  sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
1215
1285
  acpSessionId: session.acpSessionId,
1216
1286
  stopReason: promptResponse.stopReason,
1217
- });
1287
+ };
1288
+ this.sendResponse(requestId, from, responsePayload);
1289
+ this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
1218
1290
 
1219
1291
  // Broadcast task completed
1220
1292
  this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
@@ -1222,7 +1294,7 @@ export class AcpProxy {
1222
1294
  // Broadcast task failed
1223
1295
  this.taskActivity.broadcast(
1224
1296
  requestId, "acp", "failed", session.agent, promptStartedAt,
1225
- err instanceof Error ? err.message : String(err),
1297
+ errorMessage(err),
1226
1298
  );
1227
1299
  throw err;
1228
1300
  } finally {
@@ -1271,31 +1343,35 @@ export class AcpProxy {
1271
1343
  throw new Error(`Gateway returned ${res.status}: ${text}`);
1272
1344
  }
1273
1345
 
1274
- // Stream SSE response as acp_stream frames
1275
- const result = await this.streamGatewaySSE(res, requestId, from);
1346
+ // Stream SSE response as acp_stream frames (to requester + watchers)
1347
+ const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
1276
1348
 
1277
- // Send done marker
1278
- this.peerManager.sendTo(from, {
1349
+ // Send done marker to requester + watchers
1350
+ const doneFrame: AcpStreamChunk = {
1279
1351
  type: "acp_stream",
1280
1352
  id: requestId,
1281
1353
  from: this.config.nodeId,
1282
1354
  to: from,
1283
1355
  timestamp: Date.now(),
1284
- payload: { delta: "", done: true },
1285
- } satisfies AcpStreamChunk);
1356
+ payload: { delta: "", done: true, sessionId: session.sessionId },
1357
+ };
1358
+ this.peerManager.sendTo(from, doneFrame);
1359
+ this.sendToOtherWatchers(session.sessionId, from, doneFrame);
1286
1360
 
1287
- this.sendResponse(requestId, from, {
1361
+ const responsePayload: AcpTaskResponse["payload"] = {
1288
1362
  success: true,
1289
1363
  nodeId: this.config.nodeId,
1290
1364
  agent: session.agent,
1291
1365
  sessionId: session.sessionId,
1292
1366
  result,
1293
- });
1367
+ };
1368
+ this.sendResponse(requestId, from, responsePayload);
1369
+ this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
1294
1370
  this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
1295
1371
  } catch (err) {
1296
1372
  this.taskActivity.broadcast(
1297
1373
  requestId, "acp", "failed", session.agent, promptStartedAt,
1298
- err instanceof Error ? err.message : String(err),
1374
+ errorMessage(err),
1299
1375
  );
1300
1376
  throw err;
1301
1377
  } finally {
@@ -1304,7 +1380,7 @@ export class AcpProxy {
1304
1380
  }
1305
1381
 
1306
1382
  /** Parse SSE streaming response from gateway and relay as acp_stream chunks. */
1307
- private async streamGatewaySSE(res: Response, requestId: string, to: string): Promise<string> {
1383
+ private async streamGatewaySSE(res: Response, requestId: string, to: string, sessionId?: string): Promise<string> {
1308
1384
  const body = res.body;
1309
1385
  if (!body) return "";
1310
1386
 
@@ -1332,14 +1408,16 @@ export class AcpProxy {
1332
1408
  const delta = parsed.choices?.[0]?.delta?.content;
1333
1409
  if (delta) {
1334
1410
  full += delta;
1335
- this.peerManager.sendTo(to, {
1411
+ const streamFrame: AcpStreamChunk = {
1336
1412
  type: "acp_stream",
1337
1413
  id: requestId,
1338
1414
  from: this.config.nodeId,
1339
1415
  to,
1340
1416
  timestamp: Date.now(),
1341
- payload: { delta, done: false },
1342
- } satisfies AcpStreamChunk);
1417
+ payload: { delta, done: false, sessionId },
1418
+ };
1419
+ this.peerManager.sendTo(to, streamFrame);
1420
+ if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
1343
1421
  }
1344
1422
  } catch {
1345
1423
  // skip malformed SSE lines
@@ -1414,8 +1492,16 @@ export class AcpProxy {
1414
1492
  streamCallback?.(content.text, updateType);
1415
1493
  }
1416
1494
  } else if (updateType === "tool_call") {
1417
- const toolName = (update as { name?: string }).name ?? "unknown";
1495
+ const toolName = (update as { title?: string }).title
1496
+ || (update as { toolCallId?: string }).toolCallId
1497
+ || "unknown";
1498
+ debug("acp", `tool_call update: title=${JSON.stringify((update as any).title)} toolCallId=${JSON.stringify((update as any).toolCallId)} keys=${Object.keys(update).join(",")}`);
1418
1499
  streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1500
+ } else if (updateType === "tool_call_update") {
1501
+ const status = (update as { status?: string }).status;
1502
+ if (status === "completed" || status === "error") {
1503
+ streamCallback?.("", "tool_result");
1504
+ }
1419
1505
  }
1420
1506
  },
1421
1507
  }),
@@ -1628,16 +1714,38 @@ export class AcpProxy {
1628
1714
 
1629
1715
  private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
1630
1716
  debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
1631
- const sent = this.peerManager.sendTo(to, {
1632
- type: "acp_res",
1717
+ const frame = {
1718
+ type: "acp_res" as const,
1633
1719
  id,
1634
1720
  from: this.config.nodeId,
1635
1721
  to,
1636
1722
  timestamp: Date.now(),
1637
1723
  payload,
1638
- } satisfies AcpTaskResponse);
1724
+ } satisfies AcpTaskResponse;
1725
+
1726
+ const sent = this.peerManager.sendTo(to, frame);
1639
1727
  if (!sent) {
1640
- console.error(`[clawmatrix:acp] Failed to send acp_res to ${to} (no route)`);
1728
+ // Peer may be temporarily disconnected (e.g. mobile network change).
1729
+ // Retry a few times with backoff so the response isn't lost.
1730
+ debug("acp", `acp_res to ${to} failed, scheduling retries`);
1731
+ const retryDelays = [2_000, 5_000, 10_000];
1732
+ let attempt = 0;
1733
+ const retry = () => {
1734
+ if (attempt >= retryDelays.length) {
1735
+ console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
1736
+ return;
1737
+ }
1738
+ setTimeout(() => {
1739
+ frame.timestamp = Date.now();
1740
+ if (this.peerManager.sendTo(to, frame)) {
1741
+ debug("acp", `acp_res to ${to} delivered on retry ${attempt + 1}`);
1742
+ } else {
1743
+ attempt++;
1744
+ retry();
1745
+ }
1746
+ }, retryDelays[attempt]);
1747
+ };
1748
+ retry();
1641
1749
  }
1642
1750
  }
1643
1751
 
@@ -1646,6 +1754,7 @@ export class AcpProxy {
1646
1754
  for (const [id, session] of this.sessions) {
1647
1755
  if (now - session.lastActiveAt > this.sessionTTL) {
1648
1756
  this.sessions.delete(id);
1757
+ this.sessionWatchers.delete(id);
1649
1758
  this.destroySession(session);
1650
1759
  }
1651
1760
  }
@@ -1671,6 +1780,7 @@ export class AcpProxy {
1671
1780
  this.destroySession(session);
1672
1781
  }
1673
1782
  this.sessions.clear();
1783
+ this.sessionWatchers.clear();
1674
1784
  }
1675
1785
  }
1676
1786
 
package/src/handoff.ts CHANGED
@@ -51,6 +51,8 @@ export class HandoffManager {
51
51
  private inputRequiredTargets = new Map<string, string>(); // handoffId → targetNodeId
52
52
  private staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
53
53
  private taskActivity: TaskActivityBroadcaster;
54
+ // Multi-device sync: track which nodes are watching each handoff session (by sessionId)
55
+ private sessionWatchers = new Map<string, Set<string>>();
54
56
 
55
57
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
56
58
  this.config = config;
@@ -62,6 +64,27 @@ export class HandoffManager {
62
64
  this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
63
65
  }
64
66
 
67
+ // ── Multi-device sync helpers ──────────────────────────────────
68
+
69
+ private addSessionWatcher(sessionId: string, nodeId: string) {
70
+ let watchers = this.sessionWatchers.get(sessionId);
71
+ if (!watchers) {
72
+ watchers = new Set();
73
+ this.sessionWatchers.set(sessionId, watchers);
74
+ }
75
+ watchers.add(nodeId);
76
+ }
77
+
78
+ /** Send a handoff frame to all session watchers except the specified node. */
79
+ private sendToOtherWatchers(sessionId: string, exclude: string, frame: HandoffStreamChunk | HandoffResponse | HandoffInputRequired) {
80
+ const watchers = this.sessionWatchers.get(sessionId);
81
+ if (!watchers) return;
82
+ for (const nodeId of watchers) {
83
+ if (nodeId === exclude) continue;
84
+ this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
85
+ }
86
+ }
87
+
65
88
  /** Remove stale input_required and canceled entries that exceeded TTL. */
66
89
  private cleanupStale() {
67
90
  const now = Date.now();
@@ -275,6 +298,9 @@ export class HandoffManager {
275
298
  : payload.task;
276
299
  const images = payload.images;
277
300
 
301
+ // Track this node as a session watcher for multi-device sync
302
+ this.addSessionWatcher(sessionId, from);
303
+
278
304
  const activeEntry: ActiveHandoff = {
279
305
  abortController: null,
280
306
  status: "working",
@@ -367,7 +393,7 @@ export class HandoffManager {
367
393
  throw new Error(`Gateway returned ${res.status}: ${text}`);
368
394
  }
369
395
 
370
- const output = await this.streamSSE(res, id, from);
396
+ const output = await this.streamSSE(res, id, from, activeEntry.sessionId);
371
397
 
372
398
  if (activeEntry.status === "canceled") {
373
399
  this.active.delete(id);
@@ -380,7 +406,7 @@ export class HandoffManager {
380
406
  // Broadcast task completed
381
407
  this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
382
408
 
383
- this.peerManager.sendTo(from, {
409
+ const resFrame: HandoffResponse = {
384
410
  type: "handoff_res",
385
411
  id,
386
412
  from: this.config.nodeId,
@@ -393,7 +419,9 @@ export class HandoffManager {
393
419
  result: output.trim(),
394
420
  sessionId: activeEntry.sessionId,
395
421
  },
396
- } satisfies HandoffResponse);
422
+ };
423
+ this.peerManager.sendTo(from, resFrame);
424
+ this.sendToOtherWatchers(activeEntry.sessionId, from, resFrame);
397
425
  } catch (err) {
398
426
  if (activeEntry.status === "canceled") {
399
427
  this.active.delete(id);
@@ -429,6 +457,7 @@ export class HandoffManager {
429
457
  res: Response,
430
458
  handoffId: string,
431
459
  to: string,
460
+ sessionId?: string,
432
461
  ): Promise<string> {
433
462
  const body = res.body;
434
463
  if (!body) return "";
@@ -457,14 +486,16 @@ export class HandoffManager {
457
486
  const delta = parsed.choices?.[0]?.delta?.content;
458
487
  if (delta) {
459
488
  full += delta;
460
- this.peerManager.sendTo(to, {
489
+ const streamFrame: HandoffStreamChunk = {
461
490
  type: "handoff_stream",
462
491
  id: handoffId,
463
492
  from: this.config.nodeId,
464
493
  to,
465
494
  timestamp: Date.now(),
466
- payload: { delta, done: false },
467
- } satisfies HandoffStreamChunk);
495
+ payload: { delta, done: false, sessionId },
496
+ };
497
+ this.peerManager.sendTo(to, streamFrame);
498
+ if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
468
499
 
469
500
  // Broadcast progress to mobile nodes (throttled, detail is just
470
501
  // a heartbeat — don't send token-level deltas as they're meaningless fragments)
@@ -484,15 +515,17 @@ export class HandoffManager {
484
515
  reader.releaseLock();
485
516
  }
486
517
 
487
- // Send final done marker
488
- this.peerManager.sendTo(to, {
518
+ // Send final done marker to requester + watchers
519
+ const doneFrame: HandoffStreamChunk = {
489
520
  type: "handoff_stream",
490
521
  id: handoffId,
491
522
  from: this.config.nodeId,
492
523
  to,
493
524
  timestamp: Date.now(),
494
- payload: { delta: "", done: true },
495
- } satisfies HandoffStreamChunk);
525
+ payload: { delta: "", done: true, sessionId },
526
+ };
527
+ this.peerManager.sendTo(to, doneFrame);
528
+ if (sessionId) this.sendToOtherWatchers(sessionId, to, doneFrame);
496
529
 
497
530
  return full;
498
531
  }
@@ -641,6 +674,7 @@ export class HandoffManager {
641
674
  }
642
675
  this.active.clear();
643
676
  this.inputRequiredTargets.clear();
677
+ this.sessionWatchers.clear();
644
678
  }
645
679
  }
646
680
 
package/src/index.ts CHANGED
@@ -39,7 +39,7 @@ function discoverModels(
39
39
  const result: ClawMatrixConfig["models"] = [];
40
40
  const seenIds = new Set<string>(); // "provider/modelId"
41
41
 
42
- // 1. Discover from models.providers (explicit provider configs)
42
+ // Discover from models.providers (explicit provider configs with baseUrl + apiKey)
43
43
  const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
44
44
  if (providers && typeof providers === "object") {
45
45
  for (const [providerId, providerConfig] of Object.entries(providers)) {
@@ -72,47 +72,6 @@ function discoverModels(
72
72
  }
73
73
  }
74
74
 
75
- // 2. Discover from agents.defaults.model (primary + fallbacks) — the user's
76
- // configured model list, which includes pi-ai built-in models like
77
- // anthropic/claude-opus-4-6 that aren't in models.providers.
78
- const agentDefaults = (cfg.agents as { defaults?: { model?: { primary?: string; fallbacks?: string[] } } } | undefined)?.defaults;
79
- const modelConfig = agentDefaults?.model;
80
- if (modelConfig) {
81
- const refs: string[] = [];
82
- if (modelConfig.primary) refs.push(modelConfig.primary);
83
- if (Array.isArray(modelConfig.fallbacks)) refs.push(...modelConfig.fallbacks);
84
-
85
- for (const modelRef of refs) {
86
- if (typeof modelRef !== "string") continue;
87
- const slashIdx = modelRef.indexOf("/");
88
- if (slashIdx <= 0) continue;
89
- const provider = modelRef.slice(0, slashIdx);
90
- const modelId = modelRef.slice(slashIdx + 1);
91
- if (!modelId) continue;
92
-
93
- const key = `${provider}/${modelId}`;
94
- if (seenIds.has(key)) continue;
95
- if (proxyNodeIds.has(provider)) continue;
96
- seenIds.add(key);
97
-
98
- // Look up provider config for richer model info if available
99
- const providerConfig = providers?.[provider];
100
- const providerModel = providerConfig?.models?.find((m: Record<string, unknown>) => m.id === modelId);
101
- result.push({
102
- id: modelId,
103
- provider,
104
- description: providerModel?.name as string | undefined,
105
- baseUrl: providerConfig?.baseUrl,
106
- apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
107
- api: (providerModel?.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
108
- contextWindow: providerModel?.contextWindow as number | undefined,
109
- maxTokens: providerModel?.maxTokens as number | undefined,
110
- reasoning: providerModel?.reasoning as boolean | undefined,
111
- input: providerModel?.input as ("text" | "image")[] | undefined,
112
- });
113
- }
114
- }
115
-
116
75
  if (result.length > 0) {
117
76
  debug("models", `Auto-discovered ${result.length} model(s): ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
118
77
  }
@@ -158,15 +117,16 @@ const plugin = {
158
117
  }
159
118
  }
160
119
 
161
- // Filter out models that have no direct API endpoint — these rely on OpenClaw's
162
- // internal API key management and cannot be served to remote cluster nodes.
163
- // Without this filter, remote nodes discover and try to use these models via
164
- // model_req, only to get "No direct API endpoint configured" errors.
165
- const openclawProviders = ((api.config as Record<string, unknown>).models as { providers?: Record<string, { baseUrl?: string }> } | undefined)?.providers;
120
+ // Filter out models that have no direct API endpoint with apiKey — these rely
121
+ // on OpenClaw's internal API key management and cannot be served to remote
122
+ // cluster nodes. Built-in providers (e.g. anthropic) may have a default baseUrl
123
+ // but no apiKey in the provider config, so we must check both.
124
+ const openclawProviders = ((api.config as Record<string, unknown>).models as { providers?: Record<string, { baseUrl?: string; apiKey?: string }> } | undefined)?.providers;
166
125
  const servable = config.models.filter((m) => {
167
- if (m.baseUrl) return true;
168
- if (openclawProviders?.[m.provider]?.baseUrl) return true;
169
- return false;
126
+ const providerCfg = openclawProviders?.[m.provider];
127
+ const baseUrl = m.baseUrl || providerCfg?.baseUrl;
128
+ const apiKey = m.apiKey || (typeof providerCfg?.apiKey === "string" ? providerCfg.apiKey : undefined);
129
+ return !!(baseUrl && apiKey);
170
130
  });
171
131
  if (servable.length < config.models.length) {
172
132
  const dropped = config.models.length - servable.length;
package/src/types.ts CHANGED
@@ -198,6 +198,7 @@ export interface HandoffStreamChunk extends ClusterFrame {
198
198
  delta: string;
199
199
  done: boolean;
200
200
  artifacts?: Artifact[];
201
+ sessionId?: string; // included for session watchers (multi-device sync)
201
202
  };
202
203
  }
203
204
 
@@ -472,6 +473,7 @@ export interface AcpStreamChunk extends ClusterFrame {
472
473
  delta: string;
473
474
  event?: string; // "agent_message_chunk" | "tool_call" | etc.
474
475
  done: boolean;
476
+ sessionId?: string; // included for session watchers (multi-device sync)
475
477
  };
476
478
  }
477
479