session-steward 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.3] - 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - Deleted Codex sessions no longer linger in the ChatGPT sidebar.
8
+ - Claude cleanup now handles stale session markers, protects shared history, and removes leftover session files.
9
+
10
+ ## [0.10.2] - 2026-09-07
11
+
12
+ ### Fixed
13
+
14
+ - Codex cleanup now removes every rollout for a session, clears its memory jobs, and refreshes consolidated memory when needed.
15
+
3
16
  ## [0.10.1] - 2026-09-03
4
17
 
5
18
  ### Fixed
@@ -149,6 +162,8 @@
149
162
  - Support for custom Codex home folders and a saved folder preference.
150
163
  - Streaming and bounded-memory discovery for large session collections and transcripts.
151
164
 
165
+ [0.10.3]: https://github.com/mallikcheripally/session-steward/compare/v0.10.2...v0.10.3
166
+ [0.10.2]: https://github.com/mallikcheripally/session-steward/compare/v0.10.1...v0.10.2
152
167
  [0.10.1]: https://github.com/mallikcheripally/session-steward/compare/v0.10.0...v0.10.1
153
168
  [0.10.0]: https://github.com/mallikcheripally/session-steward/compare/v0.9.0...v0.10.0
154
169
  [0.9.0]: https://github.com/mallikcheripally/session-steward/compare/v0.8.0...v0.9.0
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { promisify } from "node:util";
5
+
6
+ const runCommand = promisify(execFile);
7
+ const PS_START = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/u;
8
+
9
+ export function validProcessId(pid) {
10
+ return Number.isSafeInteger(pid) && pid > 1 && pid <= 2_147_483_647;
11
+ }
12
+
13
+ export async function localClaudePidDomain(platform = process.platform) {
14
+ if (platform === "darwin") return "darwin";
15
+ if (platform === "win32") return `win32:${hostname().toLowerCase()}`;
16
+ if (platform !== "linux") return null;
17
+ try {
18
+ const [machine, namespace] = await Promise.all([
19
+ fs.readFile("/etc/machine-id", "utf8"), fs.readlink("/proc/self/ns/pid"),
20
+ ]);
21
+ return `linux:${machine.trim()}:${namespace}`;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // These are Claude's process identity tokens, not the session's startedAt:
28
+ // UTC ps lstart on macOS/Linux, and Windows creation FILETIME in procStartFt.
29
+ export async function readClaudeProcessStart(pid, { platform = process.platform, run = runCommand } = {}) {
30
+ if (!validProcessId(pid)) return null;
31
+ const windows = platform === "win32";
32
+ if (!windows && platform !== "darwin" && platform !== "linux") return null;
33
+ try {
34
+ const { stdout } = await run(windows ? "powershell.exe" : "ps", windows
35
+ ? ["-NoProfile", "-NonInteractive", "-Command", `$ErrorActionPreference='Stop'; (Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`]
36
+ : ["-o", "lstart=", "-p", String(pid)], {
37
+ // PowerShell has a noticeably slower cold start than ps, especially under load.
38
+ encoding: "utf8", timeout: windows ? 10_000 : 1_000, maxBuffer: 4_096, windowsHide: true,
39
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
40
+ });
41
+ return stdout.trim().replace(/\s+/gu, " ") || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function claudeProcessState(marker, {
48
+ platform = process.platform,
49
+ pidDomain,
50
+ kill = process.kill,
51
+ readStart = (pid) => readClaudeProcessStart(pid, { platform }),
52
+ } = {}) {
53
+ if (!validProcessId(marker.pid)) return "unverified";
54
+ // A PID from another machine/namespace cannot be checked against this one.
55
+ if (marker.pidDomain !== undefined && marker.pidDomain !== pidDomain) return "unverified";
56
+ const running = () => {
57
+ try { kill(marker.pid, 0); return "active"; }
58
+ catch (error) { return error.code === "ESRCH" ? "inactive" : "unverified"; }
59
+ };
60
+ const state = running();
61
+ if (state !== "active") return state;
62
+ const expected = platform === "win32" ? marker.procStartFt : marker.procStart;
63
+ // Legacy records have no start token: a live PID must remain protected.
64
+ if (expected === undefined) return "active";
65
+ if (typeof expected !== "string") return "unverified";
66
+ const normalized = expected.trim().replace(/\s+/gu, " ");
67
+ const validToken = (value) => typeof value === "string" && (platform === "win32"
68
+ ? /^[1-9]\d{0,19}$/u.test(value) : PS_START.test(value) && Number.isFinite(Date.parse(`${value} UTC`)));
69
+ if (!validToken(normalized)) return "unverified";
70
+ const actual = await readStart(marker.pid);
71
+ if (!validToken(actual)) return running() === "inactive" ? "inactive" : "unverified";
72
+ return normalized === actual ? "active" : "inactive";
73
+ }
@@ -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
  }
@@ -486,6 +511,14 @@ export async function getSessionOverview({ claudeHome, desktopDataHome, refresh
486
511
  storageTargets.add(path.join(path.dirname(target), record.id));
487
512
  });
488
513
  record.desktopStatePaths.forEach((target) => storageTargets.add(target));
514
+ for (const target of record.sidecarPaths) {
515
+ // Unknown directory/symlink layouts are not part of the recognized size.
516
+ const stats = await fs.lstat(target).catch((error) => {
517
+ if (error.code === "ENOENT") return null;
518
+ throw error;
519
+ });
520
+ if (stats?.isFile()) storageTargets.add(target);
521
+ }
489
522
  storageTargets.add(path.join(store.paths.sessionEnvDirectory, record.id));
490
523
  storageTargets.add(path.join(store.paths.tasksDirectory, record.id));
491
524
  storageTargets.add(path.join(store.paths.debugDirectory, `${record.id}.txt`));
@@ -607,6 +640,15 @@ export async function planSessionDeletion({ recordIds, store }) {
607
640
  for (const record of records) {
608
641
  record.transcriptPaths.forEach((item) => selectedPaths.add(item));
609
642
  record.desktopStatePaths.forEach((item) => selectedPaths.add(item));
643
+ for (const target of record.sidecarPaths) {
644
+ const stats = await fs.lstat(target).catch((error) => {
645
+ if (error.code === "ENOENT") return null;
646
+ throw error;
647
+ });
648
+ if (!stats) continue;
649
+ if (!stats.isFile()) throw new Error("Cleanup stopped because a Claude session sidecar is not a regular file. It was left unchanged.");
650
+ selectedPaths.add(target);
651
+ }
610
652
  for (const candidate of [
611
653
  path.join(store.paths.sessionEnvDirectory, record.id),
612
654
  path.join(store.paths.tasksDirectory, record.id),
@@ -656,34 +698,79 @@ export async function planSessionDeletion({ recordIds, store }) {
656
698
  };
657
699
  }
658
700
 
659
- async function activeSessionIds(paths) {
701
+ async function activeSessionIds(paths, selectedIds) {
660
702
  const active = new Set();
661
- if (!(await exists(paths.sessionsDirectory))) return { active, detection: "unavailable" };
703
+ const unverified = new Set();
704
+ const selected = new Set(selectedIds);
705
+ const pidDomain = await localClaudePidDomain();
706
+ const buffer = Buffer.alloc(256 * 1024 + 1);
662
707
  const pending = [paths.sessionsDirectory];
663
708
  while (pending.length) {
664
709
  const current = pending.pop();
665
710
  let entries;
666
- try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { continue; }
711
+ try { entries = await fs.readdir(current, { withFileTypes: true }); } catch (error) {
712
+ if (error.code === "ENOENT") continue;
713
+ for (const id of selected) unverified.add(id);
714
+ continue;
715
+ }
667
716
  for (const entry of entries) {
668
717
  const target = path.join(current, entry.name);
669
718
  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;
719
+ const nameId = /([0-9a-f]{8}-[0-9a-f-]{27,})/iu.exec(entry.name)?.[1];
720
+ const filenamePid = /^([1-9]\d*)\.json$/u.exec(entry.name)?.[1];
721
+ if (!entry.isFile()) {
722
+ if (selected.has(nameId)) unverified.add(nameId);
723
+ else if (filenamePid) for (const id of selected) unverified.add(id);
724
+ continue;
725
+ }
726
+ if (!entry.name.endsWith(".json")) {
727
+ if (selected.has(nameId)) unverified.add(nameId);
728
+ continue;
729
+ }
730
+ let parsed;
673
731
  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 {}
732
+ // Registry metadata is small; do not read an arbitrary large file.
733
+ const handle = await fs.open(target, "r");
734
+ try {
735
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
736
+ if (bytesRead === buffer.length) throw new Error("Oversized session marker.");
737
+ parsed = JSON.parse(buffer.toString("utf8", 0, bytesRead));
738
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid session marker.");
739
+ } finally { await handle.close(); }
740
+ } catch (error) {
741
+ if (error.code === "ENOENT") continue;
742
+ if (selected.has(nameId)) unverified.add(nameId);
743
+ else if (filenamePid && await claudeProcessState({ pid: Number(filenamePid) }) !== "inactive") {
744
+ for (const id of selected) unverified.add(id);
745
+ }
746
+ continue;
747
+ }
748
+ const id = parsed.sessionId ?? parsed.session_id ?? nameId;
749
+ if (selected.has(nameId) && id !== nameId) unverified.add(nameId);
750
+ if (!selected.has(id)) continue;
751
+ const pid = parsed.pid ?? (filenamePid ? Number(filenamePid) : undefined);
752
+ if (filenamePid && parsed.pid !== undefined && parsed.pid !== Number(filenamePid)) {
753
+ unverified.add(id);
754
+ continue;
755
+ }
756
+ if (pid === undefined && ["stopped", "completed", "failed"].includes(parsed.status ?? parsed.state)) continue;
757
+ const state = await claudeProcessState({ ...parsed, pid }, { pidDomain });
758
+ if (state === "active") active.add(id);
759
+ if (state === "unverified") unverified.add(id);
678
760
  }
679
761
  }
680
- return { active, detection: "available" };
762
+ return { active, unverified, detection: await exists(paths.sessionsDirectory) ? "available" : "unavailable" };
763
+ }
764
+
765
+ async function assertSelectedSessionsClosed(paths, ids) {
766
+ const result = await activeSessionIds(paths, ids);
767
+ if (result.active.size) throw new Error("Close the selected Claude sessions before cleanup.");
768
+ 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.");
769
+ return result;
681
770
  }
682
771
 
683
772
  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.");
773
+ const active = await assertSelectedSessionsClosed(store.paths, plan.ids);
687
774
  const deepBytes = scope === "deep" ? plan.deepFiles.reduce((sum, file) => sum + file.size, 0) : 0;
688
775
  const estimatedBackupBytes = plan.transcriptBytes + deepBytes + plan.historyMatchBytes + 4096;
689
776
  let diskBytes = availableDiskBytes;
@@ -784,7 +871,42 @@ async function backupHistoryRows(historyPath, destination, ids) {
784
871
  }
785
872
  }
786
873
 
787
- async function createBackup(plan, store, scope) {
874
+ // Claude's prompt-history writer uses proper-lockfile with a 10-second stale
875
+ // threshold. Use the same canonical path, directory lock and heartbeat rather
876
+ // than Session Steward's separate mutation lock (which Claude does not know).
877
+ async function lockHistory(historyPath) {
878
+ const canonicalPath = path.join(await fs.realpath(path.dirname(historyPath)), path.basename(historyPath));
879
+ const stats = await fs.lstat(canonicalPath).catch((error) => {
880
+ if (error.code === "ENOENT") return null;
881
+ throw error;
882
+ });
883
+ if (stats && !stats.isFile()) throw new Error("Claude prompt history is not a regular file. It was left unchanged.");
884
+ let compromised;
885
+ let released = false;
886
+ const release = await lockfile.lock(canonicalPath, {
887
+ realpath: false, // Restore also needs to lock history that does not exist yet.
888
+ stale: 10_000,
889
+ retries: { retries: 3, minTimeout: 50 },
890
+ onCompromised: (error) => { compromised = error; },
891
+ }).catch((error) => {
892
+ if (error.code === "ELOCKED") {
893
+ throw new Error("Claude prompt history is busy. Retry cleanup or restore in a moment.", { cause: error });
894
+ }
895
+ throw error;
896
+ });
897
+ return {
898
+ assertHeld() {
899
+ if (compromised) throw new Error("Claude prompt-history lock was lost. No further history changes were made.", { cause: compromised });
900
+ },
901
+ async release() {
902
+ if (released) return;
903
+ released = true;
904
+ if (!compromised) await release();
905
+ },
906
+ };
907
+ }
908
+
909
+ async function createBackup(plan, store, scope, acquireHistoryLock) {
788
910
  const backupDirectory = path.join(store.paths.backupRoot, `${new Date().toISOString().replaceAll(":", "-")}-${randomBytes(6).toString("hex")}`);
789
911
  const entries = [];
790
912
  await fs.mkdir(backupDirectory, { mode: 0o700, recursive: true });
@@ -802,6 +924,9 @@ async function createBackup(plan, store, scope) {
802
924
  }
803
925
  const sharedJsonl = [];
804
926
  if (plan.historyMatchCount) {
927
+ // Do not block other sessions while the large transcript copies run.
928
+ // Keep this lock from the history backup through its replacement.
929
+ await acquireHistoryLock();
805
930
  const backupRelative = path.join("shared", "history.jsonl");
806
931
  const destination = path.join(backupDirectory, backupRelative);
807
932
  await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
@@ -832,21 +957,25 @@ async function createBackup(plan, store, scope) {
832
957
  export async function executeSessionDeletion({ onProgress = () => {}, plan, scope, shouldCancel = () => false, store }) {
833
958
  onProgress({ canCancel: true, message: "Creating recovery backup", phase: "backup", progress: 8 });
834
959
  let backupDirectory;
835
- try {
836
- backupDirectory = await createBackup(plan, store, scope);
837
- } catch (error) {
838
- error.mutationStarted = false;
839
- throw error;
840
- }
960
+ let historyLock;
841
961
  let mutationStarted = false;
842
962
  try {
963
+ backupDirectory = await createBackup(plan, store, scope, async () => {
964
+ historyLock = await lockHistory(store.paths.historyPath);
965
+ });
843
966
  if (shouldCancel()) { const error = new Error("Cleanup cancelled."); error.cancelled = true; error.backupDirectory = backupDirectory; error.mutationStarted = false; throw error; }
844
967
  onProgress({ canCancel: false, message: "Removing selected session data", phase: "cleanup", progress: 55 });
968
+ await assertSelectedSessionsClosed(store.paths, plan.ids);
969
+ historyLock?.assertHeld();
845
970
  mutationStarted = true;
846
971
  if (plan.historyMatchCount && await exists(store.paths.historyPath)) {
847
972
  const ids = new Set(plan.ids);
848
- await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id));
973
+ await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id), {
974
+ beforeCommit: () => historyLock.assertHeld(),
975
+ });
849
976
  }
977
+ await historyLock?.release();
978
+ historyLock = null;
850
979
  const targets = [...plan.transcriptPaths];
851
980
  if (scope === "deep") targets.push(...plan.deepPaths);
852
981
  for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
@@ -859,9 +988,18 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
859
988
  unrecognizedLocationCount: plan.unrecognizedLocationCount,
860
989
  };
861
990
  } catch (error) {
862
- error.backupDirectory = backupDirectory;
991
+ error.backupDirectory ??= backupDirectory;
992
+ backupDirectory = error.backupDirectory;
863
993
  error.mutationStarted ??= mutationStarted;
864
994
  throw error;
995
+ } finally {
996
+ try {
997
+ await historyLock?.release();
998
+ } catch (error) {
999
+ error.backupDirectory = backupDirectory;
1000
+ error.mutationStarted = mutationStarted;
1001
+ throw error;
1002
+ }
865
1003
  }
866
1004
  }
867
1005
 
@@ -869,6 +1007,23 @@ export async function verifySessionDeletion({ plan, scope, store }) {
869
1007
  const remainingTranscriptPaths = [];
870
1008
  for (const target of plan.transcriptPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
871
1009
  if (scope === "deep") for (const target of plan.deepPaths) if (await exists(target)) remainingTranscriptPaths.push(target);
1010
+ // Also catch known sidecars created/recreated after the selection was planned.
1011
+ const sidecarsByDirectory = new Map();
1012
+ for (const record of plan.records) {
1013
+ for (const transcriptPath of record.transcriptPaths) {
1014
+ const directory = path.dirname(transcriptPath);
1015
+ if (!sidecarsByDirectory.has(directory)) {
1016
+ const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => {
1017
+ if (error.code === "ENOENT") return [];
1018
+ throw error;
1019
+ });
1020
+ sidecarsByDirectory.set(directory, indexSessionSidecars(directory, entries));
1021
+ }
1022
+ for (const target of sidecarsForTranscript(sidecarsByDirectory.get(directory), transcriptPath, record.id)) {
1023
+ if (!remainingTranscriptPaths.includes(target)) remainingTranscriptPaths.push(target);
1024
+ }
1025
+ }
1026
+ }
872
1027
  const remainingHistoryEntryCount = (await matchingHistoryStats(store.paths.historyPath, new Set(plan.ids))).count;
873
1028
  return {
874
1029
  complete: remainingTranscriptPaths.length === 0 && remainingHistoryEntryCount === 0,
@@ -974,14 +1129,21 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
974
1129
  const id = row.parsed?.sessionId ?? row.parsed?.session_id;
975
1130
  if (typeof id === "string") selectedIds.add(id);
976
1131
  }
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 });
1132
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1133
+ const historyLock = await lockHistory(destination);
1134
+ try {
1135
+ if (await exists(destination)) {
1136
+ const safety = path.join(safetyBackupDirectory, entry.root, entry.relative);
1137
+ await copyTarget(destination, safety);
1138
+ await rewriteJsonlFile(destination, (row) => !row.parsed || !selectedIds.has(row.parsed.sessionId ?? row.parsed.session_id), {
1139
+ beforeCommit: () => historyLock.assertHeld(),
1140
+ });
1141
+ }
1142
+ historyLock.assertHeld();
1143
+ await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
1144
+ } finally {
1145
+ await historyLock.release();
983
1146
  }
984
- await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
985
1147
  }
986
1148
  onProgress({ message: "Checking restored sessions", progress: 92 });
987
1149
  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
 
@@ -30,7 +30,28 @@ const SCHEMA_REQUIREMENTS = Object.freeze({
30
30
  fallback: "memories_1.sqlite",
31
31
  pattern: /^memories_(\d+)\.sqlite$/u,
32
32
  required: false,
33
- tables: [{ name: "stage1_outputs", requiredColumns: ["thread_id"] }],
33
+ tables: [
34
+ { name: "stage1_outputs", requiredColumns: ["thread_id"] },
35
+ {
36
+ name: "jobs",
37
+ optional: true,
38
+ requiredColumns: [
39
+ "kind",
40
+ "job_key",
41
+ "status",
42
+ "worker_id",
43
+ "ownership_token",
44
+ "started_at",
45
+ "finished_at",
46
+ "lease_until",
47
+ "retry_at",
48
+ "retry_remaining",
49
+ "last_error",
50
+ "input_watermark",
51
+ "last_success_watermark",
52
+ ],
53
+ },
54
+ ],
34
55
  },
35
56
  goals: {
36
57
  fallback: "goals_1.sqlite",