clawmatrix 0.3.1 → 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,
@@ -99,6 +100,7 @@ interface AcpSession {
99
100
  proc: { kill: () => void; exited: Promise<number> };
100
101
  lastActiveAt: number;
101
102
  from: string; // owning nodeId
103
+ prompting: boolean; // true while executing a prompt (busy state)
102
104
  setStreamCallback: (cb: ((delta: string, event?: string, data?: unknown) => void) | null) => void;
103
105
  availableModes?: AcpModeInfo[];
104
106
  currentModeId?: string;
@@ -114,6 +116,7 @@ interface NativeSession {
114
116
  sessionKey: string; // OpenClaw session key (e.g. "agent:main:cron:xxx")
115
117
  lastActiveAt: number;
116
118
  from: string;
119
+ prompting: boolean; // true while executing a prompt (busy state)
117
120
  abortController: AbortController | null;
118
121
  }
119
122
 
@@ -165,12 +168,16 @@ export class AcpProxy {
165
168
  // Track pending retry timers for sendResponse so they can be cancelled on destroy
166
169
  private retryTimers = new Set<ReturnType<typeof setTimeout>>();
167
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 }>();
168
173
  // Agent daemon pool: long-lived process per agent type, reused across sessions
169
174
  private daemons = new Map<string, AgentDaemon>();
170
175
  // In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
171
176
  private daemonStarting = new Map<string, Promise<AgentDaemon>>();
172
177
  // In-flight resume operations: prevents concurrent resumes for same acpSessionId
173
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>();
174
181
 
175
182
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
176
183
  this.config = config;
@@ -194,12 +201,27 @@ export class AcpProxy {
194
201
  // ── Multi-device sync helpers ──────────────────────────────────
195
202
 
196
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
+
197
217
  let watchers = this.sessionWatchers.get(sessionId);
198
218
  if (!watchers) {
199
219
  watchers = new Set();
200
220
  this.sessionWatchers.set(sessionId, watchers);
201
221
  }
222
+ const isNew = !watchers.has(nodeId);
202
223
  watchers.add(nodeId);
224
+ debug("acp", `addSessionWatcher: sessionId=${sessionId.slice(0, 8)} nodeId=${nodeId} isNew=${isNew} total=${watchers.size} all=[${[...watchers].join(",")}]`);
203
225
  }
204
226
 
205
227
  /** Send a frame to all session watchers except the specified node (usually the original `from`). */
@@ -208,6 +230,7 @@ export class AcpProxy {
208
230
  if (!watchers) return;
209
231
  for (const nodeId of watchers) {
210
232
  if (nodeId === exclude) continue;
233
+ debug("acp", `sendToOtherWatchers: sessionId=${sessionId.slice(0, 8)} → ${nodeId} (type=${frame.type})`);
211
234
  this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
212
235
  }
213
236
  }
@@ -229,6 +252,171 @@ export class AcpProxy {
229
252
  }
230
253
  }
231
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
+
232
420
  // ── Subscribe / observe (receiver side) ─────────────────────────
233
421
 
