clawmatrix 0.2.11 → 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 +407 -68
- package/src/cli.ts +478 -10
- package/src/cluster-service.ts +114 -14
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/index.ts +234 -5
- package/src/knowledge-sync.ts +44 -6
- package/src/model-proxy.ts +35 -10
- package/src/peer-manager.ts +81 -13
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +12 -4
- package/src/tools/cluster-diagnostic.ts +3 -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-write.ts +3 -1
- package/src/types.ts +103 -1
- 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). */
|
|
@@ -160,6 +169,8 @@ export class AcpProxy {
|
|
|
160
169
|
private daemons = new Map<string, AgentDaemon>();
|
|
161
170
|
// In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
|
|
162
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>>();
|
|
163
174
|
|
|
164
175
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
|
|
165
176
|
this.config = config;
|
|
@@ -218,6 +229,100 @@ export class AcpProxy {
|
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
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
|
+
|
|
221
326
|
// ── Requester side: send prompt ────────────────────────────────
|
|
222
327
|
|
|
223
328
|
async prompt(
|
|
@@ -479,8 +584,14 @@ export class AcpProxy {
|
|
|
479
584
|
const pending = this.pending.get(frame.id);
|
|
480
585
|
if (!pending) return;
|
|
481
586
|
|
|
482
|
-
// Only accumulate
|
|
483
|
-
|
|
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 ?? "")) {
|
|
484
595
|
pending.accumulated += frame.payload.delta;
|
|
485
596
|
}
|
|
486
597
|
if (pending.onStream) {
|
|
@@ -610,9 +721,12 @@ export class AcpProxy {
|
|
|
610
721
|
agent: newSession.agent,
|
|
611
722
|
sessionId: newSession.sessionId,
|
|
612
723
|
acpSessionId: newSession.acpSessionId,
|
|
724
|
+
configOptions: newSession.configOptions,
|
|
725
|
+
availableModes: newSession.availableModes,
|
|
726
|
+
currentModeId: newSession.currentModeId,
|
|
613
727
|
});
|
|
614
728
|
}
|
|
615
|
-
if (mode === "oneshot"
|
|
729
|
+
if (mode === "oneshot") {
|
|
616
730
|
this.returnToReusePool(newSession, cwd || process.cwd());
|
|
617
731
|
}
|
|
618
732
|
} else {
|
|
@@ -670,9 +784,12 @@ export class AcpProxy {
|
|
|
670
784
|
agent: session.agent,
|
|
671
785
|
sessionId: session.sessionId,
|
|
672
786
|
acpSessionId: session.acpSessionId,
|
|
787
|
+
configOptions: session.configOptions,
|
|
788
|
+
availableModes: session.availableModes,
|
|
789
|
+
currentModeId: session.currentModeId,
|
|
673
790
|
});
|
|
674
791
|
}
|
|
675
|
-
if (mode === "oneshot"
|
|
792
|
+
if (mode === "oneshot") {
|
|
676
793
|
this.returnToReusePool(session, cwd || process.cwd());
|
|
677
794
|
}
|
|
678
795
|
} else {
|
|
@@ -752,16 +869,19 @@ export class AcpProxy {
|
|
|
752
869
|
cwd: "",
|
|
753
870
|
agent: session.agent,
|
|
754
871
|
updatedAt: new Date(session.lastActiveAt).toISOString(),
|
|
872
|
+
status: "active",
|
|
755
873
|
});
|
|
756
874
|
}
|
|
757
875
|
|
|
758
876
|
// 2. Read persisted sessions directly from disk (no daemon spawn needed)
|
|
759
877
|
const diskSessions = await this.readAllSessionStoresFromDisk();
|
|
760
878
|
// Filter by agent if requested, and exclude already-tracked active sessions
|
|
761
|
-
const filteredDiskSessions = diskSessions
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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 }));
|
|
765
885
|
|
|
766
886
|
this.peerManager.sendTo(from, {
|
|
767
887
|
type: "acp_list_res", id, from: this.config.nodeId, to: from,
|
|
@@ -795,13 +915,28 @@ export class AcpProxy {
|
|
|
795
915
|
}
|
|
796
916
|
|
|
797
917
|
if (this.isAcpAgent(agent)) {
|
|
798
|
-
//
|
|
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);
|
|
799
921
|
let session: AcpSession;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
940
|
}
|
|
806
941
|
this.sessions.set(session.sessionId, session);
|
|
807
942
|
this.addSessionWatcher(session.sessionId, from);
|
|
@@ -856,11 +991,13 @@ export class AcpProxy {
|
|
|
856
991
|
return;
|
|
857
992
|
}
|
|
858
993
|
|
|
859
|
-
|
|
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)) {
|
|
860
997
|
this.peerManager.sendTo(from, {
|
|
861
998
|
type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
|
|
862
999
|
timestamp: Date.now(),
|
|
863
|
-
payload: { success: false, error: "Not the session owner" },
|
|
1000
|
+
payload: { success: false, error: "Not the session owner or watcher" },
|
|
864
1001
|
} satisfies AcpCancelResponse);
|
|
865
1002
|
return;
|
|
866
1003
|
}
|
|
@@ -962,6 +1099,100 @@ export class AcpProxy {
|
|
|
962
1099
|
} satisfies AcpGetModesResponse);
|
|
963
1100
|
}
|
|
964
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
|
+
|
|
965
1196
|
// ── Chat history (receiver side) ─────────────────────────────────
|
|
966
1197
|
|
|
967
1198
|
/** Handle chat history request: read session transcript from disk and return normalized messages. */
|
|
@@ -1151,7 +1382,7 @@ export class AcpProxy {
|
|
|
1151
1382
|
}
|
|
1152
1383
|
|
|
1153
1384
|
private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
|
|
1154
|
-
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>();
|
|
1155
1386
|
|
|
1156
1387
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
|
|
1157
1388
|
const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
|
|
@@ -1233,6 +1464,7 @@ export class AcpProxy {
|
|
|
1233
1464
|
const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
|
|
1234
1465
|
(m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
|
|
1235
1466
|
);
|
|
1467
|
+
const configOptions = (response as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined;
|
|
1236
1468
|
|
|
1237
1469
|
const session: AcpSession = {
|
|
1238
1470
|
kind: "acp",
|
|
@@ -1248,7 +1480,7 @@ export class AcpProxy {
|
|
|
1248
1480
|
},
|
|
1249
1481
|
availableModes,
|
|
1250
1482
|
currentModeId: response.modes?.currentModeId,
|
|
1251
|
-
|
|
1483
|
+
configOptions,
|
|
1252
1484
|
};
|
|
1253
1485
|
|
|
1254
1486
|
return session;
|
|
@@ -1278,11 +1510,17 @@ export class AcpProxy {
|
|
|
1278
1510
|
|
|
1279
1511
|
/** Return a completed oneshot session to the reuse pool instead of destroying it. */
|
|
1280
1512
|
private returnToReusePool(session: AcpSession, cwd: string) {
|
|
1281
|
-
// 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.
|
|
1282
1515
|
const daemon = this.findDaemonByConn(session);
|
|
1283
1516
|
if (daemon) {
|
|
1284
1517
|
daemon.streamCallbacks.delete(session.acpSessionId);
|
|
1285
|
-
|
|
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)`);
|
|
1286
1524
|
return;
|
|
1287
1525
|
}
|
|
1288
1526
|
|
|
@@ -1389,7 +1627,7 @@ export class AcpProxy {
|
|
|
1389
1627
|
agent: string,
|
|
1390
1628
|
cwd: string,
|
|
1391
1629
|
label: string,
|
|
1392
|
-
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
|
|
1630
|
+
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string, data?: unknown) => void) | null | undefined,
|
|
1393
1631
|
): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
|
|
1394
1632
|
const cmd = this.resolveCommand(agent);
|
|
1395
1633
|
debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
|
|
@@ -1445,15 +1683,68 @@ export class AcpProxy {
|
|
|
1445
1683
|
cb?.(content.text, updateType);
|
|
1446
1684
|
}
|
|
1447
1685
|
} else if (updateType === "tool_call") {
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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 });
|
|
1452
1691
|
} else if (updateType === "tool_call_update") {
|
|
1453
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);
|
|
1454
1712
|
if (status === "completed" || status === "error") {
|
|
1455
|
-
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);
|
|
1456
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 });
|
|
1457
1748
|
}
|
|
1458
1749
|
},
|
|
1459
1750
|
}),
|
|
@@ -1468,15 +1759,29 @@ export class AcpProxy {
|
|
|
1468
1759
|
});
|
|
1469
1760
|
|
|
1470
1761
|
try {
|
|
1471
|
-
await Promise.race([
|
|
1762
|
+
const initResponse = await Promise.race([
|
|
1472
1763
|
conn.initialize({
|
|
1473
1764
|
protocolVersion: 1,
|
|
1474
1765
|
clientInfo: { name: "ClawMatrix", version: "0.1.0" },
|
|
1475
|
-
clientCapabilities: {
|
|
1766
|
+
clientCapabilities: {
|
|
1767
|
+
prompt: { image: true },
|
|
1768
|
+
},
|
|
1476
1769
|
}),
|
|
1477
1770
|
initTimeout,
|
|
1478
1771
|
earlyExitPromise as Promise<never>,
|
|
1479
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
|
+
}
|
|
1480
1785
|
} catch (err) {
|
|
1481
1786
|
if (!earlyExit) proc.kill();
|
|
1482
1787
|
throw err;
|
|
@@ -1496,9 +1801,9 @@ export class AcpProxy {
|
|
|
1496
1801
|
cwd: string,
|
|
1497
1802
|
from: string,
|
|
1498
1803
|
label: string,
|
|
1499
|
-
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[] }>,
|
|
1500
1805
|
): Promise<AcpSession> {
|
|
1501
|
-
let streamCallback: ((delta: string, event?: string) => void) | null = null;
|
|
1806
|
+
let streamCallback: ((delta: string, event?: string, data?: unknown) => void) | null = null;
|
|
1502
1807
|
|
|
1503
1808
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
|
|
1504
1809
|
|
|
@@ -1527,7 +1832,7 @@ export class AcpProxy {
|
|
|
1527
1832
|
setStreamCallback: (cb) => { streamCallback = cb; },
|
|
1528
1833
|
availableModes,
|
|
1529
1834
|
currentModeId: response.modes?.currentModeId,
|
|
1530
|
-
|
|
1835
|
+
configOptions: response.configOptions,
|
|
1531
1836
|
};
|
|
1532
1837
|
|
|
1533
1838
|
this.monitorProcess(session);
|
|
@@ -1560,18 +1865,19 @@ export class AcpProxy {
|
|
|
1560
1865
|
debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
|
|
1561
1866
|
const promptStartedAt = Date.now();
|
|
1562
1867
|
|
|
1563
|
-
// Broadcast task started to mobile nodes
|
|
1868
|
+
// Broadcast task started to mobile nodes + session notify to all peers
|
|
1564
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);
|
|
1565
1871
|
|
|
1566
1872
|
// Wire streaming to send acp_stream frames (to requester + all session watchers)
|
|
1567
|
-
session.setStreamCallback((delta, event) => {
|
|
1873
|
+
session.setStreamCallback((delta, event, data) => {
|
|
1568
1874
|
const streamFrame: AcpStreamChunk = {
|
|
1569
1875
|
type: "acp_stream",
|
|
1570
1876
|
id: requestId,
|
|
1571
1877
|
from: this.config.nodeId,
|
|
1572
1878
|
to: from,
|
|
1573
1879
|
timestamp: Date.now(),
|
|
1574
|
-
payload: { delta, event, done: false, sessionId: session.sessionId },
|
|
1880
|
+
payload: { delta, event, done: false, sessionId: session.sessionId, data },
|
|
1575
1881
|
};
|
|
1576
1882
|
this.peerManager.sendTo(from, streamFrame);
|
|
1577
1883
|
this.sendToOtherWatchers(session.sessionId, from, streamFrame);
|
|
@@ -1603,25 +1909,33 @@ export class AcpProxy {
|
|
|
1603
1909
|
promptParts.push({ type: "image", data: img.data, mimeType: img.mediaType });
|
|
1604
1910
|
}
|
|
1605
1911
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
+
};
|
|
1609
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?.();
|
|
1610
1932
|
|
|
1611
|
-
// Send done marker to
|
|
1612
|
-
|
|
1613
|
-
type: "acp_stream",
|
|
1614
|
-
id: requestId,
|
|
1615
|
-
from: this.config.nodeId,
|
|
1616
|
-
to: from,
|
|
1617
|
-
timestamp: Date.now(),
|
|
1618
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1619
|
-
};
|
|
1620
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1621
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
1933
|
+
// Send done marker BEFORE response to guarantee ordering for clients
|
|
1934
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1622
1935
|
|
|
1936
|
+
const isCancelled = promptResponse.stopReason === "cancelled";
|
|
1623
1937
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1624
|
-
success:
|
|
1938
|
+
success: !isCancelled,
|
|
1625
1939
|
nodeId: this.config.nodeId,
|
|
1626
1940
|
agent: session.agent,
|
|
1627
1941
|
sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
|
|
@@ -1631,9 +1945,17 @@ export class AcpProxy {
|
|
|
1631
1945
|
this.sendResponse(requestId, from, responsePayload);
|
|
1632
1946
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1633
1947
|
|
|
1634
|
-
// Broadcast task completed
|
|
1635
|
-
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
|
+
}
|
|
1636
1956
|
} catch (err) {
|
|
1957
|
+
// Always send done marker on error so clients don't hang
|
|
1958
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1637
1959
|
// Broadcast task failed
|
|
1638
1960
|
this.taskActivity.broadcast(
|
|
1639
1961
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
@@ -1652,6 +1974,7 @@ export class AcpProxy {
|
|
|
1652
1974
|
debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
|
|
1653
1975
|
const promptStartedAt = Date.now();
|
|
1654
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);
|
|
1655
1978
|
|
|
1656
1979
|
const { port, authHeader } = this.gatewayInfo;
|
|
1657
1980
|
const abortController = new AbortController();
|
|
@@ -1689,17 +2012,7 @@ export class AcpProxy {
|
|
|
1689
2012
|
// Stream SSE response as acp_stream frames (to requester + watchers)
|
|
1690
2013
|
const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
|
|
1691
2014
|
|
|
1692
|
-
|
|
1693
|
-
const doneFrame: AcpStreamChunk = {
|
|
1694
|
-
type: "acp_stream",
|
|
1695
|
-
id: requestId,
|
|
1696
|
-
from: this.config.nodeId,
|
|
1697
|
-
to: from,
|
|
1698
|
-
timestamp: Date.now(),
|
|
1699
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1700
|
-
};
|
|
1701
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1702
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
2015
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1703
2016
|
|
|
1704
2017
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1705
2018
|
success: true,
|
|
@@ -1711,6 +2024,7 @@ export class AcpProxy {
|
|
|
1711
2024
|
this.sendResponse(requestId, from, responsePayload);
|
|
1712
2025
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1713
2026
|
this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
|
|
2027
|
+
this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
|
|
1714
2028
|
} catch (err) {
|
|
1715
2029
|
this.taskActivity.broadcast(
|
|
1716
2030
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
@@ -1787,7 +2101,7 @@ export class AcpProxy {
|
|
|
1787
2101
|
try {
|
|
1788
2102
|
const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
|
|
1789
2103
|
debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
|
|
1790
|
-
return { sessionId: acpSessionId, modes: loadResp.modes };
|
|
2104
|
+
return { sessionId: acpSessionId, modes: loadResp.modes, configOptions: (loadResp as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined };
|
|
1791
2105
|
} catch (loadErr) {
|
|
1792
2106
|
debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
|
|
1793
2107
|
}
|
|
@@ -1805,6 +2119,8 @@ export class AcpProxy {
|
|
|
1805
2119
|
|
|
1806
2120
|
private destroySession(session: AnySession) {
|
|
1807
2121
|
invalidateSessionListCache();
|
|
2122
|
+
// Clean up watchers for this session
|
|
2123
|
+
this.sessionWatchers.delete(session.sessionId);
|
|
1808
2124
|
if (session.kind === "acp") {
|
|
1809
2125
|
// Clean up daemon stream callback
|
|
1810
2126
|
const daemon = this.findDaemonByConn(session);
|
|
@@ -2017,6 +2333,20 @@ export class AcpProxy {
|
|
|
2017
2333
|
});
|
|
2018
2334
|
}
|
|
2019
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
|
+
|
|
2020
2350
|
private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
|
|
2021
2351
|
debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
|
|
2022
2352
|
const frame = {
|
|
@@ -2070,6 +2400,9 @@ export class AcpProxy {
|
|
|
2070
2400
|
}
|
|
2071
2401
|
|
|
2072
2402
|
destroy() {
|
|
2403
|
+
// Set disposed first to prevent new timers/prewarms from being scheduled during teardown
|
|
2404
|
+
this.disposed = true;
|
|
2405
|
+
|
|
2073
2406
|
if (this.cleanupTimer) {
|
|
2074
2407
|
clearInterval(this.cleanupTimer);
|
|
2075
2408
|
this.cleanupTimer = null;
|
|
@@ -2091,8 +2424,6 @@ export class AcpProxy {
|
|
|
2091
2424
|
this.sessions.clear();
|
|
2092
2425
|
this.sessionWatchers.clear();
|
|
2093
2426
|
|
|
2094
|
-
this.disposed = true;
|
|
2095
|
-
|
|
2096
2427
|
// Cancel pending retry timers
|
|
2097
2428
|
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
2098
2429
|
this.retryTimers.clear();
|
|
@@ -2124,6 +2455,7 @@ export class AcpProxy {
|
|
|
2124
2455
|
}
|
|
2125
2456
|
this.daemons.clear();
|
|
2126
2457
|
this.daemonStarting.clear();
|
|
2458
|
+
this.resumeInFlight.clear();
|
|
2127
2459
|
}
|
|
2128
2460
|
}
|
|
2129
2461
|
|
|
@@ -2536,6 +2868,12 @@ function stripThinkingTags(text: string): string {
|
|
|
2536
2868
|
.trim();
|
|
2537
2869
|
}
|
|
2538
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
|
+
|
|
2539
2877
|
/** Check if text is a silent reply (NO_REPLY). */
|
|
2540
2878
|
function isSilentReply(text: string): boolean {
|
|
2541
2879
|
return /^\s*NO_REPLY\s*$/.test(text);
|
|
@@ -2614,7 +2952,8 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
|
|
|
2614
2952
|
: typeof msg.toolName === "string" ? msg.toolName
|
|
2615
2953
|
: typeof msg.tool_name === "string" ? msg.tool_name
|
|
2616
2954
|
: inlineResults[0]?.name;
|
|
2617
|
-
const
|
|
2955
|
+
const rawResultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
|
|
2956
|
+
const resultText = stripCodexTerminalMeta(rawResultText);
|
|
2618
2957
|
|
|
2619
2958
|
if (raw.length > 0) {
|
|
2620
2959
|
const prev = raw[raw.length - 1]!;
|