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/cli/bin/clawmatrix.mjs +1006 -0
- package/cli/package.json +27 -0
- package/cli/skills/clawmatrix/SKILL.md +104 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +2 -1
- package/src/acp-proxy.ts +416 -31
- package/src/cluster-service.ts +72 -2
- package/src/health-tracker.ts +40 -11
- package/src/index.ts +471 -28
- package/src/knowledge-sync.ts +18 -4
- package/src/model-proxy.ts +5 -0
- package/src/peer-manager.ts +33 -4
- package/src/tool-proxy.ts +40 -2
- package/src/tools/cluster-notify.ts +132 -0
- package/src/types.ts +1 -1
- package/src/cli.ts +0 -711
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
|
-
|
|
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(
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
735
|
-
const sessionKey = cwd ??
|
|
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(
|
|
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
|
-
//
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
2575
|
-
updatedAt:
|
|
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:
|
|
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
|