session-steward 0.2.0 → 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.
@@ -0,0 +1,996 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { once } from "node:events";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { promises as fs } from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { finished, pipeline } from "node:stream/promises";
8
+
9
+ import { measurePath } from "../../storage/files.mjs";
10
+ import { readJsonlEntries, rewriteJsonlFile } from "../../storage/jsonl.mjs";
11
+
12
+ const PROVIDER_ID = "claude-code";
13
+ const COMPATIBILITY_PROFILE = Object.freeze({
14
+ id: "claude-local-store-2026-08",
15
+ builtFor: { claudeCli: ["2.1.199", "2.1.220"], claudeDesktop: ["1.24012.9"] },
16
+ });
17
+ const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
18
+ const KNOWN_TOP_LEVEL = new Set([
19
+ ".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl",
20
+ "ide", "paste-cache", "plans", "plugins", "projects", "session-env", "sessions", "settings.json",
21
+ "settings.local.json", "shell-snapshots", "skills", "stats-cache.json", "tasks", "telemetry",
22
+ "todos", "uploads", "usage-data", "mcp-needs-auth-cache.json", "session-steward-backups",
23
+ ]);
24
+ const MAX_TITLE_LENGTH = 180;
25
+ const DISCOVERY_CACHE_TTL_MS = 15 * 1000;
26
+ const ACTIVITY_READ_CHUNK_BYTES = 64 * 1024;
27
+ const ACTIVITY_FINGERPRINT_BYTES = 8 * 1024;
28
+ const MAX_ACTIVITY_CACHE_ENTRIES = 50_000;
29
+ const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
30
+ const discoveryCache = new Map();
31
+ const transcriptActivityCache = new Map();
32
+
33
+ function expandHome(value) {
34
+ if (value === "~") return os.homedir();
35
+ if (value?.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
36
+ return value;
37
+ }
38
+
39
+ function getPaths(claudeHomeInput, desktopDataHomeInput) {
40
+ const claudeHome = path.resolve(expandHome(claudeHomeInput || process.env.CLAUDE_CONFIG_DIR || "~/.claude"));
41
+ const desktopDataHome = desktopDataHomeInput
42
+ ? path.resolve(expandHome(desktopDataHomeInput))
43
+ : process.platform === "darwin"
44
+ ? path.join(os.homedir(), "Library", "Application Support", "Claude")
45
+ : null;
46
+ return {
47
+ backupRoot: path.join(claudeHome, "session-steward-backups"),
48
+ claudeHome,
49
+ debugDirectory: path.join(claudeHome, "debug"),
50
+ desktopDataHome,
51
+ desktopSessionsDirectory: desktopDataHome ? path.join(desktopDataHome, "claude-code-sessions") : null,
52
+ fileHistoryDirectory: path.join(claudeHome, "file-history"),
53
+ historyPath: path.join(claudeHome, "history.jsonl"),
54
+ projectsDirectory: path.join(claudeHome, "projects"),
55
+ sessionEnvDirectory: path.join(claudeHome, "session-env"),
56
+ sessionsDirectory: path.join(claudeHome, "sessions"),
57
+ tasksDirectory: path.join(claudeHome, "tasks"),
58
+ };
59
+ }
60
+
61
+ async function exists(targetPath) {
62
+ if (!targetPath) return false;
63
+ try { await fs.access(targetPath); return true; } catch { return false; }
64
+ }
65
+
66
+ function asTimestamp(value) {
67
+ if (typeof value === "number") return value < 1e12 ? value * 1000 : value;
68
+ const parsed = typeof value === "string" ? Date.parse(value) : NaN;
69
+ return Number.isFinite(parsed) ? parsed : 0;
70
+ }
71
+
72
+ function fileIdentity(stats) {
73
+ return `${stats.dev ?? ""}:${stats.ino ?? ""}`;
74
+ }
75
+
76
+ function cacheTranscriptActivity(filePath, value) {
77
+ transcriptActivityCache.delete(filePath);
78
+ transcriptActivityCache.set(filePath, value);
79
+ while (transcriptActivityCache.size > MAX_ACTIVITY_CACHE_ENTRIES) {
80
+ transcriptActivityCache.delete(transcriptActivityCache.keys().next().value);
81
+ }
82
+ }
83
+
84
+ async function readRange(handle, start, end) {
85
+ const output = Buffer.allocUnsafe(Math.max(0, end - start));
86
+ let offset = 0;
87
+ while (offset < output.length) {
88
+ const { bytesRead } = await handle.read(output, offset, output.length - offset, start + offset);
89
+ if (bytesRead === 0) break;
90
+ offset += bytesRead;
91
+ }
92
+ return offset === output.length ? output : output.subarray(0, offset);
93
+ }
94
+
95
+ async function fingerprintBefore(handle, end) {
96
+ const start = Math.max(0, end - ACTIVITY_FINGERPRINT_BYTES);
97
+ return createHash("sha256").update(await readRange(handle, start, end)).digest("hex");
98
+ }
99
+
100
+ async function endsWithNewline(handle, size) {
101
+ if (size === 0) return true;
102
+ const lastByte = await readRange(handle, size - 1, size);
103
+ return lastByte[0] === 0x0a;
104
+ }
105
+
106
+ function activityFromLine(line) {
107
+ if (line.length === 0) return 0;
108
+ try {
109
+ const parsed = JSON.parse(line.toString("utf8"));
110
+ if (parsed?.type !== "user" && parsed?.type !== "assistant") return 0;
111
+ return asTimestamp(parsed.timestamp ?? parsed.createdAt);
112
+ } catch {
113
+ return 0;
114
+ }
115
+ }
116
+
117
+ async function latestActivityInRange(handle, start, end) {
118
+ let position = end;
119
+ let trailingParts = [];
120
+
121
+ const inspectLine = (prefix) => {
122
+ const line = trailingParts.length > 0
123
+ ? Buffer.concat([prefix, ...trailingParts.reverse()])
124
+ : prefix;
125
+ trailingParts = [];
126
+ return activityFromLine(line);
127
+ };
128
+
129
+ while (position > start) {
130
+ const chunkStart = Math.max(start, position - ACTIVITY_READ_CHUNK_BYTES);
131
+ const chunk = await readRange(handle, chunkStart, position);
132
+ let lineEnd = chunk.length;
133
+
134
+ for (let index = chunk.length - 1; index >= 0; index -= 1) {
135
+ if (chunk[index] !== 0x0a) continue;
136
+ const activityAtMs = inspectLine(chunk.subarray(index + 1, lineEnd));
137
+ if (activityAtMs) return activityAtMs;
138
+ lineEnd = index;
139
+ }
140
+
141
+ if (lineEnd > 0) trailingParts.push(chunk.subarray(0, lineEnd));
142
+ position = chunkStart;
143
+ }
144
+
145
+ return trailingParts.length > 0 ? inspectLine(Buffer.alloc(0)) : 0;
146
+ }
147
+
148
+ async function readTranscriptActivity(filePath, fallbackActivityAtMs = 0) {
149
+ for (let attempt = 0; attempt < 2; attempt += 1) {
150
+ const stats = await fs.stat(filePath);
151
+ const identity = fileIdentity(stats);
152
+ const cached = transcriptActivityCache.get(filePath);
153
+
154
+ if (
155
+ cached &&
156
+ cached.identity === identity &&
157
+ cached.size === stats.size &&
158
+ cached.mtimeMs === stats.mtimeMs
159
+ ) {
160
+ cacheTranscriptActivity(filePath, cached);
161
+ return { activityAtMs: cached.activityAtMs, stats };
162
+ }
163
+
164
+ const handle = await fs.open(filePath, "r");
165
+ try {
166
+ let activityAtMs = fallbackActivityAtMs;
167
+ let canReuse = false;
168
+
169
+ if (cached?.identity === identity && cached.size <= stats.size) {
170
+ const unchangedTail = await fingerprintBefore(handle, cached.size) === cached.tailFingerprint;
171
+ if (unchangedTail && cached.size === stats.size) {
172
+ activityAtMs = cached.activityAtMs;
173
+ canReuse = true;
174
+ } else if (unchangedTail && cached.endsWithNewline) {
175
+ activityAtMs = Math.max(
176
+ cached.activityAtMs,
177
+ await latestActivityInRange(handle, cached.size, stats.size),
178
+ );
179
+ canReuse = true;
180
+ }
181
+ }
182
+
183
+ if (!canReuse) {
184
+ activityAtMs = Math.max(
185
+ fallbackActivityAtMs,
186
+ await latestActivityInRange(handle, 0, stats.size),
187
+ );
188
+ }
189
+
190
+ const value = {
191
+ activityAtMs,
192
+ endsWithNewline: await endsWithNewline(handle, stats.size),
193
+ identity,
194
+ mtimeMs: stats.mtimeMs,
195
+ size: stats.size,
196
+ tailFingerprint: await fingerprintBefore(handle, stats.size),
197
+ };
198
+ const finalStats = await handle.stat();
199
+ const changedDuringRead = fileIdentity(finalStats) !== identity
200
+ || finalStats.size !== stats.size
201
+ || finalStats.mtimeMs !== stats.mtimeMs;
202
+
203
+ if (changedDuringRead && attempt === 0) continue;
204
+ if (changedDuringRead) return { activityAtMs, stats: finalStats };
205
+
206
+ cacheTranscriptActivity(filePath, value);
207
+ return { activityAtMs, stats: finalStats };
208
+ } finally {
209
+ await handle.close();
210
+ }
211
+ }
212
+
213
+ throw new Error("Claude session activity could not be read consistently.");
214
+ }
215
+
216
+ function normalizeTitleText(value) {
217
+ if (typeof value !== "string") return "";
218
+ return value.replace(/\s+/gu, " ").trim();
219
+ }
220
+
221
+ function cleanTitle(value) {
222
+ const original = normalizeTitleText(value);
223
+ if (!original) return "";
224
+ let cleaned = original.replace(/^\[\d+\]\s+(?:user|assistant):\s*/u, "");
225
+ const nextRoleMarker = cleaned.search(/\s\[\d+\]\s+(?:user|assistant):\s*/u);
226
+ if (nextRoleMarker >= 0) cleaned = cleaned.slice(0, nextRoleMarker);
227
+ cleaned = normalizeTitleText(
228
+ cleaned.replace(/\[([^\[\]]+?)\]\([^()]*?\)/gu, "$1"),
229
+ );
230
+ const result = cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? cleaned : original;
231
+ return result.slice(0, MAX_TITLE_LENGTH);
232
+ }
233
+
234
+ function messageRawText(message) {
235
+ const raw = typeof message === "string"
236
+ ? message
237
+ : typeof message?.content === "string"
238
+ ? message.content
239
+ : Array.isArray(message?.content)
240
+ ? message.content.find((part) => part?.type === "text" && typeof part.text === "string")?.text || ""
241
+ : "";
242
+ const normalized = raw.trimStart();
243
+ if (/^<(local-command-caveat|local-command-stdout|command-name|system-reminder)>/u.test(normalized)) return "";
244
+ return normalizeTitleText(raw);
245
+ }
246
+
247
+ function messageText(message) {
248
+ return cleanTitle(messageRawText(message));
249
+ }
250
+
251
+ async function readTranscriptSummary(filePath, fallbackId) {
252
+ let id = fallbackId;
253
+ let entrypoint = null;
254
+ let cwd = "";
255
+ let createdAtMs = 0;
256
+ let title = "";
257
+ let titleSource = "";
258
+ let malformed = false;
259
+ let recordCount = 0;
260
+ let searchText = "";
261
+ let scannedActivityAtMs = 0;
262
+
263
+ for await (const { parsed } of readJsonlEntries(filePath)) {
264
+ recordCount += 1;
265
+ if (!parsed || typeof parsed !== "object") { malformed = true; continue; }
266
+ const recordId = parsed.sessionId ?? parsed.session_id;
267
+ if (typeof recordId === "string") id = recordId;
268
+ if (typeof parsed.entrypoint === "string") {
269
+ if (entrypoint && entrypoint !== parsed.entrypoint) entrypoint = "mixed";
270
+ else entrypoint = parsed.entrypoint;
271
+ }
272
+ if (!cwd && typeof parsed.cwd === "string") cwd = parsed.cwd;
273
+ const timestamp = asTimestamp(parsed.timestamp ?? parsed.createdAt);
274
+ if (timestamp && (!createdAtMs || timestamp < createdAtMs)) createdAtMs = timestamp;
275
+ if (timestamp && (parsed.type === "user" || parsed.type === "assistant")) {
276
+ scannedActivityAtMs = Math.max(scannedActivityAtMs, timestamp);
277
+ }
278
+ if (parsed.type === "custom-title" && cleanTitle(parsed.customTitle)) {
279
+ title = cleanTitle(parsed.customTitle); titleSource = "custom title"; searchText = normalizeTitleText(parsed.customTitle);
280
+ } else if (!title && parsed.type === "summary" && cleanTitle(parsed.summary)) {
281
+ title = cleanTitle(parsed.summary); titleSource = "generated title"; searchText = normalizeTitleText(parsed.summary);
282
+ } else if (!title && parsed.type === "user") {
283
+ const candidate = messageText(parsed.message);
284
+ if (candidate) { title = candidate; titleSource = "first message"; searchText = messageRawText(parsed.message); }
285
+ }
286
+ if ((id && entrypoint && cwd && title) || recordCount >= 200) break;
287
+ }
288
+ const activity = await readTranscriptActivity(filePath, scannedActivityAtMs);
289
+ return {
290
+ activityAtMs: activity.activityAtMs || createdAtMs || 0,
291
+ createdAtMs: createdAtMs || activity.stats.birthtimeMs || activity.stats.mtimeMs,
292
+ cwd,
293
+ entrypoint,
294
+ id,
295
+ malformed,
296
+ recordCount,
297
+ searchText,
298
+ title,
299
+ titleSource,
300
+ transcriptBytes: activity.stats.size,
301
+ transcriptPath: filePath,
302
+ };
303
+ }
304
+
305
+ async function listDesktopStates(directory) {
306
+ const byCliSessionId = new Map();
307
+ const unlinked = [];
308
+ if (!directory || !(await exists(directory))) return { byCliSessionId, unlinked };
309
+ const pending = [directory];
310
+ while (pending.length) {
311
+ const current = pending.pop();
312
+ let entries;
313
+ try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { continue; }
314
+ for (const entry of entries) {
315
+ const target = path.join(current, entry.name);
316
+ if (entry.isDirectory()) { pending.push(target); continue; }
317
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
318
+ try {
319
+ const state = JSON.parse(await fs.readFile(target, "utf8"));
320
+ if (typeof state?.cliSessionId !== "string" || typeof state?.sessionId !== "string") {
321
+ unlinked.push(target); continue;
322
+ }
323
+ const item = { archived: Boolean(state.isArchived), path: target, state };
324
+ const values = byCliSessionId.get(state.cliSessionId) ?? [];
325
+ values.push(item); byCliSessionId.set(state.cliSessionId, values);
326
+ } catch { unlinked.push(target); }
327
+ }
328
+ }
329
+ return { byCliSessionId, unlinked };
330
+ }
331
+
332
+ async function discover(claudeHome, desktopDataHome) {
333
+ const paths = getPaths(claudeHome, desktopDataHome);
334
+ const desktop = await listDesktopStates(paths.desktopSessionsDirectory);
335
+ const summariesById = new Map();
336
+ const unknown = [];
337
+ let projectDirectories = [];
338
+ let projectsAvailability = "available";
339
+ try { projectDirectories = await fs.readdir(paths.projectsDirectory, { withFileTypes: true }); } catch (error) {
340
+ projectsAvailability = error?.code === "ENOENT" ? "missing" : "unreadable";
341
+ }
342
+ for (const projectEntry of projectDirectories) {
343
+ if (!projectEntry.isDirectory()) continue;
344
+ const projectDirectory = path.join(paths.projectsDirectory, projectEntry.name);
345
+ let files;
346
+ try { files = await fs.readdir(projectDirectory, { withFileTypes: true }); } catch { continue; }
347
+ for (const file of files) {
348
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
349
+ const fallbackId = file.name.slice(0, -6);
350
+ const summary = await readTranscriptSummary(path.join(projectDirectory, file.name), fallbackId);
351
+ if (!SUPPORTED_ENTRYPOINTS.has(summary.entrypoint)) { unknown.push(summary.transcriptPath); continue; }
352
+ const copies = summariesById.get(summary.id) ?? [];
353
+ copies.push(summary); summariesById.set(summary.id, copies);
354
+ }
355
+ }
356
+ const records = [];
357
+ for (const [id, copies] of summariesById) {
358
+ const entrypoints = new Set(copies.map((item) => item.entrypoint));
359
+ if (entrypoints.size !== 1) { unknown.push(...copies.map((item) => item.transcriptPath)); continue; }
360
+ const entrypoint = copies[0].entrypoint;
361
+ const desktopStates = desktop.byCliSessionId.get(id) ?? [];
362
+ if (entrypoint === "claude-desktop" && desktopStates.length === 0) {
363
+ unknown.push(...copies.map((item) => item.transcriptPath)); continue;
364
+ }
365
+ const newest = copies.reduce((left, right) => left.activityAtMs >= right.activityAtMs ? left : right);
366
+ const earliest = Math.min(...copies.map((item) => item.createdAtMs || Infinity));
367
+ const state = desktopStates[0]?.state;
368
+ records.push({
369
+ agentNickname: null,
370
+ agentRole: null,
371
+ archived: entrypoint === "claude-desktop" && desktopStates.every((item) => item.archived),
372
+ childThreadIds: [],
373
+ createdAtMs: Number.isFinite(earliest) ? earliest : newest.createdAtMs,
374
+ cwd: state?.originCwd || state?.cwd || newest.cwd,
375
+ desktopStatePaths: desktopStates.map((item) => item.path),
376
+ displayName: cleanTitle(state?.title) || copies.find((item) => item.title)?.title || `Session ${id.slice(0, 8)}`,
377
+ entrypoint,
378
+ forkedFromId: null,
379
+ id,
380
+ isFork: false,
381
+ isPinned: false,
382
+ isSubagent: false,
383
+ providerId: PROVIDER_ID,
384
+ recordSource: entrypoint === "claude-desktop" ? "desktop" : "transcript",
385
+ rolloutMissing: false,
386
+ rolloutPath: newest.transcriptPath,
387
+ searchText: [state?.title, ...copies.map((item) => item.searchText)].filter(Boolean).join(" "),
388
+ surface: entrypoint === "claude-desktop" ? "desktop" : "cli",
389
+ titleSource: cleanTitle(state?.title) ? "Desktop title" : (copies.find((item) => item.titleSource)?.titleSource || "session ID"),
390
+ transcriptBytes: copies.reduce((sum, item) => sum + item.transcriptBytes, 0),
391
+ transcriptPaths: copies.map((item) => item.transcriptPath),
392
+ updatedAtMs: Math.max(...copies.map((item) => item.activityAtMs)),
393
+ });
394
+ }
395
+ return { desktop, paths, projectsAvailability, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
396
+ }
397
+
398
+ async function discoverCached(claudeHome, desktopDataHome, { refresh = false } = {}) {
399
+ const paths = getPaths(claudeHome, desktopDataHome);
400
+ const key = `${paths.claudeHome}\0${paths.desktopDataHome || ""}`;
401
+ const cached = discoveryCache.get(key);
402
+ if (!refresh && cached?.expiresAtMs > Date.now()) return cached.promise;
403
+ const promise = discover(paths.claudeHome, paths.desktopDataHome).catch((error) => {
404
+ if (discoveryCache.get(key)?.promise === promise) discoveryCache.delete(key);
405
+ throw error;
406
+ });
407
+ discoveryCache.set(key, { expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS, promise });
408
+ return promise;
409
+ }
410
+
411
+ export function invalidateSessionCache({ claudeHome, desktopDataHome }) {
412
+ const paths = getPaths(claudeHome, desktopDataHome);
413
+ discoveryCache.delete(`${paths.claudeHome}\0${paths.desktopDataHome || ""}`);
414
+ }
415
+
416
+ function filterRecords(records, options) {
417
+ const search = String(options.search || "").trim().toLowerCase();
418
+ return records.filter((record) => {
419
+ if (options.archiveStatus === "active" && record.archived) return false;
420
+ if (options.archiveStatus === "archived" && !record.archived) return false;
421
+ if (options.inactiveBeforeMs && (!record.updatedAtMs || record.updatedAtMs >= options.inactiveBeforeMs)) return false;
422
+ if (options.workspace !== undefined && record.cwd !== options.workspace) return false;
423
+ if (search && !`${record.displayName} ${record.searchText} ${record.id} ${record.cwd} ${record.surface}`.toLowerCase().includes(search)) return false;
424
+ return true;
425
+ });
426
+ }
427
+
428
+ function sortRecords(records, sort) {
429
+ const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
430
+ const compare = {
431
+ created: (a, b) => b.createdAtMs - a.createdAtMs,
432
+ cwd: (a, b) => a.cwd.localeCompare(b.cwd) || b.updatedAtMs - a.updatedAtMs,
433
+ name: (a, b) => a.displayName.localeCompare(b.displayName),
434
+ size: (a, b) => {
435
+ const leftKnown = Number.isFinite(a.transcriptBytes);
436
+ const rightKnown = Number.isFinite(b.transcriptBytes);
437
+ if (leftKnown !== rightKnown) return leftKnown ? -1 : 1;
438
+ return (b.transcriptBytes ?? 0) - (a.transcriptBytes ?? 0);
439
+ },
440
+ updated: (a, b) => b.updatedAtMs - a.updatedAtMs,
441
+ }[resolvedSort];
442
+ return [...records].sort((a, b) => compare(a, b) || a.id.localeCompare(b.id));
443
+ }
444
+
445
+ export function filterAndSortSessions({ records, ...options }) {
446
+ return sortRecords(filterRecords(records, options), options.sort);
447
+ }
448
+
449
+ export async function loadSessionStore({ claudeHome, desktopDataHome }) {
450
+ return discover(claudeHome, desktopDataHome);
451
+ }
452
+
453
+ export async function listSessions({ claudeHome, desktopDataHome, page = 1, pageSize = 25, refresh = false, ...options }) {
454
+ const store = await discoverCached(claudeHome, desktopDataHome, { refresh });
455
+ const filtered = sortRecords(filterRecords(store.records, options), options.sort);
456
+ const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
457
+ const resolvedPage = Math.min(page, pageCount);
458
+ return { page: resolvedPage, pageCount, records: filtered.slice((resolvedPage - 1) * pageSize, resolvedPage * pageSize), total: filtered.length };
459
+ }
460
+
461
+ export async function getSessionRecord({ claudeHome, desktopDataHome, id }) {
462
+ return (await discoverCached(claudeHome, desktopDataHome)).recordsById.get(id) ?? null;
463
+ }
464
+
465
+ export async function getSessionOverview({ claudeHome, desktopDataHome, refresh = false }) {
466
+ const store = await discoverCached(claudeHome, desktopDataHome, { refresh });
467
+ const workspaces = new Map();
468
+ const storageTargets = new Set();
469
+ for (const record of store.records) {
470
+ const current = workspaces.get(record.cwd) ?? { lastActivityAtMs: 0, path: record.cwd, sessionCount: 0, transcriptBytes: 0 };
471
+ current.sessionCount += 1;
472
+ current.lastActivityAtMs = Math.max(current.lastActivityAtMs, record.updatedAtMs);
473
+ if (Number.isFinite(record.transcriptBytes)) current.transcriptBytes += record.transcriptBytes;
474
+ workspaces.set(record.cwd, current);
475
+ record.transcriptPaths.forEach((target) => {
476
+ storageTargets.add(target);
477
+ storageTargets.add(path.join(path.dirname(target), record.id));
478
+ });
479
+ record.desktopStatePaths.forEach((target) => storageTargets.add(target));
480
+ storageTargets.add(path.join(store.paths.sessionEnvDirectory, record.id));
481
+ storageTargets.add(path.join(store.paths.tasksDirectory, record.id));
482
+ storageTargets.add(path.join(store.paths.debugDirectory, `${record.id}.txt`));
483
+ }
484
+ let transcriptBytes = 0;
485
+ let transcriptFileCount = 0;
486
+ for (const target of storageTargets) {
487
+ const measured = await measureTarget(target);
488
+ transcriptBytes += measured.bytes;
489
+ transcriptFileCount += measured.count;
490
+ }
491
+ const history = await matchingHistoryStats(store.paths.historyPath, new Set(store.records.map((record) => record.id)));
492
+ transcriptBytes += history.bytes;
493
+ transcriptFileCount += history.count > 0 ? 1 : 0;
494
+ return {
495
+ activeSessionCount: store.records.filter((record) => !record.archived).length,
496
+ archivedSessionCount: store.records.filter((record) => record.archived).length,
497
+ calculatedAtMs: Date.now(),
498
+ cliSessionCount: store.records.filter((record) => record.surface === "cli").length,
499
+ desktopSessionCount: store.records.filter((record) => record.surface === "desktop").length,
500
+ primarySessionCount: store.records.length,
501
+ sessionCount: store.records.length,
502
+ subagentCount: 0,
503
+ supportingCount: 0,
504
+ transcriptBytes,
505
+ transcriptFileCount,
506
+ unknownActivityCount: store.records.filter((record) => !record.updatedAtMs).length,
507
+ unreadableFileCount: store.unknown.length,
508
+ workspaces: [...workspaces.values()].sort((a, b) => b.lastActivityAtMs - a.lastActivityAtMs),
509
+ };
510
+ }
511
+
512
+ async function topLevelEntries(directory) {
513
+ try { return await fs.readdir(directory, { withFileTypes: true }); } catch (error) {
514
+ if (error?.code === "ENOENT") return [];
515
+ throw error;
516
+ }
517
+ }
518
+
519
+ export async function diagnoseStorageCompatibility({ claudeHome, desktopDataHome }) {
520
+ const store = await discoverCached(claudeHome, desktopDataHome);
521
+ const unrecognized = [];
522
+ for (const entry of await topLevelEntries(store.paths.claudeHome)) {
523
+ if (!KNOWN_TOP_LEVEL.has(entry.name)) unrecognized.push(`Unrecognized Claude data: ${entry.name}`);
524
+ }
525
+ if (store.unknown.length) unrecognized.push(`${store.unknown.length} session file${store.unknown.length === 1 ? "" : "s"} could not be classified safely.`);
526
+ if (store.desktop.unlinked.length) unrecognized.push(`${store.desktop.unlinked.length} Desktop session record${store.desktop.unlinked.length === 1 ? "" : "s"} could not be linked safely.`);
527
+ const missing = store.projectsAvailability === "available"
528
+ ? []
529
+ : [store.projectsAvailability === "missing"
530
+ ? "Claude project sessions folder was not found."
531
+ : "Claude project sessions folder could not be read."];
532
+ return {
533
+ builtFor: COMPATIBILITY_PROFILE.builtFor,
534
+ changed: [],
535
+ missing,
536
+ profileId: COMPATIBILITY_PROFILE.id,
537
+ status: missing.length ? "unsupported" : unrecognized.length ? "partial" : "ready",
538
+ unrecognized,
539
+ };
540
+ }
541
+
542
+ export async function assertDeepCleanupSupported(options) {
543
+ const diagnostic = await diagnoseStorageCompatibility(options);
544
+ if (diagnostic.status === "unsupported") throw new Error("Thorough cleanup is paused because the Claude project sessions folder could not be read.");
545
+ return diagnostic;
546
+ }
547
+
548
+ async function matchingHistoryStats(historyPath, ids) {
549
+ let count = 0;
550
+ let bytes = 0;
551
+ for await (const entry of readJsonlEntries(historyPath)) {
552
+ if (entry.parsed && ids.has(entry.parsed.sessionId ?? entry.parsed.session_id)) {
553
+ count += 1;
554
+ bytes += Buffer.byteLength(entry.raw) + 1;
555
+ }
556
+ }
557
+ return { bytes, count };
558
+ }
559
+
560
+ async function collectFiles(targetPath, output) {
561
+ let stats;
562
+ try { stats = await fs.lstat(targetPath); } catch (error) { if (error?.code === "ENOENT") return; throw error; }
563
+ if (stats.isSymbolicLink()) throw new Error("Cleanup stopped because a linked file was found in selected session data.");
564
+ if (stats.isDirectory()) {
565
+ for (const entry of await fs.readdir(targetPath)) await collectFiles(path.join(targetPath, entry), output);
566
+ } else if (stats.isFile()) output.push({ path: targetPath, size: stats.size });
567
+ }
568
+
569
+ async function measureTarget(targetPath) {
570
+ let bytes = 0;
571
+ let count = 0;
572
+ const pending = [targetPath];
573
+ while (pending.length) {
574
+ const current = pending.pop();
575
+ let stats;
576
+ try { stats = await fs.lstat(current); } catch (error) { if (error?.code === "ENOENT") continue; throw error; }
577
+ if (stats.isSymbolicLink()) continue;
578
+ if (stats.isDirectory()) {
579
+ for (const entry of await fs.readdir(current)) pending.push(path.join(current, entry));
580
+ } else if (stats.isFile()) {
581
+ bytes += stats.size;
582
+ count += 1;
583
+ }
584
+ }
585
+ return { bytes, count };
586
+ }
587
+
588
+ export async function loadDeletionStore({ claudeHome, desktopDataHome, recordIds }) {
589
+ const store = await discover(claudeHome, desktopDataHome);
590
+ if (recordIds.some((id) => !store.recordsById.has(id))) throw new Error("One or more selected sessions are no longer available.");
591
+ return store;
592
+ }
593
+
594
+ export async function planSessionDeletion({ recordIds, store }) {
595
+ const ids = [...new Set(recordIds)];
596
+ const records = ids.map((id) => store.recordsById.get(id)).filter(Boolean);
597
+ const selectedPaths = new Set();
598
+ for (const record of records) {
599
+ record.transcriptPaths.forEach((item) => selectedPaths.add(item));
600
+ record.desktopStatePaths.forEach((item) => selectedPaths.add(item));
601
+ for (const candidate of [
602
+ path.join(store.paths.sessionEnvDirectory, record.id),
603
+ path.join(store.paths.tasksDirectory, record.id),
604
+ path.join(store.paths.debugDirectory, `${record.id}.txt`),
605
+ ]) if (await exists(candidate)) selectedPaths.add(candidate);
606
+ for (const transcriptPath of record.transcriptPaths) {
607
+ const nested = path.join(path.dirname(transcriptPath), record.id);
608
+ if (await exists(nested)) selectedPaths.add(nested);
609
+ }
610
+ }
611
+ const files = [];
612
+ for (const selectedPath of selectedPaths) await collectFiles(selectedPath, files);
613
+ const deepPaths = [];
614
+ const deepFiles = [];
615
+ for (const id of ids) {
616
+ const checkpoint = path.join(store.paths.fileHistoryDirectory, id);
617
+ if (await exists(checkpoint)) {
618
+ deepPaths.push(checkpoint);
619
+ await collectFiles(checkpoint, deepFiles);
620
+ }
621
+ }
622
+ const idSet = new Set(ids);
623
+ const history = await matchingHistoryStats(store.paths.historyPath, idSet);
624
+ const newestLinkedActivityAtMs = Math.max(0, ...records.map((record) => record.updatedAtMs));
625
+ return {
626
+ childCount: 0,
627
+ desktopStateMatchCount: records.reduce((sum, record) => sum + record.desktopStatePaths.length, 0),
628
+ deepFiles,
629
+ deepPaths,
630
+ files,
631
+ goalRowCount: 0,
632
+ historyMatchBytes: history.bytes,
633
+ historyMatchCount: history.count,
634
+ ids,
635
+ logRowCount: 0,
636
+ memoryRowCount: 0,
637
+ missingTranscriptPaths: [],
638
+ newestLinkedActivityAtMs,
639
+ records,
640
+ sessionIndexMatchCount: 0,
641
+ spawnEdgeCount: 0,
642
+ transcriptBytes: files.reduce((sum, file) => sum + file.size, 0),
643
+ transcriptFileCount: files.length,
644
+ transcriptPaths: [...selectedPaths],
645
+ unrecognizedLocationCount: (await topLevelEntries(store.paths.claudeHome))
646
+ .filter((entry) => !KNOWN_TOP_LEVEL.has(entry.name)).length + store.unknown.length + store.desktop.unlinked.length,
647
+ };
648
+ }
649
+
650
+ async function activeSessionIds(paths) {
651
+ const active = new Set();
652
+ if (!(await exists(paths.sessionsDirectory))) return { active, detection: "unavailable" };
653
+ const pending = [paths.sessionsDirectory];
654
+ while (pending.length) {
655
+ const current = pending.pop();
656
+ let entries;
657
+ try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { continue; }
658
+ for (const entry of entries) {
659
+ const target = path.join(current, entry.name);
660
+ if (entry.isDirectory()) { pending.push(target); continue; }
661
+ const match = /([0-9a-f]{8}-[0-9a-f-]{27,})/iu.exec(entry.name);
662
+ if (match) active.add(match[1]);
663
+ if (!entry.name.endsWith(".json")) continue;
664
+ try {
665
+ const parsed = JSON.parse(await fs.readFile(target, "utf8"));
666
+ const id = parsed.sessionId ?? parsed.session_id;
667
+ if (typeof id === "string" && !["stopped", "completed", "failed"].includes(parsed.status ?? parsed.state)) active.add(id);
668
+ } catch {}
669
+ }
670
+ }
671
+ return { active, detection: "available" };
672
+ }
673
+
674
+ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope, store }) {
675
+ const active = await activeSessionIds(store.paths);
676
+ const selectedActive = plan.ids.filter((id) => active.active.has(id));
677
+ if (selectedActive.length) throw new Error("Close the selected Claude sessions before cleanup.");
678
+ const deepBytes = scope === "deep" ? plan.deepFiles.reduce((sum, file) => sum + file.size, 0) : 0;
679
+ const estimatedBackupBytes = plan.transcriptBytes + deepBytes + plan.historyMatchBytes + 4096;
680
+ let diskBytes = availableDiskBytes;
681
+ if (diskBytes === undefined) {
682
+ try { const stats = await fs.statfs(store.paths.claudeHome); diskBytes = stats.bavail * stats.bsize; } catch { diskBytes = null; }
683
+ }
684
+ if (Number.isFinite(diskBytes) && diskBytes < estimatedBackupBytes) throw new Error("There is not enough free space to create the recovery backup.");
685
+ return {
686
+ activeThreadDetection: plan.records.some((record) => record.surface === "desktop") ? "unavailable" : active.detection,
687
+ availableDiskBytes: diskBytes,
688
+ desktopStateMatchCount: plan.desktopStateMatchCount,
689
+ desktopStateSupport: store.paths.desktopSessionsDirectory ? "available" : "unavailable",
690
+ estimatedBackupBytes,
691
+ transcriptBytes: plan.transcriptBytes + deepBytes + plan.historyMatchBytes,
692
+ transcriptFileCount: plan.transcriptFileCount + (scope === "deep" ? plan.deepFiles.length : 0),
693
+ };
694
+ }
695
+
696
+ function contained(root, target) {
697
+ const relative = path.relative(root, target);
698
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : null;
699
+ }
700
+
701
+ function backupLocation(store, sourcePath) {
702
+ const claudeRelative = contained(store.paths.claudeHome, sourcePath);
703
+ if (claudeRelative) return { relative: claudeRelative, root: "claude" };
704
+ const desktopRelative = store.paths.desktopDataHome && contained(store.paths.desktopDataHome, sourcePath);
705
+ if (desktopRelative) return { relative: desktopRelative, root: "desktop" };
706
+ throw new Error("Cleanup stopped because a selected file is outside Claude storage.");
707
+ }
708
+
709
+ async function hashFile(filePath) {
710
+ const hash = createHash("sha256");
711
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk);
712
+ return hash.digest("hex");
713
+ }
714
+
715
+ export async function fingerprintSessionDeletion({ plan, scope, store }) {
716
+ const hash = createHash("sha256");
717
+ hash.update(`${store.paths.claudeHome}\0${scope}\0${plan.ids.join("\0")}`);
718
+ const files = scope === "deep" ? [...plan.files, ...plan.deepFiles] : plan.files;
719
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
720
+ const stats = await fs.stat(file.path);
721
+ hash.update(`${file.path}\0${stats.size}\0${stats.mtimeMs}\0`);
722
+ }
723
+ if (plan.historyMatchCount) {
724
+ for await (const entry of readJsonlEntries(store.paths.historyPath)) {
725
+ if (entry.parsed && plan.ids.includes(entry.parsed.sessionId ?? entry.parsed.session_id)) hash.update(`${entry.raw}\n`);
726
+ }
727
+ }
728
+ return hash.digest("hex");
729
+ }
730
+
731
+ async function copyTarget(source, destination) {
732
+ const stats = await fs.lstat(source);
733
+ await fs.mkdir(path.dirname(destination), { recursive: true });
734
+ if (stats.isDirectory()) await fs.cp(source, destination, { errorOnExist: true, recursive: true });
735
+ else await fs.copyFile(source, destination);
736
+ }
737
+
738
+ async function verifyCopy(source, destination) {
739
+ const sourceStats = await fs.stat(source);
740
+ if (sourceStats.isFile()) {
741
+ const destinationStats = await fs.stat(destination);
742
+ if (sourceStats.size !== destinationStats.size || await hashFile(source) !== await hashFile(destination)) {
743
+ throw new Error("The recovery backup could not be verified.");
744
+ }
745
+ return;
746
+ }
747
+ const sourceFiles = [];
748
+ const destinationFiles = [];
749
+ await collectFiles(source, sourceFiles);
750
+ await collectFiles(destination, destinationFiles);
751
+ const sourceMap = new Map(sourceFiles.map((item) => [path.relative(source, item.path), item]));
752
+ const destinationMap = new Map(destinationFiles.map((item) => [path.relative(destination, item.path), item]));
753
+ if (sourceMap.size !== destinationMap.size) throw new Error("The recovery backup could not be verified.");
754
+ for (const [relative, item] of sourceMap) {
755
+ const copied = destinationMap.get(relative);
756
+ if (!copied || copied.size !== item.size || await hashFile(item.path) !== await hashFile(copied.path)) {
757
+ throw new Error("The recovery backup could not be verified.");
758
+ }
759
+ }
760
+ }
761
+
762
+ async function backupHistoryRows(historyPath, destination, ids) {
763
+ await fs.mkdir(path.dirname(destination), { recursive: true });
764
+ const output = createWriteStream(destination, { encoding: "utf8", mode: 0o600 });
765
+ try {
766
+ for await (const entry of readJsonlEntries(historyPath)) {
767
+ if (!entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id)) continue;
768
+ if (!output.write(`${entry.raw}\n`)) await once(output, "drain");
769
+ }
770
+ output.end();
771
+ await finished(output);
772
+ } catch (error) {
773
+ output.destroy();
774
+ throw error;
775
+ }
776
+ }
777
+
778
+ async function createBackup(plan, store, scope) {
779
+ const backupDirectory = path.join(store.paths.backupRoot, `${new Date().toISOString().replaceAll(":", "-")}-${randomBytes(6).toString("hex")}`);
780
+ const entries = [];
781
+ await fs.mkdir(backupDirectory, { mode: 0o700, recursive: true });
782
+ try {
783
+ const sources = [...new Set(plan.transcriptPaths)];
784
+ if (scope === "deep") {
785
+ sources.push(...plan.deepPaths);
786
+ }
787
+ for (const source of sources) {
788
+ const location = backupLocation(store, source);
789
+ const destination = path.join(backupDirectory, "data", location.root, location.relative);
790
+ await copyTarget(source, destination);
791
+ await verifyCopy(source, destination);
792
+ entries.push({ ...location, sha256: (await fs.stat(source)).isFile() ? await hashFile(source) : null });
793
+ }
794
+ const sharedJsonl = [];
795
+ if (plan.historyMatchCount) {
796
+ const backupRelative = path.join("shared", "history.jsonl");
797
+ const destination = path.join(backupDirectory, backupRelative);
798
+ await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
799
+ sharedJsonl.push({ backupRelative, relative: "history.jsonl", root: "claude" });
800
+ }
801
+ const compatibility = await diagnoseStorageCompatibility({
802
+ claudeHome: store.paths.claudeHome,
803
+ desktopDataHome: store.paths.desktopDataHome,
804
+ });
805
+ const manifest = {
806
+ compatibilityStatus: compatibility.status,
807
+ createdAt: new Date().toISOString(),
808
+ entries,
809
+ profileId: COMPATIBILITY_PROFILE.id,
810
+ providerId: PROVIDER_ID,
811
+ scope,
812
+ sharedJsonl,
813
+ version: 2,
814
+ };
815
+ await fs.writeFile(path.join(backupDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
816
+ return backupDirectory;
817
+ } catch (error) {
818
+ error.backupDirectory = backupDirectory;
819
+ throw error;
820
+ }
821
+ }
822
+
823
+ export async function executeSessionDeletion({ onProgress = () => {}, plan, scope, shouldCancel = () => false, store }) {
824
+ onProgress({ canCancel: true, message: "Creating recovery backup", phase: "backup", progress: 8 });
825
+ const backupDirectory = await createBackup(plan, store, scope);
826
+ try {
827
+ if (shouldCancel()) { const error = new Error("Cleanup cancelled."); error.cancelled = true; error.backupDirectory = backupDirectory; throw error; }
828
+ onProgress({ canCancel: false, message: "Removing selected session data", phase: "cleanup", progress: 55 });
829
+ if (plan.historyMatchCount && await exists(store.paths.historyPath)) {
830
+ const ids = new Set(plan.ids);
831
+ await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id));
832
+ }
833
+ const targets = [...plan.transcriptPaths];
834
+ if (scope === "deep") targets.push(...plan.deepPaths);
835
+ for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
836
+ onProgress({ canCancel: false, message: "Checking cleanup", phase: "verification", progress: 90 });
837
+ return {
838
+ backupDirectory,
839
+ deletedIds: plan.ids,
840
+ deletedTranscriptPaths: plan.transcriptPaths,
841
+ skippedTranscriptPaths: [],
842
+ unrecognizedLocationCount: plan.unrecognizedLocationCount,
843
+ };
844
+ } catch (error) {
845
+ error.backupDirectory = backupDirectory;
846
+ throw error;
847
+ }
848
+ }
849
+
850
+ export async function verifySessionDeletion({ plan, scope, store }) {
851
+ const remainingTranscriptPaths = [];
852
+ for (const target of plan.transcriptPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
853
+ if (scope === "deep") for (const target of plan.deepPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
854
+ const remainingHistoryEntryCount = (await matchingHistoryStats(store.paths.historyPath, new Set(plan.ids))).count;
855
+ return {
856
+ complete: remainingTranscriptPaths.length === 0 && remainingHistoryEntryCount === 0,
857
+ remainingDesktopStateReferences: [], remainingGoalRecords: [], remainingHistoryEntryCount,
858
+ remainingLogRecords: [], remainingMemoryRecords: [], remainingSessionIndexEntryCount: 0,
859
+ remainingThreads: [], remainingTranscriptPaths,
860
+ };
861
+ }
862
+
863
+ export async function deleteSessionDeletionBackup({ backupDirectory, claudeHome }) {
864
+ const root = path.join(path.resolve(claudeHome), "session-steward-backups");
865
+ if (!contained(root, path.resolve(backupDirectory))) throw new Error("That recovery backup is outside the Claude backup folder.");
866
+ await fs.rm(backupDirectory, { force: true, recursive: true });
867
+ }
868
+
869
+ export async function listSessionDeletionBackups({ claudeHome }) {
870
+ const root = path.join(path.resolve(claudeHome), "session-steward-backups");
871
+ let entries;
872
+
873
+ try {
874
+ entries = await fs.readdir(root, { withFileTypes: true });
875
+ } catch (error) {
876
+ if (error?.code === "ENOENT") return [];
877
+ throw error;
878
+ }
879
+
880
+ const backups = [];
881
+
882
+ for (const entry of entries) {
883
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
884
+ const backupDirectory = path.join(root, entry.name);
885
+ const manifestPath = path.join(backupDirectory, "manifest.json");
886
+ let manifest = null;
887
+
888
+ try {
889
+ manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
890
+ } catch (error) {
891
+ if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
892
+ }
893
+
894
+ const [stats, measured] = await Promise.all([
895
+ fs.stat(backupDirectory),
896
+ measurePath(backupDirectory),
897
+ ]);
898
+ const restorable = manifest?.providerId === PROVIDER_ID &&
899
+ [1, 2].includes(manifest?.version) &&
900
+ Array.isArray(manifest.entries);
901
+
902
+ backups.push({
903
+ backupDirectory,
904
+ bytes: measured.bytes,
905
+ createdAtMs: asTimestamp(manifest?.createdAt) || stats.mtimeMs,
906
+ fileCount: measured.fileCount,
907
+ id: entry.name,
908
+ itemCount: Array.isArray(manifest?.entries)
909
+ ? manifest.entries.length + (manifest.sharedJsonl?.length ?? 0)
910
+ : measured.fileCount,
911
+ providerId: PROVIDER_ID,
912
+ restorable,
913
+ scope: manifest?.scope === "core" || manifest?.scope === "deep" ? manifest.scope : null,
914
+ sessionCount: null,
915
+ });
916
+ }
917
+
918
+ return backups.sort((left, right) => right.createdAtMs - left.createdAtMs || left.id.localeCompare(right.id));
919
+ }
920
+
921
+ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome, desktopDataHome, onProgress = () => {} }) {
922
+ const store = { paths: getPaths(claudeHome, desktopDataHome) };
923
+ if (!contained(store.paths.backupRoot, path.resolve(backupDirectory))) {
924
+ throw new Error("That recovery backup is outside the Claude backup folder.");
925
+ }
926
+ const manifest = JSON.parse(await fs.readFile(path.join(backupDirectory, "manifest.json"), "utf8"));
927
+ if (manifest?.providerId !== PROVIDER_ID || !Array.isArray(manifest.entries)) throw new Error("This recovery backup is not valid for Claude Code.");
928
+ invalidateSessionCache({ claudeHome, desktopDataHome });
929
+ const currentCompatibilityBeforeRestore = await diagnoseStorageCompatibility({ claudeHome, desktopDataHome });
930
+ const safetyBackupDirectory = path.join(store.paths.backupRoot, `restore-safety-${Date.now()}-${randomBytes(4).toString("hex")}`);
931
+ await fs.mkdir(safetyBackupDirectory, { mode: 0o700, recursive: true });
932
+ try {
933
+ onProgress({ message: "Restoring session data", progress: 35 });
934
+ for (const entry of manifest.entries) {
935
+ const root = entry.root === "claude" ? store.paths.claudeHome : entry.root === "desktop" ? store.paths.desktopDataHome : null;
936
+ if (!root) throw new Error("The recovery backup contains an unsupported storage location.");
937
+ const source = path.resolve(backupDirectory, "data", entry.root, entry.relative);
938
+ const destination = path.resolve(root, entry.relative);
939
+ if (!contained(backupDirectory, source) || !contained(root, destination)) throw new Error("The recovery backup contains an unsafe path.");
940
+ if (await exists(destination)) {
941
+ const safety = path.join(safetyBackupDirectory, entry.root, entry.relative);
942
+ await copyTarget(destination, safety);
943
+ await fs.rm(destination, { force: true, recursive: true });
944
+ }
945
+ await copyTarget(source, destination);
946
+ }
947
+ for (const entry of manifest.sharedJsonl ?? []) {
948
+ const root = entry.root === "claude" ? store.paths.claudeHome : null;
949
+ if (!root) throw new Error("The recovery backup contains an unsupported shared record.");
950
+ const destination = path.resolve(root, entry.relative);
951
+ if (!contained(root, destination)) throw new Error("The recovery backup contains an unsafe path.");
952
+ const source = path.resolve(backupDirectory, entry.backupRelative);
953
+ if (!contained(backupDirectory, source)) throw new Error("The recovery backup contains an unsafe path.");
954
+ const selectedIds = new Set();
955
+ for await (const row of readJsonlEntries(source)) {
956
+ const id = row.parsed?.sessionId ?? row.parsed?.session_id;
957
+ if (typeof id === "string") selectedIds.add(id);
958
+ }
959
+ if (await exists(destination)) {
960
+ const safety = path.join(safetyBackupDirectory, entry.root, entry.relative);
961
+ await copyTarget(destination, safety);
962
+ await rewriteJsonlFile(destination, (row) => !row.parsed || !selectedIds.has(row.parsed.sessionId ?? row.parsed.session_id));
963
+ } else {
964
+ await fs.mkdir(path.dirname(destination), { recursive: true });
965
+ }
966
+ await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
967
+ }
968
+ onProgress({ message: "Checking restored sessions", progress: 92 });
969
+ const layoutChanged = manifest.version === 2 && (
970
+ manifest.profileId !== COMPATIBILITY_PROFILE.id
971
+ || manifest.compatibilityStatus !== currentCompatibilityBeforeRestore.status
972
+ );
973
+ invalidateSessionCache({ claudeHome, desktopDataHome });
974
+ return {
975
+ note: layoutChanged ? "The Claude storage layout changed after this backup was created. The recorded files were restored to their original locations." : null,
976
+ recoveryBackupsDeleted: false,
977
+ restoredEntryCount: manifest.entries.length + (manifest.sharedJsonl?.length ?? 0),
978
+ safetyBackupDirectory,
979
+ };
980
+ } catch (error) {
981
+ error.safetyBackupDirectory = safetyBackupDirectory;
982
+ throw error;
983
+ }
984
+ }
985
+
986
+ export function formatSessionForJson(record) {
987
+ return {
988
+ archived: record.archived, childThreadIds: record.childThreadIds, createdAtMs: record.createdAtMs,
989
+ cwd: record.cwd, displayName: record.displayName, forkedFromId: null, id: record.id,
990
+ isFork: false, isPinned: false, isSubagent: false, parentThreadId: null,
991
+ providerId: PROVIDER_ID, recordSource: record.recordSource, rolloutMissing: false,
992
+ rolloutPath: record.rolloutPath, surface: record.surface, titleSource: record.titleSource,
993
+ transcriptBytes: record.transcriptBytes,
994
+ updatedAtMs: record.updatedAtMs,
995
+ };
996
+ }