session-steward 0.10.2 → 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 +8 -0
- package/lib/providers/claude-code/process-state.mjs +73 -0
- package/lib/providers/claude-code/store.mjs +195 -33
- package/lib/providers/codex/database-families.mjs +2 -2
- package/lib/providers/codex/desktop-catalog.mjs +179 -0
- package/lib/providers/codex/store.mjs +21 -1
- package/lib/storage/jsonl.mjs +2 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [0.10.2] - 2026-09-07
|
|
4
11
|
|
|
5
12
|
### Fixed
|
|
@@ -155,6 +162,7 @@
|
|
|
155
162
|
- Support for custom Codex home folders and a saved folder preference.
|
|
156
163
|
- Streaming and bounded-memory discovery for large session collections and transcripts.
|
|
157
164
|
|
|
165
|
+
[0.10.3]: https://github.com/mallikcheripally/session-steward/compare/v0.10.2...v0.10.3
|
|
158
166
|
[0.10.2]: https://github.com/mallikcheripally/session-steward/compare/v0.10.1...v0.10.2
|
|
159
167
|
[0.10.1]: https://github.com/mallikcheripally/session-steward/compare/v0.10.0...v0.10.1
|
|
160
168
|
[0.10.0]: https://github.com/mallikcheripally/session-steward/compare/v0.9.0...v0.10.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
|
-
|
|
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 {
|
|
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
|
|
671
|
-
|
|
672
|
-
if (!entry.
|
|
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
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
await
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
resolveCodexDatabases,
|
|
28
28
|
} from "./database-families.mjs";
|
|
29
29
|
import { expandHomePath } from "../../platform.mjs";
|
|
30
|
+
import {
|
|
31
|
+
deleteDesktopCatalogs,
|
|
32
|
+
readDesktopCatalogs,
|
|
33
|
+
restoreDesktopCatalogs,
|
|
34
|
+
validateDesktopCatalogBackup,
|
|
35
|
+
} from "./desktop-catalog.mjs";
|
|
30
36
|
|
|
31
37
|
function normalizeText(value) {
|
|
32
38
|
if (typeof value !== "string") {
|
|
@@ -2349,6 +2355,7 @@ async function getPathFingerprint(filePath) {
|
|
|
2349
2355
|
export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
2350
2356
|
const hash = createHash("sha256");
|
|
2351
2357
|
hash.update(`${store.codexHome}\0${scope}\0`);
|
|
2358
|
+
hash.update(`desktop-catalogs\0${JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids))}\0`);
|
|
2352
2359
|
|
|
2353
2360
|
const counts = [
|
|
2354
2361
|
plan.dynamicToolRowCount,
|
|
@@ -2467,7 +2474,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2467
2474
|
}
|
|
2468
2475
|
|
|
2469
2476
|
async function estimateBackupBytes({ plan, scope, store }) {
|
|
2470
|
-
let sourceBytes =
|
|
2477
|
+
let sourceBytes = Buffer.byteLength(JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids), null, 2));
|
|
2471
2478
|
const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
|
|
2472
2479
|
|
|
2473
2480
|
for (const sourcePath of uniquePaths) {
|
|
@@ -2685,6 +2692,8 @@ function formatBytes(bytes) {
|
|
|
2685
2692
|
}
|
|
2686
2693
|
|
|
2687
2694
|
export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
|
|
2695
|
+
// Fail on an unknown app catalogue before backing up or mutating sessions.
|
|
2696
|
+
readDesktopCatalogs(store.codexHome, plan.ids);
|
|
2688
2697
|
const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
|
|
2689
2698
|
const missingRequiredPaths = [];
|
|
2690
2699
|
|
|
@@ -2865,6 +2874,7 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2865
2874
|
files,
|
|
2866
2875
|
profileId: CODEX_DATABASE_PROFILE.id,
|
|
2867
2876
|
resolvedDatabases: store.resolvedDatabases ?? {},
|
|
2877
|
+
desktopCatalogs: readDesktopCatalogs(store.codexHome, plan.ids),
|
|
2868
2878
|
compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
|
|
2869
2879
|
}, null, 2)}\n`,
|
|
2870
2880
|
);
|
|
@@ -2941,6 +2951,8 @@ export async function executeSessionDeletion({
|
|
|
2941
2951
|
});
|
|
2942
2952
|
|
|
2943
2953
|
try {
|
|
2954
|
+
const operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
|
|
2955
|
+
deleteDesktopCatalogs(store.codexHome, plan.ids, operation.desktopCatalogs);
|
|
2944
2956
|
if (scope === "deep" && store.hasMemoryDatabase) {
|
|
2945
2957
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
|
|
2946
2958
|
executeTransaction(
|
|
@@ -3188,6 +3200,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3188
3200
|
throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
|
|
3189
3201
|
}
|
|
3190
3202
|
const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
|
|
3203
|
+
const desktopCatalogs = operation.desktopCatalogs ?? [];
|
|
3204
|
+
validateDesktopCatalogBackup(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
3191
3205
|
|
|
3192
3206
|
const files = operation.files.map((entry) => {
|
|
3193
3207
|
if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
|
|
@@ -3293,6 +3307,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3293
3307
|
}
|
|
3294
3308
|
}
|
|
3295
3309
|
|
|
3310
|
+
restoreDesktopCatalogs(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
3311
|
+
|
|
3296
3312
|
await reportProgress(onProgress, {
|
|
3297
3313
|
canCancel: false,
|
|
3298
3314
|
message: "Restore completed",
|
|
@@ -3318,6 +3334,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3318
3334
|
}
|
|
3319
3335
|
|
|
3320
3336
|
export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
3337
|
+
const remainingDesktopCatalogEntries = readDesktopCatalogs(store.codexHome, plan.ids)
|
|
3338
|
+
.flatMap((entry) => entry.rows.map((row) => ({ filename: entry.filename, id: row.thread_id })));
|
|
3321
3339
|
const deletedIdSet = new Set(plan.ids);
|
|
3322
3340
|
const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
|
|
3323
3341
|
.flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
|
|
@@ -3418,7 +3436,9 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3418
3436
|
sessionIndexMatches.count === 0 &&
|
|
3419
3437
|
historyMatches.count === 0 &&
|
|
3420
3438
|
remainingTranscriptPaths.length === 0 &&
|
|
3439
|
+
remainingDesktopCatalogEntries.length === 0 &&
|
|
3421
3440
|
remainingDesktopStateReferences.length === 0,
|
|
3441
|
+
remainingDesktopCatalogEntries,
|
|
3422
3442
|
remainingDesktopStateReferences,
|
|
3423
3443
|
remainingDynamicTools,
|
|
3424
3444
|
remainingGoalRecords,
|
package/lib/storage/jsonl.mjs
CHANGED
|
@@ -208,7 +208,7 @@ export async function inspectJsonlMatches(filePath, matches, { sampleLimit = 100
|
|
|
208
208
|
return { count, samples };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
export async function rewriteJsonlFile(filePath, keep) {
|
|
211
|
+
export async function rewriteJsonlFile(filePath, keep, { beforeCommit = () => {} } = {}) {
|
|
212
212
|
const fileStats = await fs.stat(filePath);
|
|
213
213
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
214
214
|
const output = createWriteStream(temporaryPath, {
|
|
@@ -233,6 +233,7 @@ export async function rewriteJsonlFile(filePath, keep) {
|
|
|
233
233
|
|
|
234
234
|
output.end();
|
|
235
235
|
await finished(output);
|
|
236
|
+
await beforeCommit();
|
|
236
237
|
await fs.rename(temporaryPath, filePath);
|
|
237
238
|
return { removedCount, retainedCount };
|
|
238
239
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-steward",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Codex and Claude Code session manager - browse, back up, and delete old sessions. Local browser UI + CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mallik Cheripally",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
86
|
+
"proper-lockfile": "4.1.2",
|
|
86
87
|
"zod": "^4.4.3"
|
|
87
88
|
}
|
|
88
89
|
}
|