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/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 non-thinking content for the final result
481
- if (frame.payload.event !== "agent_thought_chunk") {
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" && task) {
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" && task) {
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.filter((s) => {
760
- if (payload.agent && s.agent !== payload.agent) return false;
761
- return !clawSessions.some((cs) => cs.sessionId === s.sessionId);
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
- // Spawn a fresh ACP agent process and resume the session on it
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
- try {
799
- session = await this.createSessionWithResume(agent, acpSessionId, cwd, from);
800
- } catch (resumeErr) {
801
- debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
802
- session = await this.createSession(agent, cwd, from);
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
- if (session.from !== from) {
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
- debug("acp", `daemon-backed oneshot done: agent=${session.agent} (daemon stays alive)`);
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 toolName = (update as { title?: string }).title
1446
- || (update as { toolCallId?: string }).toolCallId
1447
- || "unknown";
1448
- cb?.(`\n[tool_call: ${toolName}]\n`, updateType);
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?.("", "tool_result");
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
- const promptResponse = await session.conn.prompt({
1603
- sessionId: session.acpSessionId,
1604
- prompt: promptParts as any,
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 requester + watchers
1608
- const doneFrame: AcpStreamChunk = {
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: true,
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(requestId, "acp", "completed", session.agent, promptStartedAt);
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
- // Send done marker to requester + watchers
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
- conn.unstable_resumeSession({ sessionId: acpSessionId, cwd }),
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
- console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
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
- this.disposed = true;
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 resultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
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]!;