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
package/lib/cli.mjs
CHANGED
|
@@ -8,6 +8,12 @@ import process, {
|
|
|
8
8
|
} from "node:process";
|
|
9
9
|
|
|
10
10
|
import { getProvider } from "./providers/index.mjs";
|
|
11
|
+
import {
|
|
12
|
+
acquireSessionMutationLock,
|
|
13
|
+
executePreparedSessionCleanup,
|
|
14
|
+
prepareSessionCleanup,
|
|
15
|
+
resolveSessionCleanupScope,
|
|
16
|
+
} from "./session-cleanup.mjs";
|
|
11
17
|
import {
|
|
12
18
|
SESSION_EVENT_KIND,
|
|
13
19
|
SESSION_EVENT_REASON,
|
|
@@ -19,7 +25,7 @@ function providerOptions(providerId, home) {
|
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
const PAGE_SIZE = 20;
|
|
22
|
-
const
|
|
28
|
+
const MAX_INACTIVE_DAYS = 3_650;
|
|
23
29
|
const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
|
|
24
30
|
const ALLOWED_CLEANUP_MODES = new Set(["standard", "thorough"]);
|
|
25
31
|
const HELP_TEXT = `
|
|
@@ -28,7 +34,7 @@ Commands
|
|
|
28
34
|
search Clear the active search filter
|
|
29
35
|
workspace <path> Show one exact workspace
|
|
30
36
|
workspace Clear the workspace filter
|
|
31
|
-
inactive <
|
|
37
|
+
inactive <days> Show sessions last active that many days ago
|
|
32
38
|
inactive Clear the inactivity filter
|
|
33
39
|
archive <all|active|archived> Filter sessions by archive status
|
|
34
40
|
archive Clear the archive filter
|
|
@@ -73,7 +79,7 @@ Options
|
|
|
73
79
|
--backups Print retained recovery backups
|
|
74
80
|
--search <text> Search names, workspaces, and session IDs
|
|
75
81
|
--workspace <path> Show one exact workspace
|
|
76
|
-
--inactive-days <
|
|
82
|
+
--inactive-days <days> Show sessions last active at least this long ago
|
|
77
83
|
--archive-status <status> Show all, active, or archived sessions
|
|
78
84
|
--sort <updated|created|name|cwd|size>
|
|
79
85
|
Choose the session order
|
|
@@ -576,8 +582,8 @@ function validateInactiveDays(value) {
|
|
|
576
582
|
|
|
577
583
|
const days = Number(value);
|
|
578
584
|
|
|
579
|
-
if (!
|
|
580
|
-
throw new Error(
|
|
585
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > MAX_INACTIVE_DAYS) {
|
|
586
|
+
throw new Error(`Inactive days must be a whole number between 1 and ${MAX_INACTIVE_DAYS}.`);
|
|
581
587
|
}
|
|
582
588
|
|
|
583
589
|
return days;
|
|
@@ -594,7 +600,7 @@ function validateArchiveStatus(value) {
|
|
|
594
600
|
}
|
|
595
601
|
|
|
596
602
|
function validateCleanupMode(value) {
|
|
597
|
-
const cleanupMode = value || "
|
|
603
|
+
const cleanupMode = value || "thorough";
|
|
598
604
|
|
|
599
605
|
if (!ALLOWED_CLEANUP_MODES.has(cleanupMode)) {
|
|
600
606
|
throw new Error("Cleanup must be standard or thorough.");
|
|
@@ -888,38 +894,47 @@ async function runInteractive(state) {
|
|
|
888
894
|
continue;
|
|
889
895
|
}
|
|
890
896
|
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
if (message) output.write(`${message}...\n`);
|
|
896
|
-
},
|
|
897
|
+
const options = providerOptions(state.provider.id, state.providerHome);
|
|
898
|
+
const releaseMutationLock = await acquireSessionMutationLock({
|
|
899
|
+
options,
|
|
900
|
+
provider: state.provider,
|
|
897
901
|
});
|
|
898
|
-
|
|
899
|
-
restoreResult.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
902
|
+
try {
|
|
903
|
+
const restoreResult = await state.provider.restoreSessionDeletionBackup({
|
|
904
|
+
backupDirectory: backup.backupDirectory,
|
|
905
|
+
...options,
|
|
906
|
+
onProgress: ({ message }) => {
|
|
907
|
+
if (message) output.write(`${message}...\n`);
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
const cleanupDirectories = [
|
|
911
|
+
restoreResult.safetyBackupDirectory,
|
|
912
|
+
backup.backupDirectory,
|
|
913
|
+
].filter(Boolean);
|
|
914
|
+
const retainedDirectories = [];
|
|
915
|
+
|
|
916
|
+
for (const backupDirectory of cleanupDirectories) {
|
|
917
|
+
try {
|
|
918
|
+
await state.provider.deleteSessionDeletionBackup({
|
|
919
|
+
backupDirectory,
|
|
920
|
+
...options,
|
|
921
|
+
});
|
|
922
|
+
} catch {
|
|
923
|
+
retainedDirectories.push(backupDirectory);
|
|
924
|
+
}
|
|
912
925
|
}
|
|
913
|
-
}
|
|
914
926
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
927
|
+
state.provider.invalidateSessionCache?.(options);
|
|
928
|
+
const restoredCount = restoreResult.restoredFileCount ?? restoreResult.restoredEntryCount ?? 0;
|
|
929
|
+
output.write(`Restored and verified ${restoredCount} session data files.\n`);
|
|
930
|
+
if (retainedDirectories.length > 0) {
|
|
931
|
+
output.write(`Restore completed, but recovery files remain at ${retainedDirectories.join(", ")}.\n`);
|
|
932
|
+
}
|
|
933
|
+
state.page = 1;
|
|
934
|
+
state.forceRefresh = true;
|
|
935
|
+
} finally {
|
|
936
|
+
await releaseMutationLock();
|
|
920
937
|
}
|
|
921
|
-
state.page = 1;
|
|
922
|
-
state.forceRefresh = true;
|
|
923
938
|
} catch (error) {
|
|
924
939
|
output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
|
|
925
940
|
}
|
|
@@ -941,11 +956,20 @@ async function runInteractive(state) {
|
|
|
941
956
|
continue;
|
|
942
957
|
}
|
|
943
958
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
959
|
+
const options = providerOptions(state.provider.id, state.providerHome);
|
|
960
|
+
const releaseMutationLock = await acquireSessionMutationLock({
|
|
961
|
+
options,
|
|
962
|
+
provider: state.provider,
|
|
947
963
|
});
|
|
948
|
-
|
|
964
|
+
try {
|
|
965
|
+
await state.provider.deleteSessionDeletionBackup({
|
|
966
|
+
backupDirectory: backup.backupDirectory,
|
|
967
|
+
...options,
|
|
968
|
+
});
|
|
969
|
+
output.write(`Deleted recovery backup ${backup.id}.\n`);
|
|
970
|
+
} finally {
|
|
971
|
+
await releaseMutationLock();
|
|
972
|
+
}
|
|
949
973
|
} catch (error) {
|
|
950
974
|
output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
|
|
951
975
|
}
|
|
@@ -1055,25 +1079,24 @@ async function runInteractive(state) {
|
|
|
1055
1079
|
try {
|
|
1056
1080
|
const sessionIds = parseSelectors(command.args, state.result.records);
|
|
1057
1081
|
if (sessionIds.length === 0) throw new Error("Choose at least one session to delete.");
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
)
|
|
1063
|
-
}
|
|
1064
|
-
const deletionStore = await state.provider.loadDeletionStore({
|
|
1065
|
-
...providerOptions(state.provider.id, state.providerHome),
|
|
1066
|
-
recordIds: sessionIds,
|
|
1082
|
+
const options = providerOptions(state.provider.id, state.providerHome);
|
|
1083
|
+
const cleanupResolution = await resolveSessionCleanupScope({
|
|
1084
|
+
options,
|
|
1085
|
+
provider: state.provider,
|
|
1086
|
+
scope: cleanupScope(state.cleanupMode),
|
|
1067
1087
|
});
|
|
1068
|
-
const
|
|
1088
|
+
const scope = cleanupResolution.scope;
|
|
1089
|
+
if (cleanupResolution.fallback) {
|
|
1090
|
+
output.write("Thorough cleanup is unavailable for this storage layout. Using standard cleanup.\n");
|
|
1091
|
+
}
|
|
1092
|
+
const prepared = await prepareSessionCleanup({
|
|
1093
|
+
options,
|
|
1094
|
+
provider: state.provider,
|
|
1069
1095
|
recordIds: sessionIds,
|
|
1070
|
-
store: deletionStore,
|
|
1071
|
-
});
|
|
1072
|
-
const preflight = await state.provider.preflightSessionDeletion({
|
|
1073
|
-
plan: deletionPlan,
|
|
1074
1096
|
scope,
|
|
1075
|
-
store: deletionStore,
|
|
1076
1097
|
});
|
|
1098
|
+
const deletionPlan = prepared.plan;
|
|
1099
|
+
const preflight = prepared.preflight;
|
|
1077
1100
|
|
|
1078
1101
|
printDeletionPreview(deletionPlan, preflight, scope);
|
|
1079
1102
|
output.write(
|
|
@@ -1094,77 +1117,82 @@ async function runInteractive(state) {
|
|
|
1094
1117
|
continue;
|
|
1095
1118
|
}
|
|
1096
1119
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const handleInterrupt = () => {
|
|
1101
|
-
if (canCancel) {
|
|
1102
|
-
cancelRequested = true;
|
|
1103
|
-
output.write("\nCancellation requested. Finishing the current safe step.\n");
|
|
1104
|
-
} else {
|
|
1105
|
-
output.write("\nCleanup is already applying changes and will finish safely.\n");
|
|
1106
|
-
}
|
|
1107
|
-
};
|
|
1108
|
-
process.on("SIGINT", handleInterrupt);
|
|
1109
|
-
let result;
|
|
1110
|
-
|
|
1111
|
-
try {
|
|
1112
|
-
result = await state.provider.executeSessionDeletion({
|
|
1113
|
-
onProgress: ({ canCancel: nextCanCancel, message }) => {
|
|
1114
|
-
canCancel = nextCanCancel;
|
|
1115
|
-
if (message !== lastMessage) {
|
|
1116
|
-
output.write(`${message}...\n`);
|
|
1117
|
-
lastMessage = message;
|
|
1118
|
-
}
|
|
1119
|
-
},
|
|
1120
|
-
plan: deletionPlan,
|
|
1121
|
-
scope,
|
|
1122
|
-
shouldCancel: () => cancelRequested,
|
|
1123
|
-
store: deletionStore,
|
|
1124
|
-
});
|
|
1125
|
-
} finally {
|
|
1126
|
-
process.off("SIGINT", handleInterrupt);
|
|
1127
|
-
}
|
|
1128
|
-
const verification = await state.provider.verifySessionDeletion({
|
|
1129
|
-
plan: deletionPlan,
|
|
1130
|
-
scope,
|
|
1131
|
-
store: deletionStore,
|
|
1120
|
+
const releaseMutationLock = await acquireSessionMutationLock({
|
|
1121
|
+
options,
|
|
1122
|
+
provider: state.provider,
|
|
1132
1123
|
});
|
|
1124
|
+
try {
|
|
1125
|
+
let cancelRequested = false;
|
|
1126
|
+
let canCancel = true;
|
|
1127
|
+
let lastMessage = "";
|
|
1128
|
+
const handleInterrupt = () => {
|
|
1129
|
+
if (canCancel) {
|
|
1130
|
+
cancelRequested = true;
|
|
1131
|
+
output.write("\nCancellation requested. Finishing the current safe step.\n");
|
|
1132
|
+
} else {
|
|
1133
|
+
output.write("\nCleanup is already applying changes and will finish safely.\n");
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
process.on("SIGINT", handleInterrupt);
|
|
1137
|
+
let execution;
|
|
1133
1138
|
|
|
1134
|
-
if (verification.complete) {
|
|
1135
1139
|
try {
|
|
1136
|
-
await
|
|
1137
|
-
|
|
1138
|
-
|
|
1140
|
+
execution = await executePreparedSessionCleanup({
|
|
1141
|
+
expectedFingerprint: prepared.fingerprint,
|
|
1142
|
+
onProgress: ({ canCancel: nextCanCancel, message }) => {
|
|
1143
|
+
canCancel = nextCanCancel;
|
|
1144
|
+
if (message !== lastMessage) {
|
|
1145
|
+
output.write(`${message}...\n`);
|
|
1146
|
+
lastMessage = message;
|
|
1147
|
+
}
|
|
1148
|
+
},
|
|
1149
|
+
options,
|
|
1150
|
+
provider: state.provider,
|
|
1151
|
+
recordIds: prepared.requestedIds,
|
|
1152
|
+
scope: prepared.scope,
|
|
1153
|
+
shouldCancel: () => cancelRequested,
|
|
1139
1154
|
});
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1155
|
+
} finally {
|
|
1156
|
+
process.off("SIGINT", handleInterrupt);
|
|
1157
|
+
}
|
|
1158
|
+
const result = execution.deletion;
|
|
1159
|
+
const verification = execution.verification;
|
|
1160
|
+
|
|
1161
|
+
if (verification.complete) {
|
|
1162
|
+
try {
|
|
1163
|
+
await state.provider.deleteSessionDeletionBackup({
|
|
1164
|
+
backupDirectory: result.backupDirectory,
|
|
1165
|
+
...options,
|
|
1166
|
+
});
|
|
1167
|
+
} catch {
|
|
1168
|
+
output.write(`Cleanup completed, but its recovery backup remains at ${result.backupDirectory}.\n`);
|
|
1169
|
+
}
|
|
1170
|
+
output.write(
|
|
1171
|
+
`Deleted and verified ${result.deletedIds.length} sessions and ${result.deletedTranscriptPaths.length} session paths.\n`,
|
|
1172
|
+
);
|
|
1173
|
+
} else {
|
|
1174
|
+
output.write(
|
|
1175
|
+
`Cleanup finished, but some selected artifacts remain. Backup: ${result.backupDirectory}\n`,
|
|
1176
|
+
);
|
|
1142
1177
|
}
|
|
1143
|
-
output.write(
|
|
1144
|
-
`Deleted and verified ${result.deletedIds.length} sessions and ${result.deletedTranscriptPaths.length} session paths.\n`,
|
|
1145
|
-
);
|
|
1146
|
-
} else {
|
|
1147
|
-
output.write(
|
|
1148
|
-
`Cleanup finished, but some selected artifacts remain. Backup: ${result.backupDirectory}\n`,
|
|
1149
|
-
);
|
|
1150
|
-
}
|
|
1151
1178
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1179
|
+
if (result.skippedTranscriptPaths.length > 0) {
|
|
1180
|
+
output.write(
|
|
1181
|
+
`Skipped ${result.skippedTranscriptPaths.length} missing transcript paths.\n`,
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
if (result.unrecognizedLocationCount > 0) {
|
|
1185
|
+
output.write(
|
|
1186
|
+
`${result.unrecognizedLocationCount} ${result.unrecognizedLocationCount === 1 ? "location" : "locations"} in your Claude folder ${result.unrecognizedLocationCount === 1 ? "was" : "were"} not recognized and ${result.unrecognizedLocationCount === 1 ? "was" : "were"} not examined.\n`,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1162
1189
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1190
|
+
state.provider.invalidateSessionCache?.(options);
|
|
1191
|
+
state.page = 1;
|
|
1192
|
+
state.forceRefresh = true;
|
|
1193
|
+
} finally {
|
|
1194
|
+
await releaseMutationLock();
|
|
1195
|
+
}
|
|
1168
1196
|
} catch (error) {
|
|
1169
1197
|
output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
|
|
1170
1198
|
await pause(rl);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { getCommandInvocation } from "./platform.mjs";
|
|
5
|
+
|
|
6
|
+
function readCommandVersion(command, args) {
|
|
7
|
+
try {
|
|
8
|
+
const invocation = getCommandInvocation(command, args);
|
|
9
|
+
return execFileSync(invocation.command, invocation.args, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
12
|
+
windowsHide: invocation.windowsHide,
|
|
13
|
+
}).trim() || null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getInstalledProductVersions() {
|
|
20
|
+
const versions = {
|
|
21
|
+
chatgptDesktop: null,
|
|
22
|
+
claudeCli: readCommandVersion("claude", ["--version"]),
|
|
23
|
+
claudeDesktop: null,
|
|
24
|
+
codexCli: readCommandVersion("codex", ["--version"]),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (process.platform !== "darwin") return versions;
|
|
28
|
+
|
|
29
|
+
const applications = [
|
|
30
|
+
["chatgptDesktop", "/Applications/ChatGPT.app/Contents/Info.plist"],
|
|
31
|
+
["claudeDesktop", "/Applications/Claude.app/Contents/Info.plist"],
|
|
32
|
+
];
|
|
33
|
+
for (const [key, infoPath] of applications) {
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(infoPath);
|
|
36
|
+
versions[key] = readCommandVersion(
|
|
37
|
+
"/usr/libexec/PlistBuddy",
|
|
38
|
+
["-c", "Print :CFBundleShortVersionString", infoPath],
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return versions;
|
|
44
|
+
}
|