clawmatrix 0.2.0 → 0.2.2
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 +3 -2
- package/src/acp-proxy.ts +156 -46
- package/src/handoff.ts +44 -10
- package/src/index.ts +10 -50
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -31,12 +31,13 @@
|
|
|
31
31
|
"release": "bunx bumpp"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@agentclientprotocol/sdk": "^0.16.1",
|
|
34
35
|
"@automerge/automerge": "^3.2.4",
|
|
35
36
|
"@mariozechner/pi-coding-agent": ">=0.55.0",
|
|
36
37
|
"ignore": "^7.0.5",
|
|
38
|
+
"node-pty": "^1.0.0",
|
|
37
39
|
"picomatch": "^4.0.3",
|
|
38
40
|
"ws": "^8.19.0",
|
|
39
|
-
"node-pty": "^1.0.0",
|
|
40
41
|
"zod": "^4.3.6"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
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
|
|
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 (${
|
|
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
|
-
|
|
548
|
-
|
|
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:
|
|
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:
|
|
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 (${
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
}
|
|
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<
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
|
162
|
-
// internal API key management and cannot be served to remote
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|