session-steward 0.10.2 → 0.11.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.
@@ -4,6 +4,7 @@ import { createReadStream, createWriteStream } from "node:fs";
4
4
  import { promises as fs } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { finished, pipeline } from "node:stream/promises";
7
+ import lockfile from "proper-lockfile";
7
8
 
8
9
  import {
9
10
  expandHomePath,
@@ -12,19 +13,20 @@ import {
12
13
  } from "../../platform.mjs";
13
14
  import { measurePath } from "../../storage/files.mjs";
14
15
  import { readJsonlEntries, rewriteJsonlFile } from "../../storage/jsonl.mjs";
16
+ import { claudeProcessState, localClaudePidDomain } from "./process-state.mjs";
15
17
 
16
18
  const PROVIDER_ID = "claude-code";
17
19
  const COMPATIBILITY_PROFILE = Object.freeze({
18
20
  id: "claude-local-store-2026-08",
19
21
  builtFor: {
20
- claudeCli: ["2.1.199", "2.1.220", "2.1.228", "2.1.237"],
21
- claudeDesktop: ["1.24012.9", "1.28929.0", "1.32885.1", "1.40609.0"],
22
+ claudeCli: ["2.1.199", "2.1.220", "2.1.228", "2.1.237", "2.1.267"],
23
+ claudeDesktop: ["1.24012.9", "1.28929.0", "1.32885.1", "1.40609.0", "1.49585.0"],
22
24
  },
23
25
  });
24
26
  const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
25
27
  const SHARED_DESKTOP_STATE_FILES = new Set(["scheduled-tasks.json"]);
26
28
  const KNOWN_TOP_LEVEL = new Set([
27
- ".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl",
29
+ ".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl", "history.jsonl.lock",
28
30
  "ide", "paste-cache", "plans", "plugins", "projects", "session-env", "sessions", "settings.json",
29
31
  "settings.local.json", "shell-snapshots", "skills", "stats-cache.json", "tasks", "telemetry",
30
32
  "todos", "uploads", "usage-data", "mcp-needs-auth-cache.json", "session-steward-backups",
@@ -330,6 +332,26 @@ async function listDesktopStates(directory) {
330
332
  return { byCliSessionId, unlinked };
331
333
  }
332
334
 
335
+ // Claude's project-level siblings. Do not use a broad session-ID prefix:
336
+ // unknown suffixes and another session's files must remain untouched.
337
+ function indexSessionSidecars(directory, entries) {
338
+ const byId = new Map();
339
+ for (const entry of entries) {
340
+ const id = /^(.*)\.(?:ccr-tip|precompact|desktop-released|dir-sync)\.json$/u.exec(entry.name)?.[1]
341
+ ?? /^(.*)-[0-9]{1,16}\.cast$/u.exec(entry.name)?.[1];
342
+ if (!id) continue;
343
+ const paths = byId.get(id) ?? [];
344
+ paths.push(path.join(directory, entry.name));
345
+ byId.set(id, paths);
346
+ }
347
+ return byId;
348
+ }
349
+
350
+ function sidecarsForTranscript(index, transcriptPath, id) {
351
+ // Conflicting transcript filenames/content do not establish ownership.
352
+ return path.basename(transcriptPath) === `${id}.jsonl` ? index.get(id) ?? [] : [];
353
+ }
354
+
333
355
  async function discover(claudeHome, desktopDataHome) {
334
356
  const paths = getPaths(claudeHome, desktopDataHome);
335
357
  const desktop = await listDesktopStates(paths.desktopSessionsDirectory);
@@ -345,10 +367,12 @@ async function discover(claudeHome, desktopDataHome) {
345
367
  const projectDirectory = path.join(paths.projectsDirectory, projectEntry.name);
346
368
  let files;
347
369
  try { files = await fs.readdir(projectDirectory, { withFileTypes: true }); } catch { continue; }
370
+ const sidecars = indexSessionSidecars(projectDirectory, files);
348
371
  for (const file of files) {
349
372
  if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
350
373
  const fallbackId = file.name.slice(0, -6);
351
374
  const summary = await readTranscriptSummary(path.join(projectDirectory, file.name), fallbackId);
375
+ summary.sidecarPaths = sidecarsForTranscript(sidecars, summary.transcriptPath, summary.id);
352
376
  if (!SUPPORTED_ENTRYPOINTS.has(summary.entrypoint)) { unknown.push(summary.transcriptPath); continue; }
353
377
  const copies = summariesById.get(summary.id) ?? [];
354
378
  copies.push(summary); summariesById.set(summary.id, copies);
@@ -390,6 +414,7 @@ async function discover(claudeHome, desktopDataHome) {
390
414
  titleSource: cleanTitle(state?.title) ? "Desktop title" : (copies.find((item) => item.titleSource)?.titleSource || "session ID"),
391
415
  transcriptBytes: copies.reduce((sum, item) => sum + item.transcriptBytes, 0),
392
416
  transcriptPaths: copies.map((item) => item.transcriptPath),
417
+ sidecarPaths: copies.flatMap((item) => item.sidecarPaths),
393
418
  updatedAtMs: Math.max(...copies.map((item) => item.activityAtMs)),
394
419
  });
395
420
  }
@@ -421,6 +446,7 @@ export function invalidateSessionCache({ claudeHome, desktopDataHome }) {
421
446
  function filterRecords(records, options) {
422
447
  const search = String(options.search || "").trim().toLowerCase();
423
448
  return records.filter((record) => {
449
+ if (typeof options.recordFilter === "function" && !options.recordFilter(record)) return false;
424
450
  if (options.archiveStatus === "active" && record.archived) return false;
425
451
  if (options.archiveStatus === "archived" && !record.archived) return false;
426
452
  if (options.inactiveBeforeMs && (!record.updatedAtMs || record.updatedAtMs >= options.inactiveBeforeMs)) return false;
@@ -486,6 +512,14 @@ export async function getSessionOverview({ claudeHome, desktopDataHome, refresh
486
512
  storageTargets.add(path.join(path.dirname(target), record.id));
487
513
  });
488
514
  record.desktopStatePaths.forEach((target) => storageTargets.add(target));
515
+ for (const target of record.sidecarPaths) {
516
+ // Unknown directory/symlink layouts are not part of the recognized size.
517
+ const stats = await fs.lstat(target).catch((error) => {
518
+ if (error.code === "ENOENT") return null;
519
+ throw error;
520
+ });
521
+ if (stats?.isFile()) storageTargets.add(target);
522
+ }
489
523
  storageTargets.add(path.join(store.paths.sessionEnvDirectory, record.id));
490
524
  storageTargets.add(path.join(store.paths.tasksDirectory, record.id));
491
525
  storageTargets.add(path.join(store.paths.debugDirectory, `${record.id}.txt`));
@@ -607,6 +641,15 @@ export async function planSessionDeletion({ recordIds, store }) {
607
641
  for (const record of records) {
608
642
  record.transcriptPaths.forEach((item) => selectedPaths.add(item));
609
643
  record.desktopStatePaths.forEach((item) => selectedPaths.add(item));
644
+ for (const target of record.sidecarPaths) {
645
+ const stats = await fs.lstat(target).catch((error) => {
646
+ if (error.code === "ENOENT") return null;
647
+ throw error;
648
+ });
649
+ if (!stats) continue;
650
+ if (!stats.isFile()) throw new Error("Cleanup stopped because a Claude session sidecar is not a regular file. It was left unchanged.");
651
+ selectedPaths.add(target);
652
+ }
610
653
  for (const candidate of [
611
654
  path.join(store.paths.sessionEnvDirectory, record.id),
612
655
  path.join(store.paths.tasksDirectory, record.id),
@@ -656,34 +699,79 @@ export async function planSessionDeletion({ recordIds, store }) {
656
699
  };
657
700
  }
658
701
 
659
- async function activeSessionIds(paths) {
702
+ async function activeSessionIds(paths, selectedIds) {
660
703
  const active = new Set();
661
- if (!(await exists(paths.sessionsDirectory))) return { active, detection: "unavailable" };
704
+ const unverified = new Set();
705
+ const selected = new Set(selectedIds);
706
+ const pidDomain = await localClaudePidDomain();
707
+ const buffer = Buffer.alloc(256 * 1024 + 1);
662
708
  const pending = [paths.sessionsDirectory];
663
709
  while (pending.length) {
664
710
  const current = pending.pop();
665
711
  let entries;
666
- try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { continue; }
712
+ try { entries = await fs.readdir(current, { withFileTypes: true }); } catch (error) {
713
+ if (error.code === "ENOENT") continue;
714
+ for (const id of selected) unverified.add(id);
715
+ continue;
716
+ }
667
717
  for (const entry of entries) {
668
718
  const target = path.join(current, entry.name);
669
719
  if (entry.isDirectory()) { pending.push(target); continue; }
670
- const match = /([0-9a-f]{8}-[0-9a-f-]{27,})/iu.exec(entry.name);
671
- if (match) active.add(match[1]);
672
- if (!entry.name.endsWith(".json")) continue;
720
+ const nameId = /([0-9a-f]{8}-[0-9a-f-]{27,})/iu.exec(entry.name)?.[1];
721
+ const filenamePid = /^([1-9]\d*)\.json$/u.exec(entry.name)?.[1];
722
+ if (!entry.isFile()) {
723
+ if (selected.has(nameId)) unverified.add(nameId);
724
+ else if (filenamePid) for (const id of selected) unverified.add(id);
725
+ continue;
726
+ }
727
+ if (!entry.name.endsWith(".json")) {
728
+ if (selected.has(nameId)) unverified.add(nameId);
729
+ continue;
730
+ }
731
+ let parsed;
673
732
  try {
674
- const parsed = JSON.parse(await fs.readFile(target, "utf8"));
675
- const id = parsed.sessionId ?? parsed.session_id;
676
- if (typeof id === "string" && !["stopped", "completed", "failed"].includes(parsed.status ?? parsed.state)) active.add(id);
677
- } catch {}
733
+ // Registry metadata is small; do not read an arbitrary large file.
734
+ const handle = await fs.open(target, "r");
735
+ try {
736
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
737
+ if (bytesRead === buffer.length) throw new Error("Oversized session marker.");
738
+ parsed = JSON.parse(buffer.toString("utf8", 0, bytesRead));
739
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid session marker.");
740
+ } finally { await handle.close(); }
741
+ } catch (error) {
742
+ if (error.code === "ENOENT") continue;
743
+ if (selected.has(nameId)) unverified.add(nameId);
744
+ else if (filenamePid && await claudeProcessState({ pid: Number(filenamePid) }) !== "inactive") {
745
+ for (const id of selected) unverified.add(id);
746
+ }
747
+ continue;
748
+ }
749
+ const id = parsed.sessionId ?? parsed.session_id ?? nameId;
750
+ if (selected.has(nameId) && id !== nameId) unverified.add(nameId);
751
+ if (!selected.has(id)) continue;
752
+ const pid = parsed.pid ?? (filenamePid ? Number(filenamePid) : undefined);
753
+ if (filenamePid && parsed.pid !== undefined && parsed.pid !== Number(filenamePid)) {
754
+ unverified.add(id);
755
+ continue;
756
+ }
757
+ if (pid === undefined && ["stopped", "completed", "failed"].includes(parsed.status ?? parsed.state)) continue;
758
+ const state = await claudeProcessState({ ...parsed, pid }, { pidDomain });
759
+ if (state === "active") active.add(id);
760
+ if (state === "unverified") unverified.add(id);
678
761
  }
679
762
  }
680
- return { active, detection: "available" };
763
+ return { active, unverified, detection: await exists(paths.sessionsDirectory) ? "available" : "unavailable" };
764
+ }
765
+
766
+ async function assertSelectedSessionsClosed(paths, ids) {
767
+ const result = await activeSessionIds(paths, ids);
768
+ if (result.active.size) throw new Error("Close the selected Claude sessions before cleanup.");
769
+ if (result.unverified.size) throw new Error("Could not verify whether the selected Claude sessions are closed. Their process markers could not be checked safely; retry cleanup after Claude has exited.");
770
+ return result;
681
771
  }
682
772
 
683
773
  export async function preflightSessionDeletion({ availableDiskBytes, plan, scope, store }) {
684
- const active = await activeSessionIds(store.paths);
685
- const selectedActive = plan.ids.filter((id) => active.active.has(id));
686
- if (selectedActive.length) throw new Error("Close the selected Claude sessions before cleanup.");
774
+ const active = await assertSelectedSessionsClosed(store.paths, plan.ids);
687
775
  const deepBytes = scope === "deep" ? plan.deepFiles.reduce((sum, file) => sum + file.size, 0) : 0;
688
776
  const estimatedBackupBytes = plan.transcriptBytes + deepBytes + plan.historyMatchBytes + 4096;
689
777
  let diskBytes = availableDiskBytes;
@@ -784,7 +872,42 @@ async function backupHistoryRows(historyPath, destination, ids) {
784
872
  }
785
873
  }
786
874
 
787
- async function createBackup(plan, store, scope) {
875
+ // Claude's prompt-history writer uses proper-lockfile with a 10-second stale
876
+ // threshold. Use the same canonical path, directory lock and heartbeat rather
877
+ // than Session Steward's separate mutation lock (which Claude does not know).
878
+ async function lockHistory(historyPath) {
879
+ const canonicalPath = path.join(await fs.realpath(path.dirname(historyPath)), path.basename(historyPath));
880
+ const stats = await fs.lstat(canonicalPath).catch((error) => {
881
+ if (error.code === "ENOENT") return null;
882
+ throw error;
883
+ });
884
+ if (stats && !stats.isFile()) throw new Error("Claude prompt history is not a regular file. It was left unchanged.");
885
+ let compromised;
886
+ let released = false;
887
+ const release = await lockfile.lock(canonicalPath, {
888
+ realpath: false, // Restore also needs to lock history that does not exist yet.
889
+ stale: 10_000,
890
+ retries: { retries: 3, minTimeout: 50 },
891
+ onCompromised: (error) => { compromised = error; },
892
+ }).catch((error) => {
893
+ if (error.code === "ELOCKED") {
894
+ throw new Error("Claude prompt history is busy. Retry cleanup or restore in a moment.", { cause: error });
895
+ }
896
+ throw error;
897
+ });
898
+ return {
899
+ assertHeld() {
900
+ if (compromised) throw new Error("Claude prompt-history lock was lost. No further history changes were made.", { cause: compromised });
901
+ },
902
+ async release() {
903
+ if (released) return;
904
+ released = true;
905
+ if (!compromised) await release();
906
+ },
907
+ };
908
+ }
909
+
910
+ async function createBackup(plan, store, scope, acquireHistoryLock) {
788
911
  const backupDirectory = path.join(store.paths.backupRoot, `${new Date().toISOString().replaceAll(":", "-")}-${randomBytes(6).toString("hex")}`);
789
912
  const entries = [];
790
913
  await fs.mkdir(backupDirectory, { mode: 0o700, recursive: true });
@@ -802,6 +925,9 @@ async function createBackup(plan, store, scope) {
802
925
  }
803
926
  const sharedJsonl = [];
804
927
  if (plan.historyMatchCount) {
928
+ // Do not block other sessions while the large transcript copies run.
929
+ // Keep this lock from the history backup through its replacement.
930
+ await acquireHistoryLock();
805
931
  const backupRelative = path.join("shared", "history.jsonl");
806
932
  const destination = path.join(backupDirectory, backupRelative);
807
933
  await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
@@ -832,21 +958,25 @@ async function createBackup(plan, store, scope) {
832
958
  export async function executeSessionDeletion({ onProgress = () => {}, plan, scope, shouldCancel = () => false, store }) {
833
959
  onProgress({ canCancel: true, message: "Creating recovery backup", phase: "backup", progress: 8 });
834
960
  let backupDirectory;
835
- try {
836
- backupDirectory = await createBackup(plan, store, scope);
837
- } catch (error) {
838
- error.mutationStarted = false;
839
- throw error;
840
- }
961
+ let historyLock;
841
962
  let mutationStarted = false;
842
963
  try {
964
+ backupDirectory = await createBackup(plan, store, scope, async () => {
965
+ historyLock = await lockHistory(store.paths.historyPath);
966
+ });
843
967
  if (shouldCancel()) { const error = new Error("Cleanup cancelled."); error.cancelled = true; error.backupDirectory = backupDirectory; error.mutationStarted = false; throw error; }
844
968
  onProgress({ canCancel: false, message: "Removing selected session data", phase: "cleanup", progress: 55 });
969
+ await assertSelectedSessionsClosed(store.paths, plan.ids);
970
+ historyLock?.assertHeld();
845
971
  mutationStarted = true;
846
972
  if (plan.historyMatchCount && await exists(store.paths.historyPath)) {
847
973
  const ids = new Set(plan.ids);
848
- await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id));
974
+ await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id), {
975
+ beforeCommit: () => historyLock.assertHeld(),
976
+ });
849
977
  }
978
+ await historyLock?.release();
979
+ historyLock = null;
850
980
  const targets = [...plan.transcriptPaths];
851
981
  if (scope === "deep") targets.push(...plan.deepPaths);
852
982
  for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
@@ -859,9 +989,18 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
859
989
  unrecognizedLocationCount: plan.unrecognizedLocationCount,
860
990
  };
861
991
  } catch (error) {
862
- error.backupDirectory = backupDirectory;
992
+ error.backupDirectory ??= backupDirectory;
993
+ backupDirectory = error.backupDirectory;
863
994
  error.mutationStarted ??= mutationStarted;
864
995
  throw error;
996
+ } finally {
997
+ try {
998
+ await historyLock?.release();
999
+ } catch (error) {
1000
+ error.backupDirectory = backupDirectory;
1001
+ error.mutationStarted = mutationStarted;
1002
+ throw error;
1003
+ }
865
1004
  }
866
1005
  }
867
1006
 
@@ -869,6 +1008,23 @@ export async function verifySessionDeletion({ plan, scope, store }) {
869
1008
  const remainingTranscriptPaths = [];
870
1009
  for (const target of plan.transcriptPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
871
1010
  if (scope === "deep") for (const target of plan.deepPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
1011
+ // Also catch known sidecars created/recreated after the selection was planned.
1012
+ const sidecarsByDirectory = new Map();
1013
+ for (const record of plan.records) {
1014
+ for (const transcriptPath of record.transcriptPaths) {
1015
+ const directory = path.dirname(transcriptPath);
1016
+ if (!sidecarsByDirectory.has(directory)) {
1017
+ const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => {
1018
+ if (error.code === "ENOENT") return [];
1019
+ throw error;
1020
+ });
1021
+ sidecarsByDirectory.set(directory, indexSessionSidecars(directory, entries));
1022
+ }
1023
+ for (const target of sidecarsForTranscript(sidecarsByDirectory.get(directory), transcriptPath, record.id)) {
1024
+ if (!remainingTranscriptPaths.includes(target)) remainingTranscriptPaths.push(target);
1025
+ }
1026
+ }
1027
+ }
872
1028
  const remainingHistoryEntryCount = (await matchingHistoryStats(store.paths.historyPath, new Set(plan.ids))).count;
873
1029
  return {
874
1030
  complete: remainingTranscriptPaths.length === 0 && remainingHistoryEntryCount === 0,
@@ -974,14 +1130,21 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
974
1130
  const id = row.parsed?.sessionId ?? row.parsed?.session_id;
975
1131
  if (typeof id === "string") selectedIds.add(id);
976
1132
  }
977
- if (await exists(destination)) {
978
- const safety = path.join(safetyBackupDirectory, entry.root, entry.relative);
979
- await copyTarget(destination, safety);
980
- await rewriteJsonlFile(destination, (row) => !row.parsed || !selectedIds.has(row.parsed.sessionId ?? row.parsed.session_id));
981
- } else {
982
- await fs.mkdir(path.dirname(destination), { recursive: true });
1133
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1134
+ const historyLock = await lockHistory(destination);
1135
+ try {
1136
+ if (await exists(destination)) {
1137
+ const safety = path.join(safetyBackupDirectory, entry.root, entry.relative);
1138
+ await copyTarget(destination, safety);
1139
+ await rewriteJsonlFile(destination, (row) => !row.parsed || !selectedIds.has(row.parsed.sessionId ?? row.parsed.session_id), {
1140
+ beforeCommit: () => historyLock.assertHeld(),
1141
+ });
1142
+ }
1143
+ historyLock.assertHeld();
1144
+ await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
1145
+ } finally {
1146
+ await historyLock.release();
983
1147
  }
984
- await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
985
1148
  }
986
1149
  onProgress({ message: "Checking restored sessions", progress: 92 });
987
1150
  const layoutChanged = manifest.version === 2 && (
@@ -8,8 +8,8 @@ const CACHE_TTL_MS = 2_000;
8
8
  export const CODEX_DATABASE_PROFILE = Object.freeze({
9
9
  id: "codex-local-store-2026-08",
10
10
  builtFor: {
11
- chatgptDesktop: ["26.727.40816", "26.803.61601", "26.818.21641", "26.818.22352", "26.825.41651"],
12
- codexCli: ["0.144.1", "0.146.0", "0.147.0", "0.148.0"],
11
+ chatgptDesktop: ["26.727.40816", "26.803.61601", "26.818.21641", "26.818.22352", "26.825.41651", "26.903.61454", "26.903.71938"],
12
+ codexCli: ["0.144.1", "0.146.0", "0.147.0", "0.148.0", "0.153.4", "0.154.0"],
13
13
  },
14
14
  });
15
15
 
@@ -0,0 +1,179 @@
1
+ import { lstatSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { batches, placeholders } from "../../storage/sqlite.mjs";
5
+
6
+ const FILENAMES = ["codex.db", "codex-dev.db"];
7
+ const TABLE = "local_thread_catalog";
8
+ const SCAN_TABLE = "local_thread_catalog_scan_entries";
9
+ const REQUIREMENTS = {
10
+ [TABLE]: ["host_id", "thread_id", "observation_sequence", "missing_candidate"],
11
+ [SCAN_TABLE]: ["host_id", "thread_id", "removed"],
12
+ local_thread_catalog_hosts: ["host_id", "host_kind"],
13
+ local_thread_catalog_metadata: ["id", "catalog_revision"],
14
+ local_thread_catalog_scan_checkpoints: ["host_id", "checkpoint"],
15
+ local_thread_catalog_sync_state: ["host_id", "observation_sequence"],
16
+ };
17
+
18
+ function regularPath(filePath, directory = false) {
19
+ try {
20
+ const stats = lstatSync(filePath);
21
+ if (directory ? !stats.isDirectory() : !stats.isFile()) {
22
+ throw new Error("The Codex desktop catalogue path is not a regular file or directory. It was left unchanged.");
23
+ }
24
+ return true;
25
+ } catch (error) {
26
+ if (error.code === "ENOENT") return false;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ function catalogPath(codexHome, filename) {
32
+ if (!FILENAMES.includes(filename)) throw new Error("Unrecognized Codex desktop catalogue backup.");
33
+ const directory = path.join(codexHome, "sqlite");
34
+ return regularPath(directory, true) ? path.join(directory, filename) : null;
35
+ }
36
+
37
+ function schema(db) {
38
+ if (!db.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(TABLE)) return null;
39
+ const columns = {};
40
+ for (const [table, required] of Object.entries(REQUIREMENTS)) {
41
+ const info = db.prepare(`pragma table_info(${table})`).all();
42
+ columns[table] = info.map((column) => column.name);
43
+ if (!required.every((name) => columns[table].includes(name))
44
+ || ([TABLE, SCAN_TABLE].includes(table)
45
+ && info.filter((column) => column.pk).sort((a, b) => a.pk - b.pk).map((column) => column.name).join() !== "host_id,thread_id")) {
46
+ throw new Error("The Codex desktop catalogue layout is not recognized. Cleanup stopped without changing it.");
47
+ }
48
+ }
49
+ const host = db.prepare("select host_kind from local_thread_catalog_hosts where host_id = 'local'").get();
50
+ const hasLocalRows = db.prepare(`select 1 from ${TABLE} where host_id = 'local' limit 1`).get();
51
+ if ((host && host.host_kind !== "local") || (hasLocalRows && !host)) {
52
+ throw new Error("The Codex desktop catalogue local host could not be verified.");
53
+ }
54
+ if (!db.prepare("select 1 from local_thread_catalog_metadata where id = 1").get()) {
55
+ throw new Error("The Codex desktop catalogue revision is missing.");
56
+ }
57
+ return columns;
58
+ }
59
+
60
+ function selectedRows(db, table, ids) {
61
+ return [...batches([...new Set(ids)].sort())].flatMap((batch) => db.prepare(
62
+ `select * from ${table} where host_id = 'local' and thread_id in (${placeholders(batch)}) order by thread_id`,
63
+ ).all(...batch));
64
+ }
65
+
66
+ function snapshot(db, filename, ids) {
67
+ const columns = schema(db);
68
+ return columns ? {
69
+ filename,
70
+ columns: columns[TABLE],
71
+ rows: selectedRows(db, TABLE, ids),
72
+ scanRows: selectedRows(db, SCAN_TABLE, ids),
73
+ } : null;
74
+ }
75
+
76
+ // Only known app-owned databases beneath this run's Codex home, never a
77
+ // process-global/default home or arbitrary database supplied by a backup.
78
+ export function readDesktopCatalogs(codexHome, ids) {
79
+ const result = [];
80
+ for (const filename of FILENAMES) {
81
+ const filePath = catalogPath(codexHome, filename);
82
+ if (!filePath || !regularPath(filePath)) continue;
83
+ const db = new DatabaseSync(filePath, { readOnly: true, timeout: 5_000 });
84
+ try {
85
+ db.exec("begin");
86
+ const entry = snapshot(db, filename, ids);
87
+ if (entry) result.push(entry);
88
+ } finally { db.close(); }
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function transaction(codexHome, entry, callback) {
94
+ const filePath = catalogPath(codexHome, entry.filename);
95
+ if (!filePath || !regularPath(filePath)) throw new Error("The Codex desktop catalogue disappeared. Restart cleanup after refreshing.");
96
+ const db = new DatabaseSync(filePath, { timeout: 5_000 });
97
+ try {
98
+ db.exec("begin immediate");
99
+ const columns = schema(db);
100
+ if (!columns) throw new Error("The Codex desktop catalogue layout changed.");
101
+ callback(db, columns);
102
+ db.exec("commit");
103
+ } catch (error) {
104
+ if (db.isTransaction) db.exec("rollback");
105
+ throw error;
106
+ } finally { db.close(); }
107
+ }
108
+
109
+ export function deleteDesktopCatalogs(codexHome, ids, backup) {
110
+ if (JSON.stringify(readDesktopCatalogs(codexHome, ids)) !== JSON.stringify(backup)) {
111
+ throw new Error("The selected Codex desktop catalogue entries changed after backup. Refresh and try again.");
112
+ }
113
+ for (const entry of backup) {
114
+ transaction(codexHome, entry, (db) => {
115
+ if (JSON.stringify(snapshot(db, entry.filename, ids)) !== JSON.stringify(entry)) {
116
+ throw new Error("The selected Codex desktop catalogue entries changed after backup. Refresh and try again.");
117
+ }
118
+ if (entry.rows.length === 0) return;
119
+ // Match the app's authoritative-removal behavior: an in-flight scan
120
+ // must not reinsert an entry from a page fetched before deletion.
121
+ db.prepare("update local_thread_catalog_sync_state set observation_sequence = observation_sequence + 1 where host_id = 'local'").run();
122
+ for (const batch of batches(entry.rows.map((row) => row.thread_id))) {
123
+ // Also protect a scan whose first page is still in flight (it has no
124
+ // persisted checkpoint yet). The app clears these at the next scan.
125
+ const tombstone = db.prepare(`insert into ${SCAN_TABLE} (host_id, thread_id, removed) values ('local', ?, 1)
126
+ on conflict(host_id, thread_id) do update set removed = 1`);
127
+ for (const id of batch) tombstone.run(id);
128
+ db.prepare(`delete from ${TABLE} where host_id = 'local' and thread_id in (${placeholders(batch)})`).run(...batch);
129
+ }
130
+ db.prepare("update local_thread_catalog_metadata set catalog_revision = catalog_revision + 1 where id = 1").run();
131
+ });
132
+ }
133
+ }
134
+
135
+ export function validateDesktopCatalogBackup(codexHome, ids, backup) {
136
+ if (!Array.isArray(backup)) throw new Error("Invalid Codex desktop catalogue backup.");
137
+ const allowed = new Set(ids);
138
+ const names = new Set();
139
+ for (const entry of backup) {
140
+ if (!FILENAMES.includes(entry?.filename) || names.has(entry.filename)
141
+ || !Array.isArray(entry.columns) || !Array.isArray(entry.rows) || !Array.isArray(entry.scanRows)
142
+ || ![...entry.rows, ...entry.scanRows].every((row) => row.host_id === "local" && allowed.has(row.thread_id))) {
143
+ throw new Error("Invalid Codex desktop catalogue backup.");
144
+ }
145
+ names.add(entry.filename);
146
+ if (entry.rows.length === 0) continue;
147
+ transaction(codexHome, entry, (_db, columns) => {
148
+ if (JSON.stringify(columns[TABLE]) !== JSON.stringify(entry.columns)
149
+ || entry.rows.some((row) => Object.keys(row).join() !== entry.columns.join())
150
+ || entry.scanRows.some((row) => Object.keys(row).join() !== columns[SCAN_TABLE].join())) {
151
+ throw new Error("The Codex desktop catalogue schema changed since backup. Its rows were left unchanged.");
152
+ }
153
+ });
154
+ }
155
+ }
156
+
157
+ export function restoreDesktopCatalogs(codexHome, ids, backup) {
158
+ validateDesktopCatalogBackup(codexHome, ids, backup);
159
+ for (const entry of backup) {
160
+ if (entry.rows.length === 0) continue;
161
+ transaction(codexHome, entry, (db, columns) => {
162
+ if (JSON.stringify(columns[TABLE]) !== JSON.stringify(entry.columns)) throw new Error("The Codex desktop catalogue schema changed during restore.");
163
+ const quoted = entry.columns.map((column) => `"${column.replaceAll('"', '""')}"`).join(", ");
164
+ const insert = db.prepare(`insert into ${TABLE} (${quoted}) values (${placeholders(entry.columns)}) on conflict(host_id, thread_id) do nothing`);
165
+ const restored = [];
166
+ for (const row of entry.rows) {
167
+ if (insert.run(...entry.columns.map((column) => row[column])).changes) restored.push(row.thread_id);
168
+ }
169
+ // Merge selected rows only: never restore the entire app database or
170
+ // overwrite a newer entry, other hosts, settings, or scan checkpoints.
171
+ for (const id of restored) {
172
+ const prior = entry.scanRows.find((row) => row.thread_id === id);
173
+ db.prepare(`delete from ${SCAN_TABLE} where host_id = 'local' and thread_id = ?`).run(id);
174
+ if (prior) db.prepare(`insert into ${SCAN_TABLE} (host_id, thread_id, removed) values ('local', ?, ?)`).run(id, prior.removed);
175
+ }
176
+ if (restored.length) db.prepare("update local_thread_catalog_metadata set catalog_revision = catalog_revision + 1 where id = 1").run();
177
+ });
178
+ }
179
+ }