234
422
  /** Handle acp_subscribe: register caller as observer and return history snapshot.
@@ -688,15 +876,27 @@ export class AcpProxy {
688
876
  debug("acp", `handleRequest: id=${id} from=${from} agent=${agent} mode=${mode} sessionId=${sessionId ?? "(new)"} acpSessionId=${resumeAcpSessionId ?? "(none)"}`);
689
877
 
690
878
  try {
691
- 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) {
692
892
  // Follow-up on existing session
693
- const session = this.sessions.get(sessionId);
893
+ const session = this.sessions.get(resolvedSessionId);
694
894
  if (!session) {
695
895
  // Session expired or process restarted – try to resume
696
896
  if (this.isAcpAgent(agent)) {
697
897
  let newSession: AcpSession;
698
898
  if (resumeAcpSessionId) {
699
- 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)}..."`);
700
900
  const effectiveCwd = cwd || process.cwd();
701
901
  try {
702
902
  newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
@@ -705,7 +905,7 @@ export class AcpProxy {
705
905
  newSession = await this.createSession(agent, cwd, from);
706
906
  }
707
907
  } else {
708
- debug("acp", `Session "${sessionId}" not found, creating new session as fallback`);
908
+ debug("acp", `Session "${resolvedSessionId}" not found, creating new session as fallback`);
709
909
  newSession = await this.createSession(agent, cwd, from);
710
910
  }
711
911
  if (mode === "persistent") {
@@ -731,12 +931,12 @@ export class AcpProxy {
731
931
  }
732
932
  } else {
733
933
  // Re-create native session
734
- debug("acp", `Native session "${sessionId}" not found, re-creating with key from cwd`);
735
- const sessionKey = cwd ?? sessionId;
934
+ debug("acp", `Native session "${resolvedSessionId}" not found, re-creating with key from cwd`);
935
+ const sessionKey = cwd ?? resolvedSessionId;
736
936
  const newId = `native-${crypto.randomUUID()}`;
737
937
  const nativeSession: NativeSession = {
738
938
  kind: "native", sessionId: newId, agent, sessionKey,
739
- lastActiveAt: Date.now(), from, abortController: null,
939
+ lastActiveAt: Date.now(), from, prompting: false, abortController: null,
740
940
  };
741
941
  if (mode === "persistent") {
742
942
  this.sessions.set(newId, nativeSession);
@@ -751,7 +951,7 @@ export class AcpProxy {
751
951
  return;
752
952
  }
753
953
  // Track this node as a session watcher for multi-device sync
754
- this.addSessionWatcher(sessionId, from);
954
+ this.addSessionWatcher(resolvedSessionId, from);
755
955
  session.lastActiveAt = Date.now();
756
956
  if (session.kind === "native") {
757
957
  await this.runNativePrompt(id, from, session, task);
@@ -769,8 +969,20 @@ export class AcpProxy {
769
969
  }
770
970
 
771
971
  if (this.isAcpAgent(agent)) {
772
- // Create new ACP session
773
- 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
+ }
774
986
  if (mode === "persistent") {
775
987
  this.sessions.set(session.sessionId, session);
776
988
  this.addSessionWatcher(session.sessionId, from);
@@ -802,7 +1014,7 @@ export class AcpProxy {
802
1014
  const newId = `native-${crypto.randomUUID()}`;
803
1015
  const nativeSession: NativeSession = {
804
1016
  kind: "native", sessionId: newId, agent, sessionKey,
805
- lastActiveAt: Date.now(), from, abortController: null,
1017
+ lastActiveAt: Date.now(), from, prompting: false, abortController: null,
806
1018
  };
807
1019
  if (mode === "persistent") {
808
1020
  this.sessions.set(newId, nativeSession);
@@ -825,7 +1037,19 @@ export class AcpProxy {
825
1037
 
826
1038
  async handleClose(frame: AcpCloseRequest): Promise<void> {
827
1039
  const { id, from, payload } = frame;
828
- 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
+ }
829
1053
 
830
1054
  if (!session) {
831
1055
  this.peerManager.sendTo(from, {
@@ -836,18 +1060,37 @@ export class AcpProxy {
836
1060
  return;
837
1061
  }
838
1062
 
839
- 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)) {
840
1066
  this.peerManager.sendTo(from, {
841
1067
  type: "acp_close_res", id, from: this.config.nodeId, to: from,
842
1068
  timestamp: Date.now(),
843
- payload: { success: false, error: "Not the session owner" },
1069
+ payload: { success: false, error: "Not the session owner or watcher" },
844
1070
  } satisfies AcpCloseResponse);
845
1071
  return;
846
1072
  }
847
1073
 
848
- this.sessions.delete(payload.sessionId);
1074
+ this.sessions.delete(resolvedId);
849
1075
  this.destroySession(session);
850
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
+
851
1094
  this.peerManager.sendTo(from, {
852
1095
  type: "acp_close_res", id, from: this.config.nodeId, to: from,
853
1096
  timestamp: Date.now(),
@@ -860,26 +1103,42 @@ export class AcpProxy {
860
1103
  const { id, from, payload } = frame;
861
1104
 
862
1105
  try {
863
- // 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
864
1115
  const clawSessions: AcpSessionInfo[] = [];
1116
+ const activeAcpSessionIds = new Set<string>();
865
1117
  for (const [, session] of this.sessions) {
866
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;
867
1123
  clawSessions.push({
868
- sessionId: session.sessionId,
869
- cwd: "",
1124
+ sessionId: acpSid ?? session.sessionId,
1125
+ cwd: diskInfo?.cwd ?? "",
1126
+ title: diskInfo?.title,
1127
+ description: diskInfo?.description,
870
1128
  agent: session.agent,
871
1129
  updatedAt: new Date(session.lastActiveAt).toISOString(),
872
- status: "active",
1130
+ status: session.prompting ? "busy" : "active",
873
1131
  });
874
1132
  }
875
1133
 
876
- // 2. Read persisted sessions directly from disk (no daemon spawn needed)
877
- const diskSessions = await this.readAllSessionStoresFromDisk();
878
- // Filter by agent if requested, and exclude already-tracked active sessions
1134
+ // 3. Filter disk sessions: exclude already-tracked active sessions and explicitly closed ones
879
1135
  const filteredDiskSessions = diskSessions
880
1136
  .filter((s) => {
881
1137
  if (payload.agent && s.agent !== payload.agent) return false;
882
- return !clawSessions.some((cs) => cs.sessionId === s.sessionId);
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;
883
1142
  })
884
1143
  .map((s) => ({ ...s, status: "idle" as const }));
885
1144
 
@@ -904,6 +1163,28 @@ export class AcpProxy {
904
1163
  const cwd = payload.cwd || process.cwd();
905
1164
 
906
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
+
907
1188
  // Enforce concurrent session limit
908
1189
  if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
909
1190
  this.peerManager.sendTo(from, {
@@ -946,6 +1227,8 @@ export class AcpProxy {
946
1227
  timestamp: Date.now(),
947
1228
  payload: { success: true, sessionId: session.sessionId },
948
1229
  } satisfies AcpResumeResponse);
1230
+ // Start transcript watcher for external activity (terminal Claude Code)
1231
+ this.startTranscriptWatcher(session.sessionId, session.acpSessionId).catch(() => {});
949
1232
  } else {
950
1233
  // Native OpenClaw session — no process to spawn, just record the session key
951
1234
  // cwd contains the OpenClaw session key (e.g. "agent:main:clawmatrix-handoff:xxx")
@@ -957,6 +1240,7 @@ export class AcpProxy {
957
1240
  sessionKey: cwd,
958
1241
  lastActiveAt: Date.now(),
959
1242
  from,
1243
+ prompting: false,
960
1244
  abortController: null,
961
1245
  };
962
1246
  this.sessions.set(sessionId, nativeSession);
@@ -1475,6 +1759,7 @@ export class AcpProxy {
1475
1759
  proc: daemon.proc,
1476
1760
  lastActiveAt: Date.now(),
1477
1761
  from,
1762
+ prompting: false,
1478
1763
  setStreamCallback: (cb) => {
1479
1764
  daemon.streamCallbacks.set(response.sessionId, cb);
1480
1765
  },
@@ -1829,6 +2114,7 @@ export class AcpProxy {
1829
2114
  proc,
1830
2115
  lastActiveAt: Date.now(),
1831
2116
  from,
2117
+ prompting: false,
1832
2118
  setStreamCallback: (cb) => { streamCallback = cb; },
1833
2119
  availableModes,
1834
2120
  currentModeId: response.modes?.currentModeId,
@@ -1863,6 +2149,7 @@ export class AcpProxy {
1863
2149
 
1864
2150
  private async runPrompt(requestId: string, from: string, session: AcpSession, task: string, images?: import("./types.ts").ImageContent[]): Promise<void> {
1865
2151
  debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
2152
+ session.prompting = true;
1866
2153
  const promptStartedAt = Date.now();
1867
2154
 
1868
2155
  // Broadcast task started to mobile nodes + session notify to all peers
@@ -1870,7 +2157,15 @@ export class AcpProxy {
1870
2157
  this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
1871
2158
 
1872
2159
  // Wire streaming to send acp_stream frames (to requester + all session watchers)
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;
1873
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();
1874
2169
  const streamFrame: AcpStreamChunk = {
1875
2170
  type: "acp_stream",
1876
2171
  id: requestId,
@@ -1963,6 +2258,7 @@ export class AcpProxy {
1963
2258
  );
1964
2259
  throw err;
1965
2260
  } finally {
2261
+ session.prompting = false;
1966
2262
  session.setStreamCallback(null);
1967
2263
  }
1968
2264
  }
@@ -1972,6 +2268,7 @@ export class AcpProxy {
1972
2268
  if (!this.gatewayInfo) throw new Error("Gateway info not available for native session");
1973
2269
 
1974
2270
  debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
2271
+ session.prompting = true;
1975
2272
  const promptStartedAt = Date.now();
1976
2273
  this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
1977
2274
  this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
@@ -2010,6 +2307,7 @@ export class AcpProxy {
2010
2307
  }
2011
2308
 
2012
2309
  // Stream SSE response as acp_stream frames (to requester + watchers)
2310
+ session.lastActiveAt = Date.now();
2013
2311
  const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
2014
2312
 
2015
2313
  this.sendDoneMarker(requestId, from, session.sessionId);
@@ -2026,12 +2324,15 @@ export class AcpProxy {
2026
2324
  this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
2027
2325
  this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
2028
2326
  } catch (err) {
2327
+ // Always send done marker on error so clients don't hang
2328
+ this.sendDoneMarker(requestId, from, session.sessionId);
2029
2329
  this.taskActivity.broadcast(
2030
2330
  requestId, "acp", "failed", session.agent, promptStartedAt,
2031
2331
  errorMessage(err),
2032
2332
  );
2033
2333
  throw err;
2034
2334
  } finally {
2335
+ session.prompting = false;
2035
2336
  session.abortController = null;
2036
2337
  }
2037
2338
  }
@@ -2119,7 +2420,8 @@ export class AcpProxy {
2119
2420
 
2120
2421
  private destroySession(session: AnySession) {
2121
2422
  invalidateSessionListCache();
2122
- // Clean up watchers for this session
2423
+ // Clean up transcript watcher and session watchers
2424
+ this.stopTranscriptWatcher(session.sessionId);
2123
2425
  this.sessionWatchers.delete(session.sessionId);
2124
2426
  if (session.kind === "acp") {
2125
2427
  // Clean up daemon stream callback
@@ -2423,6 +2725,9 @@ export class AcpProxy {
2423
2725
  }
2424
2726
  this.sessions.clear();
2425
2727
  this.sessionWatchers.clear();
2728
+ // Stop all transcript watchers
2729
+ for (const [sid] of this.transcriptWatchers) this.stopTranscriptWatcher(sid);
2730
+ this.transcriptWatchers.clear();
2426
2731
 
2427
2732
  // Cancel pending retry timers
2428
2733
  for (const timer of this.retryTimers) clearTimeout(timer);
@@ -2557,22 +2862,29 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2557
2862
  const content = await readFileText(storePath);
2558
2863
  const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
2559
2864
  const agent = agentId;
2560
- // Read all transcripts in parallel instead of sequentially
2865
+ // Read all transcripts in parallel (first message + mtime)
2561
2866
  const entryList = Object.entries(entries).filter(([, e]) => e.sessionId);
2562
2867
  const transcriptResults = await Promise.all(
2563
- entryList.map(([, entry]) => {
2868
+ entryList.map(async ([, entry]) => {
2564
2869
  const transcriptPath = join(sessionsDir, `${entry.sessionId!}.jsonl`);
2565
- 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 };
2566
2875
  }),
2567
2876
  );
2568
2877
  for (let i = 0; i < entryList.length; i++) {
2569
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;
2570
2882
  results.push({
2571
2883
  sessionId: entry.sessionId!,
2572
2884
  cwd: key,
2573
2885
  title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
2574
- description: transcriptResults[i] ?? undefined,
2575
- updatedAt: entry.updatedAt ? new Date(entry.updatedAt).toISOString() : undefined,
2886
+ description: firstMsg ?? undefined,
2887
+ updatedAt: effectiveTs ? new Date(effectiveTs).toISOString() : undefined,
2576
2888
  agent,
2577
2889
  });
2578
2890
  }
@@ -2620,14 +2932,41 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2620
2932
 
2621
2933
  const ccResults: AcpSessionInfo[] = [];
2622
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 } }[] = [];
2623
2941
  for (const [sessionId, info] of sessions) {
2624
2942
  if (!info.title || info.title.startsWith("/")) continue;
2625
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;
2626
2965
  ccResults.push({
2627
2966
  sessionId,
2628
2967
  cwd: info.cwd,
2629
2968
  title: info.title,
2630
- updatedAt: info.updatedAt ? new Date(info.updatedAt).toISOString() : undefined,
2969
+ updatedAt: effectiveUpdatedAt ? new Date(effectiveUpdatedAt).toISOString() : undefined,
2631
2970
  agent: "claude",
2632
2971
  });
2633
2972
  }
@@ -2696,6 +3035,52 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2696
3035
  return results;
2697
3036
  }
2698
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
+
2699
3084
  // ── Transcript normalization ───────────────────────────────────────
2700
3085
  // Mirrors the OpenClaw web dashboard pipeline:
2701
3086
  // Server: readSessionMessages → stripEnvelopeFromMessages → sanitizeChatHistoryMessages