session-steward 0.9.0 → 0.10.1

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.
@@ -31,6 +31,8 @@ const RECORD_CLASSIFICATION = Object.freeze({
31
31
  const SKIPPED_CONTENT_TYPES = new Set(["attachment", "image", "thinking"]);
32
32
  const SKIPPED_RECORD_TYPES = new Set([
33
33
  "attachment",
34
+ "atis-latch",
35
+ "bridge-session",
34
36
  "custom-title",
35
37
  "file-history-snapshot",
36
38
  "frame-link",
@@ -18,7 +18,7 @@ const COMPATIBILITY_PROFILE = Object.freeze({
18
18
  id: "claude-local-store-2026-08",
19
19
  builtFor: {
20
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"],
21
+ claudeDesktop: ["1.24012.9", "1.28929.0", "1.32885.1", "1.40609.0"],
22
22
  },
23
23
  });
24
24
  const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
@@ -831,10 +831,18 @@ async function createBackup(plan, store, scope) {
831
831
 
832
832
  export async function executeSessionDeletion({ onProgress = () => {}, plan, scope, shouldCancel = () => false, store }) {
833
833
  onProgress({ canCancel: true, message: "Creating recovery backup", phase: "backup", progress: 8 });
834
- const backupDirectory = await createBackup(plan, store, scope);
834
+ let backupDirectory;
835
835
  try {
836
- if (shouldCancel()) { const error = new Error("Cleanup cancelled."); error.cancelled = true; error.backupDirectory = backupDirectory; throw error; }
836
+ backupDirectory = await createBackup(plan, store, scope);
837
+ } catch (error) {
838
+ error.mutationStarted = false;
839
+ throw error;
840
+ }
841
+ let mutationStarted = false;
842
+ try {
843
+ if (shouldCancel()) { const error = new Error("Cleanup cancelled."); error.cancelled = true; error.backupDirectory = backupDirectory; error.mutationStarted = false; throw error; }
837
844
  onProgress({ canCancel: false, message: "Removing selected session data", phase: "cleanup", progress: 55 });
845
+ mutationStarted = true;
838
846
  if (plan.historyMatchCount && await exists(store.paths.historyPath)) {
839
847
  const ids = new Set(plan.ids);
840
848
  await rewriteJsonlFile(store.paths.historyPath, (entry) => !entry.parsed || !ids.has(entry.parsed.sessionId ?? entry.parsed.session_id));
@@ -852,6 +860,7 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
852
860
  };
853
861
  } catch (error) {
854
862
  error.backupDirectory = backupDirectory;
863
+ error.mutationStarted ??= mutationStarted;
855
864
  throw error;
856
865
  }
857
866
  }
@@ -8,7 +8,7 @@ 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"],
11
+ chatgptDesktop: ["26.727.40816", "26.803.61601", "26.818.21641", "26.818.22352", "26.825.41651"],
12
12
  codexCli: ["0.144.1", "0.146.0", "0.147.0", "0.148.0"],
13
13
  },
14
14
  });
@@ -53,6 +53,7 @@ const SCHEMA_REQUIREMENTS = Object.freeze({
53
53
  required: false,
54
54
  tables: [
55
55
  { name: "thread_items", requiredColumns: ["thread_id"] },
56
+ { name: "thread_realtime_items", optional: true, requiredColumns: ["thread_id"] },
56
57
  { name: "thread_turns", requiredColumns: ["thread_id"] },
57
58
  { name: "thread_history_projection_state", requiredColumns: ["thread_id"] },
58
59
  ],
@@ -91,7 +92,8 @@ function inspectCandidate(codexHome, filename, version, family) {
91
92
  );
92
93
  const invalidTable = family.tables.find((requirement) => {
93
94
  const table = tables[requirement.name];
94
- return !table.exists || table.missingColumns.length > 0;
95
+ return (!table.exists && !requirement.optional) ||
96
+ (table.exists && table.missingColumns.length > 0);
95
97
  });
96
98
  return {
97
99
  filename,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
2
3
  import { constants as fsConstants, createReadStream } from "node:fs";
3
4
  import { promises as fs } from "node:fs";
4
5
  import path from "node:path";
@@ -1515,6 +1516,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1515
1516
  stateDatabasePath: paths.stateDatabasePath,
1516
1517
  stateDatabasePaths: paths.stateDatabasePaths,
1517
1518
  stateDatabases: paths.stateDatabases,
1519
+ threadWriterLocksDirectory: paths.threadWriterLocksDirectory,
1518
1520
  resolvedDatabases: paths.resolvedDatabases,
1519
1521
  logsDatabasePaths: paths.logsDatabasePaths,
1520
1522
  memoryDatabasePaths: paths.memoryDatabasePaths,
@@ -1902,6 +1904,24 @@ function* deleteStatements(tableName, columnName, ids) {
1902
1904
  }
1903
1905
  }
1904
1906
 
1907
+ const REQUIRED_THREAD_HISTORY_TABLES = Object.freeze([
1908
+ "thread_items",
1909
+ "thread_turns",
1910
+ "thread_history_projection_state",
1911
+ ]);
1912
+ const THREAD_HISTORY_TABLES_WITH_REALTIME = Object.freeze([
1913
+ "thread_items",
1914
+ "thread_realtime_items",
1915
+ "thread_turns",
1916
+ "thread_history_projection_state",
1917
+ ]);
1918
+
1919
+ function threadHistoryTables(databasePath) {
1920
+ return inspectSqliteTable(databasePath, "thread_realtime_items").exists
1921
+ ? THREAD_HISTORY_TABLES_WITH_REALTIME
1922
+ : REQUIRED_THREAD_HISTORY_TABLES;
1923
+ }
1924
+
1905
1925
  export async function planSessionDeletion({ recordIds, store }) {
1906
1926
  const idsToDelete = new Set();
1907
1927
  const pendingIds = [...recordIds];
@@ -1999,11 +2019,7 @@ export async function planSessionDeletion({ recordIds, store }) {
1999
2019
  ? countRowsForIds(databasePath, "queued_thread_revisions", "thread_id", deletionIds)
2000
2020
  : 0), 0);
2001
2021
  const threadHistoryRowCount = (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean))
2002
- .reduce((total, databasePath) => total + [
2003
- "thread_items",
2004
- "thread_turns",
2005
- "thread_history_projection_state",
2006
- ].reduce((databaseTotal, tableName) =>
2022
+ .reduce((total, databasePath) => total + threadHistoryTables(databasePath).reduce((databaseTotal, tableName) =>
2007
2023
  databaseTotal + countRowsForIds(databasePath, tableName, "thread_id", deletionIds), 0), 0);
2008
2024
  const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
2009
2025
  database,
@@ -2147,9 +2163,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
2147
2163
  }
2148
2164
  }
2149
2165
  for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean)) {
2150
- fingerprintRowsForIds(hash, databasePath, "thread_items", "thread_id", plan.ids);
2151
- fingerprintRowsForIds(hash, databasePath, "thread_turns", "thread_id", plan.ids);
2152
- fingerprintRowsForIds(hash, databasePath, "thread_history_projection_state", "thread_id", plan.ids);
2166
+ for (const tableName of threadHistoryTables(databasePath)) {
2167
+ fingerprintRowsForIds(hash, databasePath, tableName, "thread_id", plan.ids);
2168
+ }
2153
2169
  }
2154
2170
  if (scope === "deep") {
2155
2171
  for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
@@ -2219,6 +2235,108 @@ async function getAvailableDiskBytes(directoryPath) {
2219
2235
  return stats.bavail * stats.bsize;
2220
2236
  }
2221
2237
 
2238
+ const LOCK_PROBE_CONFLICT_EXIT_CODE = 75;
2239
+ const LOCK_PROBE_CONCURRENCY = 8;
2240
+ const LOCK_PROBE_TIMEOUT_MS = 5_000;
2241
+ const WINDOWS_LOCK_PROBE_SCRIPT = String.raw`
2242
+ $ErrorActionPreference = "Stop"
2243
+ $stream = $null
2244
+ try {
2245
+ $stream = [System.IO.File]::Open(
2246
+ $env:SESSION_STEWARD_WRITER_LOCK_PATH,
2247
+ [System.IO.FileMode]::Open,
2248
+ [System.IO.FileAccess]::ReadWrite,
2249
+ ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)
2250
+ )
2251
+ try {
2252
+ $stream.Lock(0, 1)
2253
+ $stream.Unlock(0, 1)
2254
+ exit 0
2255
+ } catch [System.IO.IOException] {
2256
+ if (($_.Exception.HResult -band 0xffff) -eq 33) { exit 75 }
2257
+ exit 70
2258
+ }
2259
+ } catch [System.IO.FileNotFoundException] {
2260
+ exit 66
2261
+ } catch [System.IO.DirectoryNotFoundException] {
2262
+ exit 66
2263
+ } catch {
2264
+ exit 70
2265
+ } finally {
2266
+ if ($null -ne $stream) { $stream.Dispose() }
2267
+ }
2268
+ `;
2269
+
2270
+ function runLockProbe(command, args, options = {}) {
2271
+ return new Promise((resolve) => {
2272
+ let settled = false;
2273
+ let child;
2274
+ const finish = (result) => {
2275
+ if (settled) return;
2276
+ settled = true;
2277
+ clearTimeout(timeout);
2278
+ resolve(result);
2279
+ };
2280
+ const timeout = setTimeout(() => {
2281
+ child?.kill();
2282
+ finish({ error: new Error("The writer-lock probe timed out."), exitCode: null });
2283
+ }, LOCK_PROBE_TIMEOUT_MS);
2284
+
2285
+ try {
2286
+ child = spawn(command, args, {
2287
+ stdio: "ignore",
2288
+ windowsHide: true,
2289
+ ...options,
2290
+ });
2291
+ } catch (error) {
2292
+ finish({ error, exitCode: null });
2293
+ return;
2294
+ }
2295
+
2296
+ child.once("error", (error) => finish({ error, exitCode: null }));
2297
+ child.once("exit", (exitCode) => finish({ error: null, exitCode }));
2298
+ });
2299
+ }
2300
+
2301
+ async function probeWriterLock(lockPath) {
2302
+ if (process.platform === "win32") {
2303
+ const result = await runLockProbe(
2304
+ "powershell.exe",
2305
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_LOCK_PROBE_SCRIPT],
2306
+ {
2307
+ env: { ...process.env, SESSION_STEWARD_WRITER_LOCK_PATH: lockPath },
2308
+ },
2309
+ );
2310
+ if (result.exitCode === 0) return "free";
2311
+ if (result.exitCode === LOCK_PROBE_CONFLICT_EXIT_CODE) return "held";
2312
+ if (result.exitCode === 66) return "missing";
2313
+ return "unknown";
2314
+ }
2315
+
2316
+ let handle;
2317
+ try {
2318
+ handle = await fs.open(lockPath, "r+");
2319
+ } catch (error) {
2320
+ if (error?.code === "ENOENT") return "missing";
2321
+ return "unknown";
2322
+ }
2323
+
2324
+ try {
2325
+ const command = process.platform === "darwin" ? "/usr/bin/lockf" : "flock";
2326
+ const args = process.platform === "darwin"
2327
+ ? ["-s", "-t", "0", "3"]
2328
+ : ["-n", "-E", String(LOCK_PROBE_CONFLICT_EXIT_CODE), "3"];
2329
+ const result = await runLockProbe(command, args, {
2330
+ stdio: ["ignore", "ignore", "ignore", handle.fd],
2331
+ });
2332
+ if (result.exitCode === 0) return "free";
2333
+ if (result.exitCode === LOCK_PROBE_CONFLICT_EXIT_CODE) return "held";
2334
+ return "unknown";
2335
+ } finally {
2336
+ await handle.close();
2337
+ }
2338
+ }
2339
+
2222
2340
  async function findActiveThreadIds(store, ids) {
2223
2341
  if (!store.threadWriterLocksDirectory) return { available: false, ids: [] };
2224
2342
 
@@ -2230,20 +2348,48 @@ async function findActiveThreadIds(store, ids) {
2230
2348
  throw error;
2231
2349
  }
2232
2350
 
2233
- const lockedIds = new Set(entries
2351
+ const lockPathsById = new Map(entries
2234
2352
  .filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
2235
- .map((entry) => entry.name.slice(0, -5)));
2353
+ .map((entry) => {
2354
+ const id = entry.name.slice(0, -5);
2355
+ return [id, path.join(store.threadWriterLocksDirectory, entry.name)];
2356
+ }));
2357
+ const candidates = ids.filter((id) => lockPathsById.has(id));
2358
+ const states = new Array(candidates.length);
2359
+ let nextIndex = 0;
2360
+ await Promise.all(Array.from(
2361
+ { length: Math.min(LOCK_PROBE_CONCURRENCY, candidates.length) },
2362
+ async () => {
2363
+ while (nextIndex < candidates.length) {
2364
+ const index = nextIndex;
2365
+ nextIndex += 1;
2366
+ const id = candidates[index];
2367
+ states[index] = { id, state: await probeWriterLock(lockPathsById.get(id)) };
2368
+ }
2369
+ },
2370
+ ));
2236
2371
  return {
2237
2372
  available: true,
2238
- ids: ids.filter((id) => lockedIds.has(id)),
2373
+ ids: states.filter(({ state }) => state === "held").map(({ id }) => id),
2374
+ unverifiedIds: states.filter(({ state }) => state === "unknown").map(({ id }) => id),
2239
2375
  };
2240
2376
  }
2241
2377
 
2242
2378
  function activeThreadError(ids) {
2243
2379
  const error = new Error(
2244
2380
  ids.length === 1
2245
- ? "Close the selected Codex session before cleanup."
2246
- : `Close the ${ids.length} selected Codex sessions that are still open before cleanup.`,
2381
+ ? "Quit ChatGPT or Codex completely before cleaning up the selected Codex session."
2382
+ : `Quit ChatGPT or Codex completely before cleaning up the ${ids.length} selected Codex sessions that are still open.`,
2383
+ );
2384
+ error.activeThreadIds = ids;
2385
+ return error;
2386
+ }
2387
+
2388
+ function unverifiedThreadError(ids) {
2389
+ const error = new Error(
2390
+ ids.length === 1
2391
+ ? "Session Steward could not verify whether the selected Codex session is still open. Try cleanup again."
2392
+ : `Session Steward could not verify whether ${ids.length} selected Codex sessions are still open. Try cleanup again.`,
2247
2393
  );
2248
2394
  error.activeThreadIds = ids;
2249
2395
  return error;
@@ -2286,6 +2432,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
2286
2432
 
2287
2433
  const activeThreads = await findActiveThreadIds(store, plan.ids);
2288
2434
  if (activeThreads.ids.length > 0) throw activeThreadError(activeThreads.ids);
2435
+ if (activeThreads.unverifiedIds?.length > 0) throw unverifiedThreadError(activeThreads.unverifiedIds);
2289
2436
 
2290
2437
  const deletedIdSet = new Set(plan.ids);
2291
2438
  const desktopStates = await Promise.all([
@@ -2457,13 +2604,14 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
2457
2604
  return backupDirectory;
2458
2605
  }
2459
2606
 
2460
- function cleanupErrorWithBackup(error, backupDirectory) {
2607
+ function cleanupErrorWithBackup(error, backupDirectory, { mutationStarted = false } = {}) {
2461
2608
  const detail = error instanceof Error ? error.message : "The cleanup could not be completed.";
2462
2609
  const wrappedError = new Error(
2463
2610
  `Cleanup stopped after the backup was created. Backup: ${backupDirectory}. ${detail}`,
2464
2611
  { cause: error },
2465
2612
  );
2466
2613
  wrappedError.backupDirectory = backupDirectory;
2614
+ wrappedError.mutationStarted = mutationStarted;
2467
2615
  return wrappedError;
2468
2616
  }
2469
2617
 
@@ -2513,6 +2661,9 @@ export async function executeSessionDeletion({
2513
2661
  if (activeThreads.ids.length > 0) {
2514
2662
  throw cleanupErrorWithBackup(activeThreadError(activeThreads.ids), backupDirectory);
2515
2663
  }
2664
+ if (activeThreads.unverifiedIds?.length > 0) {
2665
+ throw cleanupErrorWithBackup(unverifiedThreadError(activeThreads.unverifiedIds), backupDirectory);
2666
+ }
2516
2667
 
2517
2668
  await reportProgress(onProgress, {
2518
2669
  canCancel: false,
@@ -2553,11 +2704,8 @@ export async function executeSessionDeletion({
2553
2704
  }
2554
2705
  if (store.hasThreadHistoryDatabase) {
2555
2706
  for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]) {
2556
- executeTransaction(databasePath, [
2557
- ...deleteStatements("thread_items", "thread_id", plan.ids),
2558
- ...deleteStatements("thread_turns", "thread_id", plan.ids),
2559
- ...deleteStatements("thread_history_projection_state", "thread_id", plan.ids),
2560
- ]);
2707
+ executeTransaction(databasePath, threadHistoryTables(databasePath).flatMap((tableName) =>
2708
+ [...deleteStatements(tableName, "thread_id", plan.ids)]));
2561
2709
  }
2562
2710
  }
2563
2711
  for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
@@ -2655,7 +2803,7 @@ export async function executeSessionDeletion({
2655
2803
  skippedTranscriptPaths,
2656
2804
  };
2657
2805
  } catch (error) {
2658
- throw cleanupErrorWithBackup(error, backupDirectory);
2806
+ throw cleanupErrorWithBackup(error, backupDirectory, { mutationStarted: true });
2659
2807
  }
2660
2808
  }
2661
2809
 
@@ -2930,11 +3078,9 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2930
3078
  ])
2931
3079
  : [];
2932
3080
  const remainingThreadHistoryRecords = store.hasThreadHistoryDatabase
2933
- ? (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]).flatMap((databasePath) => [
2934
- ...findRowsForIds(databasePath, "thread_items", "thread_id", plan.ids, { limitOne: true }),
2935
- ...findRowsForIds(databasePath, "thread_turns", "thread_id", plan.ids, { limitOne: true }),
2936
- ...findRowsForIds(databasePath, "thread_history_projection_state", "thread_id", plan.ids, { limitOne: true }),
2937
- ])
3081
+ ? (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]).flatMap((databasePath) =>
3082
+ threadHistoryTables(databasePath).flatMap((tableName) =>
3083
+ findRowsForIds(databasePath, tableName, "thread_id", plan.ids, { limitOne: true })))
2938
3084
  : [];
2939
3085
  const [sessionIndexMatches, historyMatches] = await Promise.all([
2940
3086
  inspectJsonlMatches(
package/lib/server.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { execFileSync } from "node:child_process";
4
3
  import { promises as fs } from "node:fs";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
 
8
7
  import { getProvider, listProviders } from "./providers/index.mjs";
8
+ import { getInstalledProductVersions } from "./installed-products.mjs";
9
9
  import {
10
10
  DEFAULT_SESSION_EVENT_LIMIT,
11
11
  MAX_SESSION_EVENT_LIMIT,
12
12
  } from "./session-event-reader.mjs";
13
13
  import { createProviderSettings } from "./settings.mjs";
14
14
  import { classifyInstalledVersion } from "./version-support.mjs";
15
- import { getCommandInvocation } from "./platform.mjs";
15
+ import {
16
+ acquireSessionMutationLock,
17
+ executePreparedSessionCleanup,
18
+ prepareSessionCleanup,
19
+ SESSION_CLEANUP_REVIEW_REQUIRED,
20
+ } from "./session-cleanup.mjs";
16
21
 
17
22
  const MAX_BODY_BYTES = 64 * 1024;
18
23
  const ALLOWED_SCOPES = new Set(["core", "deep"]);
@@ -21,9 +26,9 @@ const OPERATION_TTL_MS = 60 * 60 * 1000;
21
26
  const MAX_SAVED_PLANS = 20;
22
27
  const MAX_SAVED_OPERATIONS = 50;
23
28
  const PLAN_RECORD_SAMPLE_LIMIT = 20;
24
- const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
29
+ const PLAN_REVIEW_REQUIRED = SESSION_CLEANUP_REVIEW_REQUIRED;
25
30
  const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
26
- const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
31
+ const MAX_INACTIVE_DAYS = 3_650;
27
32
  const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
28
33
  const ALLOWED_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
29
34
  const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
@@ -31,47 +36,6 @@ const staticAssets = new Map([
31
36
  ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
32
37
  ]);
33
38
 
34
- function readCommandVersion(command, args) {
35
- try {
36
- const invocation = getCommandInvocation(command, args);
37
- return execFileSync(invocation.command, invocation.args, {
38
- encoding: "utf8",
39
- stdio: ["ignore", "pipe", "ignore"],
40
- windowsHide: invocation.windowsHide,
41
- }).trim() || null;
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- async function getInstalledProductVersions() {
48
- const versions = {
49
- chatgptDesktop: null,
50
- claudeCli: readCommandVersion("claude", ["--version"]),
51
- claudeDesktop: null,
52
- codexCli: readCommandVersion("codex", ["--version"]),
53
- };
54
-
55
- if (process.platform !== "darwin") {
56
- return versions;
57
- }
58
-
59
- const chatGptInfoPath = "/Applications/ChatGPT.app/Contents/Info.plist";
60
- const claudeInfoPath = "/Applications/Claude.app/Contents/Info.plist";
61
- try {
62
- await fs.access(chatGptInfoPath);
63
- versions.chatgptDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", chatGptInfoPath]);
64
- } catch {
65
- }
66
- try {
67
- await fs.access(claudeInfoPath);
68
- versions.claudeDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", claudeInfoPath]);
69
- } catch {
70
- }
71
-
72
- return versions;
73
- }
74
-
75
39
  function providerOptions(providerId, home) {
76
40
  return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
77
41
  }
@@ -220,8 +184,8 @@ function getInactiveBeforeMs(value) {
220
184
 
221
185
  const days = Number(value);
222
186
 
223
- if (!ALLOWED_INACTIVE_DAYS.has(days)) {
224
- throw new Error("Last activity must be 30, 60, or 90 days.");
187
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_INACTIVE_DAYS) {
188
+ throw new Error(`Last activity must be a whole number between 1 and ${MAX_INACTIVE_DAYS} days.`);
225
189
  }
226
190
 
227
191
  return Date.now() - days * 24 * 60 * 60 * 1000;
@@ -490,72 +454,26 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
490
454
  operation.phase = "preflight";
491
455
  operation.progress = 2;
492
456
 
457
+ let releaseMutationLock;
493
458
  try {
494
459
  const provider = getProvider(savedPlan.providerId);
495
460
  const options = providerOptions(savedPlan.providerId, savedPlan.home);
496
- let currentStore;
497
-
498
- try {
499
- currentStore = await provider.loadDeletionStore({
500
- ...options,
501
- recordIds: savedPlan.requestedIds,
502
- });
503
- } catch (error) {
504
- if (error?.message === "One or more selected sessions are no longer available.") {
505
- throw codedError(
506
- "The selected sessions changed after this preview. Review the selection again.",
507
- PLAN_REVIEW_REQUIRED,
508
- );
509
- }
510
- throw error;
511
- }
512
-
513
- const currentPlan = await provider.planSessionDeletion({
514
- recordIds: savedPlan.requestedIds,
515
- store: currentStore,
516
- });
517
- const currentFingerprint = await provider.fingerprintSessionDeletion({
518
- plan: currentPlan,
519
- scope: savedPlan.scope,
520
- store: currentStore,
521
- });
522
-
523
- if (currentFingerprint !== savedPlan.fingerprint) {
524
- throw codedError(
525
- "Session data changed after this preview. Review the selection again.",
526
- PLAN_REVIEW_REQUIRED,
527
- );
528
- }
529
-
530
- await provider.preflightSessionDeletion({
531
- plan: currentPlan,
532
- scope: savedPlan.scope,
533
- store: currentStore,
534
- });
535
-
536
- if (savedPlan.scope === "deep") {
537
- await provider.assertDeepCleanupSupported(options);
538
- }
539
-
540
- const result = await provider.executeSessionDeletion({
461
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
462
+ const execution = await executePreparedSessionCleanup({
463
+ expectedFingerprint: savedPlan.fingerprint,
541
464
  onProgress: (update) => Object.assign(operation, update),
542
- plan: currentPlan,
465
+ options,
466
+ provider,
467
+ recordIds: savedPlan.requestedIds,
543
468
  scope: savedPlan.scope,
544
469
  shouldCancel: () => operation.cancelRequested,
545
- store: currentStore,
546
470
  });
471
+ const result = execution.deletion;
547
472
  operation.backupDirectory = result.backupDirectory;
548
473
  operation.backupDirectories = [result.backupDirectory];
549
474
  operation.result = summarizeDeletionResult(result);
550
475
  operation.canCancel = false;
551
- operation.message = "Checking that cleanup completed";
552
- operation.phase = "verification";
553
- operation.progress = 94;
554
- const verification = await provider.verifySessionDeletion({
555
- plan: currentPlan,
556
- scope: savedPlan.scope,
557
- store: currentStore,
558
- });
476
+ const verification = execution.verification;
559
477
  operation.verification = summarizeVerification(verification);
560
478
  operation.progress = 100;
561
479
 
@@ -596,6 +514,9 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
596
514
  operation.status = "failed";
597
515
  }
598
516
  } finally {
517
+ const terminalStatus = operation.status;
518
+ operation.status = "running";
519
+ await releaseMutationLock?.().catch(() => {});
599
520
  getProvider(savedPlan.providerId).invalidateSessionCache?.(
600
521
  providerOptions(savedPlan.providerId, savedPlan.home),
601
522
  );
@@ -604,6 +525,7 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
604
525
  deletionPlans.delete(savedPlan.id);
605
526
  operation.finishedAtMs = Date.now();
606
527
  if (activeOperationId === operation.id) activeOperationId = null;
528
+ operation.status = terminalStatus;
607
529
  }
608
530
  }
609
531
 
@@ -653,10 +575,14 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
653
575
  operation.status = "restoring";
654
576
  activeOperationId = operation.id;
655
577
  const task = (async () => {
578
+ let releaseMutationLock;
656
579
  try {
657
- const restoreResult = await getProvider(operation.providerId).restoreSessionDeletionBackup({
580
+ const provider = getProvider(operation.providerId);
581
+ const options = providerOptions(operation.providerId, operation.home);
582
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
583
+ const restoreResult = await provider.restoreSessionDeletionBackup({
658
584
  backupDirectory: operation.backupDirectory,
659
- ...providerOptions(operation.providerId, operation.home),
585
+ ...options,
660
586
  onProgress: (update) => Object.assign(operation, update),
661
587
  });
662
588
  operation.restoreResult = restoreResult;
@@ -685,12 +611,16 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
685
611
  operation.message = "Restore could not be completed";
686
612
  operation.status = "restore-failed";
687
613
  } finally {
614
+ const terminalStatus = operation.status;
615
+ operation.status = "restoring";
616
+ await releaseMutationLock?.().catch(() => {});
688
617
  getProvider(operation.providerId).invalidateSessionCache?.(
689
618
  providerOptions(operation.providerId, operation.home),
690
619
  );
691
620
  invalidateSessionOverview();
692
621
  operation.finishedAtMs = Date.now();
693
622
  if (activeOperationId === operation.id) activeOperationId = null;
623
+ operation.status = terminalStatus;
694
624
  }
695
625
  })();
696
626
  activeTasks.add(task);
@@ -935,26 +865,23 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
935
865
  const home = settings.getHome(providerId);
936
866
  const options = providerOptions(providerId, home);
937
867
 
938
- if (scope === "deep") {
939
- await provider.assertDeepCleanupSupported(options);
940
- }
941
-
942
- const store = await provider.loadDeletionStore({
943
- ...options,
868
+ const prepared = await prepareSessionCleanup({
869
+ options,
870
+ provider,
944
871
  recordIds: ids,
872
+ scope,
945
873
  });
946
- const plan = await provider.planSessionDeletion({ recordIds: ids, store });
947
- const preflight = await provider.preflightSessionDeletion({ plan, scope, store });
874
+ const { plan, preflight } = prepared;
948
875
  const id = randomBytes(18).toString("base64url");
949
876
  const expiresAtMs = Date.now() + PLAN_TTL_MS;
950
877
  const savedPlan = {
951
878
  home,
952
879
  consumed: false,
953
880
  expiresAtMs,
954
- fingerprint: await provider.fingerprintSessionDeletion({ plan, scope, store }),
881
+ fingerprint: prepared.fingerprint,
955
882
  id,
956
883
  providerId,
957
- requestedIds: ids,
884
+ requestedIds: prepared.requestedIds,
958
885
  scope,
959
886
  };
960
887
  removeExpiredEntries(deletionPlans, PLAN_TTL_MS, MAX_SAVED_PLANS);
@@ -1043,17 +970,24 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
1043
970
  }
1044
971
 
1045
972
  mutationInProgress = true;
973
+ let releaseMutationLock;
974
+ let responsePayload;
1046
975
  try {
976
+ const provider = getProvider(operation.providerId);
977
+ const options = providerOptions(operation.providerId, operation.home);
978
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
1047
979
  if (!(await removeOperationBackups(operation))) {
1048
980
  throw new Error("The recovery backup could not be deleted.");
1049
981
  }
1050
982
  operation.backupDeleteError = null;
1051
983
  operation.canRestore = false;
1052
984
  if (operation.result) operation.result.recoveryBackupDeleted = true;
1053
- sendJson(response, 200, { operation: publicOperation(operation) });
985
+ responsePayload = { operation: publicOperation(operation) };
1054
986
  } finally {
987
+ await releaseMutationLock?.().catch(() => {});
1055
988
  mutationInProgress = false;
1056
989
  }
990
+ sendJson(response, 200, responsePayload);
1057
991
  return;
1058
992
  }
1059
993