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/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 non-thinking content for the final result
483
- 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 ?? "")) {
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" && task) {
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" && task) {
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.filter((s) => {
762
- if (payload.agent && s.agent !== payload.agent) return false;
763
- return !clawSessions.some((cs) => cs.sessionId === s.sessionId);
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
- // 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);
799
921
  let session: AcpSession;
800
- try {
801
- session = await this.createSessionWithResume(agent, acpSessionId, cwd, from);
802
- } catch (resumeErr) {
803
- debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
804
- 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
+ }
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
- 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)) {
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
- 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)`);
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 toolName = (update as { title?: string }).title
1449
- || (update as { toolCallId?: string }).toolCallId
1450
- || "unknown";
1451
- 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 });
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?.("", "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);
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
- const promptResponse = await session.conn.prompt({
1607
- sessionId: session.acpSessionId,
1608
- 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
+ };
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 requester + watchers
1612
- const doneFrame: AcpStreamChunk = {
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: true,
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(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
+ }
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
- // Send done marker to requester + watchers
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 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);
2618
2957
 
2619
2958
  if (raw.length > 0) {
2620
2959
  const prev = raw[raw.length - 1]!;