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/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 ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
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 <30|60|90> Show sessions last active that many days ago
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 <30|60|90> Show sessions last active at least this long ago
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 (!ALLOWED_INACTIVE_DAYS.has(days)) {
580
- throw new Error("Inactive days must be 30, 60, or 90.");
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 || "standard";
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 restoreResult = await state.provider.restoreSessionDeletionBackup({
892
- backupDirectory: backup.backupDirectory,
893
- ...providerOptions(state.provider.id, state.providerHome),
894
- onProgress: ({ message }) => {
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
- const cleanupDirectories = [
899
- restoreResult.safetyBackupDirectory,
900
- backup.backupDirectory,
901
- ].filter(Boolean);
902
- const retainedDirectories = [];
903
-
904
- for (const backupDirectory of cleanupDirectories) {
905
- try {
906
- await state.provider.deleteSessionDeletionBackup({
907
- backupDirectory,
908
- ...providerOptions(state.provider.id, state.providerHome),
909
- });
910
- } catch {
911
- retainedDirectories.push(backupDirectory);
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
- state.provider.invalidateSessionCache?.(providerOptions(state.provider.id, state.providerHome));
916
- const restoredCount = restoreResult.restoredFileCount ?? restoreResult.restoredEntryCount ?? 0;
917
- output.write(`Restored and verified ${restoredCount} session data files.\n`);
918
- if (retainedDirectories.length > 0) {
919
- output.write(`Restore completed, but recovery files remain at ${retainedDirectories.join(", ")}.\n`);
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
- await state.provider.deleteSessionDeletionBackup({
945
- backupDirectory: backup.backupDirectory,
946
- ...providerOptions(state.provider.id, state.providerHome),
959
+ const options = providerOptions(state.provider.id, state.providerHome);
960
+ const releaseMutationLock = await acquireSessionMutationLock({
961
+ options,
962
+ provider: state.provider,
947
963
  });
948
- output.write(`Deleted recovery backup ${backup.id}.\n`);
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 scope = cleanupScope(state.cleanupMode);
1059
- if (scope === "deep") {
1060
- await state.provider.assertDeepCleanupSupported(
1061
- providerOptions(state.provider.id, state.providerHome),
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 deletionPlan = await state.provider.planSessionDeletion({
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
- let cancelRequested = false;
1098
- let canCancel = true;
1099
- let lastMessage = "";
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 state.provider.deleteSessionDeletionBackup({
1137
- backupDirectory: result.backupDirectory,
1138
- ...providerOptions(state.provider.id, state.providerHome),
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
- } catch {
1141
- output.write(`Cleanup completed, but its recovery backup remains at ${result.backupDirectory}.\n`);
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
- if (result.skippedTranscriptPaths.length > 0) {
1153
- output.write(
1154
- `Skipped ${result.skippedTranscriptPaths.length} missing transcript paths.\n`,
1155
- );
1156
- }
1157
- if (result.unrecognizedLocationCount > 0) {
1158
- output.write(
1159
- `${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`,
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
- state.provider.invalidateSessionCache?.(
1164
- providerOptions(state.provider.id, state.providerHome),
1165
- );
1166
- state.page = 1;
1167
- state.forceRefresh = true;
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
+ }