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.
- package/CHANGELOG.md +14 -0
- package/README.md +37 -49
- package/bin/session-steward-mcp.mjs +4 -4
- package/bin/session-steward-scheduler.mjs +61 -0
- package/dist/assets/{index-C94A1O5c.js → index-BDiEQG6G.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/cleanup-scheduler-service.mjs +205 -0
- package/lib/cleanup-schedules.mjs +439 -0
- package/lib/cli.mjs +145 -117
- package/lib/installed-products.mjs +44 -0
- package/lib/mcp.mjs +426 -416
- package/lib/providers/claude-code/events.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +12 -3
- package/lib/providers/codex/database-families.mjs +4 -2
- package/lib/providers/codex/store.mjs +171 -25
- package/lib/server.mjs +49 -115
- package/lib/session-cleanup.mjs +540 -0
- package/lib/settings.mjs +1 -0
- package/package.json +3 -2
|
@@ -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
|
-
|
|
834
|
+
let backupDirectory;
|
|
835
835
|
try {
|
|
836
|
-
|
|
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
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
|
2351
|
+
const lockPathsById = new Map(entries
|
|
2234
2352
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
|
|
2235
|
-
.map((entry) =>
|
|
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:
|
|
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
|
-
? "
|
|
2246
|
-
: `
|
|
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(
|
|
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
|
-
|
|
2935
|
-
|
|
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 {
|
|
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 =
|
|
29
|
+
const PLAN_REVIEW_REQUIRED = SESSION_CLEANUP_REVIEW_REQUIRED;
|
|
25
30
|
const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
|
|
26
|
-
const
|
|
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 (!
|
|
224
|
-
throw new Error(
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
939
|
-
|
|
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
|
|
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:
|
|
881
|
+
fingerprint: prepared.fingerprint,
|
|
955
882
|
id,
|
|
956
883
|
providerId,
|
|
957
|
-
requestedIds:
|
|
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
|
-
|
|
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
|
|