session-steward 0.3.0 → 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.
@@ -0,0 +1,498 @@
1
+ import {
2
+ createSessionEvent,
3
+ createSessionEventCoverage,
4
+ createSessionEventHeader,
5
+ createSessionEventsResult,
6
+ SESSION_EVENT_KIND,
7
+ SESSION_EVENT_REASON,
8
+ } from "../../session-events.mjs";
9
+ import {
10
+ createSessionEventReadState,
11
+ createUnmappedSessionEventTracker,
12
+ isInjectedSessionAsk,
13
+ } from "../../session-event-reader.mjs";
14
+ import { visitJsonlSnapshotEntries } from "../../storage/jsonl.mjs";
15
+ import { getSessionRecord } from "./store.mjs";
16
+
17
+ const MAX_PENDING_DECISIONS = 2_048;
18
+ const PROVIDER_ID = "claude-code";
19
+ const READ_ONLY_TOOLS = new Set(["Glob", "Grep", "Read", "WebFetch", "WebSearch"]);
20
+ const RECORD_CLASSIFICATION = Object.freeze({
21
+ RECOGNIZED: "recognized",
22
+ SKIPPED: "skipped",
23
+ UNMAPPED: "unmapped",
24
+ UNPARSEABLE: "unparseable",
25
+ });
26
+ const SKIPPED_CONTENT_TYPES = new Set(["attachment", "image", "thinking"]);
27
+ const SKIPPED_RECORD_TYPES = new Set([
28
+ "attachment",
29
+ "custom-title",
30
+ "file-history-snapshot",
31
+ "frame-link",
32
+ "last-prompt",
33
+ "mode",
34
+ "queue-operation",
35
+ "summary",
36
+ "system",
37
+ ]);
38
+
39
+ function asTimestamp(value) {
40
+ if (typeof value === "number" && Number.isFinite(value)) return value;
41
+ if (typeof value !== "string") return null;
42
+ const timestamp = Date.parse(value);
43
+ return Number.isFinite(timestamp) ? timestamp : null;
44
+ }
45
+
46
+ function contentText(value) {
47
+ if (typeof value === "string") return value;
48
+
49
+ if (Array.isArray(value)) {
50
+ return value
51
+ .map((part) => contentText(part))
52
+ .filter((part) => part !== "")
53
+ .join("\n");
54
+ }
55
+
56
+ if (!value || typeof value !== "object") return "";
57
+ if (typeof value.text === "string") return value.text;
58
+ if (typeof value.content === "string" || Array.isArray(value.content)) {
59
+ return contentText(value.content);
60
+ }
61
+ return "";
62
+ }
63
+
64
+ function emptyResult({ cwd = null, origin = null, reason }) {
65
+ return createSessionEventsResult({
66
+ coverage: createSessionEventCoverage(),
67
+ events: [],
68
+ header: createSessionEventHeader({ cwd, origin, provider: PROVIDER_ID }),
69
+ reason,
70
+ window: {
71
+ complete: true,
72
+ end: null,
73
+ outcomesMayBeUnresolved: false,
74
+ },
75
+ });
76
+ }
77
+
78
+ function messageParts(record) {
79
+ const content = record.message?.content;
80
+ if (typeof content === "string") return [{ text: content, type: "text" }];
81
+ return Array.isArray(content) ? content : [];
82
+ }
83
+
84
+ function planSteps(input) {
85
+ const todos = Array.isArray(input?.todos) ? input.todos : [];
86
+ return todos
87
+ .filter((todo) => typeof todo?.status === "string")
88
+ .map((todo) => ({
89
+ status: todo.status,
90
+ text: typeof todo.content === "string"
91
+ ? todo.content
92
+ : typeof todo.activeForm === "string"
93
+ ? todo.activeForm
94
+ : "Untitled task",
95
+ }));
96
+ }
97
+
98
+ function questionText(input) {
99
+ if (typeof input?.question === "string") return input.question;
100
+ if (!Array.isArray(input?.questions)) return "Question";
101
+ return input.questions
102
+ .map((question) => question?.question)
103
+ .filter((question) => typeof question === "string")
104
+ .join("\n") || "Question";
105
+ }
106
+
107
+ export async function readSessionEvents({
108
+ claudeHome,
109
+ desktopDataHome,
110
+ id,
111
+ limit,
112
+ maxLineBytes,
113
+ mode,
114
+ signal,
115
+ }) {
116
+ const record = await getSessionRecord({ claudeHome, desktopDataHome, id });
117
+ if (!record) return null;
118
+
119
+ const origin = record.surface ?? record.recordSource ?? null;
120
+ if (!record.rolloutPath) {
121
+ return emptyResult({
122
+ cwd: record.cwd || null,
123
+ origin,
124
+ reason: SESSION_EVENT_REASON.NO_TRANSCRIPT_PATH,
125
+ });
126
+ }
127
+
128
+ const coverage = createSessionEventCoverage();
129
+ const pendingDecisionIds = [];
130
+ const readState = createSessionEventReadState({ limit, mode });
131
+ const unmappedTypes = createUnmappedSessionEventTracker();
132
+ let acceptingInjectedAsks = true;
133
+ let header = createSessionEventHeader({
134
+ cwd: record.cwd || null,
135
+ origin,
136
+ provider: PROVIDER_ID,
137
+ });
138
+
139
+ function addEvent(event, pendingId = null) {
140
+ return readState.add(event, { pendingId });
141
+ }
142
+
143
+ function rememberDecision(pendingId) {
144
+ if (!pendingId) return;
145
+ pendingDecisionIds.push(pendingId);
146
+ if (pendingDecisionIds.length > MAX_PENDING_DECISIONS) pendingDecisionIds.shift();
147
+ }
148
+
149
+ function resolveDecision(answer) {
150
+ while (pendingDecisionIds.length > 0) {
151
+ const pendingId = pendingDecisionIds.shift();
152
+ if (readState.resolve(pendingId, (event) => {
153
+ if (event.kind === SESSION_EVENT_KIND.DECIDED) event.answer = answer;
154
+ })) return true;
155
+ }
156
+
157
+ return false;
158
+ }
159
+
160
+ function toolEvent(part, atMs, sequence) {
161
+ const input = part.input && typeof part.input === "object" ? part.input : {};
162
+ const name = typeof part.name === "string" ? part.name : "unknown tool";
163
+ const pendingId = typeof part.id === "string" ? part.id : null;
164
+
165
+ if (name === "Edit" || name === "Write") {
166
+ return {
167
+ event: createSessionEvent({
168
+ atMs,
169
+ files: typeof input.file_path === "string" ? [input.file_path] : [],
170
+ kind: SESSION_EVENT_KIND.EDIT,
171
+ sequence,
172
+ }),
173
+ pendingId,
174
+ };
175
+ }
176
+
177
+ if (name === "Bash") {
178
+ return {
179
+ event: createSessionEvent({
180
+ atMs,
181
+ command: typeof input.command === "string" ? input.command : null,
182
+ kind: SESSION_EVENT_KIND.RAN,
183
+ sequence,
184
+ workdir: typeof input.workdir === "string" ? input.workdir : record.cwd || null,
185
+ }),
186
+ pendingId,
187
+ };
188
+ }
189
+
190
+ if (name === "AskUserQuestion") {
191
+ return {
192
+ decision: true,
193
+ event: createSessionEvent({
194
+ atMs,
195
+ kind: SESSION_EVENT_KIND.DECIDED,
196
+ question: questionText(input),
197
+ sequence,
198
+ }),
199
+ pendingId,
200
+ };
201
+ }
202
+
203
+ if (name === "TodoWrite") {
204
+ return {
205
+ event: createSessionEvent({
206
+ atMs,
207
+ kind: SESSION_EVENT_KIND.PLAN,
208
+ sequence,
209
+ steps: planSteps(input),
210
+ }),
211
+ pendingId: null,
212
+ };
213
+ }
214
+
215
+ if (!READ_ONLY_TOOLS.has(name) && typeof input.file_path === "string") {
216
+ return {
217
+ event: createSessionEvent({
218
+ atMs,
219
+ files: [input.file_path],
220
+ kind: SESSION_EVENT_KIND.EDIT,
221
+ sequence,
222
+ }),
223
+ pendingId,
224
+ };
225
+ }
226
+
227
+ if (typeof input.command === "string") {
228
+ return {
229
+ event: createSessionEvent({
230
+ atMs,
231
+ command: input.command,
232
+ kind: SESSION_EVENT_KIND.RAN,
233
+ sequence,
234
+ workdir: typeof input.workdir === "string" ? input.workdir : record.cwd || null,
235
+ }),
236
+ pendingId,
237
+ };
238
+ }
239
+
240
+ return {
241
+ event: createSessionEvent({
242
+ atMs,
243
+ command: name,
244
+ kind: SESSION_EVENT_KIND.RAN,
245
+ sequence,
246
+ unclassified: true,
247
+ workdir: record.cwd || null,
248
+ }),
249
+ pendingId,
250
+ };
251
+ }
252
+
253
+ function resolveToolResult(part) {
254
+ const pendingId = part.tool_use_id;
255
+ const answer = contentText(part.content).trim();
256
+ const failed = part.is_error === true;
257
+ return readState.resolve(pendingId, (event) => {
258
+ if (event.kind === SESSION_EVENT_KIND.DECIDED) {
259
+ event.answer = answer || null;
260
+ } else if (event.kind === SESSION_EVENT_KIND.EDIT) {
261
+ event.applied = !failed;
262
+ } else if (event.kind === SESSION_EVENT_KIND.RAN) {
263
+ event.error = failed ? answer || "Tool failed." : null;
264
+ event.failed = failed;
265
+ }
266
+ });
267
+ }
268
+
269
+ function updateHeader(recordValue) {
270
+ const branch = recordValue.gitBranch ?? recordValue.git?.branch ?? header.git?.branch ?? null;
271
+ const commit = recordValue.git?.commit ?? recordValue.git?.commit_hash ?? header.git?.commit ?? null;
272
+ const repository = recordValue.git?.repository
273
+ ?? recordValue.git?.repository_url
274
+ ?? header.git?.repository
275
+ ?? null;
276
+ const hasGit = branch !== null || commit !== null || repository !== null;
277
+ header = createSessionEventHeader({
278
+ cwd: recordValue.cwd ?? header.cwd,
279
+ git: hasGit ? { branch, commit, repository } : null,
280
+ model: recordValue.message?.model ?? recordValue.model ?? header.model,
281
+ origin: recordValue.entrypoint ?? header.origin,
282
+ provider: PROVIDER_ID,
283
+ version: recordValue.version ?? header.version,
284
+ });
285
+ }
286
+
287
+ function handleRecord(recordValue, sequence) {
288
+ updateHeader(recordValue);
289
+ const atMs = asTimestamp(recordValue.timestamp ?? recordValue.createdAt);
290
+ const parts = messageParts(recordValue);
291
+
292
+ if (recordValue.type === "user") {
293
+ if (recordValue.isCompactSummary === true) {
294
+ acceptingInjectedAsks = false;
295
+ const text = contentText(recordValue.message?.content);
296
+ if (!text) {
297
+ return { classification: RECORD_CLASSIFICATION.UNPARSEABLE, stop: false };
298
+ }
299
+ return {
300
+ classification: RECORD_CLASSIFICATION.RECOGNIZED,
301
+ stop: addEvent(createSessionEvent({
302
+ atMs,
303
+ kind: SESSION_EVENT_KIND.SUMMARY,
304
+ sequence,
305
+ text,
306
+ })),
307
+ };
308
+ }
309
+
310
+ let recognized = false;
311
+ let resolvedDecision = false;
312
+ let unparseable = parts.length === 0;
313
+ let unmappedType = null;
314
+
315
+ for (const part of parts) {
316
+ if (typeof part?.type !== "string") {
317
+ unparseable = true;
318
+ continue;
319
+ }
320
+ if (part?.type === "text" && typeof part.text === "string") continue;
321
+ if (part?.type !== "tool_result") {
322
+ if (!SKIPPED_CONTENT_TYPES.has(part?.type)) {
323
+ unmappedType = `user:${part?.type ?? "missing content type"}`;
324
+ }
325
+ continue;
326
+ }
327
+ const resolved = resolveToolResult(part);
328
+ if (part.is_error === true) recognized = true;
329
+ if (part.is_error === true && !resolved) unparseable = true;
330
+ }
331
+
332
+ const text = parts
333
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
334
+ .map((part) => part.text)
335
+ .join("\n");
336
+
337
+ if (text) {
338
+ resolvedDecision = resolveDecision(text);
339
+ recognized = true;
340
+ if (resolvedDecision) acceptingInjectedAsks = false;
341
+ if (!resolvedDecision) {
342
+ const injected = acceptingInjectedAsks && isInjectedSessionAsk(text);
343
+ if (!injected) acceptingInjectedAsks = false;
344
+ return {
345
+ classification: unmappedType
346
+ ? RECORD_CLASSIFICATION.UNMAPPED
347
+ : unparseable
348
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
349
+ : RECORD_CLASSIFICATION.RECOGNIZED,
350
+ stop: addEvent(createSessionEvent({
351
+ atMs,
352
+ injected,
353
+ kind: SESSION_EVENT_KIND.ASK,
354
+ sequence,
355
+ text,
356
+ })),
357
+ unmappedType,
358
+ };
359
+ }
360
+ }
361
+
362
+ return {
363
+ classification: unmappedType
364
+ ? RECORD_CLASSIFICATION.UNMAPPED
365
+ : unparseable
366
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
367
+ : recognized
368
+ ? RECORD_CLASSIFICATION.RECOGNIZED
369
+ : RECORD_CLASSIFICATION.SKIPPED,
370
+ stop: false,
371
+ unmappedType,
372
+ };
373
+ }
374
+
375
+ if (recordValue.type === "assistant") {
376
+ acceptingInjectedAsks = false;
377
+ let recognized = false;
378
+ let stop = false;
379
+ const unparseable = parts.length === 0 || parts.some((part) => (
380
+ typeof part?.type !== "string"
381
+ || (part.type === "text" && typeof part.text !== "string")
382
+ ));
383
+ const unmappedPart = parts.find((part) => (
384
+ typeof part?.type === "string"
385
+ && part.type !== "text"
386
+ && part?.type !== "tool_use"
387
+ && !SKIPPED_CONTENT_TYPES.has(part?.type)
388
+ ));
389
+ const unmappedType = unmappedPart
390
+ ? `assistant:${unmappedPart?.type ?? "missing content type"}`
391
+ : null;
392
+ const text = parts
393
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
394
+ .map((part) => part.text)
395
+ .join("\n");
396
+
397
+ if (text) {
398
+ recognized = true;
399
+ stop = addEvent(createSessionEvent({
400
+ atMs,
401
+ kind: SESSION_EVENT_KIND.SAID,
402
+ sequence,
403
+ text,
404
+ }));
405
+ }
406
+
407
+ if (!stop) {
408
+ for (const part of parts) {
409
+ if (part?.type === "text" && typeof part.text === "string") continue;
410
+ if (part?.type !== "tool_use") {
411
+ continue;
412
+ }
413
+ const result = toolEvent(part, atMs, sequence);
414
+ recognized = true;
415
+ stop = addEvent(result.event, result.pendingId);
416
+ if (result.decision) rememberDecision(result.pendingId);
417
+ if (stop) break;
418
+ }
419
+ }
420
+
421
+ return {
422
+ classification: unmappedType
423
+ ? RECORD_CLASSIFICATION.UNMAPPED
424
+ : unparseable
425
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
426
+ : recognized
427
+ ? RECORD_CLASSIFICATION.RECOGNIZED
428
+ : RECORD_CLASSIFICATION.SKIPPED,
429
+ stop,
430
+ unmappedType,
431
+ };
432
+ }
433
+
434
+ if (typeof recordValue.type !== "string") {
435
+ return { classification: RECORD_CLASSIFICATION.UNPARSEABLE, stop: false };
436
+ }
437
+
438
+ return {
439
+ classification: SKIPPED_RECORD_TYPES.has(recordValue.type)
440
+ ? RECORD_CLASSIFICATION.SKIPPED
441
+ : RECORD_CLASSIFICATION.UNMAPPED,
442
+ stop: false,
443
+ unmappedType: `claude:${recordValue.type ?? "missing record type"}`,
444
+ };
445
+ }
446
+
447
+ let read;
448
+
449
+ try {
450
+ read = await visitJsonlSnapshotEntries(
451
+ record.rolloutPath,
452
+ (entry) => {
453
+ if (signal?.aborted) return false;
454
+ coverage.total += 1;
455
+
456
+ if (entry.oversized) {
457
+ coverage.oversized += 1;
458
+ return true;
459
+ }
460
+
461
+ if (!entry.parsed || typeof entry.parsed !== "object") {
462
+ coverage.unparseable += 1;
463
+ return true;
464
+ }
465
+
466
+ const result = handleRecord(entry.parsed, entry.index);
467
+ coverage[result.classification] += 1;
468
+ if (result.classification === RECORD_CLASSIFICATION.UNMAPPED) {
469
+ unmappedTypes.add(result.unmappedType);
470
+ }
471
+ return !result.stop;
472
+ },
473
+ { maxLineBytes },
474
+ );
475
+ } catch (error) {
476
+ if (error?.code === "ENOENT") {
477
+ return emptyResult({
478
+ cwd: record.cwd || null,
479
+ origin,
480
+ reason: SESSION_EVENT_REASON.TRANSCRIPT_MISSING,
481
+ });
482
+ }
483
+
484
+ throw error;
485
+ }
486
+
487
+ const events = readState.values();
488
+ coverage.unmappedTypes = unmappedTypes.values();
489
+ return createSessionEventsResult({
490
+ coverage,
491
+ events,
492
+ header,
493
+ reason: events.length === 0 && read.complete
494
+ ? SESSION_EVENT_REASON.NO_RECOGNIZED_EVENTS
495
+ : null,
496
+ window: readState.window(read),
497
+ });
498
+ }
@@ -1,7 +1,9 @@
1
+ import { readSessionEvents } from "./events.mjs";
1
2
  import * as store from "./store.mjs";
