session-steward 0.8.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 +15 -0
- package/README.md +73 -4
- package/bin/session-steward-mcp.mjs +65 -0
- 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 +617 -0
- package/lib/providers/claude-code/events.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +16 -3
- package/lib/providers/codex/database-families.mjs +1 -1
- package/lib/providers/codex/store.mjs +18 -4
- package/lib/server.mjs +49 -115
- package/lib/session-cleanup.mjs +540 -0
- package/lib/settings.mjs +1 -0
- package/package.json +9 -2
|
@@ -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,
|
|
@@ -1534,6 +1535,7 @@ export async function listSessions({
|
|
|
1534
1535
|
inactiveBeforeMs = null,
|
|
1535
1536
|
includeInternals = false,
|
|
1536
1537
|
includeSupporting = false,
|
|
1538
|
+
minimumTranscriptBytes = null,
|
|
1537
1539
|
page = 1,
|
|
1538
1540
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
1539
1541
|
refresh = false,
|
|
@@ -1548,7 +1550,12 @@ export async function listSessions({
|
|
|
1548
1550
|
: DEFAULT_PAGE_SIZE;
|
|
1549
1551
|
const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
|
|
1550
1552
|
const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
|
|
1551
|
-
|
|
1553
|
+
const resolvedMinimumTranscriptBytes = Number.isFinite(minimumTranscriptBytes)
|
|
1554
|
+
&& minimumTranscriptBytes > 0
|
|
1555
|
+
? Math.trunc(minimumTranscriptBytes)
|
|
1556
|
+
: null;
|
|
1557
|
+
const needsSizeIndex = resolvedSort === "size" || resolvedMinimumTranscriptBytes !== null;
|
|
1558
|
+
if (paths.stateDatabases.length === 1 && !needsSizeIndex && !forceUnion) {
|
|
1552
1559
|
const database = paths.stateDatabases[0];
|
|
1553
1560
|
const conditions = getSessionConditions(database, {
|
|
1554
1561
|
archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
|
|
@@ -1583,6 +1590,7 @@ export async function listSessions({
|
|
|
1583
1590
|
const ordered = [];
|
|
1584
1591
|
const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
|
|
1585
1592
|
const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
|
|
1593
|
+
const sizes = needsSizeIndex ? await getSessionSizeIndex(paths, { refresh }) : null;
|
|
1586
1594
|
let compareSortRows = null;
|
|
1587
1595
|
for (const database of paths.stateDatabases) {
|
|
1588
1596
|
const conditions = getSessionConditions(database, {
|
|
@@ -1599,6 +1607,12 @@ export async function listSessions({
|
|
|
1599
1607
|
const id = String(row.id);
|
|
1600
1608
|
if (seenIds?.has(id)) continue;
|
|
1601
1609
|
seenIds?.add(id);
|
|
1610
|
+
if (resolvedMinimumTranscriptBytes !== null) {
|
|
1611
|
+
const transcriptBytes = sizes.get(id);
|
|
1612
|
+
if (!Number.isFinite(transcriptBytes) || transcriptBytes < resolvedMinimumTranscriptBytes) {
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1602
1616
|
if (compactSizeIds) {
|
|
1603
1617
|
ordered.push(id);
|
|
1604
1618
|
continue;
|
|
@@ -1609,7 +1623,6 @@ export async function listSessions({
|
|
|
1609
1623
|
}
|
|
1610
1624
|
}
|
|
1611
1625
|
if (resolvedSort === "size") {
|
|
1612
|
-
const sizes = await getSessionSizeIndex(paths, { refresh });
|
|
1613
1626
|
ordered.sort((left, right) => compareSessionIdsBySize(
|
|
1614
1627
|
compactSizeIds ? left : left.id,
|
|
1615
1628
|
compactSizeIds ? right : right.id,
|
|
@@ -2445,13 +2458,14 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2445
2458
|
return backupDirectory;
|
|
2446
2459
|
}
|
|
2447
2460
|
|
|
2448
|
-
function cleanupErrorWithBackup(error, backupDirectory) {
|
|
2461
|
+
function cleanupErrorWithBackup(error, backupDirectory, { mutationStarted = false } = {}) {
|
|
2449
2462
|
const detail = error instanceof Error ? error.message : "The cleanup could not be completed.";
|
|
2450
2463
|
const wrappedError = new Error(
|
|
2451
2464
|
`Cleanup stopped after the backup was created. Backup: ${backupDirectory}. ${detail}`,
|
|
2452
2465
|
{ cause: error },
|
|
2453
2466
|
);
|
|
2454
2467
|
wrappedError.backupDirectory = backupDirectory;
|
|
2468
|
+
wrappedError.mutationStarted = mutationStarted;
|
|
2455
2469
|
return wrappedError;
|
|
2456
2470
|
}
|
|
2457
2471
|
|
|
@@ -2643,7 +2657,7 @@ export async function executeSessionDeletion({
|
|
|
2643
2657
|
skippedTranscriptPaths,
|
|
2644
2658
|
};
|
|
2645
2659
|
} catch (error) {
|
|
2646
|
-
throw cleanupErrorWithBackup(error, backupDirectory);
|
|
2660
|
+
throw cleanupErrorWithBackup(error, backupDirectory, { mutationStarted: true });
|
|
2647
2661
|
}
|
|
2648
2662
|
}
|
|
2649
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
|
|