clawmatrix 0.2.11 → 0.4.0

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
@@ -12,7 +12,8 @@ import { spawnProcess, readFileText, readFileHead, readFileTail } from "./compat
12
12
  import { debug } from "./debug.ts";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { readdir, access } from "node:fs/promises";
15
+ import { readdir, access, open as fsOpen, stat as fsStat, unlink, writeFile as fsWriteFile } from "node:fs/promises";
16
+ import { watch as fsWatch, type FSWatcher } from "node:fs";
16
17
  import type {
17
18
  AcpTaskRequest,
18
19
  AcpTaskResponse,
@@ -35,6 +36,13 @@ import type {
35
36
  ChatHistoryRequest,
36
37
  ChatHistoryResponse,
37
38
  ChatHistoryMessage,
39
+ AcpSetConfigRequest,
40
+ AcpSetConfigResponse,
41
+ AcpConfigOption,
42
+ AcpSubscribeRequest,
43
+ AcpSubscribeResponse,
44
+ AcpUnsubscribeRequest,
45
+ AcpSessionNotify,
38
46
  } from "./types.ts";
39
47
  import { TaskActivityBroadcaster } from "./task-activity.ts";
40
48
 
@@ -74,7 +82,7 @@ interface AgentDaemon {
74
82
  conn: ClientSideConnection;
75
83
  proc: { kill: () => void; exited: Promise<number> };
76
84
  /** Per-session stream callbacks keyed by ACP session ID */
77
- streamCallbacks: Map<string, ((delta: string, event?: string) => void) | null>;
85
+ streamCallbacks: Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>;
78
86
  lastActiveAt: number;
79
87
  idleTimer: ReturnType<typeof setTimeout>;
80
88
  /** Set to true when process has exited */
@@ -92,9 +100,12 @@ interface AcpSession {
92
100
  proc: { kill: () => void; exited: Promise<number> };
93
101
  lastActiveAt: number;
94
102
  from: string; // owning nodeId
95
- setStreamCallback: (cb: ((delta: string, event?: string) => void) | null) => void;
103
+ prompting: boolean; // true while executing a prompt (busy state)
104
+ setStreamCallback: (cb: ((delta: string, event?: string, data?: unknown) => void) | null) => void;
96
105
  availableModes?: AcpModeInfo[];
97
106
  currentModeId?: string;
107
+ configOptions?: AcpConfigOption[];
108
+ slashCommands?: import("./types.ts").AcpSlashCommand[];
98
109
  }
99
110
 
100
111
  /** Session backed by local OpenClaw gateway (not a spawned ACP agent). */
@@ -105,6 +116,7 @@ interface NativeSession {
105
116
  sessionKey: string; // OpenClaw session key (e.g. "agent:main:cron:xxx")
106
117
  lastActiveAt: number;
107
118
  from: string;
119
+ prompting: boolean; // true while executing a prompt (busy state)
108
120
  abortController: AbortController | null;
109
121
  }
110
122
 
@@ -156,10 +168,16 @@ export class AcpProxy {
156
168
  // Track pending retry timers for sendResponse so they can be cancelled on destroy
157
169
  private retryTimers = new Set<ReturnType<typeof setTimeout>>();
158
170
  private disposed = false;
171
+ // Transcript file watchers: watch transcript files for external changes (terminal Claude Code)
172
+ private transcriptWatchers = new Map<string, { watcher: FSWatcher; path: string; offset: number; lineBuffer: string; reading: boolean; idleTimer: ReturnType<typeof setTimeout> | null; hadActivity: boolean }>();
159
173
  // Agent daemon pool: long-lived process per agent type, reused across sessions
160
174
  private daemons = new Map<string, AgentDaemon>();
161
175
  // In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
162
176
  private daemonStarting = new Map<string, Promise<AgentDaemon>>();
177
+ // In-flight resume operations: prevents concurrent resumes for same acpSessionId
178
+ private resumeInFlight = new Map<string, Promise<AcpSession>>();
179
+ // Sessions explicitly closed by users — excluded from disk session list
180
+ private closedSessionIds = new Set<string>();
163
181
 
164
182
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
165
183
  this.config = config;
@@ -183,12 +201,27 @@ export class AcpProxy {
183
201
  // ── Multi-device sync helpers ──────────────────────────────────
184
202
 
185
203
  private addSessionWatcher(sessionId: string, nodeId: string) {
204
+ // A mobile device typically watches one session at a time.
205
+ // Remove from any other session this node was watching to prevent cross-talk.
206
+ for (const [otherId, otherWatchers] of this.sessionWatchers) {
207
+ if (otherId !== sessionId && otherWatchers.has(nodeId)) {
208
+ otherWatchers.delete(nodeId);
209
+ debug("acp", `addSessionWatcher: removed ${nodeId} from old session ${otherId.slice(0, 8)}`);
210
+ if (otherWatchers.size === 0) {
211
+ this.sessionWatchers.delete(otherId);
212
+ this.stopTranscriptWatcher(otherId);
213
+ }
214
+ }
215
+ }
216
+
186
217
  let watchers = this.sessionWatchers.get(sessionId);
187
218
  if (!watchers) {
188
219
  watchers = new Set();
189
220
  this.sessionWatchers.set(sessionId, watchers);
190
221
  }
222
+ const isNew = !watchers.has(nodeId);
191
223
  watchers.add(nodeId);
224
+ debug("acp", `addSessionWatcher: sessionId=${sessionId.slice(0, 8)} nodeId=${nodeId} isNew=${isNew} total=${watchers.size} all=[${[...watchers].join(",")}]`);
192
225
  }
193
226
 
194
227
  /** Send a frame to all session watchers except the specified node (usually the original `from`). */
@@ -197,6 +230,7 @@ export class AcpProxy {
197
230
  if (!watchers) return;
198
231
  for (const nodeId of watchers) {
199
232
  if (nodeId === exclude) continue;
233
+ debug("acp", `sendToOtherWatchers: sessionId=${sessionId.slice(0, 8)} → ${nodeId} (type=${frame.type})`);
200
234
  this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
201
235
  }
202
236
  }
@@ -218,6 +252,265 @@ export class AcpProxy {
218
252
  }
219
253
  }
220
254
 
255
+ // ── Transcript file watcher (for external Claude Code sessions) ──
256
+
257
+ /** Start watching a transcript file for new content and forward to session watchers. */
258
+ private async startTranscriptWatcher(sessionId: string, acpSessionId: string) {
259
+ // Don't start duplicate watchers
260
+ if (this.transcriptWatchers.has(sessionId)) return;
261
+
262
+ const transcriptPath = await this.findTranscriptPath(acpSessionId);
263
+ if (!transcriptPath) {
264
+ debug("acp", `transcript watcher: no transcript found for ${acpSessionId.slice(0, 8)}`);
265
+ return;
266
+ }
267
+
268
+ // Start from end of file (only forward NEW content)
269
+ let offset: number;
270
+ try {
271
+ const s = await fsStat(transcriptPath);
272
+ offset = s.size;
273
+ } catch {
274
+ return;
275
+ }
276
+
277
+ debug("acp", `transcript watcher: started for sessionId=${sessionId.slice(0, 8)} path=${transcriptPath} offset=${offset}`);
278
+
279
+ const state = { watcher: null as unknown as FSWatcher, path: transcriptPath, offset, lineBuffer: "", reading: false, idleTimer: null as ReturnType<typeof setTimeout> | null, hadActivity: false };
280
+
281
+ // Use fs.watch (kqueue on macOS) for near-instant file change detection
282
+ try {
283
+ state.watcher = fsWatch(transcriptPath, () => {
284
+ this.readTranscriptChanges(sessionId, state);
285
+ });
286
+ // Handle watcher errors gracefully (e.g., file deleted)
287
+ state.watcher.on("error", () => {
288
+ this.stopTranscriptWatcher(sessionId);
289
+ });
290
+ } catch {
291
+ debug("acp", `transcript watcher: fs.watch failed for ${transcriptPath}, skipping`);
292
+ return;
293
+ }
294
+
295
+ this.transcriptWatchers.set(sessionId, state);
296
+ }
297
+
298
+ /** Stop watching a transcript file. */
299
+ private stopTranscriptWatcher(sessionId: string) {
300
+ const entry = this.transcriptWatchers.get(sessionId);
301
+ if (!entry) return;
302
+ try { entry.watcher.close(); } catch { /* best effort */ }
303
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
304
+ this.transcriptWatchers.delete(sessionId);
305
+ debug("acp", `transcript watcher: stopped for sessionId=${sessionId.slice(0, 8)}`);
306
+ }
307
+
308
+ /** Read new bytes from transcript file and forward as stream events. */
309
+ private async readTranscriptChanges(sessionId: string, state: { path: string; offset: number; lineBuffer: string; reading: boolean; idleTimer: ReturnType<typeof setTimeout> | null; hadActivity: boolean }) {
310
+ // Prevent concurrent reads (fs.watch can fire multiple times rapidly)
311
+ if (state.reading) return;
312
+ state.reading = true;
313
+
314
+ try {
315
+ // Skip if session is prompting through ACP (stream callback handles it)
316
+ const session = this.sessions.get(sessionId);
317
+ if (session?.prompting) return;
318
+
319
+ // Skip if no watchers
320
+ const watchers = this.sessionWatchers.get(sessionId);
321
+ if (!watchers || watchers.size === 0) {
322
+ this.stopTranscriptWatcher(sessionId);
323
+ return;
324
+ }
325
+
326
+ const s = await fsStat(state.path);
327
+ if (s.size <= state.offset) return; // No new data
328
+
329
+ // Read new bytes from the file
330
+ const bytesToRead = s.size - state.offset;
331
+ const fh = await fsOpen(state.path, "r");
332
+ try {
333
+ const buf = Buffer.alloc(Math.min(bytesToRead, 256 * 1024)); // Cap at 256KB per read
334
+ const { bytesRead } = await fh.read(buf, 0, buf.length, state.offset);
335
+ if (bytesRead === 0) return;
336
+ state.offset += bytesRead;
337
+
338
+ // Append to line buffer and extract complete lines
339
+ state.lineBuffer += buf.toString("utf8", 0, bytesRead);
340
+ const lines = state.lineBuffer.split("\n");
341
+ state.lineBuffer = lines.pop()!; // Keep incomplete last line in buffer
342
+
343
+ let forwarded = false;
344
+ for (const line of lines) {
345
+ const trimmed = line.trim();
346
+ if (!trimmed) continue;
347
+ this.forwardTranscriptLine(sessionId, trimmed);
348
+ forwarded = true;
349
+ }
350
+
351
+ // After forwarding content, reset the idle timer.
352
+ // When transcript goes idle for 3s after activity, send transcript_idle
353
+ // so iOS can re-fetch full history for clean rendering.
354
+ if (forwarded) {
355
+ state.hadActivity = true;
356
+ if (state.idleTimer) clearTimeout(state.idleTimer);
357
+ state.idleTimer = setTimeout(() => {
358
+ if (!state.hadActivity) return;
359
+ state.hadActivity = false;
360
+ debug("acp", `transcript watcher: idle after activity, sending transcript_idle for ${sessionId.slice(0, 8)}`);
361
+ this.broadcastTranscriptEvent(sessionId, "", "transcript_idle");
362
+ }, 3000);
363
+ }
364
+ } finally {
365
+ await fh.close();
366
+ }
367
+ } catch (err) {
368
+ debug("acp", `transcript watcher: read error for ${sessionId.slice(0, 8)}: ${errorMessage(err)}`);
369
+ } finally {
370
+ state.reading = false;
371
+ }
372
+ }
373
+
374
+ /** Parse a single JSONL line and forward as acp_stream frames to watchers.
375
+ * Uses the same normalizeTranscriptMessages logic for consistent rendering. */
376
+ private forwardTranscriptLine(sessionId: string, line: string) {
377
+ // Reuse the shared normalizer for consistent output with chat history
378
+ const messages = normalizeTranscriptMessages([line], 10);
379
+ for (const msg of messages) {
380
+ if (msg.role === "user") {
381
+ this.broadcastTranscriptEvent(sessionId, msg.text, "user_message");
382
+ } else if (msg.role === "assistant") {
383
+ if (msg.thinking) {
384
+ this.broadcastTranscriptEvent(sessionId, msg.thinking, "agent_thought_chunk");
385
+ }
386
+ if (msg.text) {
387
+ this.broadcastTranscriptEvent(sessionId, msg.text, "agent_message_chunk");
388
+ }
389
+ } else if (msg.role === "tool") {
390
+ if (msg.toolName) {
391
+ this.broadcastTranscriptEvent(sessionId, `\n[tool_call: ${msg.toolName}]\n`, "tool_call");
392
+ }
393
+ if (msg.toolResult) {
394
+ this.broadcastTranscriptEvent(sessionId, msg.toolResult, "tool_result");
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ /** Send a transcript event to all watchers of a session. */
401
+ private broadcastTranscriptEvent(sessionId: string, delta: string, event: string) {
402
+ const watchers = this.sessionWatchers.get(sessionId);
403
+ if (!watchers) return;
404
+
405
+ const frame: AcpStreamChunk = {
406
+ type: "acp_stream",
407
+ id: `transcript-${Date.now()}`,
408
+ from: this.config.nodeId,
409
+ to: "",
410
+ timestamp: Date.now(),
411
+ payload: { delta, event, done: false, sessionId },
412
+ };
413
+
414
+ for (const nodeId of watchers) {
415
+ this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
416
+ }
417
+ debug("acp", `transcript watcher: broadcast event=${event} delta=${delta.slice(0, 40)} to ${watchers.size} watchers`);
418
+ }
419
+
420
+ // ── Subscribe / observe (receiver side) ─────────────────────────
421
+
422
+ /** Handle acp_subscribe: register caller as observer and return history snapshot.
423
+ *
424
+ * `payload.sessionId` may be an ACP/OpenClaw session ID (from the list) or a
425
+ * ClawMatrix session ID. We resolve to the ClawMatrix session ID for watcher
426
+ * registration, while using the original ID for transcript lookup (disk files
427
+ * are named by ACP session ID).
428
+ */
429
+ async handleSubscribeRequest(frame: AcpSubscribeRequest): Promise<void> {
430
+ const { id, from, payload } = frame;
431
+ const { sessionId } = payload;
432
+
433
+ // Resolve to ClawMatrix session ID for watcher registration (reverse lookup by acpSessionId)
434
+ let watcherKey = sessionId;
435
+ if (!this.sessions.has(sessionId)) {
436
+ for (const [sid, s] of this.sessions) {
437
+ if (s.kind === "acp" && s.acpSessionId === sessionId) {
438
+ watcherKey = sid;
439
+ break;
440
+ }
441
+ }
442
+ }
443
+ this.addSessionWatcher(watcherKey, from);
444
+
445
+ try {
446
+ const transcriptPath = await this.findTranscriptPath(sessionId);
447
+ let history: import("./types.ts").ChatHistoryMessage[] = [];
448
+ if (transcriptPath) {
449
+ const content = await readFileText(transcriptPath);
450
+ const lines = content.split(/\r?\n/).filter((l: string) => l.trim());
451
+ history = normalizeTranscriptMessages(lines, 200);
452
+ }
453
+ this.peerManager.sendTo(from, {
454
+ type: "acp_subscribe_res",
455
+ id,
456
+ from: this.config.nodeId,
457
+ to: from,
458
+ timestamp: Date.now(),
459
+ payload: { success: true, history },
460
+ } satisfies AcpSubscribeResponse);
461
+ } catch (err) {
462
+ this.peerManager.sendTo(from, {
463
+ type: "acp_subscribe_res",
464
+ id,
465
+ from: this.config.nodeId,
466
+ to: from,
467
+ timestamp: Date.now(),
468
+ payload: { success: false, error: errorMessage(err) },
469
+ } satisfies AcpSubscribeResponse);
470
+ }
471
+ }
472
+
473
+ /** Handle acp_unsubscribe: remove caller from observer list. */
474
+ handleUnsubscribeRequest(frame: AcpUnsubscribeRequest): void {
475
+ const { from, payload } = frame;
476
+ // Resolve watcher key (same logic as subscribe)
477
+ let watcherKey = payload.sessionId;
478
+ if (!this.sessionWatchers.has(watcherKey)) {
479
+ for (const [sid, s] of this.sessions) {
480
+ if (s.kind === "acp" && s.acpSessionId === payload.sessionId) {
481
+ watcherKey = sid;
482
+ break;
483
+ }
484
+ }
485
+ }
486
+ const watchers = this.sessionWatchers.get(watcherKey);
487
+ if (watchers) {
488
+ watchers.delete(from);
489
+ if (watchers.size === 0) this.sessionWatchers.delete(watcherKey);
490
+ }
491
+ }
492
+
493
+ /** Broadcast acp_session_notify to all connected peers. */
494
+ private broadcastSessionNotify(
495
+ sessionId: string,
496
+ event: AcpSessionNotify["payload"]["event"],
497
+ title?: string,
498
+ updatedAt?: string,
499
+ agent?: string,
500
+ ): void {
501
+ const peers = this.peerManager.router.getAllPeers();
502
+ if (peers.length === 0) return;
503
+ const frame: AcpSessionNotify = {
504
+ type: "acp_session_notify",
505
+ from: this.config.nodeId,
506
+ timestamp: Date.now(),
507
+ payload: { sessionId, nodeId: this.config.nodeId, event, title, updatedAt, agent },
508
+ };
509
+ for (const peer of peers) {
510
+ this.peerManager.sendTo(peer.nodeId, { ...frame, to: peer.nodeId });
511
+ }
512
+ }
513
+
221
514
  // ── Requester side: send prompt ────────────────────────────────
222
515
 
223
516
  async prompt(
@@ -479,8 +772,14 @@ export class AcpProxy {
479
772
  const pending = this.pending.get(frame.id);
480
773
  if (!pending) return;
481
774
 
482
- // Only accumulate non-thinking content for the final result
483
- if (frame.payload.event !== "agent_thought_chunk") {
775
+ // Only accumulate agent text content for the final result.
776
+ // Skip events that produce markers/metadata (not actual response text).
777
+ const skipAccumulate = new Set([
778
+ "agent_thought_chunk", "tool_call", "tool_result", "plan",
779
+ "usage", "available_commands", "config_options",
780
+ "current_mode_update", "session_info_update",
781
+ ]);
782
+ if (!skipAccumulate.has(frame.payload.event ?? "")) {
484
783
  pending.accumulated += frame.payload.delta;
485
784
  }
486
785
  if (pending.onStream) {
@@ -577,15 +876,27 @@ export class AcpProxy {
577
876
  debug("acp", `handleRequest: id=${id} from=${from} agent=${agent} mode=${mode} sessionId=${sessionId ?? "(new)"} acpSessionId=${resumeAcpSessionId ?? "(none)"}`);
578
877
 
579
878
  try {
580
- if (sessionId) {
879
+ // Resolve sessionId: if absent but resumeAcpSessionId is provided, look up by acpSessionId
880
+ let resolvedSessionId = sessionId;
881
+ if (!resolvedSessionId && resumeAcpSessionId) {
882
+ for (const [sid, s] of this.sessions) {
883
+ if (s.kind === "acp" && s.acpSessionId === resumeAcpSessionId) {
884
+ resolvedSessionId = sid;
885
+ debug("acp", `Resolved sessionId from acpSessionId: ${resumeAcpSessionId.slice(0, 8)} → ${sid.slice(0, 8)}`);
886
+ break;
887
+ }
888
+ }
889
+ }
890
+
891
+ if (resolvedSessionId) {
581
892
  // Follow-up on existing session
582
- const session = this.sessions.get(sessionId);
893
+ const session = this.sessions.get(resolvedSessionId);
583
894
  if (!session) {
584
895
  // Session expired or process restarted – try to resume
585
896
  if (this.isAcpAgent(agent)) {
586
897
  let newSession: AcpSession;
587
898
  if (resumeAcpSessionId) {
588
- debug("acp", `Session "${sessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
899
+ debug("acp", `Session "${resolvedSessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
589
900
  const effectiveCwd = cwd || process.cwd();
590
901
  try {
591
902
  newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
@@ -594,7 +905,7 @@ export class AcpProxy {
594
905
  newSession = await this.createSession(agent, cwd, from);
595
906
  }
596
907
  } else {
597
- debug("acp", `Session "${sessionId}" not found, creating new session as fallback`);
908
+ debug("acp", `Session "${resolvedSessionId}" not found, creating new session as fallback`);
598
909
  newSession = await this.createSession(agent, cwd, from);
599
910
  }
600
911
  if (mode === "persistent") {
@@ -610,19 +921,22 @@ export class AcpProxy {
610
921
  agent: newSession.agent,
611
922
  sessionId: newSession.sessionId,
612
923
  acpSessionId: newSession.acpSessionId,
924
+ configOptions: newSession.configOptions,
925
+ availableModes: newSession.availableModes,
926
+ currentModeId: newSession.currentModeId,
613
927
  });
614
928
  }
615
- if (mode === "oneshot" && task) {
929
+ if (mode === "oneshot") {
616
930
  this.returnToReusePool(newSession, cwd || process.cwd());
617
931
  }
618
932
  } else {
619
933
  // Re-create native session
620
- debug("acp", `Native session "${sessionId}" not found, re-creating with key from cwd`);
621
- const sessionKey = cwd ?? sessionId;
934
+ debug("acp", `Native session "${resolvedSessionId}" not found, re-creating with key from cwd`);
935
+ const sessionKey = cwd ?? resolvedSessionId;
622
936
  const newId = `native-${crypto.randomUUID()}`;
623
937
  const nativeSession: NativeSession = {
624
938
  kind: "native", sessionId: newId, agent, sessionKey,
625
- lastActiveAt: Date.now(), from, abortController: null,
939
+ lastActiveAt: Date.now(), from, prompting: false, abortController: null,
626
940
  };
627
941
  if (mode === "persistent") {
628
942
  this.sessions.set(newId, nativeSession);
@@ -637,7 +951,7 @@ export class AcpProxy {
637
951
  return;
638
952
  }
639
953
  // Track this node as a session watcher for multi-device sync
640
- this.addSessionWatcher(sessionId, from);
954
+ this.addSessionWatcher(resolvedSessionId, from);
641
955
  session.lastActiveAt = Date.now();
642
956
  if (session.kind === "native") {
643
957
  await this.runNativePrompt(id, from, session, task);
@@ -655,8 +969,20 @@ export class AcpProxy {
655
969
  }
656
970
 
657
971
  if (this.isAcpAgent(agent)) {
658
- // Create new ACP session
659
- const session = await this.createSession(agent, cwd, from);
972
+ // Resume existing ACP session if acpSessionId is provided, otherwise create new
973
+ let session: AcpSession;
974
+ if (resumeAcpSessionId) {
975
+ debug("acp", `No sessionId but acpSessionId=${resumeAcpSessionId.slice(0, 8)} provided, attempting resume`);
976
+ const effectiveCwd = cwd || process.cwd();
977
+ try {
978
+ session = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
979
+ } catch (resumeErr) {
980
+ debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
981
+ session = await this.createSession(agent, cwd, from);
982
+ }
983
+ } else {
984
+ session = await this.createSession(agent, cwd, from);
985
+ }
660
986
  if (mode === "persistent") {
661
987
  this.sessions.set(session.sessionId, session);
662
988
  this.addSessionWatcher(session.sessionId, from);
@@ -670,9 +996,12 @@ export class AcpProxy {
670
996
  agent: session.agent,
671
997
  sessionId: session.sessionId,
672
998
  acpSessionId: session.acpSessionId,
999
+ configOptions: session.configOptions,
1000
+ availableModes: session.availableModes,
1001
+ currentModeId: session.currentModeId,
673
1002
  });
674
1003
  }
675
- if (mode === "oneshot" && task) {
1004
+ if (mode === "oneshot") {
676
1005
  this.returnToReusePool(session, cwd || process.cwd());
677
1006
  }
678
1007
  } else {
@@ -685,7 +1014,7 @@ export class AcpProxy {
685
1014
  const newId = `native-${crypto.randomUUID()}`;
686
1015
  const nativeSession: NativeSession = {
687
1016
  kind: "native", sessionId: newId, agent, sessionKey,
688
- lastActiveAt: Date.now(), from, abortController: null,
1017
+ lastActiveAt: Date.now(), from, prompting: false, abortController: null,
689
1018
  };
690
1019
  if (mode === "persistent") {
691
1020
  this.sessions.set(newId, nativeSession);
@@ -708,7 +1037,19 @@ export class AcpProxy {
708
1037
 
709
1038
  async handleClose(frame: AcpCloseRequest): Promise<void> {
710
1039
  const { id, from, payload } = frame;
711
- const session = this.sessions.get(payload.sessionId);
1040
+
1041
+ // Resolve session: direct lookup, then reverse lookup by acpSessionId
1042
+ let resolvedId = payload.sessionId;
1043
+ let session = this.sessions.get(resolvedId);
1044
+ if (!session) {
1045
+ for (const [sid, s] of this.sessions) {
1046
+ if (s.kind === "acp" && s.acpSessionId === payload.sessionId) {
1047
+ resolvedId = sid;
1048
+ session = s;
1049
+ break;
1050
+ }
1051
+ }
1052
+ }
712
1053
 
713
1054
  if (!session) {
714
1055
  this.peerManager.sendTo(from, {
@@ -719,18 +1060,37 @@ export class AcpProxy {
719
1060
  return;
720
1061
  }
721
1062
 
722
- if (session.from !== from) {
1063
+ // Allow close from session owner or any watcher (multi-device sync)
1064
+ const watchers = this.sessionWatchers.get(resolvedId);
1065
+ if (session.from !== from && !watchers?.has(from)) {
723
1066
  this.peerManager.sendTo(from, {
724
1067
  type: "acp_close_res", id, from: this.config.nodeId, to: from,
725
1068
  timestamp: Date.now(),
726
- payload: { success: false, error: "Not the session owner" },
1069
+ payload: { success: false, error: "Not the session owner or watcher" },
727
1070
  } satisfies AcpCloseResponse);
728
1071
  return;
729
1072
  }
730
1073
 
731
- this.sessions.delete(payload.sessionId);
1074
+ this.sessions.delete(resolvedId);
732
1075
  this.destroySession(session);
733
1076
 
1077
+ // Mark session as closed so it won't reappear from disk scan
1078
+ const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
1079
+ if (acpSid) this.closedSessionIds.add(acpSid);
1080
+ this.closedSessionIds.add(resolvedId);
1081
+
1082
+ // Physical deletion: only OpenClaw sessions can be safely deleted from disk.
1083
+ // Claude Code and Codex don't expose a deleteSession API, and their index files
1084
+ // (history.jsonl, SQLite DB, session_index.jsonl) would still reference deleted
1085
+ // transcripts, breaking their own UI/CLI. Both have open GitHub issues requesting
1086
+ // delete support (CC: #25304/#26904/#13514, Codex: #13018/#8784).
1087
+ // For now, rely on the in-memory closedSessionIds set to hide them from list.
1088
+ // TODO: enable physical deletion when these agents add delete APIs
1089
+ // const deleteId = acpSid ?? resolvedId;
1090
+ // deleteSessionFromDisk(deleteId).catch((err) => {
1091
+ // debug("acp", `deleteSessionFromDisk failed for ${deleteId.slice(0, 8)}: ${errorMessage(err)}`);
1092
+ // });
1093
+
734
1094
  this.peerManager.sendTo(from, {
735
1095
  type: "acp_close_res", id, from: this.config.nodeId, to: from,
736
1096
  timestamp: Date.now(),
@@ -743,25 +1103,44 @@ export class AcpProxy {
743
1103
  const { id, from, payload } = frame;
744
1104
 
745
1105
  try {
746
- // 1. ClawMatrix-managed active sessions
1106
+ // 1. Read persisted sessions from disk first (they have full metadata)
1107
+ const diskSessions = await this.readAllSessionStoresFromDisk();
1108
+ // Index disk sessions by sessionId for quick lookup
1109
+ const diskSessionMap = new Map<string, AcpSessionInfo>();
1110
+ for (const ds of diskSessions) {
1111
+ if (ds.sessionId) diskSessionMap.set(ds.sessionId, ds);
1112
+ }
1113
+
1114
+ // 2. ClawMatrix-managed active sessions — merge with disk metadata
747
1115
  const clawSessions: AcpSessionInfo[] = [];
1116
+ const activeAcpSessionIds = new Set<string>();
748
1117
  for (const [, session] of this.sessions) {
749
1118
  if (payload.agent && session.agent !== payload.agent) continue;
1119
+ const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
1120
+ if (acpSid) activeAcpSessionIds.add(acpSid);
1121
+ // Look up disk metadata for this active session (by ACP session ID)
1122
+ const diskInfo = acpSid ? diskSessionMap.get(acpSid) : undefined;
750
1123
  clawSessions.push({
751
- sessionId: session.sessionId,
752
- cwd: "",
1124
+ sessionId: acpSid ?? session.sessionId,
1125
+ cwd: diskInfo?.cwd ?? "",
1126
+ title: diskInfo?.title,
1127
+ description: diskInfo?.description,
753
1128
  agent: session.agent,
754
1129
  updatedAt: new Date(session.lastActiveAt).toISOString(),
1130
+ status: session.prompting ? "busy" : "active",
755
1131
  });
756
1132
  }
757
1133
 
758
- // 2. Read persisted sessions directly from disk (no daemon spawn needed)
759
- const diskSessions = await this.readAllSessionStoresFromDisk();
760
- // 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
- });
1134
+ // 3. Filter disk sessions: exclude already-tracked active sessions and explicitly closed ones
1135
+ const filteredDiskSessions = diskSessions
1136
+ .filter((s) => {
1137
+ if (payload.agent && s.agent !== payload.agent) return false;
1138
+ if (clawSessions.some((cs) => cs.sessionId === s.sessionId)) return false;
1139
+ if (activeAcpSessionIds.has(s.sessionId)) return false;
1140
+ if (this.closedSessionIds.has(s.sessionId)) return false;
1141
+ return true;
1142
+ })
1143
+ .map((s) => ({ ...s, status: "idle" as const }));
765
1144
 
766
1145
  this.peerManager.sendTo(from, {
767
1146
  type: "acp_list_res", id, from: this.config.nodeId, to: from,
@@ -784,6 +1163,28 @@ export class AcpProxy {
784
1163
  const cwd = payload.cwd || process.cwd();
785
1164
 
786
1165
  try {
1166
+ // If the session is already active in memory (by ClawMatrix ID or ACP session ID),
1167
+ // just add the caller as a watcher and return the existing session ID.
1168
+ // This avoids spawning a duplicate process and ensures stream forwarding works.
1169
+ debug("acp", `resume: looking for acpSessionId=${acpSessionId.slice(0, 8)} in ${this.sessions.size} active sessions: [${[...this.sessions].map(([sid, s]) => `${sid.slice(0, 8)}(${s.kind === "acp" ? "acp:" + s.acpSessionId.slice(0, 8) : "native"} prompting=${s.prompting})`).join(", ")}]`);
1170
+ for (const [sid, s] of this.sessions) {
1171
+ const isMatch = sid === acpSessionId || (s.kind === "acp" && s.acpSessionId === acpSessionId);
1172
+ if (isMatch) {
1173
+ debug("acp", `resume: session already active (${sid}), adding watcher ${from}`);
1174
+ this.addSessionWatcher(sid, from);
1175
+ this.peerManager.sendTo(from, {
1176
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
1177
+ timestamp: Date.now(),
1178
+ payload: { success: true, sessionId: sid },
1179
+ } satisfies AcpResumeResponse);
1180
+ s.lastActiveAt = Date.now();
1181
+ // Start transcript watcher for external activity (terminal Claude Code)
1182
+ const acpSidForWatch = s.kind === "acp" ? s.acpSessionId : acpSessionId;
1183
+ this.startTranscriptWatcher(sid, acpSidForWatch).catch(() => {});
1184
+ return;
1185
+ }
1186
+ }
1187
+
787
1188
  // Enforce concurrent session limit
788
1189
  if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
789
1190
  this.peerManager.sendTo(from, {
@@ -795,13 +1196,28 @@ export class AcpProxy {
795
1196
  }
796
1197
 
797
1198
  if (this.isAcpAgent(agent)) {
798
- // Spawn a fresh ACP agent process and resume the session on it
1199
+ // Deduplicate concurrent resumes for the same acpSessionId:
1200
+ // if another request is already resuming this session, reuse its result.
1201
+ const existingResume = this.resumeInFlight.get(acpSessionId);
799
1202
  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);
1203
+ if (existingResume) {
1204
+ debug("acp", `resume already in-flight for acpSessionId=${acpSessionId.slice(0, 8)}..., reusing`);
1205
+ session = await existingResume;
1206
+ } else {
1207
+ const resumePromise = (async (): Promise<AcpSession> => {
1208
+ try {
1209
+ return await this.createSessionWithResume(agent, acpSessionId, cwd, from);
1210
+ } catch (resumeErr) {
1211
+ debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
1212
+ return await this.createSession(agent, cwd, from);
1213
+ }
1214
+ })();
1215
+ this.resumeInFlight.set(acpSessionId, resumePromise);
1216
+ try {
1217
+ session = await resumePromise;
1218
+ } finally {
1219
+ this.resumeInFlight.delete(acpSessionId);
1220
+ }
805
1221
  }
806
1222
  this.sessions.set(session.sessionId, session);
807
1223
  this.addSessionWatcher(session.sessionId, from);
@@ -811,6 +1227,8 @@ export class AcpProxy {
811
1227
  timestamp: Date.now(),
812
1228
  payload: { success: true, sessionId: session.sessionId },
813
1229
  } satisfies AcpResumeResponse);
1230
+ // Start transcript watcher for external activity (terminal Claude Code)
1231
+ this.startTranscriptWatcher(session.sessionId, session.acpSessionId).catch(() => {});
814
1232
  } else {
815
1233
  // Native OpenClaw session — no process to spawn, just record the session key
816
1234
  // cwd contains the OpenClaw session key (e.g. "agent:main:clawmatrix-handoff:xxx")
@@ -822,6 +1240,7 @@ export class AcpProxy {
822
1240
  sessionKey: cwd,
823
1241
  lastActiveAt: Date.now(),
824
1242
  from,
1243
+ prompting: false,
825
1244
  abortController: null,
826
1245
  };
827
1246
  this.sessions.set(sessionId, nativeSession);
@@ -856,11 +1275,13 @@ export class AcpProxy {
856
1275
  return;
857
1276
  }
858
1277
 
859
- if (session.from !== from) {
1278
+ // Allow cancel from session owner OR any session watcher (multi-device sync)
1279
+ const watchers = this.sessionWatchers.get(payload.sessionId);
1280
+ if (session.from !== from && !watchers?.has(from)) {
860
1281
  this.peerManager.sendTo(from, {
861
1282
  type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
862
1283
  timestamp: Date.now(),
863
- payload: { success: false, error: "Not the session owner" },
1284
+ payload: { success: false, error: "Not the session owner or watcher" },
864
1285
  } satisfies AcpCancelResponse);
865
1286
  return;
866
1287
  }
@@ -962,6 +1383,100 @@ export class AcpProxy {
962
1383
  } satisfies AcpGetModesResponse);
963
1384
  }
964
1385
 
1386
+ // ── Config options (receiver side) ──────────────────────────────
1387
+
1388
+ /** Handle set config option request (receiver side): change model, thinking level, etc. */
1389
+ async handleSetConfigRequest(frame: AcpSetConfigRequest): Promise<void> {
1390
+ const { id, from, payload } = frame;
1391
+ const session = this.sessions.get(payload.sessionId);
1392
+
1393
+ if (!session) {
1394
+ this.peerManager.sendTo(from, {
1395
+ type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
1396
+ timestamp: Date.now(),
1397
+ payload: { success: false, error: "Session not found" },
1398
+ } satisfies AcpSetConfigResponse);
1399
+ return;
1400
+ }
1401
+
1402
+ if (session.kind !== "acp") {
1403
+ this.peerManager.sendTo(from, {
1404
+ type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
1405
+ timestamp: Date.now(),
1406
+ payload: { success: false, error: "Config options not supported for native sessions" },
1407
+ } satisfies AcpSetConfigResponse);
1408
+ return;
1409
+ }
1410
+
1411
+ try {
1412
+ const response = await session.conn.setSessionConfigOption({
1413
+ sessionId: session.acpSessionId,
1414
+ configId: payload.configId,
1415
+ value: payload.value as string,
1416
+ });
1417
+ const configOptions = (response as Record<string, unknown>)?.configOptions as AcpConfigOption[] | undefined;
1418
+ this.peerManager.sendTo(from, {
1419
+ type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
1420
+ timestamp: Date.now(),
1421
+ payload: { success: true, configOptions },
1422
+ } satisfies AcpSetConfigResponse);
1423
+ } catch (err) {
1424
+ this.peerManager.sendTo(from, {
1425
+ type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
1426
+ timestamp: Date.now(),
1427
+ payload: { success: false, error: errorMessage(err) },
1428
+ } satisfies AcpSetConfigResponse);
1429
+ }
1430
+ }
1431
+
1432
+ /** Handle set config option response (requester side). */
1433
+ handleSetConfigResponse(frame: AcpSetConfigResponse): void {
1434
+ const pending = this.pending.get(frame.id);
1435
+ if (!pending) return;
1436
+ clearTimeout(pending.timer);
1437
+ this.pending.delete(frame.id);
1438
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
1439
+ }
1440
+
1441
+ /** Set a config option on a remote session (requester side). */
1442
+ setSessionConfig(
1443
+ targetNodeId: string,
1444
+ sessionId: string,
1445
+ configId: string,
1446
+ value: string | boolean,
1447
+ ): Promise<AcpSetConfigResponse["payload"]> {
1448
+ const id = crypto.randomUUID();
1449
+ return new Promise((resolve, reject) => {
1450
+ const timer = setTimeout(() => {
1451
+ this.pending.delete(id);
1452
+ reject(new Error("ACP set config request timed out"));
1453
+ }, 15_000);
1454
+
1455
+ this.pending.set(id, {
1456
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
1457
+ reject,
1458
+ timer,
1459
+ targetNodeId,
1460
+ accumulated: "",
1461
+ });
1462
+
1463
+ const frame: AcpSetConfigRequest = {
1464
+ type: "acp_set_config",
1465
+ id,
1466
+ from: this.config.nodeId,
1467
+ to: targetNodeId,
1468
+ timestamp: Date.now(),
1469
+ payload: { sessionId, configId, value },
1470
+ };
1471
+
1472
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
1473
+ this.pending.delete(id);
1474
+ clearTimeout(timer);
1475
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
1476
+ }
1477
+ });
1478
+ }
1479
+
965
1480
  // ── Chat history (receiver side) ─────────────────────────────────
966
1481
 
967
1482
  /** Handle chat history request: read session transcript from disk and return normalized messages. */
@@ -1151,7 +1666,7 @@ export class AcpProxy {
1151
1666
  }
1152
1667
 
1153
1668
  private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
1154
- const streamCallbacks = new Map<string, ((delta: string, event?: string) => void) | null>();
1669
+ const streamCallbacks = new Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>();
1155
1670
 
1156
1671
  const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
1157
1672
  const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
@@ -1233,6 +1748,7 @@ export class AcpProxy {
1233
1748
  const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
1234
1749
  (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1235
1750
  );
1751
+ const configOptions = (response as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined;
1236
1752
 
1237
1753
  const session: AcpSession = {
1238
1754
  kind: "acp",
@@ -1243,12 +1759,13 @@ export class AcpProxy {
1243
1759
  proc: daemon.proc,
1244
1760
  lastActiveAt: Date.now(),
1245
1761
  from,
1762
+ prompting: false,
1246
1763
  setStreamCallback: (cb) => {
1247
1764
  daemon.streamCallbacks.set(response.sessionId, cb);
1248
1765
  },
1249
1766
  availableModes,
1250
1767
  currentModeId: response.modes?.currentModeId,
1251
-
1768
+ configOptions,
1252
1769
  };
1253
1770
 
1254
1771
  return session;
@@ -1278,11 +1795,17 @@ export class AcpProxy {
1278
1795
 
1279
1796
  /** Return a completed oneshot session to the reuse pool instead of destroying it. */
1280
1797
  private returnToReusePool(session: AcpSession, cwd: string) {
1281
- // Daemon-backed sessions don't need reuse pool — the daemon stays alive
1798
+ // Daemon-backed sessions don't need reuse pool — the daemon stays alive.
1799
+ // But we must close the ACP session on the daemon to prevent accumulation.
1282
1800
  const daemon = this.findDaemonByConn(session);
1283
1801
  if (daemon) {
1284
1802
  daemon.streamCallbacks.delete(session.acpSessionId);
1285
- debug("acp", `daemon-backed oneshot done: agent=${session.agent} (daemon stays alive)`);
1803
+ // Close the ACP session on the daemon connection (best-effort, don't block).
1804
+ // Uses unstable_closeSession per ACP SDK — session/close is still experimental.
1805
+ daemon.conn.unstable_closeSession({ sessionId: session.acpSessionId }).catch((err) => {
1806
+ debug("acp", `daemon closeSession failed for ${session.acpSessionId}: ${errorMessage(err)}`);
1807
+ });
1808
+ debug("acp", `daemon-backed oneshot done: agent=${session.agent} (session closed, daemon stays alive)`);
1286
1809
  return;
1287
1810
  }
1288
1811
 
@@ -1389,7 +1912,7 @@ export class AcpProxy {
1389
1912
  agent: string,
1390
1913
  cwd: string,
1391
1914
  label: string,
1392
- resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
1915
+ resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string, data?: unknown) => void) | null | undefined,
1393
1916
  ): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
1394
1917
  const cmd = this.resolveCommand(agent);
1395
1918
  debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
@@ -1445,15 +1968,68 @@ export class AcpProxy {
1445
1968
  cb?.(content.text, updateType);
1446
1969
  }
1447
1970
  } 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);
1971
+ const title = (update as { title?: string }).title;
1972
+ const toolCallId = (update as { toolCallId?: string }).toolCallId;
1973
+ const kind = (update as { kind?: string }).kind;
1974
+ const toolName = title || toolCallId || "unknown";
1975
+ cb?.(`\n[tool_call: ${toolName}]\n`, updateType, { title, toolCallId, kind });
1452
1976
  } else if (updateType === "tool_call_update") {
1453
1977
  const status = (update as { status?: string }).status;
1978
+ // Extract tool output content from completed tool calls
1979
+ const contentBlocks = (update as { content?: Array<{ type?: string; text?: string; content?: Array<{ type?: string; text?: string }> }> }).content;
1980
+ let toolOutput = "";
1981
+ if (contentBlocks) {
1982
+ for (const block of contentBlocks) {
1983
+ if (block.type === "content" && Array.isArray(block.content)) {
1984
+ // Nested content block (e.g. from tool call content wrapper)
1985
+ for (const inner of block.content) {
1986
+ if (inner.type === "text" && inner.text) toolOutput += inner.text;
1987
+ }
1988
+ } else if (block.type === "text" && block.text) {
1989
+ toolOutput += block.text;
1990
+ } else if (typeof block.text === "string") {
1991
+ toolOutput += block.text;
1992
+ }
1993
+ }
1994
+ }
1995
+ // Strip Codex terminal output metadata prefix
1996
+ toolOutput = stripCodexTerminalMeta(toolOutput);
1454
1997
  if (status === "completed" || status === "error") {
1455
- cb?.("", "tool_result");
1998
+ cb?.(toolOutput, "tool_result");
1456
1999
  }
2000
+ } else if (updateType === "plan") {
2001
+ // Forward execution plan with structured data
2002
+ const entries = (update as { entries?: Array<{ content?: string; status?: string; priority?: string }> }).entries;
2003
+ if (entries && entries.length > 0) {
2004
+ const planText = entries.map((e) => `[${e.status ?? "pending"}] ${e.content ?? ""}`).join("\n");
2005
+ cb?.(`\n[plan]\n${planText}\n`, "plan", entries);
2006
+ }
2007
+ } else if (updateType === "current_mode_update") {
2008
+ const modeId = (update as { modeId?: string }).modeId;
2009
+ if (modeId) cb?.("", "current_mode_update", { modeId });
2010
+ } else if (updateType === "session_info_update") {
2011
+ const title = (update as { title?: string }).title;
2012
+ if (title) {
2013
+ cb?.("", "session_info_update", { title });
2014
+ // Broadcast title update so observers can refresh session list
2015
+ const acpSid = params.sessionId;
2016
+ for (const s of this.sessions.values()) {
2017
+ if (s.kind === "acp" && s.acpSessionId === acpSid) {
2018
+ this.broadcastSessionNotify(s.sessionId, "updated", title, new Date().toISOString(), s.agent);
2019
+ break;
2020
+ }
2021
+ }
2022
+ }
2023
+ } else if (updateType === "available_commands_update") {
2024
+ const commands = (update as { commands?: Array<{ name?: string; description?: string; input?: { hint?: string } }> }).commands;
2025
+ if (commands) cb?.("", "available_commands", commands);
2026
+ } else if (updateType === "config_option_update") {
2027
+ const options = (update as { configOptions?: unknown[] }).configOptions;
2028
+ if (options) cb?.("", "config_options", options);
2029
+ } else if (updateType === "usage_update") {
2030
+ // Forward token usage stats
2031
+ const { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens } = update as Record<string, unknown>;
2032
+ cb?.("", "usage", { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens });
1457
2033
  }
1458
2034
  },
1459
2035
  }),
@@ -1468,15 +2044,29 @@ export class AcpProxy {
1468
2044
  });
1469
2045
 
1470
2046
  try {
1471
- await Promise.race([
2047
+ const initResponse = await Promise.race([
1472
2048
  conn.initialize({
1473
2049
  protocolVersion: 1,
1474
2050
  clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1475
- clientCapabilities: {},
2051
+ clientCapabilities: {
2052
+ prompt: { image: true },
2053
+ },
1476
2054
  }),
1477
2055
  initTimeout,
1478
2056
  earlyExitPromise as Promise<never>,
1479
2057
  ]);
2058
+
2059
+ // Handle agents that require authentication (e.g. Codex with ChatGPT login)
2060
+ const authMethods = (initResponse as Record<string, unknown>)?.authMethods as Array<{ id: string }> | undefined;
2061
+ if (authMethods && authMethods.length > 0) {
2062
+ // Auto-authenticate with the first available method (typically OAuth/API key already in env)
2063
+ try {
2064
+ await conn.authenticate({ method: authMethods[0]!.id });
2065
+ debug("acp", `[${agent}] authenticated with method "${authMethods[0]!.id}"`);
2066
+ } catch (authErr) {
2067
+ debug("acp", `[${agent}] authentication failed: ${errorMessage(authErr)} (continuing without auth)`);
2068
+ }
2069
+ }
1480
2070
  } catch (err) {
1481
2071
  if (!earlyExit) proc.kill();
1482
2072
  throw err;
@@ -1496,9 +2086,9 @@ export class AcpProxy {
1496
2086
  cwd: string,
1497
2087
  from: string,
1498
2088
  label: string,
1499
- sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string } }>,
2089
+ sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string }; configOptions?: AcpConfigOption[] }>,
1500
2090
  ): Promise<AcpSession> {
1501
- let streamCallback: ((delta: string, event?: string) => void) | null = null;
2091
+ let streamCallback: ((delta: string, event?: string, data?: unknown) => void) | null = null;
1502
2092
 
1503
2093
  const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
1504
2094
 
@@ -1524,10 +2114,11 @@ export class AcpProxy {
1524
2114
  proc,
1525
2115
  lastActiveAt: Date.now(),
1526
2116
  from,
2117
+ prompting: false,
1527
2118
  setStreamCallback: (cb) => { streamCallback = cb; },
1528
2119
  availableModes,
1529
2120
  currentModeId: response.modes?.currentModeId,
1530
-
2121
+ configOptions: response.configOptions,
1531
2122
  };
1532
2123
 
1533
2124
  this.monitorProcess(session);
@@ -1558,20 +2149,30 @@ export class AcpProxy {
1558
2149
 
1559
2150
  private async runPrompt(requestId: string, from: string, session: AcpSession, task: string, images?: import("./types.ts").ImageContent[]): Promise<void> {
1560
2151
  debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
2152
+ session.prompting = true;
1561
2153
  const promptStartedAt = Date.now();
1562
2154
 
1563
- // Broadcast task started to mobile nodes
2155
+ // Broadcast task started to mobile nodes + session notify to all peers
1564
2156
  this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
2157
+ this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
1565
2158
 
1566
2159
  // Wire streaming to send acp_stream frames (to requester + all session watchers)
1567
- session.setStreamCallback((delta, event) => {
2160
+ const watchers = this.sessionWatchers.get(session.sessionId);
2161
+ debug("acp", `runPrompt: wiring stream callback. sessionId=${session.sessionId.slice(0, 8)} from=${from} watchers=[${watchers ? [...watchers].join(",") : "none"}]`);
2162
+ let streamChunkCount = 0;
2163
+ session.setStreamCallback((delta, event, data) => {
2164
+ streamChunkCount++;
2165
+ if (streamChunkCount <= 3 || streamChunkCount % 50 === 0) {
2166
+ debug("acp", `stream chunk #${streamChunkCount}: event=${event ?? "text"} delta=${delta.slice(0, 40)} sessionId=${session.sessionId.slice(0, 8)}`);
2167
+ }
2168
+ session.lastActiveAt = Date.now();
1568
2169
  const streamFrame: AcpStreamChunk = {
1569
2170
  type: "acp_stream",
1570
2171
  id: requestId,
1571
2172
  from: this.config.nodeId,
1572
2173
  to: from,
1573
2174
  timestamp: Date.now(),
1574
- payload: { delta, event, done: false, sessionId: session.sessionId },
2175
+ payload: { delta, event, done: false, sessionId: session.sessionId, data },
1575
2176
  };
1576
2177
  this.peerManager.sendTo(from, streamFrame);
1577
2178
  this.sendToOtherWatchers(session.sessionId, from, streamFrame);
@@ -1603,25 +2204,33 @@ export class AcpProxy {
1603
2204
  promptParts.push({ type: "image", data: img.data, mimeType: img.mediaType });
1604
2205
  }
1605
2206
  }
1606
- const promptResponse = await session.conn.prompt({
1607
- sessionId: session.acpSessionId,
1608
- prompt: promptParts as any,
2207
+ // Race prompt against connection close to detect unexpected agent crashes.
2208
+ // Suppress the rejection on the losing branch to avoid unhandled promise rejection.
2209
+ let cancelConnWatch: (() => void) | undefined;
2210
+ const connClosed = new Promise<never>((_, reject) => {
2211
+ const onClose = () => reject(new Error("ACP agent connection closed unexpectedly during prompt"));
2212
+ session.conn.closed.then(onClose, onClose);
2213
+ cancelConnWatch = () => {
2214
+ // Swallow the eventual rejection so it doesn't become unhandled
2215
+ session.conn.closed.catch(() => {});
2216
+ };
1609
2217
  });
2218
+ connClosed.catch(() => {}); // prevent unhandled rejection on the race loser
2219
+ const promptResponse = await Promise.race([
2220
+ session.conn.prompt({
2221
+ sessionId: session.acpSessionId,
2222
+ prompt: promptParts as any,
2223
+ }),
2224
+ connClosed,
2225
+ ]);
2226
+ cancelConnWatch?.();
1610
2227
 
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);
2228
+ // Send done marker BEFORE response to guarantee ordering for clients
2229
+ this.sendDoneMarker(requestId, from, session.sessionId);
1622
2230
 
2231
+ const isCancelled = promptResponse.stopReason === "cancelled";
1623
2232
  const responsePayload: AcpTaskResponse["payload"] = {
1624
- success: true,
2233
+ success: !isCancelled,
1625
2234
  nodeId: this.config.nodeId,
1626
2235
  agent: session.agent,
1627
2236
  sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
@@ -1631,9 +2240,17 @@ export class AcpProxy {
1631
2240
  this.sendResponse(requestId, from, responsePayload);
1632
2241
  this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
1633
2242
 
1634
- // Broadcast task completed
1635
- this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
2243
+ // Broadcast task completed (or cancelled)
2244
+ this.taskActivity.broadcast(
2245
+ requestId, "acp", isCancelled ? "failed" : "completed", session.agent, promptStartedAt,
2246
+ isCancelled ? "已取消" : undefined,
2247
+ );
2248
+ if (!isCancelled) {
2249
+ this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
2250
+ }
1636
2251
  } catch (err) {
2252
+ // Always send done marker on error so clients don't hang
2253
+ this.sendDoneMarker(requestId, from, session.sessionId);
1637
2254
  // Broadcast task failed
1638
2255
  this.taskActivity.broadcast(
1639
2256
  requestId, "acp", "failed", session.agent, promptStartedAt,
@@ -1641,6 +2258,7 @@ export class AcpProxy {
1641
2258
  );
1642
2259
  throw err;
1643
2260
  } finally {
2261
+ session.prompting = false;
1644
2262
  session.setStreamCallback(null);
1645
2263
  }
1646
2264
  }
@@ -1650,8 +2268,10 @@ export class AcpProxy {
1650
2268
  if (!this.gatewayInfo) throw new Error("Gateway info not available for native session");
1651
2269
 
1652
2270
  debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
2271
+ session.prompting = true;
1653
2272
  const promptStartedAt = Date.now();
1654
2273
  this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
2274
+ this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
1655
2275
 
1656
2276
  const { port, authHeader } = this.gatewayInfo;
1657
2277
  const abortController = new AbortController();
@@ -1687,19 +2307,10 @@ export class AcpProxy {
1687
2307
  }
1688
2308
 
1689
2309
  // Stream SSE response as acp_stream frames (to requester + watchers)
2310
+ session.lastActiveAt = Date.now();
1690
2311
  const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
1691
2312
 
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);
2313
+ this.sendDoneMarker(requestId, from, session.sessionId);
1703
2314
 
1704
2315
  const responsePayload: AcpTaskResponse["payload"] = {
1705
2316
  success: true,
@@ -1711,13 +2322,17 @@ export class AcpProxy {
1711
2322
  this.sendResponse(requestId, from, responsePayload);
1712
2323
  this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
1713
2324
  this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
2325
+ this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
1714
2326
  } catch (err) {
2327
+ // Always send done marker on error so clients don't hang
2328
+ this.sendDoneMarker(requestId, from, session.sessionId);
1715
2329
  this.taskActivity.broadcast(
1716
2330
  requestId, "acp", "failed", session.agent, promptStartedAt,
1717
2331
  errorMessage(err),
1718
2332
  );
1719
2333
  throw err;
1720
2334
  } finally {
2335
+ session.prompting = false;
1721
2336
  session.abortController = null;
1722
2337
  }
1723
2338
  }
@@ -1787,7 +2402,7 @@ export class AcpProxy {
1787
2402
  try {
1788
2403
  const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
1789
2404
  debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
1790
- return { sessionId: acpSessionId, modes: loadResp.modes };
2405
+ return { sessionId: acpSessionId, modes: loadResp.modes, configOptions: (loadResp as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined };
1791
2406
  } catch (loadErr) {
1792
2407
  debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
1793
2408
  }
@@ -1805,6 +2420,9 @@ export class AcpProxy {
1805
2420
 
1806
2421
  private destroySession(session: AnySession) {
1807
2422
  invalidateSessionListCache();
2423
+ // Clean up transcript watcher and session watchers
2424
+ this.stopTranscriptWatcher(session.sessionId);
2425
+ this.sessionWatchers.delete(session.sessionId);
1808
2426
  if (session.kind === "acp") {
1809
2427
  // Clean up daemon stream callback
1810
2428
  const daemon = this.findDaemonByConn(session);
@@ -2017,6 +2635,20 @@ export class AcpProxy {
2017
2635
  });
2018
2636
  }
2019
2637
 
2638
+ /** Send a done marker to requester + all session watchers. */
2639
+ private sendDoneMarker(requestId: string, to: string, sessionId: string) {
2640
+ const doneFrame: AcpStreamChunk = {
2641
+ type: "acp_stream",
2642
+ id: requestId,
2643
+ from: this.config.nodeId,
2644
+ to,
2645
+ timestamp: Date.now(),
2646
+ payload: { delta: "", done: true, sessionId },
2647
+ };
2648
+ this.peerManager.sendTo(to, doneFrame);
2649
+ this.sendToOtherWatchers(sessionId, to, doneFrame);
2650
+ }
2651
+
2020
2652
  private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
2021
2653
  debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
2022
2654
  const frame = {
@@ -2070,6 +2702,9 @@ export class AcpProxy {
2070
2702
  }
2071
2703
 
2072
2704
  destroy() {
2705
+ // Set disposed first to prevent new timers/prewarms from being scheduled during teardown
2706
+ this.disposed = true;
2707
+
2073
2708
  if (this.cleanupTimer) {
2074
2709
  clearInterval(this.cleanupTimer);
2075
2710
  this.cleanupTimer = null;
@@ -2090,8 +2725,9 @@ export class AcpProxy {
2090
2725
  }
2091
2726
  this.sessions.clear();
2092
2727
  this.sessionWatchers.clear();
2093
-
2094
- this.disposed = true;
2728
+ // Stop all transcript watchers
2729
+ for (const [sid] of this.transcriptWatchers) this.stopTranscriptWatcher(sid);
2730
+ this.transcriptWatchers.clear();
2095
2731
 
2096
2732
  // Cancel pending retry timers
2097
2733
  for (const timer of this.retryTimers) clearTimeout(timer);
@@ -2124,6 +2760,7 @@ export class AcpProxy {
2124
2760
  }
2125
2761
  this.daemons.clear();
2126
2762
  this.daemonStarting.clear();
2763
+ this.resumeInFlight.clear();
2127
2764
  }
2128
2765
  }
2129
2766
 
@@ -2225,22 +2862,29 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2225
2862
  const content = await readFileText(storePath);
2226
2863
  const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
2227
2864
  const agent = agentId;
2228
- // Read all transcripts in parallel instead of sequentially
2865
+ // Read all transcripts in parallel (first message + mtime)
2229
2866
  const entryList = Object.entries(entries).filter(([, e]) => e.sessionId);
2230
2867
  const transcriptResults = await Promise.all(
2231
- entryList.map(([, entry]) => {
2868
+ entryList.map(async ([, entry]) => {
2232
2869
  const transcriptPath = join(sessionsDir, `${entry.sessionId!}.jsonl`);
2233
- return readFirstUserMessageFromTranscript(transcriptPath);
2870
+ const [firstMsg, fileStat] = await Promise.all([
2871
+ readFirstUserMessageFromTranscript(transcriptPath),
2872
+ fsStat(transcriptPath).catch(() => null),
2873
+ ]);
2874
+ return { firstMsg, mtimeMs: fileStat?.mtimeMs ?? null };
2234
2875
  }),
2235
2876
  );
2236
2877
  for (let i = 0; i < entryList.length; i++) {
2237
2878
  const [key, entry] = entryList[i]!;
2879
+ const { firstMsg, mtimeMs } = transcriptResults[i]!;
2880
+ const storeTs = entry.updatedAt ?? 0;
2881
+ const effectiveTs = mtimeMs && mtimeMs > storeTs ? mtimeMs : storeTs;
2238
2882
  results.push({
2239
2883
  sessionId: entry.sessionId!,
2240
2884
  cwd: key,
2241
2885
  title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
2242
- description: transcriptResults[i] ?? undefined,
2243
- updatedAt: entry.updatedAt ? new Date(entry.updatedAt).toISOString() : undefined,
2886
+ description: firstMsg ?? undefined,
2887
+ updatedAt: effectiveTs ? new Date(effectiveTs).toISOString() : undefined,
2244
2888
  agent,
2245
2889
  });
2246
2890
  }
@@ -2288,14 +2932,41 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2288
2932
 
2289
2933
  const ccResults: AcpSessionInfo[] = [];
2290
2934
  const existingIds = new Set(results.map((r) => r.sessionId));
2935
+ // Stat transcript files in parallel to get more accurate mtime
2936
+ const claudeProjectsDir = join(homedir(), ".claude", "projects");
2937
+ let projectDirs: string[] = [];
2938
+ try { projectDirs = await readdir(claudeProjectsDir); } catch { /* no projects dir */ }
2939
+
2940
+ const pendingEntries: { sessionId: string; info: { title: string; updatedAt: number; cwd: string } }[] = [];
2291
2941
  for (const [sessionId, info] of sessions) {
2292
2942
  if (!info.title || info.title.startsWith("/")) continue;
2293
2943
  if (existingIds.has(sessionId)) continue;
2944
+ pendingEntries.push({ sessionId, info });
2945
+ }
2946
+
2947
+ // For each session, try to find its transcript file and stat it
2948
+ const mtimeResults = await Promise.all(
2949
+ pendingEntries.map(async ({ sessionId }) => {
2950
+ for (const dir of projectDirs) {
2951
+ try {
2952
+ const s = await fsStat(join(claudeProjectsDir, dir, `${sessionId}.jsonl`));
2953
+ return s.mtimeMs;
2954
+ } catch { /* not in this project dir */ }
2955
+ }
2956
+ return null;
2957
+ }),
2958
+ );
2959
+
2960
+ for (let i = 0; i < pendingEntries.length; i++) {
2961
+ const { sessionId, info } = pendingEntries[i]!;
2962
+ const mtime = mtimeResults[i];
2963
+ // Use the more recent of history timestamp and transcript file mtime
2964
+ const effectiveUpdatedAt = mtime && mtime > info.updatedAt ? mtime : info.updatedAt;
2294
2965
  ccResults.push({
2295
2966
  sessionId,
2296
2967
  cwd: info.cwd,
2297
2968
  title: info.title,
2298
- updatedAt: info.updatedAt ? new Date(info.updatedAt).toISOString() : undefined,
2969
+ updatedAt: effectiveUpdatedAt ? new Date(effectiveUpdatedAt).toISOString() : undefined,
2299
2970
  agent: "claude",
2300
2971
  });
2301
2972
  }
@@ -2364,6 +3035,52 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2364
3035
  return results;
2365
3036
  }
2366
3037
 
3038
+ // ── Physical session deletion ──────────────────────────────────────
3039
+
3040
+ /**
3041
+ * Delete a session's files from disk (only for agents we fully control).
3042
+ *
3043
+ * Claude Code and Codex have their own index files (history.jsonl, SQLite DB,
3044
+ * session_index.jsonl) that still reference sessions — deleting transcript
3045
+ * files without cleaning those indexes would break their own UI/CLI.
3046
+ * Since they don't expose a deleteSession API, we only physically delete
3047
+ * OpenClaw-managed session files where we control both the store and transcripts.
3048
+ *
3049
+ * For Claude Code and Codex, sessions are hidden via the in-memory
3050
+ * closedSessionIds set (which filters them from list responses).
3051
+ */
3052
+ async function deleteSessionFromDisk(sessionId: string): Promise<void> {
3053
+ const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
3054
+
3055
+ // OpenClaw agent session stores: remove entry from sessions.json + delete transcript
3056
+ const agentsDir = join(stateDir, "agents");
3057
+ try {
3058
+ const agentIds = await readdir(agentsDir);
3059
+ for (const agentId of agentIds) {
3060
+ const sessionsDir = join(agentsDir, agentId, "sessions");
3061
+ const storePath = join(sessionsDir, "sessions.json");
3062
+ try {
3063
+ const content = await readFileText(storePath);
3064
+ const entries: Record<string, Record<string, unknown>> = JSON.parse(content);
3065
+ let modified = false;
3066
+ for (const [key, entry] of Object.entries(entries)) {
3067
+ if ((entry as { sessionId?: string }).sessionId === sessionId) {
3068
+ delete entries[key];
3069
+ modified = true;
3070
+ }
3071
+ }
3072
+ if (modified) {
3073
+ await fsWriteFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
3074
+ }
3075
+ await unlink(join(sessionsDir, `${sessionId}.jsonl`)).catch(() => {});
3076
+ } catch { /* store unreadable */ }
3077
+ }
3078
+ } catch { /* no agents directory */ }
3079
+
3080
+ invalidateSessionListCache();
3081
+ debug("acp", `deleteSessionFromDisk: cleaned up OpenClaw files for ${sessionId.slice(0, 8)}`);
3082
+ }
3083
+
2367
3084
  // ── Transcript normalization ───────────────────────────────────────
2368
3085
  // Mirrors the OpenClaw web dashboard pipeline:
2369
3086
  // Server: readSessionMessages → stripEnvelopeFromMessages → sanitizeChatHistoryMessages
@@ -2536,6 +3253,12 @@ function stripThinkingTags(text: string): string {
2536
3253
  .trim();
2537
3254
  }
2538
3255
 
3256
+ /** Strip Codex terminal output metadata (Chunk ID, Wall time, etc.) from tool results. */
3257
+ const CODEX_TERMINAL_META_RE = /^Chunk ID: [^\n]*\nWall time: [^\n]*\nProcess exited with code \d+\n(?:Original token count: [^\n]*\n)?Output:\n/;
3258
+ function stripCodexTerminalMeta(text: string): string {
3259
+ return text.replace(CODEX_TERMINAL_META_RE, "");
3260
+ }
3261
+
2539
3262
  /** Check if text is a silent reply (NO_REPLY). */
2540
3263
  function isSilentReply(text: string): boolean {
2541
3264
  return /^\s*NO_REPLY\s*$/.test(text);
@@ -2614,7 +3337,8 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
2614
3337
  : typeof msg.toolName === "string" ? msg.toolName
2615
3338
  : typeof msg.tool_name === "string" ? msg.tool_name
2616
3339
  : inlineResults[0]?.name;
2617
- const resultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
3340
+ const rawResultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
3341
+ const resultText = stripCodexTerminalMeta(rawResultText);
2618
3342
 
2619
3343
  if (raw.length > 0) {
2620
3344
  const prev = raw[raw.length - 1]!;