clawmatrix 0.4.2 → 0.5.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/README.md +17 -21
- package/cli/bin/clawmatrix.mjs +300 -1
- package/package.json +8 -1
- package/src/acp-proxy.ts +122 -50
- package/src/{web.ts → api.ts} +646 -25
- package/src/audit.ts +37 -2
- package/src/auth.ts +5 -10
- package/src/automation.ts +625 -0
- package/src/cluster-service.ts +172 -16
- package/src/compat.ts +103 -0
- package/src/config.ts +75 -27
- package/src/connection.ts +215 -37
- package/src/crypto.ts +72 -5
- package/src/device-info.ts +21 -2
- package/src/file-transfer.ts +3 -2
- package/src/handoff.ts +90 -32
- package/src/health-tracker.ts +91 -356
- package/src/index.ts +421 -13
- package/src/kanban.ts +507 -0
- package/src/knowledge-sync.ts +158 -7
- package/src/local-tools.ts +65 -2
- package/src/log-replication.ts +198 -0
- package/src/model-proxy.ts +152 -60
- package/src/peer-approval.ts +3 -2
- package/src/peer-manager.ts +230 -44
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +85 -51
- package/src/store.ts +578 -0
- package/src/terminal.ts +17 -8
- package/src/tool-proxy.ts +6 -5
- package/src/tools/cluster-events.ts +6 -6
- package/src/tools/cluster-kanban.ts +345 -0
- package/src/tools/cluster-peers.ts +1 -1
- package/src/tools/cluster-query.ts +145 -0
- package/src/types.ts +95 -9
package/src/acp-proxy.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
AcpUnsubscribeRequest,
|
|
45
45
|
AcpSessionNotify,
|
|
46
46
|
} from "./types.ts";
|
|
47
|
+
import { nanoid } from "nanoid";
|
|
47
48
|
import { TaskActivityBroadcaster } from "./task-activity.ts";
|
|
48
49
|
|
|
49
50
|
/** Extract a human-readable message from any thrown value (Error, JSON-RPC error object, etc.) */
|
|
@@ -146,7 +147,8 @@ export class AcpProxy {
|
|
|
146
147
|
private maxSessions: number;
|
|
147
148
|
private permissionMode: AcpPermissionMode;
|
|
148
149
|
private taskActivity: TaskActivityBroadcaster;
|
|
149
|
-
|
|
150
|
+
/** Callback for file write attribution (set by ClusterRuntime). */
|
|
151
|
+
onToolWrite: ((filePath: string, agentId: string) => void) | null = null;
|
|
150
152
|
// Receiver: active sessions (ACP or native OpenClaw)
|
|
151
153
|
private sessions = new Map<string, AnySession>();
|
|
152
154
|
// Requester: pending requests
|
|
@@ -176,8 +178,11 @@ export class AcpProxy {
|
|
|
176
178
|
private daemonStarting = new Map<string, Promise<AgentDaemon>>();
|
|
177
179
|
// In-flight resume operations: prevents concurrent resumes for same acpSessionId
|
|
178
180
|
private resumeInFlight = new Map<string, Promise<AcpSession>>();
|
|
179
|
-
// Sessions explicitly closed by users — excluded from disk session list
|
|
181
|
+
// Sessions explicitly closed by users — excluded from disk session list.
|
|
182
|
+
// Falls back to in-memory Set when Store is not available.
|
|
180
183
|
private closedSessionIds = new Set<string>();
|
|
184
|
+
/** Optional SQLite store for persistent closedSessionIds (survives restarts). */
|
|
185
|
+
store: import("./store.ts").Store | null = null;
|
|
181
186
|
|
|
182
187
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
|
|
183
188
|
this.config = config;
|
|
@@ -537,7 +542,7 @@ export class AcpProxy {
|
|
|
537
542
|
targetNodeId: string,
|
|
538
543
|
sessionId: string,
|
|
539
544
|
): Promise<AcpCloseResponse["payload"]> {
|
|
540
|
-
const id =
|
|
545
|
+
const id = nanoid();
|
|
541
546
|
return new Promise((resolve, reject) => {
|
|
542
547
|
const timer = setTimeout(() => {
|
|
543
548
|
this.pending.delete(id);
|
|
@@ -577,7 +582,7 @@ export class AcpProxy {
|
|
|
577
582
|
targetNodeId: string,
|
|
578
583
|
options?: { agent?: string; cwd?: string },
|
|
579
584
|
): Promise<AcpListResponse["payload"]> {
|
|
580
|
-
const id =
|
|
585
|
+
const id = nanoid();
|
|
581
586
|
return new Promise((resolve, reject) => {
|
|
582
587
|
const timer = setTimeout(() => {
|
|
583
588
|
this.pending.delete(id);
|
|
@@ -619,7 +624,7 @@ export class AcpProxy {
|
|
|
619
624
|
acpSessionId: string,
|
|
620
625
|
cwd: string,
|
|
621
626
|
): Promise<AcpResumeResponse["payload"]> {
|
|
622
|
-
const id =
|
|
627
|
+
const id = nanoid();
|
|
623
628
|
return new Promise((resolve, reject) => {
|
|
624
629
|
const timer = setTimeout(() => {
|
|
625
630
|
this.pending.delete(id);
|
|
@@ -659,7 +664,7 @@ export class AcpProxy {
|
|
|
659
664
|
targetNodeId: string,
|
|
660
665
|
sessionId: string,
|
|
661
666
|
): Promise<AcpCancelResponse["payload"]> {
|
|
662
|
-
const id =
|
|
667
|
+
const id = nanoid();
|
|
663
668
|
return new Promise((resolve, reject) => {
|
|
664
669
|
const timer = setTimeout(() => {
|
|
665
670
|
this.pending.delete(id);
|
|
@@ -697,7 +702,7 @@ export class AcpProxy {
|
|
|
697
702
|
sessionId: string,
|
|
698
703
|
modeId: string,
|
|
699
704
|
): Promise<AcpSetModeResponse["payload"]> {
|
|
700
|
-
const id =
|
|
705
|
+
const id = nanoid();
|
|
701
706
|
return new Promise((resolve, reject) => {
|
|
702
707
|
const timer = setTimeout(() => {
|
|
703
708
|
this.pending.delete(id);
|
|
@@ -734,7 +739,7 @@ export class AcpProxy {
|
|
|
734
739
|
targetNodeId: string,
|
|
735
740
|
sessionId: string,
|
|
736
741
|
): Promise<AcpGetModesResponse["payload"]> {
|
|
737
|
-
const id =
|
|
742
|
+
const id = nanoid();
|
|
738
743
|
return new Promise((resolve, reject) => {
|
|
739
744
|
const timer = setTimeout(() => {
|
|
740
745
|
this.pending.delete(id);
|
|
@@ -933,7 +938,7 @@ export class AcpProxy {
|
|
|
933
938
|
// Re-create native session
|
|
934
939
|
debug("acp", `Native session "${resolvedSessionId}" not found, re-creating with key from cwd`);
|
|
935
940
|
const sessionKey = cwd ?? resolvedSessionId;
|
|
936
|
-
const newId = `native-${
|
|
941
|
+
const newId = `native-${nanoid()}`;
|
|
937
942
|
const nativeSession: NativeSession = {
|
|
938
943
|
kind: "native", sessionId: newId, agent, sessionKey,
|
|
939
944
|
lastActiveAt: Date.now(), from, prompting: false, abortController: null,
|
|
@@ -959,13 +964,16 @@ export class AcpProxy {
|
|
|
959
964
|
await this.runPrompt(id, from, session, task, images);
|
|
960
965
|
}
|
|
961
966
|
} else {
|
|
962
|
-
// Enforce concurrent session limit
|
|
967
|
+
// Enforce concurrent session limit — evict oldest idle session if at capacity
|
|
963
968
|
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions && mode === "persistent") {
|
|
964
|
-
this.
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
+
const evicted = this.evictOldestIdleSession();
|
|
970
|
+
if (!evicted) {
|
|
971
|
+
this.sendResponse(id, from, {
|
|
972
|
+
success: false,
|
|
973
|
+
error: `Max concurrent sessions reached (${this.maxSessions})`,
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
969
977
|
}
|
|
970
978
|
|
|
971
979
|
if (this.isAcpAgent(agent)) {
|
|
@@ -1010,8 +1018,8 @@ export class AcpProxy {
|
|
|
1010
1018
|
this.sendResponse(id, from, { success: false, error: "Gateway info not available for native sessions" });
|
|
1011
1019
|
return;
|
|
1012
1020
|
}
|
|
1013
|
-
const sessionKey = cwd ?? `agent:main:clawmatrix:${
|
|
1014
|
-
const newId = `native-${
|
|
1021
|
+
const sessionKey = cwd ?? `agent:main:clawmatrix:${nanoid()}`;
|
|
1022
|
+
const newId = `native-${nanoid()}`;
|
|
1015
1023
|
const nativeSession: NativeSession = {
|
|
1016
1024
|
kind: "native", sessionId: newId, agent, sessionKey,
|
|
1017
1025
|
lastActiveAt: Date.now(), from, prompting: false, abortController: null,
|
|
@@ -1076,8 +1084,10 @@ export class AcpProxy {
|
|
|
1076
1084
|
|
|
1077
1085
|
// Mark session as closed so it won't reappear from disk scan
|
|
1078
1086
|
const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
|
|
1079
|
-
|
|
1087
|
+
const now = Date.now();
|
|
1088
|
+
if (acpSid) { this.closedSessionIds.add(acpSid); this.store?.insertClosedSession(acpSid, now); }
|
|
1080
1089
|
this.closedSessionIds.add(resolvedId);
|
|
1090
|
+
this.store?.insertClosedSession(resolvedId, now);
|
|
1081
1091
|
|
|
1082
1092
|
// Physical deletion: only OpenClaw sessions can be safely deleted from disk.
|
|
1083
1093
|
// Claude Code and Codex don't expose a deleteSession API, and their index files
|
|
@@ -1137,7 +1147,7 @@ export class AcpProxy {
|
|
|
1137
1147
|
if (payload.agent && s.agent !== payload.agent) return false;
|
|
1138
1148
|
if (clawSessions.some((cs) => cs.sessionId === s.sessionId)) return false;
|
|
1139
1149
|
if (activeAcpSessionIds.has(s.sessionId)) return false;
|
|
1140
|
-
if (this.closedSessionIds.has(s.sessionId)) return false;
|
|
1150
|
+
if (this.closedSessionIds.has(s.sessionId) || this.store?.isSessionClosed(s.sessionId)) return false;
|
|
1141
1151
|
return true;
|
|
1142
1152
|
})
|
|
1143
1153
|
.map((s) => ({ ...s, status: "idle" as const }));
|
|
@@ -1185,14 +1195,17 @@ export class AcpProxy {
|
|
|
1185
1195
|
}
|
|
1186
1196
|
}
|
|
1187
1197
|
|
|
1188
|
-
// Enforce concurrent session limit
|
|
1198
|
+
// Enforce concurrent session limit — evict oldest idle session if at capacity
|
|
1189
1199
|
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
|
|
1190
|
-
this.
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1200
|
+
const evicted = this.evictOldestIdleSession();
|
|
1201
|
+
if (!evicted) {
|
|
1202
|
+
this.peerManager.sendTo(from, {
|
|
1203
|
+
type: "acp_resume_res", id, from: this.config.nodeId, to: from,
|
|
1204
|
+
timestamp: Date.now(),
|
|
1205
|
+
payload: { success: false, error: `Max concurrent sessions reached (${this.maxSessions})` },
|
|
1206
|
+
} satisfies AcpResumeResponse);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1196
1209
|
}
|
|
1197
1210
|
|
|
1198
1211
|
if (this.isAcpAgent(agent)) {
|
|
@@ -1445,7 +1458,7 @@ export class AcpProxy {
|
|
|
1445
1458
|
configId: string,
|
|
1446
1459
|
value: string | boolean,
|
|
1447
1460
|
): Promise<AcpSetConfigResponse["payload"]> {
|
|
1448
|
-
const id =
|
|
1461
|
+
const id = nanoid();
|
|
1449
1462
|
return new Promise((resolve, reject) => {
|
|
1450
1463
|
const timer = setTimeout(() => {
|
|
1451
1464
|
this.pending.delete(id);
|
|
@@ -1484,6 +1497,24 @@ export class AcpProxy {
|
|
|
1484
1497
|
const { id, from, payload } = frame;
|
|
1485
1498
|
const { sessionId, limit = 200 } = payload;
|
|
1486
1499
|
|
|
1500
|
+
// Register requester as session watcher so they receive real-time transcript updates.
|
|
1501
|
+
// Resolve to ClawMatrix session ID for watcher registration (same logic as subscribe).
|
|
1502
|
+
let watcherKey = sessionId;
|
|
1503
|
+
let acpSidForWatch = sessionId;
|
|
1504
|
+
if (this.sessions.has(sessionId)) {
|
|
1505
|
+
const s = this.sessions.get(sessionId)!;
|
|
1506
|
+
acpSidForWatch = s.kind === "acp" ? s.acpSessionId : sessionId;
|
|
1507
|
+
} else {
|
|
1508
|
+
for (const [sid, s] of this.sessions) {
|
|
1509
|
+
if (s.kind === "acp" && s.acpSessionId === sessionId) {
|
|
1510
|
+
watcherKey = sid;
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
this.addSessionWatcher(watcherKey, from);
|
|
1516
|
+
this.startTranscriptWatcher(watcherKey, acpSidForWatch).catch(() => {});
|
|
1517
|
+
|
|
1487
1518
|
try {
|
|
1488
1519
|
const transcriptPath = await this.findTranscriptPath(sessionId);
|
|
1489
1520
|
if (!transcriptPath) {
|
|
@@ -1529,7 +1560,7 @@ export class AcpProxy {
|
|
|
1529
1560
|
sessionId: string,
|
|
1530
1561
|
limit?: number,
|
|
1531
1562
|
): Promise<ChatHistoryResponse["payload"]> {
|
|
1532
|
-
const id =
|
|
1563
|
+
const id = nanoid();
|
|
1533
1564
|
return new Promise((resolve, reject) => {
|
|
1534
1565
|
const timer = setTimeout(() => {
|
|
1535
1566
|
this.pending.delete(id);
|
|
@@ -1752,7 +1783,7 @@ export class AcpProxy {
|
|
|
1752
1783
|
|
|
1753
1784
|
const session: AcpSession = {
|
|
1754
1785
|
kind: "acp",
|
|
1755
|
-
sessionId:
|
|
1786
|
+
sessionId: nanoid(),
|
|
1756
1787
|
agent,
|
|
1757
1788
|
acpSessionId: response.sessionId,
|
|
1758
1789
|
conn: daemon.conn,
|
|
@@ -1878,7 +1909,7 @@ export class AcpProxy {
|
|
|
1878
1909
|
if (reused) {
|
|
1879
1910
|
reused.from = from;
|
|
1880
1911
|
reused.lastActiveAt = Date.now();
|
|
1881
|
-
reused.sessionId =
|
|
1912
|
+
reused.sessionId = nanoid();
|
|
1882
1913
|
this.monitorProcess(reused);
|
|
1883
1914
|
return reused;
|
|
1884
1915
|
}
|
|
@@ -1888,7 +1919,7 @@ export class AcpProxy {
|
|
|
1888
1919
|
if (warm) {
|
|
1889
1920
|
warm.from = from;
|
|
1890
1921
|
warm.lastActiveAt = Date.now();
|
|
1891
|
-
warm.sessionId =
|
|
1922
|
+
warm.sessionId = nanoid();
|
|
1892
1923
|
this.monitorProcess(warm);
|
|
1893
1924
|
this.schedulePrewarm(agent, effectiveCwd);
|
|
1894
1925
|
return warm;
|
|
@@ -1973,6 +2004,13 @@ export class AcpProxy {
|
|
|
1973
2004
|
const kind = (update as { kind?: string }).kind;
|
|
1974
2005
|
const toolName = title || toolCallId || "unknown";
|
|
1975
2006
|
cb?.(`\n[tool_call: ${toolName}]\n`, updateType, { title, toolCallId, kind });
|
|
2007
|
+
// Track file write attribution for knowledge-sync
|
|
2008
|
+
if (title && this.onToolWrite) {
|
|
2009
|
+
const writeMatch = title.match(/^(?:Write|Edit|Create)\s+(.+)/i);
|
|
2010
|
+
if (writeMatch) {
|
|
2011
|
+
this.onToolWrite(writeMatch[1].trim(), params.agent);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
1976
2014
|
} else if (updateType === "tool_call_update") {
|
|
1977
2015
|
const status = (update as { status?: string }).status;
|
|
1978
2016
|
// Extract tool output content from completed tool calls
|
|
@@ -2107,7 +2145,7 @@ export class AcpProxy {
|
|
|
2107
2145
|
|
|
2108
2146
|
const session: AcpSession = {
|
|
2109
2147
|
kind: "acp",
|
|
2110
|
-
sessionId:
|
|
2148
|
+
sessionId: nanoid(),
|
|
2111
2149
|
agent,
|
|
2112
2150
|
acpSessionId: response.sessionId,
|
|
2113
2151
|
conn,
|
|
@@ -2162,6 +2200,7 @@ export class AcpProxy {
|
|
|
2162
2200
|
let streamChunkCount = 0;
|
|
2163
2201
|
session.setStreamCallback((delta, event, data) => {
|
|
2164
2202
|
streamChunkCount++;
|
|
2203
|
+
|
|
2165
2204
|
if (streamChunkCount <= 3 || streamChunkCount % 50 === 0) {
|
|
2166
2205
|
debug("acp", `stream chunk #${streamChunkCount}: event=${event ?? "text"} delta=${delta.slice(0, 40)} sessionId=${session.sessionId.slice(0, 8)}`);
|
|
2167
2206
|
}
|
|
@@ -2598,7 +2637,7 @@ export class AcpProxy {
|
|
|
2598
2637
|
onStream?: (delta: string) => void;
|
|
2599
2638
|
},
|
|
2600
2639
|
): Promise<AcpTaskResponse["payload"]> {
|
|
2601
|
-
const id =
|
|
2640
|
+
const id = nanoid();
|
|
2602
2641
|
return new Promise((resolve, reject) => {
|
|
2603
2642
|
const timer = setTimeout(() => {
|
|
2604
2643
|
this.pending.delete(id);
|
|
@@ -2690,6 +2729,26 @@ export class AcpProxy {
|
|
|
2690
2729
|
}
|
|
2691
2730
|
}
|
|
2692
2731
|
|
|
2732
|
+
/**
|
|
2733
|
+
* Evict the oldest non-prompting session to make room for a new one.
|
|
2734
|
+
* Returns true if a session was evicted, false if all sessions are actively prompting.
|
|
2735
|
+
*/
|
|
2736
|
+
private evictOldestIdleSession(): boolean {
|
|
2737
|
+
let oldest: { id: string; session: AnySession } | null = null;
|
|
2738
|
+
for (const [id, session] of this.sessions) {
|
|
2739
|
+
if (session.prompting) continue; // never evict mid-prompt sessions
|
|
2740
|
+
if (!oldest || session.lastActiveAt < oldest.session.lastActiveAt) {
|
|
2741
|
+
oldest = { id, session };
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
if (!oldest) return false;
|
|
2745
|
+
debug("acp", `evicting idle session ${oldest.id.slice(0, 8)} (lastActive=${new Date(oldest.session.lastActiveAt).toISOString()}) to make room`);
|
|
2746
|
+
this.sessions.delete(oldest.id);
|
|
2747
|
+
this.sessionWatchers.delete(oldest.id);
|
|
2748
|
+
this.destroySession(oldest.session);
|
|
2749
|
+
return true;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2693
2752
|
private cleanupIdleSessions() {
|
|
2694
2753
|
const now = Date.now();
|
|
2695
2754
|
for (const [id, session] of this.sessions) {
|
|
@@ -2834,14 +2893,13 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
|
|
|
2834
2893
|
// Fresh cache — return immediately
|
|
2835
2894
|
return sessionListCache.result;
|
|
2836
2895
|
}
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
}
|
|
2896
|
+
// Stale cache — return stale data immediately, refresh in background
|
|
2897
|
+
// Never block on disk I/O when we have any cached data
|
|
2898
|
+
refreshSessionListInBackground();
|
|
2899
|
+
return sessionListCache.result;
|
|
2842
2900
|
}
|
|
2843
2901
|
|
|
2844
|
-
//
|
|
2902
|
+
// No cache at all — must fetch from disk synchronously
|
|
2845
2903
|
const results = await fetchSessionListFromDisk();
|
|
2846
2904
|
const ts = Date.now();
|
|
2847
2905
|
sessionListCache = { result: results, expiresAt: ts + SESSION_LIST_CACHE_TTL, staleAt: ts + SESSION_LIST_STALE_TTL };
|
|
@@ -2935,10 +2993,25 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
|
2935
2993
|
|
|
2936
2994
|
const ccResults: AcpSessionInfo[] = [];
|
|
2937
2995
|
const existingIds = new Set(results.map((r) => r.sessionId));
|
|
2938
|
-
//
|
|
2996
|
+
// Build a sessionId → file path index from project dirs (one readdir per dir)
|
|
2997
|
+
// instead of O(sessions × dirs) stat calls
|
|
2939
2998
|
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
2940
|
-
|
|
2941
|
-
try {
|
|
2999
|
+
const sessionFileIndex = new Map<string, string>(); // sessionId → full path
|
|
3000
|
+
try {
|
|
3001
|
+
const projectDirs = await readdir(claudeProjectsDir);
|
|
3002
|
+
await Promise.all(
|
|
3003
|
+
projectDirs.map(async (dir) => {
|
|
3004
|
+
try {
|
|
3005
|
+
const files = await readdir(join(claudeProjectsDir, dir));
|
|
3006
|
+
for (const f of files) {
|
|
3007
|
+
if (f.endsWith(".jsonl")) {
|
|
3008
|
+
sessionFileIndex.set(f.slice(0, -6), join(claudeProjectsDir, dir, f));
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
} catch { /* unreadable dir */ }
|
|
3012
|
+
}),
|
|
3013
|
+
);
|
|
3014
|
+
} catch { /* no projects dir */ }
|
|
2942
3015
|
|
|
2943
3016
|
const pendingEntries: { sessionId: string; info: { title: string; updatedAt: number; cwd: string } }[] = [];
|
|
2944
3017
|
for (const [sessionId, info] of sessions) {
|
|
@@ -2947,16 +3020,15 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
|
2947
3020
|
pendingEntries.push({ sessionId, info });
|
|
2948
3021
|
}
|
|
2949
3022
|
|
|
2950
|
-
//
|
|
3023
|
+
// Stat only matching files (O(matched) instead of O(sessions × dirs))
|
|
2951
3024
|
const mtimeResults = await Promise.all(
|
|
2952
3025
|
pendingEntries.map(async ({ sessionId }) => {
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
}
|
|
2959
|
-
return null;
|
|
3026
|
+
const filePath = sessionFileIndex.get(sessionId);
|
|
3027
|
+
if (!filePath) return null;
|
|
3028
|
+
try {
|
|
3029
|
+
const s = await fsStat(filePath);
|
|
3030
|
+
return s.mtimeMs;
|
|
3031
|
+
} catch { return null; }
|
|
2960
3032
|
}),
|
|
2961
3033
|
);
|
|
2962
3034
|
|