session-steward 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +37 -49
- package/bin/session-steward-mcp.mjs +4 -4
- package/bin/session-steward-scheduler.mjs +61 -0
- 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 +1 -1
- package/lib/providers/codex/store.mjs +4 -2
- 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
|
});
|
|
@@ -1515,6 +1515,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1515
1515
|
stateDatabasePath: paths.stateDatabasePath,
|
|
1516
1516
|
stateDatabasePaths: paths.stateDatabasePaths,
|
|
1517
1517
|
stateDatabases: paths.stateDatabases,
|
|
1518
|
+
threadWriterLocksDirectory: paths.threadWriterLocksDirectory,
|
|
1518
1519
|
resolvedDatabases: paths.resolvedDatabases,
|
|
1519
1520
|
logsDatabasePaths: paths.logsDatabasePaths,
|
|
1520
1521
|
memoryDatabasePaths: paths.memoryDatabasePaths,
|
|
@@ -2457,13 +2458,14 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2457
2458
|
return backupDirectory;
|
|
2458
2459
|
}
|
|
2459
2460
|
|
|
2460
|
-
function cleanupErrorWithBackup(error, backupDirectory) {
|
|
2461
|
+
function cleanupErrorWithBackup(error, backupDirectory, { mutationStarted = false } = {}) {
|
|
2461
2462
|
const detail = error instanceof Error ? error.message : "The cleanup could not be completed.";
|
|
2462
2463
|
const wrappedError = new Error(
|
|
2463
2464
|
`Cleanup stopped after the backup was created. Backup: ${backupDirectory}. ${detail}`,
|
|
2464
2465
|
{ cause: error },
|
|
2465
2466
|
);
|
|
2466
2467
|
wrappedError.backupDirectory = backupDirectory;
|
|
2468
|
+
wrappedError.mutationStarted = mutationStarted;
|
|
2467
2469
|
return wrappedError;
|
|
2468
2470
|
}
|
|
2469
2471
|
|
|
@@ -2655,7 +2657,7 @@ export async function executeSessionDeletion({
|
|
|
2655
2657
|
skippedTranscriptPaths,
|
|
2656
2658
|
};
|
|
2657
2659
|
} catch (error) {
|
|
2658
|
-
throw cleanupErrorWithBackup(error, backupDirectory);
|
|
2660
|
+
throw cleanupErrorWithBackup(error, backupDirectory, { mutationStarted: true });
|
|
2659
2661
|
}
|
|
2660
2662
|
}
|
|
2661
2663
|
|
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
|
|