2
3
 
3
4
  export const claudeCodeProvider = Object.freeze({
4
5
  id: "claude-code",
5
6
  displayName: "Claude Code",
6
7
  ...store,
8
+ readSessionEvents,
7
9
  });
@@ -10,6 +10,10 @@ import { measurePath } from "../../storage/files.mjs";
10
10
  import { readJsonlEntries, rewriteJsonlFile } from "../../storage/jsonl.mjs";
11
11
 
12
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
+ });
13
17
  const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
14
18
  const KNOWN_TOP_LEVEL = new Set([
15
19
  ".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl",
@@ -331,8 +335,9 @@ async function discover(claudeHome, desktopDataHome) {
331
335
  const summariesById = new Map();
332
336
  const unknown = [];
333
337
  let projectDirectories = [];
338
+ let projectsAvailability = "available";
334
339
  try { projectDirectories = await fs.readdir(paths.projectsDirectory, { withFileTypes: true }); } catch (error) {
335
- if (error?.code !== "ENOENT") throw error;
340
+ projectsAvailability = error?.code === "ENOENT" ? "missing" : "unreadable";
336
341
  }
337
342
  for (const projectEntry of projectDirectories) {
338
343
  if (!projectEntry.isDirectory()) continue;
@@ -387,7 +392,7 @@ async function discover(claudeHome, desktopDataHome) {
387
392
  updatedAtMs: Math.max(...copies.map((item) => item.activityAtMs)),
388
393
  });
389
394
  }
390
- return { desktop, paths, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
395
+ return { desktop, paths, projectsAvailability, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
391
396
  }
392
397
 
393
398
  async function discoverCached(claudeHome, desktopDataHome, { refresh = false } = {}) {
@@ -513,24 +518,31 @@ async function topLevelEntries(directory) {
513
518
 
514
519
  export async function diagnoseStorageCompatibility({ claudeHome, desktopDataHome }) {
515
520
  const store = await discoverCached(claudeHome, desktopDataHome);
516
- const newlyDiscovered = [];
521
+ const unrecognized = [];
517
522
  for (const entry of await topLevelEntries(store.paths.claudeHome)) {
518
- if (!KNOWN_TOP_LEVEL.has(entry.name)) newlyDiscovered.push(`Unrecognized Claude data: ${entry.name}`);
523
+ if (!KNOWN_TOP_LEVEL.has(entry.name)) unrecognized.push(`Unrecognized Claude data: ${entry.name}`);
519
524
  }
520
- if (store.unknown.length) newlyDiscovered.push(`${store.unknown.length} session file${store.unknown.length === 1 ? "" : "s"} could not be classified safely.`);
521
- if (store.desktop.unlinked.length) newlyDiscovered.push(`${store.desktop.unlinked.length} Desktop session record${store.desktop.unlinked.length === 1 ? "" : "s"} could not be linked safely.`);
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."];
522
532
  return {
523
- builtFor: { claudeCli: ["2.1.199", "2.1.220"], claudeDesktop: ["1.24012.9"] },
533
+ builtFor: COMPATIBILITY_PROFILE.builtFor,
524
534
  changed: [],
525
- missing: (await exists(store.paths.projectsDirectory)) ? [] : ["Claude project sessions folder was not found."],
526
- newlyDiscovered,
527
- status: newlyDiscovered.length ? "newer-version" : "ready",
535
+ missing,
536
+ profileId: COMPATIBILITY_PROFILE.id,
537
+ status: missing.length ? "unsupported" : unrecognized.length ? "partial" : "ready",
538
+ unrecognized,
528
539
  };
529
540
  }
530
541
 
531
542
  export async function assertDeepCleanupSupported(options) {
532
543
  const diagnostic = await diagnoseStorageCompatibility(options);
533
- if (diagnostic.status !== "ready") throw new Error("Thorough cleanup is paused because some Claude session data is not recognized yet. Standard cleanup is still available.");
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;
534
546
  }
535
547
 
536
548
  async function matchingHistoryStats(historyPath, ids) {
@@ -630,6 +642,8 @@ export async function planSessionDeletion({ recordIds, store }) {
630
642
  transcriptBytes: files.reduce((sum, file) => sum + file.size, 0),
631
643
  transcriptFileCount: files.length,
632
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,
633
647
  };
634
648
  }
635
649
 
@@ -784,7 +798,20 @@ async function createBackup(plan, store, scope) {
784
798
  await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
785
799
  sharedJsonl.push({ backupRelative, relative: "history.jsonl", root: "claude" });
786
800
  }
787
- const manifest = { createdAt: new Date().toISOString(), entries, providerId: PROVIDER_ID, scope, sharedJsonl, version: 1 };
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
+ };
788
815
  await fs.writeFile(path.join(backupDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
789
816
  return backupDirectory;
790
817
  } catch (error) {
@@ -807,7 +834,13 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
807
834
  if (scope === "deep") targets.push(...plan.deepPaths);
808
835
  for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
809
836
  onProgress({ canCancel: false, message: "Checking cleanup", phase: "verification", progress: 90 });
810
- return { backupDirectory, deletedIds: plan.ids, deletedTranscriptPaths: plan.transcriptPaths, skippedTranscriptPaths: [] };
837
+ return {
838
+ backupDirectory,
839
+ deletedIds: plan.ids,
840
+ deletedTranscriptPaths: plan.transcriptPaths,
841
+ skippedTranscriptPaths: [],
842
+ unrecognizedLocationCount: plan.unrecognizedLocationCount,
843
+ };
811
844
  } catch (error) {
812
845
  error.backupDirectory = backupDirectory;
813
846
  throw error;
@@ -863,7 +896,7 @@ export async function listSessionDeletionBackups({ claudeHome }) {
863
896
  measurePath(backupDirectory),
864
897
  ]);
865
898
  const restorable = manifest?.providerId === PROVIDER_ID &&
866
- manifest?.version === 1 &&
899
+ [1, 2].includes(manifest?.version) &&
867
900
  Array.isArray(manifest.entries);
868
901
 
869
902
  backups.push({
@@ -892,6 +925,8 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
892
925
  }
893
926
  const manifest = JSON.parse(await fs.readFile(path.join(backupDirectory, "manifest.json"), "utf8"));
894
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 });
895
930
  const safetyBackupDirectory = path.join(store.paths.backupRoot, `restore-safety-${Date.now()}-${randomBytes(4).toString("hex")}`);
896
931
  await fs.mkdir(safetyBackupDirectory, { mode: 0o700, recursive: true });
897
932
  try {
@@ -931,7 +966,17 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
931
966
  await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
932
967
  }
933
968
  onProgress({ message: "Checking restored sessions", progress: 92 });
934
- return { recoveryBackupsDeleted: false, restoredEntryCount: manifest.entries.length + (manifest.sharedJsonl?.length ?? 0), safetyBackupDirectory };
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
+ };
935
980
  } catch (error) {
936
981
  error.safetyBackupDirectory = safetyBackupDirectory;
937
982
  throw error;