clawmatrix 0.2.9 → 0.3.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/LICENSE +27 -0
- package/README.md +123 -12
- package/package.json +2 -1
- package/src/acp-proxy.ts +433 -70
- package/src/cli.ts +478 -10
- package/src/cluster-service.ts +158 -14
- package/src/compat.ts +0 -6
- package/src/config.ts +17 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +721 -0
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +6 -1
- package/src/index.ts +245 -11
- package/src/knowledge-sync.ts +74 -7
- package/src/model-proxy.ts +35 -10
- package/src/peer-manager.ts +84 -13
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +59 -7
- package/src/sentinel.ts +13 -3
- package/src/terminal.ts +2 -1
- package/src/tool-proxy.ts +12 -4
- package/src/tools/cluster-diagnostic.ts +5 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-transfer.ts +91 -0
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +191 -2
- package/src/web.ts +2 -10
- package/src/web-ui.ts +0 -1622
package/src/acp-proxy.ts
CHANGED
|
@@ -35,6 +35,13 @@ import type {
|
|
|
35
35
|
ChatHistoryRequest,
|
|
36
36
|
ChatHistoryResponse,
|
|
37
37
|
ChatHistoryMessage,
|
|
38
|
+
AcpSetConfigRequest,
|
|
39
|
+
AcpSetConfigResponse,
|
|
40
|
+
AcpConfigOption,
|
|
41
|
+
AcpSubscribeRequest,
|
|
42
|
+
AcpSubscribeResponse,
|
|
43
|
+
AcpUnsubscribeRequest,
|
|
44
|
+
AcpSessionNotify,
|
|
38
45
|
} from "./types.ts";
|
|
39
46
|
import { TaskActivityBroadcaster } from "./task-activity.ts";
|
|
40
47
|
|
|
@@ -74,7 +81,7 @@ interface AgentDaemon {
|
|
|
74
81
|
conn: ClientSideConnection;
|
|
75
82
|
proc: { kill: () => void; exited: Promise<number> };
|
|
76
83
|
/** Per-session stream callbacks keyed by ACP session ID */
|
|
77
|
-
streamCallbacks: Map<string, ((delta: string, event?: string) => void) | null>;
|
|
84
|
+
streamCallbacks: Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>;
|
|
78
85
|
lastActiveAt: number;
|
|
79
86
|
idleTimer: ReturnType<typeof setTimeout>;
|
|
80
87
|
/** Set to true when process has exited */
|
|
@@ -92,9 +99,11 @@ interface AcpSession {
|
|
|
92
99
|
proc: { kill: () => void; exited: Promise<number> };
|
|
93
100
|
lastActiveAt: number;
|
|
94
101
|
from: string; // owning nodeId
|
|
95
|
-
setStreamCallback: (cb: ((delta: string, event?: string) => void) | null) => void;
|
|
102
|
+
setStreamCallback: (cb: ((delta: string, event?: string, data?: unknown) => void) | null) => void;
|
|
96
103
|
availableModes?: AcpModeInfo[];
|
|
97
104
|
currentModeId?: string;
|
|
105
|
+
configOptions?: AcpConfigOption[];
|
|
106
|
+
slashCommands?: import("./types.ts").AcpSlashCommand[];
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
/** Session backed by local OpenClaw gateway (not a spawned ACP agent). */
|
|
@@ -153,11 +162,15 @@ export class AcpProxy {
|
|
|
153
162
|
private warmPool = new Map<string, AcpSession[]>();
|
|
154
163
|
// Track pending prewarm timers so they can be cancelled on dispose
|
|
155
164
|
private prewarmTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
165
|
+
// Track pending retry timers for sendResponse so they can be cancelled on destroy
|
|
166
|
+
private retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
156
167
|
private disposed = false;
|
|
157
168
|
// Agent daemon pool: long-lived process per agent type, reused across sessions
|
|
158
169
|
private daemons = new Map<string, AgentDaemon>();
|
|
159
170
|
// In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
|
|
160
171
|
private daemonStarting = new Map<string, Promise<AgentDaemon>>();
|
|
172
|
+
// In-flight resume operations: prevents concurrent resumes for same acpSessionId
|
|
173
|
+
private resumeInFlight = new Map<string, Promise<AcpSession>>();
|
|
161
174
|
|
|
162
175
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
|
|
163
176
|
this.config = config;
|
|
@@ -216,6 +229,100 @@ export class AcpProxy {
|
|
|
216
229
|
}
|
|
217
230
|
}
|
|
218
231
|
|
|
232
|
+
// ── Subscribe / observe (receiver side) ─────────────────────────
|
|
233
|
+
|
|
234
|
+
/** Handle acp_subscribe: register caller as observer and return history snapshot.
|
|
235
|
+
*
|
|
236
|
+
* `payload.sessionId` may be an ACP/OpenClaw session ID (from the list) or a
|
|
237
|
+
* ClawMatrix session ID. We resolve to the ClawMatrix session ID for watcher
|
|
238
|
+
* registration, while using the original ID for transcript lookup (disk files
|
|
239
|
+
* are named by ACP session ID).
|
|
240
|
+
*/
|
|
241
|
+
async handleSubscribeRequest(frame: AcpSubscribeRequest): Promise<void> {
|
|
242
|
+
const { id, from, payload } = frame;
|
|
243
|
+
const { sessionId } = payload;
|
|
244
|
+
|
|
245
|
+
// Resolve to ClawMatrix session ID for watcher registration (reverse lookup by acpSessionId)
|
|
246
|
+
let watcherKey = sessionId;
|
|
247
|
+
if (!this.sessions.has(sessionId)) {
|
|
248
|
+
for (const [sid, s] of this.sessions) {
|
|
249
|
+
if (s.kind === "acp" && s.acpSessionId === sessionId) {
|
|
250
|
+
watcherKey = sid;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
this.addSessionWatcher(watcherKey, from);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const transcriptPath = await this.findTranscriptPath(sessionId);
|
|
259
|
+
let history: import("./types.ts").ChatHistoryMessage[] = [];
|
|
260
|
+
if (transcriptPath) {
|
|
261
|
+
const content = await readFileText(transcriptPath);
|
|
262
|
+
const lines = content.split(/\r?\n/).filter((l: string) => l.trim());
|
|
263
|
+
history = normalizeTranscriptMessages(lines, 200);
|
|
264
|
+
}
|
|
265
|
+
this.peerManager.sendTo(from, {
|
|
266
|
+
type: "acp_subscribe_res",
|
|
267
|
+
id,
|
|
268
|
+
from: this.config.nodeId,
|
|
269
|
+
to: from,
|
|
270
|
+
timestamp: Date.now(),
|
|
271
|
+
payload: { success: true, history },
|
|
272
|
+
} satisfies AcpSubscribeResponse);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
this.peerManager.sendTo(from, {
|
|
275
|
+
type: "acp_subscribe_res",
|
|
276
|
+
id,
|
|
277
|
+
from: this.config.nodeId,
|
|
278
|
+
to: from,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
payload: { success: false, error: errorMessage(err) },
|
|
281
|
+
} satisfies AcpSubscribeResponse);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Handle acp_unsubscribe: remove caller from observer list. */
|
|
286
|
+
handleUnsubscribeRequest(frame: AcpUnsubscribeRequest): void {
|
|
287
|
+
const { from, payload } = frame;
|
|
288
|
+
// Resolve watcher key (same logic as subscribe)
|
|
289
|
+
let watcherKey = payload.sessionId;
|
|
290
|
+
if (!this.sessionWatchers.has(watcherKey)) {
|
|
291
|
+
for (const [sid, s] of this.sessions) {
|
|
292
|
+
if (s.kind === "acp" && s.acpSessionId === payload.sessionId) {
|
|
293
|
+
watcherKey = sid;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const watchers = this.sessionWatchers.get(watcherKey);
|
|
299
|
+
if (watchers) {
|
|
300
|
+
watchers.delete(from);
|
|
301
|
+
if (watchers.size === 0) this.sessionWatchers.delete(watcherKey);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Broadcast acp_session_notify to all connected peers. */
|
|
306
|
+
private broadcastSessionNotify(
|
|
307
|
+
sessionId: string,
|
|
308
|
+
event: AcpSessionNotify["payload"]["event"],
|
|
309
|
+
title?: string,
|
|
310
|
+
updatedAt?: string,
|
|
311
|
+
agent?: string,
|
|
312
|
+
): void {
|
|
313
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
314
|
+
if (peers.length === 0) return;
|
|
315
|
+
const frame: AcpSessionNotify = {
|
|
316
|
+
type: "acp_session_notify",
|
|
317
|
+
from: this.config.nodeId,
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
payload: { sessionId, nodeId: this.config.nodeId, event, title, updatedAt, agent },
|
|
320
|
+
};
|
|
321
|
+
for (const peer of peers) {
|
|
322
|
+
this.peerManager.sendTo(peer.nodeId, { ...frame, to: peer.nodeId });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
219
326
|
// ── Requester side: send prompt ────────────────────────────────
|
|
220
327
|
|
|
221
328
|
async prompt(
|
|
@@ -477,8 +584,14 @@ export class AcpProxy {
|
|
|
477
584
|
const pending = this.pending.get(frame.id);
|
|
478
585
|
if (!pending) return;
|
|
479
586
|
|
|
480
|
-
// Only accumulate
|
|
481
|
-
|
|
587
|
+
// Only accumulate agent text content for the final result.
|
|
588
|
+
// Skip events that produce markers/metadata (not actual response text).
|
|
589
|
+
const skipAccumulate = new Set([
|
|
590
|
+
"agent_thought_chunk", "tool_call", "tool_result", "plan",
|
|
591
|
+
"usage", "available_commands", "config_options",
|
|
592
|
+
"current_mode_update", "session_info_update",
|
|
593
|
+
]);
|
|
594
|
+
if (!skipAccumulate.has(frame.payload.event ?? "")) {
|
|
482
595
|
pending.accumulated += frame.payload.delta;
|
|
483
596
|
}
|
|
484
597
|
if (pending.onStream) {
|
|
@@ -608,9 +721,12 @@ export class AcpProxy {
|
|
|
608
721
|
agent: newSession.agent,
|
|
609
722
|
sessionId: newSession.sessionId,
|
|
610
723
|
acpSessionId: newSession.acpSessionId,
|
|
724
|
+
configOptions: newSession.configOptions,
|
|
725
|
+
availableModes: newSession.availableModes,
|
|
726
|
+
currentModeId: newSession.currentModeId,
|
|
611
727
|
});
|
|
612
728
|
}
|
|
613
|
-
if (mode === "oneshot"
|
|
729
|
+
if (mode === "oneshot") {
|
|
614
730
|
this.returnToReusePool(newSession, cwd || process.cwd());
|
|
615
731
|
}
|
|
616
732
|
} else {
|
|
@@ -668,9 +784,12 @@ export class AcpProxy {
|
|
|
668
784
|
agent: session.agent,
|
|
669
785
|
sessionId: session.sessionId,
|
|
670
786
|
acpSessionId: session.acpSessionId,
|
|
787
|
+
configOptions: session.configOptions,
|
|
788
|
+
availableModes: session.availableModes,
|
|
789
|
+
currentModeId: session.currentModeId,
|
|
671
790
|
});
|
|
672
791
|
}
|
|
673
|
-
if (mode === "oneshot"
|
|
792
|
+
if (mode === "oneshot") {
|
|
674
793
|
this.returnToReusePool(session, cwd || process.cwd());
|
|
675
794
|
}
|
|
676
795
|
} else {
|
|
@@ -750,16 +869,19 @@ export class AcpProxy {
|
|
|
750
869
|
cwd: "",
|
|
751
870
|
agent: session.agent,
|
|
752
871
|
updatedAt: new Date(session.lastActiveAt).toISOString(),
|
|
872
|
+
status: "active",
|
|
753
873
|
});
|
|
754
874
|
}
|
|
755
875
|
|
|
756
876
|
// 2. Read persisted sessions directly from disk (no daemon spawn needed)
|
|
757
877
|
const diskSessions = await this.readAllSessionStoresFromDisk();
|
|
758
878
|
// Filter by agent if requested, and exclude already-tracked active sessions
|
|
759
|
-
const filteredDiskSessions = diskSessions
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
879
|
+
const filteredDiskSessions = diskSessions
|
|
880
|
+
.filter((s) => {
|
|
881
|
+
if (payload.agent && s.agent !== payload.agent) return false;
|
|
882
|
+
return !clawSessions.some((cs) => cs.sessionId === s.sessionId);
|
|
883
|
+
})
|
|
884
|
+
.map((s) => ({ ...s, status: "idle" as const }));
|
|
763
885
|
|
|
764
886
|
this.peerManager.sendTo(from, {
|
|
765
887
|
type: "acp_list_res", id, from: this.config.nodeId, to: from,
|
|
@@ -793,13 +915,28 @@ export class AcpProxy {
|
|
|
793
915
|
}
|
|
794
916
|
|
|
795
917
|
if (this.isAcpAgent(agent)) {
|
|
796
|
-
//
|
|
918
|
+
// Deduplicate concurrent resumes for the same acpSessionId:
|
|
919
|
+
// if another request is already resuming this session, reuse its result.
|
|
920
|
+
const existingResume = this.resumeInFlight.get(acpSessionId);
|
|
797
921
|
let session: AcpSession;
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
922
|
+
if (existingResume) {
|
|
923
|
+
debug("acp", `resume already in-flight for acpSessionId=${acpSessionId.slice(0, 8)}..., reusing`);
|
|
924
|
+
session = await existingResume;
|
|
925
|
+
} else {
|
|
926
|
+
const resumePromise = (async (): Promise<AcpSession> => {
|
|
927
|
+
try {
|
|
928
|
+
return await this.createSessionWithResume(agent, acpSessionId, cwd, from);
|
|
929
|
+
} catch (resumeErr) {
|
|
930
|
+
debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
|
|
931
|
+
return await this.createSession(agent, cwd, from);
|
|
932
|
+
}
|
|
933
|
+
})();
|
|
934
|
+
this.resumeInFlight.set(acpSessionId, resumePromise);
|
|
935
|
+
try {
|
|
936
|
+
session = await resumePromise;
|
|
937
|
+
} finally {
|
|
938
|
+
this.resumeInFlight.delete(acpSessionId);
|
|
939
|
+
}
|
|
803
940
|
}
|
|
804
941
|
this.sessions.set(session.sessionId, session);
|
|
805
942
|
this.addSessionWatcher(session.sessionId, from);
|
|
@@ -854,11 +991,13 @@ export class AcpProxy {
|
|
|
854
991
|
return;
|
|
855
992
|
}
|
|
856
993
|
|
|
857
|
-
|
|
994
|
+
// Allow cancel from session owner OR any session watcher (multi-device sync)
|
|
995
|
+
const watchers = this.sessionWatchers.get(payload.sessionId);
|
|
996
|
+
if (session.from !== from && !watchers?.has(from)) {
|
|
858
997
|
this.peerManager.sendTo(from, {
|
|
859
998
|
type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
|
|
860
999
|
timestamp: Date.now(),
|
|
861
|
-
payload: { success: false, error: "Not the session owner" },
|
|
1000
|
+
payload: { success: false, error: "Not the session owner or watcher" },
|
|
862
1001
|
} satisfies AcpCancelResponse);
|
|
863
1002
|
return;
|
|
864
1003
|
}
|
|
@@ -960,6 +1099,100 @@ export class AcpProxy {
|
|
|
960
1099
|
} satisfies AcpGetModesResponse);
|
|
961
1100
|
}
|
|
962
1101
|
|
|
1102
|
+
// ── Config options (receiver side) ──────────────────────────────
|
|
1103
|
+
|
|
1104
|
+
/** Handle set config option request (receiver side): change model, thinking level, etc. */
|
|
1105
|
+
async handleSetConfigRequest(frame: AcpSetConfigRequest): Promise<void> {
|
|
1106
|
+
const { id, from, payload } = frame;
|
|
1107
|
+
const session = this.sessions.get(payload.sessionId);
|
|
1108
|
+
|
|
1109
|
+
if (!session) {
|
|
1110
|
+
this.peerManager.sendTo(from, {
|
|
1111
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1112
|
+
timestamp: Date.now(),
|
|
1113
|
+
payload: { success: false, error: "Session not found" },
|
|
1114
|
+
} satisfies AcpSetConfigResponse);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (session.kind !== "acp") {
|
|
1119
|
+
this.peerManager.sendTo(from, {
|
|
1120
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1121
|
+
timestamp: Date.now(),
|
|
1122
|
+
payload: { success: false, error: "Config options not supported for native sessions" },
|
|
1123
|
+
} satisfies AcpSetConfigResponse);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
try {
|
|
1128
|
+
const response = await session.conn.setSessionConfigOption({
|
|
1129
|
+
sessionId: session.acpSessionId,
|
|
1130
|
+
configId: payload.configId,
|
|
1131
|
+
value: payload.value as string,
|
|
1132
|
+
});
|
|
1133
|
+
const configOptions = (response as Record<string, unknown>)?.configOptions as AcpConfigOption[] | undefined;
|
|
1134
|
+
this.peerManager.sendTo(from, {
|
|
1135
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1136
|
+
timestamp: Date.now(),
|
|
1137
|
+
payload: { success: true, configOptions },
|
|
1138
|
+
} satisfies AcpSetConfigResponse);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
this.peerManager.sendTo(from, {
|
|
1141
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1142
|
+
timestamp: Date.now(),
|
|
1143
|
+
payload: { success: false, error: errorMessage(err) },
|
|
1144
|
+
} satisfies AcpSetConfigResponse);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Handle set config option response (requester side). */
|
|
1149
|
+
handleSetConfigResponse(frame: AcpSetConfigResponse): void {
|
|
1150
|
+
const pending = this.pending.get(frame.id);
|
|
1151
|
+
if (!pending) return;
|
|
1152
|
+
clearTimeout(pending.timer);
|
|
1153
|
+
this.pending.delete(frame.id);
|
|
1154
|
+
pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Set a config option on a remote session (requester side). */
|
|
1158
|
+
setSessionConfig(
|
|
1159
|
+
targetNodeId: string,
|
|
1160
|
+
sessionId: string,
|
|
1161
|
+
configId: string,
|
|
1162
|
+
value: string | boolean,
|
|
1163
|
+
): Promise<AcpSetConfigResponse["payload"]> {
|
|
1164
|
+
const id = crypto.randomUUID();
|
|
1165
|
+
return new Promise((resolve, reject) => {
|
|
1166
|
+
const timer = setTimeout(() => {
|
|
1167
|
+
this.pending.delete(id);
|
|
1168
|
+
reject(new Error("ACP set config request timed out"));
|
|
1169
|
+
}, 15_000);
|
|
1170
|
+
|
|
1171
|
+
this.pending.set(id, {
|
|
1172
|
+
resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
|
|
1173
|
+
reject,
|
|
1174
|
+
timer,
|
|
1175
|
+
targetNodeId,
|
|
1176
|
+
accumulated: "",
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
const frame: AcpSetConfigRequest = {
|
|
1180
|
+
type: "acp_set_config",
|
|
1181
|
+
id,
|
|
1182
|
+
from: this.config.nodeId,
|
|
1183
|
+
to: targetNodeId,
|
|
1184
|
+
timestamp: Date.now(),
|
|
1185
|
+
payload: { sessionId, configId, value },
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
if (!this.peerManager.sendTo(targetNodeId, frame)) {
|
|
1189
|
+
this.pending.delete(id);
|
|
1190
|
+
clearTimeout(timer);
|
|
1191
|
+
reject(new Error(`Cannot reach node "${targetNodeId}"`));
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
963
1196
|
// ── Chat history (receiver side) ─────────────────────────────────
|
|
964
1197
|
|
|
965
1198
|
/** Handle chat history request: read session transcript from disk and return normalized messages. */
|
|
@@ -1149,7 +1382,7 @@ export class AcpProxy {
|
|
|
1149
1382
|
}
|
|
1150
1383
|
|
|
1151
1384
|
private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
|
|
1152
|
-
const streamCallbacks = new Map<string, ((delta: string, event?: string) => void) | null>();
|
|
1385
|
+
const streamCallbacks = new Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>();
|
|
1153
1386
|
|
|
1154
1387
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
|
|
1155
1388
|
const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
|
|
@@ -1231,6 +1464,7 @@ export class AcpProxy {
|
|
|
1231
1464
|
const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
|
|
1232
1465
|
(m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
|
|
1233
1466
|
);
|
|
1467
|
+
const configOptions = (response as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined;
|
|
1234
1468
|
|
|
1235
1469
|
const session: AcpSession = {
|
|
1236
1470
|
kind: "acp",
|
|
@@ -1246,6 +1480,7 @@ export class AcpProxy {
|
|
|
1246
1480
|
},
|
|
1247
1481
|
availableModes,
|
|
1248
1482
|
currentModeId: response.modes?.currentModeId,
|
|
1483
|
+
configOptions,
|
|
1249
1484
|
};
|
|
1250
1485
|
|
|
1251
1486
|
return session;
|
|
@@ -1275,11 +1510,17 @@ export class AcpProxy {
|
|
|
1275
1510
|
|
|
1276
1511
|
/** Return a completed oneshot session to the reuse pool instead of destroying it. */
|
|
1277
1512
|
private returnToReusePool(session: AcpSession, cwd: string) {
|
|
1278
|
-
// Daemon-backed sessions don't need reuse pool — the daemon stays alive
|
|
1513
|
+
// Daemon-backed sessions don't need reuse pool — the daemon stays alive.
|
|
1514
|
+
// But we must close the ACP session on the daemon to prevent accumulation.
|
|
1279
1515
|
const daemon = this.findDaemonByConn(session);
|
|
1280
1516
|
if (daemon) {
|
|
1281
1517
|
daemon.streamCallbacks.delete(session.acpSessionId);
|
|
1282
|
-
|
|
1518
|
+
// Close the ACP session on the daemon connection (best-effort, don't block).
|
|
1519
|
+
// Uses unstable_closeSession per ACP SDK — session/close is still experimental.
|
|
1520
|
+
daemon.conn.unstable_closeSession({ sessionId: session.acpSessionId }).catch((err) => {
|
|
1521
|
+
debug("acp", `daemon closeSession failed for ${session.acpSessionId}: ${errorMessage(err)}`);
|
|
1522
|
+
});
|
|
1523
|
+
debug("acp", `daemon-backed oneshot done: agent=${session.agent} (session closed, daemon stays alive)`);
|
|
1283
1524
|
return;
|
|
1284
1525
|
}
|
|
1285
1526
|
|
|
@@ -1386,7 +1627,7 @@ export class AcpProxy {
|
|
|
1386
1627
|
agent: string,
|
|
1387
1628
|
cwd: string,
|
|
1388
1629
|
label: string,
|
|
1389
|
-
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
|
|
1630
|
+
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string, data?: unknown) => void) | null | undefined,
|
|
1390
1631
|
): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
|
|
1391
1632
|
const cmd = this.resolveCommand(agent);
|
|
1392
1633
|
debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
|
|
@@ -1442,15 +1683,68 @@ export class AcpProxy {
|
|
|
1442
1683
|
cb?.(content.text, updateType);
|
|
1443
1684
|
}
|
|
1444
1685
|
} else if (updateType === "tool_call") {
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1686
|
+
const title = (update as { title?: string }).title;
|
|
1687
|
+
const toolCallId = (update as { toolCallId?: string }).toolCallId;
|
|
1688
|
+
const kind = (update as { kind?: string }).kind;
|
|
1689
|
+
const toolName = title || toolCallId || "unknown";
|
|
1690
|
+
cb?.(`\n[tool_call: ${toolName}]\n`, updateType, { title, toolCallId, kind });
|
|
1449
1691
|
} else if (updateType === "tool_call_update") {
|
|
1450
1692
|
const status = (update as { status?: string }).status;
|
|
1693
|
+
// Extract tool output content from completed tool calls
|
|
1694
|
+
const contentBlocks = (update as { content?: Array<{ type?: string; text?: string; content?: Array<{ type?: string; text?: string }> }> }).content;
|
|
1695
|
+
let toolOutput = "";
|
|
1696
|
+
if (contentBlocks) {
|
|
1697
|
+
for (const block of contentBlocks) {
|
|
1698
|
+
if (block.type === "content" && Array.isArray(block.content)) {
|
|
1699
|
+
// Nested content block (e.g. from tool call content wrapper)
|
|
1700
|
+
for (const inner of block.content) {
|
|
1701
|
+
if (inner.type === "text" && inner.text) toolOutput += inner.text;
|
|
1702
|
+
}
|
|
1703
|
+
} else if (block.type === "text" && block.text) {
|
|
1704
|
+
toolOutput += block.text;
|
|
1705
|
+
} else if (typeof block.text === "string") {
|
|
1706
|
+
toolOutput += block.text;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
// Strip Codex terminal output metadata prefix
|
|
1711
|
+
toolOutput = stripCodexTerminalMeta(toolOutput);
|
|
1451
1712
|
if (status === "completed" || status === "error") {
|
|
1452
|
-
cb?.(
|
|
1713
|
+
cb?.(toolOutput, "tool_result");
|
|
1714
|
+
}
|
|
1715
|
+
} else if (updateType === "plan") {
|
|
1716
|
+
// Forward execution plan with structured data
|
|
1717
|
+
const entries = (update as { entries?: Array<{ content?: string; status?: string; priority?: string }> }).entries;
|
|
1718
|
+
if (entries && entries.length > 0) {
|
|
1719
|
+
const planText = entries.map((e) => `[${e.status ?? "pending"}] ${e.content ?? ""}`).join("\n");
|
|
1720
|
+
cb?.(`\n[plan]\n${planText}\n`, "plan", entries);
|
|
1453
1721
|
}
|
|
1722
|
+
} else if (updateType === "current_mode_update") {
|
|
1723
|
+
const modeId = (update as { modeId?: string }).modeId;
|
|
1724
|
+
if (modeId) cb?.("", "current_mode_update", { modeId });
|
|
1725
|
+
} else if (updateType === "session_info_update") {
|
|
1726
|
+
const title = (update as { title?: string }).title;
|
|
1727
|
+
if (title) {
|
|
1728
|
+
cb?.("", "session_info_update", { title });
|
|
1729
|
+
// Broadcast title update so observers can refresh session list
|
|
1730
|
+
const acpSid = params.sessionId;
|
|
1731
|
+
for (const s of this.sessions.values()) {
|
|
1732
|
+
if (s.kind === "acp" && s.acpSessionId === acpSid) {
|
|
1733
|
+
this.broadcastSessionNotify(s.sessionId, "updated", title, new Date().toISOString(), s.agent);
|
|
1734
|
+
break;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
} else if (updateType === "available_commands_update") {
|
|
1739
|
+
const commands = (update as { commands?: Array<{ name?: string; description?: string; input?: { hint?: string } }> }).commands;
|
|
1740
|
+
if (commands) cb?.("", "available_commands", commands);
|
|
1741
|
+
} else if (updateType === "config_option_update") {
|
|
1742
|
+
const options = (update as { configOptions?: unknown[] }).configOptions;
|
|
1743
|
+
if (options) cb?.("", "config_options", options);
|
|
1744
|
+
} else if (updateType === "usage_update") {
|
|
1745
|
+
// Forward token usage stats
|
|
1746
|
+
const { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens } = update as Record<string, unknown>;
|
|
1747
|
+
cb?.("", "usage", { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens });
|
|
1454
1748
|
}
|
|
1455
1749
|
},
|
|
1456
1750
|
}),
|
|
@@ -1465,15 +1759,29 @@ export class AcpProxy {
|
|
|
1465
1759
|
});
|
|
1466
1760
|
|
|
1467
1761
|
try {
|
|
1468
|
-
await Promise.race([
|
|
1762
|
+
const initResponse = await Promise.race([
|
|
1469
1763
|
conn.initialize({
|
|
1470
1764
|
protocolVersion: 1,
|
|
1471
1765
|
clientInfo: { name: "ClawMatrix", version: "0.1.0" },
|
|
1472
|
-
clientCapabilities: {
|
|
1766
|
+
clientCapabilities: {
|
|
1767
|
+
prompt: { image: true },
|
|
1768
|
+
},
|
|
1473
1769
|
}),
|
|
1474
1770
|
initTimeout,
|
|
1475
1771
|
earlyExitPromise as Promise<never>,
|
|
1476
1772
|
]);
|
|
1773
|
+
|
|
1774
|
+
// Handle agents that require authentication (e.g. Codex with ChatGPT login)
|
|
1775
|
+
const authMethods = (initResponse as Record<string, unknown>)?.authMethods as Array<{ id: string }> | undefined;
|
|
1776
|
+
if (authMethods && authMethods.length > 0) {
|
|
1777
|
+
// Auto-authenticate with the first available method (typically OAuth/API key already in env)
|
|
1778
|
+
try {
|
|
1779
|
+
await conn.authenticate({ method: authMethods[0]!.id });
|
|
1780
|
+
debug("acp", `[${agent}] authenticated with method "${authMethods[0]!.id}"`);
|
|
1781
|
+
} catch (authErr) {
|
|
1782
|
+
debug("acp", `[${agent}] authentication failed: ${errorMessage(authErr)} (continuing without auth)`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1477
1785
|
} catch (err) {
|
|
1478
1786
|
if (!earlyExit) proc.kill();
|
|
1479
1787
|
throw err;
|
|
@@ -1493,9 +1801,9 @@ export class AcpProxy {
|
|
|
1493
1801
|
cwd: string,
|
|
1494
1802
|
from: string,
|
|
1495
1803
|
label: string,
|
|
1496
|
-
sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string } }>,
|
|
1804
|
+
sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string }; configOptions?: AcpConfigOption[] }>,
|
|
1497
1805
|
): Promise<AcpSession> {
|
|
1498
|
-
let streamCallback: ((delta: string, event?: string) => void) | null = null;
|
|
1806
|
+
let streamCallback: ((delta: string, event?: string, data?: unknown) => void) | null = null;
|
|
1499
1807
|
|
|
1500
1808
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
|
|
1501
1809
|
|
|
@@ -1524,6 +1832,7 @@ export class AcpProxy {
|
|
|
1524
1832
|
setStreamCallback: (cb) => { streamCallback = cb; },
|
|
1525
1833
|
availableModes,
|
|
1526
1834
|
currentModeId: response.modes?.currentModeId,
|
|
1835
|
+
configOptions: response.configOptions,
|
|
1527
1836
|
};
|
|
1528
1837
|
|
|
1529
1838
|
this.monitorProcess(session);
|
|
@@ -1556,18 +1865,19 @@ export class AcpProxy {
|
|
|
1556
1865
|
debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
|
|
1557
1866
|
const promptStartedAt = Date.now();
|
|
1558
1867
|
|
|
1559
|
-
// Broadcast task started to mobile nodes
|
|
1868
|
+
// Broadcast task started to mobile nodes + session notify to all peers
|
|
1560
1869
|
this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
|
|
1870
|
+
this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
|
|
1561
1871
|
|
|
1562
1872
|
// Wire streaming to send acp_stream frames (to requester + all session watchers)
|
|
1563
|
-
session.setStreamCallback((delta, event) => {
|
|
1873
|
+
session.setStreamCallback((delta, event, data) => {
|
|
1564
1874
|
const streamFrame: AcpStreamChunk = {
|
|
1565
1875
|
type: "acp_stream",
|
|
1566
1876
|
id: requestId,
|
|
1567
1877
|
from: this.config.nodeId,
|
|
1568
1878
|
to: from,
|
|
1569
1879
|
timestamp: Date.now(),
|
|
1570
|
-
payload: { delta, event, done: false, sessionId: session.sessionId },
|
|
1880
|
+
payload: { delta, event, done: false, sessionId: session.sessionId, data },
|
|
1571
1881
|
};
|
|
1572
1882
|
this.peerManager.sendTo(from, streamFrame);
|
|
1573
1883
|
this.sendToOtherWatchers(session.sessionId, from, streamFrame);
|
|
@@ -1599,25 +1909,33 @@ export class AcpProxy {
|
|
|
1599
1909
|
promptParts.push({ type: "image", data: img.data, mimeType: img.mediaType });
|
|
1600
1910
|
}
|
|
1601
1911
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1912
|
+
// Race prompt against connection close to detect unexpected agent crashes.
|
|
1913
|
+
// Suppress the rejection on the losing branch to avoid unhandled promise rejection.
|
|
1914
|
+
let cancelConnWatch: (() => void) | undefined;
|
|
1915
|
+
const connClosed = new Promise<never>((_, reject) => {
|
|
1916
|
+
const onClose = () => reject(new Error("ACP agent connection closed unexpectedly during prompt"));
|
|
1917
|
+
session.conn.closed.then(onClose, onClose);
|
|
1918
|
+
cancelConnWatch = () => {
|
|
1919
|
+
// Swallow the eventual rejection so it doesn't become unhandled
|
|
1920
|
+
session.conn.closed.catch(() => {});
|
|
1921
|
+
};
|
|
1605
1922
|
});
|
|
1923
|
+
connClosed.catch(() => {}); // prevent unhandled rejection on the race loser
|
|
1924
|
+
const promptResponse = await Promise.race([
|
|
1925
|
+
session.conn.prompt({
|
|
1926
|
+
sessionId: session.acpSessionId,
|
|
1927
|
+
prompt: promptParts as any,
|
|
1928
|
+
}),
|
|
1929
|
+
connClosed,
|
|
1930
|
+
]);
|
|
1931
|
+
cancelConnWatch?.();
|
|
1606
1932
|
|
|
1607
|
-
// Send done marker to
|
|
1608
|
-
|
|
1609
|
-
type: "acp_stream",
|
|
1610
|
-
id: requestId,
|
|
1611
|
-
from: this.config.nodeId,
|
|
1612
|
-
to: from,
|
|
1613
|
-
timestamp: Date.now(),
|
|
1614
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1615
|
-
};
|
|
1616
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1617
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
1933
|
+
// Send done marker BEFORE response to guarantee ordering for clients
|
|
1934
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1618
1935
|
|
|
1936
|
+
const isCancelled = promptResponse.stopReason === "cancelled";
|
|
1619
1937
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1620
|
-
success:
|
|
1938
|
+
success: !isCancelled,
|
|
1621
1939
|
nodeId: this.config.nodeId,
|
|
1622
1940
|
agent: session.agent,
|
|
1623
1941
|
sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
|
|
@@ -1627,9 +1945,17 @@ export class AcpProxy {
|
|
|
1627
1945
|
this.sendResponse(requestId, from, responsePayload);
|
|
1628
1946
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1629
1947
|
|
|
1630
|
-
// Broadcast task completed
|
|
1631
|
-
this.taskActivity.broadcast(
|
|
1948
|
+
// Broadcast task completed (or cancelled)
|
|
1949
|
+
this.taskActivity.broadcast(
|
|
1950
|
+
requestId, "acp", isCancelled ? "failed" : "completed", session.agent, promptStartedAt,
|
|
1951
|
+
isCancelled ? "已取消" : undefined,
|
|
1952
|
+
);
|
|
1953
|
+
if (!isCancelled) {
|
|
1954
|
+
this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
|
|
1955
|
+
}
|
|
1632
1956
|
} catch (err) {
|
|
1957
|
+
// Always send done marker on error so clients don't hang
|
|
1958
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1633
1959
|
// Broadcast task failed
|
|
1634
1960
|
this.taskActivity.broadcast(
|
|
1635
1961
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
@@ -1648,6 +1974,7 @@ export class AcpProxy {
|
|
|
1648
1974
|
debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
|
|
1649
1975
|
const promptStartedAt = Date.now();
|
|
1650
1976
|
this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
|
|
1977
|
+
this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
|
|
1651
1978
|
|
|
1652
1979
|
const { port, authHeader } = this.gatewayInfo;
|
|
1653
1980
|
const abortController = new AbortController();
|
|
@@ -1685,17 +2012,7 @@ export class AcpProxy {
|
|
|
1685
2012
|
// Stream SSE response as acp_stream frames (to requester + watchers)
|
|
1686
2013
|
const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
|
|
1687
2014
|
|
|
1688
|
-
|
|
1689
|
-
const doneFrame: AcpStreamChunk = {
|
|
1690
|
-
type: "acp_stream",
|
|
1691
|
-
id: requestId,
|
|
1692
|
-
from: this.config.nodeId,
|
|
1693
|
-
to: from,
|
|
1694
|
-
timestamp: Date.now(),
|
|
1695
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1696
|
-
};
|
|
1697
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1698
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
2015
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1699
2016
|
|
|
1700
2017
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1701
2018
|
success: true,
|
|
@@ -1707,6 +2024,7 @@ export class AcpProxy {
|
|
|
1707
2024
|
this.sendResponse(requestId, from, responsePayload);
|
|
1708
2025
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1709
2026
|
this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
|
|
2027
|
+
this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
|
|
1710
2028
|
} catch (err) {
|
|
1711
2029
|
this.taskActivity.broadcast(
|
|
1712
2030
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
@@ -1777,9 +2095,21 @@ export class AcpProxy {
|
|
|
1777
2095
|
cwd: string,
|
|
1778
2096
|
from: string,
|
|
1779
2097
|
): Promise<AcpSession> {
|
|
1780
|
-
return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
|
|
1781
|
-
|
|
1782
|
-
|
|
2098
|
+
return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", async (conn, effectiveCwd) => {
|
|
2099
|
+
// Try session/load first (supported by Codex, Claude Code, and most ACP agents).
|
|
2100
|
+
// It restores conversation history and replays it via notifications.
|
|
2101
|
+
try {
|
|
2102
|
+
const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
|
|
2103
|
+
debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
|
|
2104
|
+
return { sessionId: acpSessionId, modes: loadResp.modes, configOptions: (loadResp as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined };
|
|
2105
|
+
} catch (loadErr) {
|
|
2106
|
+
debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Fallback to session/resume (unstable, supported by Claude Code).
|
|
2110
|
+
// Resumes without replaying history — faster but less widely supported.
|
|
2111
|
+
return conn.unstable_resumeSession({ sessionId: acpSessionId, cwd: effectiveCwd });
|
|
2112
|
+
});
|
|
1783
2113
|
}
|
|
1784
2114
|
|
|
1785
2115
|
/** Read all session stores from disk (OpenClaw + Claude Code). */
|
|
@@ -1789,6 +2119,8 @@ export class AcpProxy {
|
|
|
1789
2119
|
|
|
1790
2120
|
private destroySession(session: AnySession) {
|
|
1791
2121
|
invalidateSessionListCache();
|
|
2122
|
+
// Clean up watchers for this session
|
|
2123
|
+
this.sessionWatchers.delete(session.sessionId);
|
|
1792
2124
|
if (session.kind === "acp") {
|
|
1793
2125
|
// Clean up daemon stream callback
|
|
1794
2126
|
const daemon = this.findDaemonByConn(session);
|
|
@@ -2001,6 +2333,20 @@ export class AcpProxy {
|
|
|
2001
2333
|
});
|
|
2002
2334
|
}
|
|
2003
2335
|
|
|
2336
|
+
/** Send a done marker to requester + all session watchers. */
|
|
2337
|
+
private sendDoneMarker(requestId: string, to: string, sessionId: string) {
|
|
2338
|
+
const doneFrame: AcpStreamChunk = {
|
|
2339
|
+
type: "acp_stream",
|
|
2340
|
+
id: requestId,
|
|
2341
|
+
from: this.config.nodeId,
|
|
2342
|
+
to,
|
|
2343
|
+
timestamp: Date.now(),
|
|
2344
|
+
payload: { delta: "", done: true, sessionId },
|
|
2345
|
+
};
|
|
2346
|
+
this.peerManager.sendTo(to, doneFrame);
|
|
2347
|
+
this.sendToOtherWatchers(sessionId, to, doneFrame);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2004
2350
|
private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
|
|
2005
2351
|
debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
|
|
2006
2352
|
const frame = {
|
|
@@ -2020,11 +2366,14 @@ export class AcpProxy {
|
|
|
2020
2366
|
const retryDelays = [2_000, 5_000, 10_000];
|
|
2021
2367
|
let attempt = 0;
|
|
2022
2368
|
const retry = () => {
|
|
2023
|
-
if (attempt >= retryDelays.length) {
|
|
2024
|
-
|
|
2369
|
+
if (this.disposed || attempt >= retryDelays.length) {
|
|
2370
|
+
if (!this.disposed) {
|
|
2371
|
+
console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
|
|
2372
|
+
}
|
|
2025
2373
|
return;
|
|
2026
2374
|
}
|
|
2027
|
-
setTimeout(() => {
|
|
2375
|
+
const timer = setTimeout(() => {
|
|
2376
|
+
this.retryTimers.delete(timer);
|
|
2028
2377
|
frame.timestamp = Date.now();
|
|
2029
2378
|
if (this.peerManager.sendTo(to, frame)) {
|
|
2030
2379
|
debug("acp", `acp_res to ${to} delivered on retry ${attempt + 1}`);
|
|
@@ -2033,6 +2382,7 @@ export class AcpProxy {
|
|
|
2033
2382
|
retry();
|
|
2034
2383
|
}
|
|
2035
2384
|
}, retryDelays[attempt]);
|
|
2385
|
+
this.retryTimers.add(timer);
|
|
2036
2386
|
};
|
|
2037
2387
|
retry();
|
|
2038
2388
|
}
|
|
@@ -2050,6 +2400,9 @@ export class AcpProxy {
|
|
|
2050
2400
|
}
|
|
2051
2401
|
|
|
2052
2402
|
destroy() {
|
|
2403
|
+
// Set disposed first to prevent new timers/prewarms from being scheduled during teardown
|
|
2404
|
+
this.disposed = true;
|
|
2405
|
+
|
|
2053
2406
|
if (this.cleanupTimer) {
|
|
2054
2407
|
clearInterval(this.cleanupTimer);
|
|
2055
2408
|
this.cleanupTimer = null;
|
|
@@ -2071,7 +2424,9 @@ export class AcpProxy {
|
|
|
2071
2424
|
this.sessions.clear();
|
|
2072
2425
|
this.sessionWatchers.clear();
|
|
2073
2426
|
|
|
2074
|
-
|
|
2427
|
+
// Cancel pending retry timers
|
|
2428
|
+
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
2429
|
+
this.retryTimers.clear();
|
|
2075
2430
|
|
|
2076
2431
|
// Cancel pending prewarm timers
|
|
2077
2432
|
for (const timer of this.prewarmTimers) clearTimeout(timer);
|
|
@@ -2100,6 +2455,7 @@ export class AcpProxy {
|
|
|
2100
2455
|
}
|
|
2101
2456
|
this.daemons.clear();
|
|
2102
2457
|
this.daemonStarting.clear();
|
|
2458
|
+
this.resumeInFlight.clear();
|
|
2103
2459
|
}
|
|
2104
2460
|
}
|
|
2105
2461
|
|
|
@@ -2512,6 +2868,12 @@ function stripThinkingTags(text: string): string {
|
|
|
2512
2868
|
.trim();
|
|
2513
2869
|
}
|
|
2514
2870
|
|
|
2871
|
+
/** Strip Codex terminal output metadata (Chunk ID, Wall time, etc.) from tool results. */
|
|
2872
|
+
const CODEX_TERMINAL_META_RE = /^Chunk ID: [^\n]*\nWall time: [^\n]*\nProcess exited with code \d+\n(?:Original token count: [^\n]*\n)?Output:\n/;
|
|
2873
|
+
function stripCodexTerminalMeta(text: string): string {
|
|
2874
|
+
return text.replace(CODEX_TERMINAL_META_RE, "");
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2515
2877
|
/** Check if text is a silent reply (NO_REPLY). */
|
|
2516
2878
|
function isSilentReply(text: string): boolean {
|
|
2517
2879
|
return /^\s*NO_REPLY\s*$/.test(text);
|
|
@@ -2590,7 +2952,8 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
|
|
|
2590
2952
|
: typeof msg.toolName === "string" ? msg.toolName
|
|
2591
2953
|
: typeof msg.tool_name === "string" ? msg.tool_name
|
|
2592
2954
|
: inlineResults[0]?.name;
|
|
2593
|
-
const
|
|
2955
|
+
const rawResultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
|
|
2956
|
+
const resultText = stripCodexTerminalMeta(rawResultText);
|
|
2594
2957
|
|
|
2595
2958
|
if (raw.length > 0) {
|
|
2596
2959
|
const prev = raw[raw.length - 1]!;
|