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
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SESSION_CLEANUP_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
|
|
6
|
+
export const SESSION_MUTATION_BUSY = "SESSION_MUTATION_BUSY";
|
|
7
|
+
|
|
8
|
+
const CLEANUP_SCOPES = new Set(["core", "deep"]);
|
|
9
|
+
const MUTATION_LOCK_INCOMPLETE_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
function providerHome({ options, provider }) {
|
|
12
|
+
const home = provider?.id === "codex"
|
|
13
|
+
? options?.codexHome
|
|
14
|
+
: provider?.id === "claude-code" ? options?.claudeHome : null;
|
|
15
|
+
if (typeof home !== "string" || !path.isAbsolute(home)) {
|
|
16
|
+
throw new Error("Session Steward could not identify the provider folder for this change.");
|
|
17
|
+
}
|
|
18
|
+
return path.resolve(home);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function processIsRunning(pid) {
|
|
22
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error?.code === "ESRCH") return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function mutationLockIsStale(lockPath, now) {
|
|
33
|
+
let stats;
|
|
34
|
+
try {
|
|
35
|
+
stats = await fs.stat(lockPath);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error?.code === "ENOENT") return true;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
const age = now() - stats.mtimeMs;
|
|
41
|
+
try {
|
|
42
|
+
const metadata = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
43
|
+
const running = await processIsRunning(metadata?.pid);
|
|
44
|
+
return running === null ? age >= MUTATION_LOCK_INCOMPLETE_MS : !running;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === "ENOENT") return true;
|
|
47
|
+
return age >= MUTATION_LOCK_INCOMPLETE_MS;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function acquireSessionMutationLock({
|
|
52
|
+
now = Date.now,
|
|
53
|
+
options,
|
|
54
|
+
provider,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const home = providerHome({ options, provider });
|
|
57
|
+
const lockDirectory = path.join(home, "session-steward-backups");
|
|
58
|
+
const lockPath = path.join(lockDirectory, ".mutation.lock");
|
|
59
|
+
const token = randomBytes(18).toString("base64url");
|
|
60
|
+
await fs.mkdir(lockDirectory, { mode: 0o700, recursive: true });
|
|
61
|
+
|
|
62
|
+
let handle;
|
|
63
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
64
|
+
try {
|
|
65
|
+
handle = await fs.open(lockPath, "wx", 0o600);
|
|
66
|
+
break;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code !== "EEXIST") throw error;
|
|
69
|
+
if (attempt === 0 && await mutationLockIsStale(lockPath, now)) {
|
|
70
|
+
await fs.rm(lockPath, { force: true });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const busy = new Error(
|
|
74
|
+
`Another Session Steward cleanup or restore is already using this ${provider.displayName} folder.`,
|
|
75
|
+
);
|
|
76
|
+
busy.code = SESSION_MUTATION_BUSY;
|
|
77
|
+
throw busy;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!handle) throw new Error("Session Steward could not lock the provider folder.");
|
|
82
|
+
try {
|
|
83
|
+
await handle.writeFile(`${JSON.stringify({
|
|
84
|
+
createdAtMs: now(),
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
providerId: provider.id,
|
|
87
|
+
token,
|
|
88
|
+
version: 1,
|
|
89
|
+
})}\n`, "utf8");
|
|
90
|
+
await handle.sync();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await handle.close().catch(() => {});
|
|
93
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let released = false;
|
|
98
|
+
return async function releaseSessionMutationLock() {
|
|
99
|
+
if (released) return;
|
|
100
|
+
released = true;
|
|
101
|
+
await handle.close().catch(() => {});
|
|
102
|
+
try {
|
|
103
|
+
const metadata = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
104
|
+
if (metadata?.token === token) await fs.rm(lockPath, { force: true });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function withSessionMutationLock({ options, provider }, operation) {
|
|
112
|
+
const release = await acquireSessionMutationLock({ options, provider });
|
|
113
|
+
try {
|
|
114
|
+
return await operation();
|
|
115
|
+
} finally {
|
|
116
|
+
await release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cleanupReviewError(message) {
|
|
121
|
+
const error = new Error(message);
|
|
122
|
+
error.code = SESSION_CLEANUP_REVIEW_REQUIRED;
|
|
123
|
+
return error;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeRecordIds(recordIds) {
|
|
127
|
+
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
|
128
|
+
throw new Error("Choose at least one session to clean.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ids = [...new Set(recordIds)];
|
|
132
|
+
if (!ids.every((id) => typeof id === "string" && id.length > 0)) {
|
|
133
|
+
throw new Error("Every selected session must have a valid ID.");
|
|
134
|
+
}
|
|
135
|
+
return ids;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateScope(scope) {
|
|
139
|
+
if (!CLEANUP_SCOPES.has(scope)) {
|
|
140
|
+
throw new Error("Cleanup scope must be core or deep.");
|
|
141
|
+
}
|
|
142
|
+
return scope;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function resolveSessionCleanupScope({ options, provider, scope }) {
|
|
146
|
+
const requestedScope = validateScope(scope);
|
|
147
|
+
if (requestedScope !== "deep") {
|
|
148
|
+
return { fallback: false, requestedScope, scope: requestedScope };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const compatibility = await provider.diagnoseStorageCompatibility(options);
|
|
152
|
+
const fallback = compatibility.status === "unsupported";
|
|
153
|
+
return {
|
|
154
|
+
compatibility,
|
|
155
|
+
fallback,
|
|
156
|
+
requestedScope,
|
|
157
|
+
scope: fallback ? "core" : requestedScope,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function summarizeVerification(verification) {
|
|
162
|
+
return {
|
|
163
|
+
complete: Boolean(verification.complete),
|
|
164
|
+
remainingDesktopStateReferenceCount: verification.remainingDesktopStateReferences.length,
|
|
165
|
+
remainingGoalRecordCount: verification.remainingGoalRecords.length,
|
|
166
|
+
remainingHistoryEntryCount: verification.remainingHistoryEntryCount,
|
|
167
|
+
remainingLogRecordCount: verification.remainingLogRecords.length,
|
|
168
|
+
remainingMemoryRecordCount: verification.remainingMemoryRecords.length,
|
|
169
|
+
remainingSessionIndexEntryCount: verification.remainingSessionIndexEntryCount,
|
|
170
|
+
remainingThreadCount: verification.remainingThreads.length,
|
|
171
|
+
remainingTranscriptCount: verification.remainingTranscriptPaths.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadCurrentCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
176
|
+
if (scope === "deep") await provider.assertDeepCleanupSupported(options);
|
|
177
|
+
|
|
178
|
+
let store;
|
|
179
|
+
try {
|
|
180
|
+
store = await provider.loadDeletionStore({ ...options, recordIds });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.message === "One or more selected sessions are no longer available.") {
|
|
183
|
+
throw cleanupReviewError("The selected sessions changed after this preview. Review the selection again.");
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const plan = await provider.planSessionDeletion({ recordIds, store });
|
|
189
|
+
const fingerprint = await provider.fingerprintSessionDeletion({ plan, scope, store });
|
|
190
|
+
if (expectedFingerprint !== undefined && fingerprint !== expectedFingerprint) {
|
|
191
|
+
throw cleanupReviewError("Session data changed after this preview. Review the selection again.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const preflight = await provider.preflightSessionDeletion({ plan, scope, store });
|
|
195
|
+
return { fingerprint, plan, preflight, store };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function prepareSessionCleanup({ options, provider, recordIds, scope }) {
|
|
199
|
+
const requestedIds = normalizeRecordIds(recordIds);
|
|
200
|
+
const resolvedScope = validateScope(scope);
|
|
201
|
+
const current = await loadCurrentCleanup({
|
|
202
|
+
options,
|
|
203
|
+
provider,
|
|
204
|
+
recordIds: requestedIds,
|
|
205
|
+
scope: resolvedScope,
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
fingerprint: current.fingerprint,
|
|
209
|
+
plan: current.plan,
|
|
210
|
+
preflight: current.preflight,
|
|
211
|
+
requestedIds,
|
|
212
|
+
scope: resolvedScope,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function revalidateSessionCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
217
|
+
return loadCurrentCleanup({
|
|
218
|
+
expectedFingerprint,
|
|
219
|
+
options,
|
|
220
|
+
provider,
|
|
221
|
+
recordIds: normalizeRecordIds(recordIds),
|
|
222
|
+
scope: validateScope(scope),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function executePreparedSessionCleanup({
|
|
227
|
+
expectedFingerprint,
|
|
228
|
+
onProgress = () => {},
|
|
229
|
+
options,
|
|
230
|
+
provider,
|
|
231
|
+
recordIds,
|
|
232
|
+
scope,
|
|
233
|
+
shouldCancel = () => false,
|
|
234
|
+
}) {
|
|
235
|
+
const current = await revalidateSessionCleanup({
|
|
236
|
+
expectedFingerprint,
|
|
237
|
+
options,
|
|
238
|
+
provider,
|
|
239
|
+
recordIds,
|
|
240
|
+
scope,
|
|
241
|
+
});
|
|
242
|
+
let deletion;
|
|
243
|
+
try {
|
|
244
|
+
deletion = await provider.executeSessionDeletion({
|
|
245
|
+
onProgress,
|
|
246
|
+
plan: current.plan,
|
|
247
|
+
scope,
|
|
248
|
+
shouldCancel,
|
|
249
|
+
store: current.store,
|
|
250
|
+
});
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error && typeof error === "object") error.cleanupPlan ??= current.plan;
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
onProgress({
|
|
256
|
+
canCancel: false,
|
|
257
|
+
message: "Checking that cleanup completed",
|
|
258
|
+
phase: "verification",
|
|
259
|
+
progress: 94,
|
|
260
|
+
});
|
|
261
|
+
let verification;
|
|
262
|
+
try {
|
|
263
|
+
verification = await provider.verifySessionDeletion({
|
|
264
|
+
plan: current.plan,
|
|
265
|
+
scope,
|
|
266
|
+
store: current.store,
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error && typeof error === "object") {
|
|
270
|
+
error.backupDirectory ??= deletion.backupDirectory;
|
|
271
|
+
error.cleanupPlan ??= current.plan;
|
|
272
|
+
error.deletionResult ??= deletion;
|
|
273
|
+
error.mutationStarted ??= true;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
deletion,
|
|
279
|
+
plan: current.plan,
|
|
280
|
+
preflight: current.preflight,
|
|
281
|
+
verification,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function deleteBackups(provider, options, backupDirectories) {
|
|
286
|
+
const retained = [];
|
|
287
|
+
for (const backupDirectory of [...new Set(backupDirectories.filter(Boolean))]
|
|
288
|
+
.sort((left, right) => right.length - left.length)) {
|
|
289
|
+
try {
|
|
290
|
+
await provider.deleteSessionDeletionBackup({ ...options, backupDirectory });
|
|
291
|
+
} catch {
|
|
292
|
+
retained.push(backupDirectory);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return retained;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function restoreAndCheck({ backupDirectory, options, plan, provider, onProgress }) {
|
|
299
|
+
const backupDirectories = [backupDirectory];
|
|
300
|
+
try {
|
|
301
|
+
const restoreResult = await provider.restoreSessionDeletionBackup({
|
|
302
|
+
...options,
|
|
303
|
+
backupDirectory,
|
|
304
|
+
onProgress,
|
|
305
|
+
});
|
|
306
|
+
if (restoreResult?.safetyBackupDirectory) {
|
|
307
|
+
backupDirectories.push(restoreResult.safetyBackupDirectory);
|
|
308
|
+
}
|
|
309
|
+
provider.invalidateSessionCache?.(options);
|
|
310
|
+
const restoredRecords = await Promise.all(
|
|
311
|
+
plan.ids.map((id) => provider.getSessionRecord({ ...options, id })),
|
|
312
|
+
);
|
|
313
|
+
if (restoredRecords.some((record) => !record)) {
|
|
314
|
+
throw new Error("One or more sessions were not restored.");
|
|
315
|
+
}
|
|
316
|
+
const retained = await deleteBackups(provider, options, backupDirectories);
|
|
317
|
+
return {
|
|
318
|
+
backupRetained: retained.length > 0,
|
|
319
|
+
completed: true,
|
|
320
|
+
};
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error?.safetyBackupDirectory) backupDirectories.push(error.safetyBackupDirectory);
|
|
323
|
+
return {
|
|
324
|
+
backupRetained: true,
|
|
325
|
+
completed: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function baseResult({ plan, preflight, requestedIds, scope }) {
|
|
331
|
+
return {
|
|
332
|
+
affectedSessionCount: plan.ids.length,
|
|
333
|
+
childSessionCount: Math.max(0, plan.ids.length - requestedIds.length),
|
|
334
|
+
cleanupMode: scope === "deep" ? "thorough" : "standard",
|
|
335
|
+
requestedSessionCount: requestedIds.length,
|
|
336
|
+
transcriptBytes: preflight.transcriptBytes ?? plan.transcriptBytes ?? 0,
|
|
337
|
+
transcriptFileCount: preflight.transcriptFileCount ?? plan.transcriptFileCount ?? 0,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function safeCleanupError(error, backupRetained) {
|
|
342
|
+
const cause = error?.cause instanceof Error ? error.cause : error;
|
|
343
|
+
const detail = cause instanceof Error && cause.message
|
|
344
|
+
? cause.message
|
|
345
|
+
: "Cleanup could not be started.";
|
|
346
|
+
return new Error(
|
|
347
|
+
backupRetained
|
|
348
|
+
? `${detail} The temporary recovery backup could not be removed; open Session Steward's browser UI or terminal CLI to review it.`
|
|
349
|
+
: detail,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function runSessionCleanupUnlocked({ onProgress, options, provider, recordIds, scope, signal }) {
|
|
354
|
+
let prepared;
|
|
355
|
+
try {
|
|
356
|
+
prepared = await prepareSessionCleanup({ options, provider, recordIds, scope });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error?.code === SESSION_CLEANUP_REVIEW_REQUIRED) {
|
|
359
|
+
throw cleanupReviewError("The selected session data changed before cleanup. Find matching sessions again and retry.");
|
|
360
|
+
}
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
const base = baseResult({
|
|
364
|
+
plan: prepared.plan,
|
|
365
|
+
preflight: prepared.preflight,
|
|
366
|
+
requestedIds: prepared.requestedIds,
|
|
367
|
+
scope: prepared.scope,
|
|
368
|
+
});
|
|
369
|
+
let backupDirectory = null;
|
|
370
|
+
let deletionResult = null;
|
|
371
|
+
let execution = null;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
execution = await executePreparedSessionCleanup({
|
|
375
|
+
expectedFingerprint: prepared.fingerprint,
|
|
376
|
+
onProgress,
|
|
377
|
+
options,
|
|
378
|
+
provider,
|
|
379
|
+
recordIds: prepared.requestedIds,
|
|
380
|
+
scope: prepared.scope,
|
|
381
|
+
shouldCancel: () => Boolean(signal?.aborted),
|
|
382
|
+
});
|
|
383
|
+
deletionResult = execution.deletion;
|
|
384
|
+
backupDirectory = deletionResult.backupDirectory;
|
|
385
|
+
const { verification } = execution;
|
|
386
|
+
|
|
387
|
+
if (verification.complete) {
|
|
388
|
+
const retained = await deleteBackups(provider, options, [backupDirectory]);
|
|
389
|
+
return {
|
|
390
|
+
...base,
|
|
391
|
+
deletedSessionCount: deletionResult.deletedIds.length,
|
|
392
|
+
deletedTranscriptCount: deletionResult.deletedTranscriptPaths.length,
|
|
393
|
+
recovery: {
|
|
394
|
+
attempted: false,
|
|
395
|
+
backupRetained: retained.length > 0,
|
|
396
|
+
completed: false,
|
|
397
|
+
},
|
|
398
|
+
skippedTranscriptCount: deletionResult.skippedTranscriptPaths.length,
|
|
399
|
+
status: "completed",
|
|
400
|
+
unrecognizedLocationCount: deletionResult.unrecognizedLocationCount ?? 0,
|
|
401
|
+
verification: summarizeVerification(verification),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const recovery = await restoreAndCheck({
|
|
406
|
+
backupDirectory,
|
|
407
|
+
onProgress,
|
|
408
|
+
options,
|
|
409
|
+
plan: execution.plan,
|
|
410
|
+
provider,
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
deletedSessionCount: recovery.completed ? 0 : null,
|
|
415
|
+
deletedTranscriptCount: recovery.completed ? 0 : null,
|
|
416
|
+
recovery: { attempted: true, ...recovery },
|
|
417
|
+
skippedTranscriptCount: deletionResult.skippedTranscriptPaths.length,
|
|
418
|
+
status: recovery.completed ? "restored" : "recovery-failed",
|
|
419
|
+
unrecognizedLocationCount: deletionResult.unrecognizedLocationCount ?? 0,
|
|
420
|
+
verification: summarizeVerification(verification),
|
|
421
|
+
};
|
|
422
|
+
} catch (error) {
|
|
423
|
+
backupDirectory = error?.backupDirectory ?? backupDirectory;
|
|
424
|
+
deletionResult = error?.deletionResult ?? deletionResult;
|
|
425
|
+
if (error?.cancelled) {
|
|
426
|
+
const retained = await deleteBackups(provider, options, [backupDirectory]);
|
|
427
|
+
return {
|
|
428
|
+
...base,
|
|
429
|
+
deletedSessionCount: 0,
|
|
430
|
+
deletedTranscriptCount: 0,
|
|
431
|
+
recovery: {
|
|
432
|
+
attempted: false,
|
|
433
|
+
backupRetained: retained.length > 0,
|
|
434
|
+
completed: false,
|
|
435
|
+
},
|
|
436
|
+
skippedTranscriptCount: 0,
|
|
437
|
+
status: "cancelled",
|
|
438
|
+
unrecognizedLocationCount: 0,
|
|
439
|
+
verification: null,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
if (backupDirectory && error?.mutationStarted === false) {
|
|
443
|
+
const retained = await deleteBackups(provider, options, [backupDirectory]);
|
|
444
|
+
throw safeCleanupError(error, retained.length > 0);
|
|
445
|
+
}
|
|
446
|
+
if (error?.code === SESSION_CLEANUP_REVIEW_REQUIRED) {
|
|
447
|
+
throw cleanupReviewError("The selected session data changed before cleanup. Find matching sessions again and retry.");
|
|
448
|
+
}
|
|
449
|
+
if (!backupDirectory) throw error;
|
|
450
|
+
const recovery = await restoreAndCheck({
|
|
451
|
+
backupDirectory,
|
|
452
|
+
onProgress,
|
|
453
|
+
options,
|
|
454
|
+
plan: error?.cleanupPlan ?? execution?.plan ?? prepared.plan,
|
|
455
|
+
provider,
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
deletedSessionCount: recovery.completed ? 0 : null,
|
|
460
|
+
deletedTranscriptCount: recovery.completed ? 0 : null,
|
|
461
|
+
recovery: { attempted: true, ...recovery },
|
|
462
|
+
skippedTranscriptCount: deletionResult?.skippedTranscriptPaths?.length ?? 0,
|
|
463
|
+
status: recovery.completed ? "restored" : "recovery-failed",
|
|
464
|
+
unrecognizedLocationCount: deletionResult?.unrecognizedLocationCount ?? 0,
|
|
465
|
+
verification: null,
|
|
466
|
+
};
|
|
467
|
+
} finally {
|
|
468
|
+
provider.invalidateSessionCache?.(options);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function runSessionCleanup(args) {
|
|
473
|
+
const resolution = await resolveSessionCleanupScope(args);
|
|
474
|
+
const result = await withSessionMutationLock(
|
|
475
|
+
{ options: args.options, provider: args.provider },
|
|
476
|
+
() => runSessionCleanupUnlocked({ ...args, scope: resolution.scope }),
|
|
477
|
+
);
|
|
478
|
+
return {
|
|
479
|
+
...result,
|
|
480
|
+
cleanupFallback: resolution.fallback,
|
|
481
|
+
requestedCleanupMode: resolution.requestedScope === "deep" ? "thorough" : "standard",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizeBackupId(backupId) {
|
|
486
|
+
if (typeof backupId !== "string" || backupId.length === 0 || backupId.length > 500) {
|
|
487
|
+
throw new Error("Choose one valid recovery backup ID.");
|
|
488
|
+
}
|
|
489
|
+
return backupId;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function runSessionRestoreUnlocked({ backupId, onProgress, options, provider }) {
|
|
493
|
+
const resolvedBackupId = normalizeBackupId(backupId);
|
|
494
|
+
const backups = await provider.listSessionDeletionBackups(options);
|
|
495
|
+
const backup = backups.find((item) => item.id === resolvedBackupId);
|
|
496
|
+
if (!backup) throw new Error("That recovery backup is no longer available.");
|
|
497
|
+
if (!backup.restorable) {
|
|
498
|
+
throw new Error("That recovery backup cannot be restored automatically. Its files were left unchanged for manual recovery.");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const restoreResult = await provider.restoreSessionDeletionBackup({
|
|
503
|
+
...options,
|
|
504
|
+
backupDirectory: backup.backupDirectory,
|
|
505
|
+
onProgress,
|
|
506
|
+
});
|
|
507
|
+
provider.invalidateSessionCache?.(options);
|
|
508
|
+
const retained = await deleteBackups(provider, options, [
|
|
509
|
+
restoreResult.safetyBackupDirectory,
|
|
510
|
+
backup.backupDirectory,
|
|
511
|
+
]);
|
|
512
|
+
return {
|
|
513
|
+
backupId: resolvedBackupId,
|
|
514
|
+
note: typeof restoreResult.note === "string" ? restoreResult.note : null,
|
|
515
|
+
restoredItemCount:
|
|
516
|
+
restoreResult.restoredFileCount
|
|
517
|
+
?? restoreResult.restoredEntryCount
|
|
518
|
+
?? backup.itemCount
|
|
519
|
+
?? 0,
|
|
520
|
+
status: "restored",
|
|
521
|
+
temporaryBackupRetained: retained.length > 0,
|
|
522
|
+
};
|
|
523
|
+
} catch (error) {
|
|
524
|
+
provider.invalidateSessionCache?.(options);
|
|
525
|
+
return {
|
|
526
|
+
backupId: resolvedBackupId,
|
|
527
|
+
note: null,
|
|
528
|
+
restoredItemCount: null,
|
|
529
|
+
status: "restore-failed",
|
|
530
|
+
temporaryBackupRetained: true,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function runSessionRestore(args) {
|
|
536
|
+
return withSessionMutationLock(
|
|
537
|
+
{ options: args.options, provider: args.provider },
|
|
538
|
+
() => runSessionRestoreUnlocked(args),
|
|
539
|
+
);
|
|
540
|
+
}
|
package/lib/settings.mjs
CHANGED
|
@@ -243,6 +243,7 @@ export async function createProviderSettings({ configDirectory, providerHomeOver
|
|
|
243
243
|
return {
|
|
244
244
|
getActiveProviderId,
|
|
245
245
|
getAll: () => Object.fromEntries(Object.keys(PROVIDERS).map((providerId) => [providerId, getProvider(providerId)])),
|
|
246
|
+
getConfigDirectory: () => resolvedConfigDirectory,
|
|
246
247
|
getHome: (providerId) => getProvider(providerId).home,
|
|
247
248
|
resetProviderHome,
|
|
248
249
|
setActiveProviderId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-steward",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Codex and Claude Code session manager - browse, back up, and delete old sessions. Local browser UI + CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mallik Cheripally",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"session-steward": "bin/session-steward.mjs",
|
|
10
10
|
"session-steward-cli": "bin/session-steward-cli.mjs",
|
|
11
|
-
"session-steward-mcp": "bin/session-steward-mcp.mjs"
|
|
11
|
+
"session-steward-mcp": "bin/session-steward-mcp.mjs",
|
|
12
|
+
"session-steward-scheduler": "bin/session-steward-scheduler.mjs"
|
|
12
13
|
},
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|