session-steward 0.10.3 → 0.11.1
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 +14 -0
- package/README.md +101 -234
- package/bin/session-steward-cli.mjs +1 -0
- package/bin/session-steward-scheduler.mjs +5 -1
- package/bin/session-steward.mjs +62 -49
- package/dist/assets/index-BAo7Iw_z.js +9 -0
- package/dist/assets/index-DQxHKbAB.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cleanup-schedules.mjs +38 -18
- package/lib/cli.mjs +81 -2
- package/lib/mcp.mjs +82 -13
- package/lib/providers/claude-code/store.mjs +1 -0
- package/lib/providers/codex/store.mjs +10 -3
- package/lib/server.mjs +90 -5
- package/lib/session-cleanup.mjs +230 -53
- package/lib/session-protections.mjs +467 -0
- package/package.json +7 -4
- package/dist/assets/index-BDiEQG6G.js +0 -9
- package/dist/assets/index-CXq8Tw8T.css +0 -2
package/lib/session-cleanup.mjs
CHANGED
|
@@ -142,6 +142,124 @@ function validateScope(scope) {
|
|
|
142
142
|
return scope;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
function emptyDeletionPlan(plan) {
|
|
146
|
+
return {
|
|
147
|
+
...plan,
|
|
148
|
+
childCount: 0,
|
|
149
|
+
deepFiles: [],
|
|
150
|
+
deepPaths: [],
|
|
151
|
+
desktopStateMatchCount: 0,
|
|
152
|
+
dynamicToolRowCount: 0,
|
|
153
|
+
goalRowCount: 0,
|
|
154
|
+
historyMatchBytes: 0,
|
|
155
|
+
historyMatchCount: 0,
|
|
156
|
+
ids: [],
|
|
157
|
+
logRowCount: 0,
|
|
158
|
+
memoryJobRowCount: 0,
|
|
159
|
+
memoryOutputRowCount: 0,
|
|
160
|
+
memoryRowCount: 0,
|
|
161
|
+
memoryConsolidations: [],
|
|
162
|
+
missingTranscriptPaths: [],
|
|
163
|
+
newestLinkedActivityAtMs: 0,
|
|
164
|
+
queueRevisionRowCount: 0,
|
|
165
|
+
queueRowCount: 0,
|
|
166
|
+
records: [],
|
|
167
|
+
sessionIndexMatchCount: 0,
|
|
168
|
+
spawnEdgeCount: 0,
|
|
169
|
+
stateTargets: [],
|
|
170
|
+
threadHistoryRowCount: 0,
|
|
171
|
+
transcriptBytes: 0,
|
|
172
|
+
transcriptFileCount: 0,
|
|
173
|
+
transcriptPaths: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function emptyPreflight() {
|
|
178
|
+
return {
|
|
179
|
+
desktopStateMatchCount: 0,
|
|
180
|
+
estimatedBackupBytes: 0,
|
|
181
|
+
transcriptBytes: 0,
|
|
182
|
+
transcriptFileCount: 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function applySessionProtections({ options, plan, protectionStore, provider, recordIds, store }) {
|
|
187
|
+
if (!protectionStore) {
|
|
188
|
+
return { plan, protectionRevision: null, skippedProtections: [] };
|
|
189
|
+
}
|
|
190
|
+
const snapshot = await protectionStore.list();
|
|
191
|
+
const home = providerHome({ options, provider });
|
|
192
|
+
const classifications = new Map(plan.records.map((record) => [
|
|
193
|
+
record.id,
|
|
194
|
+
protectionStore.classify({ providerHome: home, providerId: provider.id, record, snapshot }),
|
|
195
|
+
]));
|
|
196
|
+
const requested = new Set(recordIds);
|
|
197
|
+
const skippedById = new Map();
|
|
198
|
+
|
|
199
|
+
for (const id of recordIds) {
|
|
200
|
+
const protection = classifications.get(id);
|
|
201
|
+
if (protection?.kept) {
|
|
202
|
+
skippedById.set(id, { id, keep: protection, reason: "kept-session" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const recordsById = new Map(plan.records.map((record) => [record.id, record]));
|
|
207
|
+
for (const [protectedId, protection] of classifications) {
|
|
208
|
+
if (!protection.kept || requested.has(protectedId)) continue;
|
|
209
|
+
let current = recordsById.get(protectedId);
|
|
210
|
+
const visited = new Set();
|
|
211
|
+
let assignedToRequestedRoot = false;
|
|
212
|
+
while (current?.parentThreadId && !visited.has(current.id)) {
|
|
213
|
+
visited.add(current.id);
|
|
214
|
+
const parentId = current.parentThreadId;
|
|
215
|
+
if (requested.has(parentId)) {
|
|
216
|
+
if (!skippedById.has(parentId)) {
|
|
217
|
+
skippedById.set(parentId, {
|
|
218
|
+
id: parentId,
|
|
219
|
+
keep: protection,
|
|
220
|
+
linkedSessionId: protectedId,
|
|
221
|
+
reason: "kept-linked-session",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
assignedToRequestedRoot = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
current = recordsById.get(parentId);
|
|
228
|
+
}
|
|
229
|
+
if (!assignedToRequestedRoot) {
|
|
230
|
+
throw cleanupReviewError("A linked session is marked Keep. Review the selection again before cleanup.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const effectiveIds = recordIds.filter((id) => !skippedById.has(id));
|
|
235
|
+
if (effectiveIds.length === recordIds.length) {
|
|
236
|
+
return { plan, protectionRevision: snapshot.revision, skippedProtections: [] };
|
|
237
|
+
}
|
|
238
|
+
if (effectiveIds.length === 0) {
|
|
239
|
+
return {
|
|
240
|
+
plan: emptyDeletionPlan(plan),
|
|
241
|
+
protectionRevision: snapshot.revision,
|
|
242
|
+
skippedProtections: [...skippedById.values()],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const effectivePlan = await provider.planSessionDeletion({ recordIds: effectiveIds, store });
|
|
246
|
+
for (const record of effectivePlan.records) {
|
|
247
|
+
if (protectionStore.classify({
|
|
248
|
+
providerHome: home,
|
|
249
|
+
providerId: provider.id,
|
|
250
|
+
record,
|
|
251
|
+
snapshot,
|
|
252
|
+
}).kept) {
|
|
253
|
+
throw cleanupReviewError("A linked session is marked Keep. Review the selection again before cleanup.");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
plan: effectivePlan,
|
|
258
|
+
protectionRevision: snapshot.revision,
|
|
259
|
+
skippedProtections: [...skippedById.values()],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
145
263
|
export async function resolveSessionCleanupScope({ options, provider, scope }) {
|
|
146
264
|
const requestedScope = validateScope(scope);
|
|
147
265
|
if (requestedScope !== "deep") {
|
|
@@ -172,7 +290,7 @@ function summarizeVerification(verification) {
|
|
|
172
290
|
};
|
|
173
291
|
}
|
|
174
292
|
|
|
175
|
-
async function loadCurrentCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
293
|
+
async function loadCurrentCleanup({ expectedFingerprint, options, protectionStore, provider, recordIds, scope }) {
|
|
176
294
|
if (scope === "deep") await provider.assertDeepCleanupSupported(options);
|
|
177
295
|
|
|
178
296
|
let store;
|
|
@@ -185,21 +303,45 @@ async function loadCurrentCleanup({ expectedFingerprint, options, provider, reco
|
|
|
185
303
|
throw error;
|
|
186
304
|
}
|
|
187
305
|
|
|
188
|
-
const
|
|
189
|
-
const
|
|
306
|
+
const requestedPlan = await provider.planSessionDeletion({ recordIds, store });
|
|
307
|
+
const protectedPlan = await applySessionProtections({
|
|
308
|
+
options,
|
|
309
|
+
plan: requestedPlan,
|
|
310
|
+
protectionStore,
|
|
311
|
+
provider,
|
|
312
|
+
recordIds,
|
|
313
|
+
store,
|
|
314
|
+
});
|
|
315
|
+
const plan = protectedPlan.plan;
|
|
316
|
+
const providerFingerprint = plan.ids.length > 0
|
|
317
|
+
? await provider.fingerprintSessionDeletion({ plan, scope, store })
|
|
318
|
+
: "no-unprotected-sessions";
|
|
319
|
+
const fingerprint = JSON.stringify({
|
|
320
|
+
protectionRevision: protectedPlan.protectionRevision,
|
|
321
|
+
providerFingerprint,
|
|
322
|
+
});
|
|
190
323
|
if (expectedFingerprint !== undefined && fingerprint !== expectedFingerprint) {
|
|
191
324
|
throw cleanupReviewError("Session data changed after this preview. Review the selection again.");
|
|
192
325
|
}
|
|
193
326
|
|
|
194
|
-
const preflight =
|
|
195
|
-
|
|
327
|
+
const preflight = plan.ids.length > 0
|
|
328
|
+
? await provider.preflightSessionDeletion({ plan, scope, store })
|
|
329
|
+
: emptyPreflight();
|
|
330
|
+
return {
|
|
331
|
+
fingerprint,
|
|
332
|
+
plan,
|
|
333
|
+
preflight,
|
|
334
|
+
skippedProtections: protectedPlan.skippedProtections,
|
|
335
|
+
store,
|
|
336
|
+
};
|
|
196
337
|
}
|
|
197
338
|
|
|
198
|
-
export async function prepareSessionCleanup({ options, provider, recordIds, scope }) {
|
|
339
|
+
export async function prepareSessionCleanup({ options, protectionStore, provider, recordIds, scope }) {
|
|
199
340
|
const requestedIds = normalizeRecordIds(recordIds);
|
|
200
341
|
const resolvedScope = validateScope(scope);
|
|
201
342
|
const current = await loadCurrentCleanup({
|
|
202
343
|
options,
|
|
344
|
+
protectionStore,
|
|
203
345
|
provider,
|
|
204
346
|
recordIds: requestedIds,
|
|
205
347
|
scope: resolvedScope,
|
|
@@ -209,14 +351,16 @@ export async function prepareSessionCleanup({ options, provider, recordIds, scop
|
|
|
209
351
|
plan: current.plan,
|
|
210
352
|
preflight: current.preflight,
|
|
211
353
|
requestedIds,
|
|
354
|
+
skippedProtections: current.skippedProtections,
|
|
212
355
|
scope: resolvedScope,
|
|
213
356
|
};
|
|
214
357
|
}
|
|
215
358
|
|
|
216
|
-
export async function revalidateSessionCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
359
|
+
export async function revalidateSessionCleanup({ expectedFingerprint, options, protectionStore, provider, recordIds, scope }) {
|
|
217
360
|
return loadCurrentCleanup({
|
|
218
361
|
expectedFingerprint,
|
|
219
362
|
options,
|
|
363
|
+
protectionStore,
|
|
220
364
|
provider,
|
|
221
365
|
recordIds: normalizeRecordIds(recordIds),
|
|
222
366
|
scope: validateScope(scope),
|
|
@@ -227,59 +371,76 @@ export async function executePreparedSessionCleanup({
|
|
|
227
371
|
expectedFingerprint,
|
|
228
372
|
onProgress = () => {},
|
|
229
373
|
options,
|
|
374
|
+
protectionStore,
|
|
230
375
|
provider,
|
|
231
376
|
recordIds,
|
|
232
377
|
scope,
|
|
233
378
|
shouldCancel = () => false,
|
|
234
379
|
}) {
|
|
235
|
-
const
|
|
236
|
-
expectedFingerprint,
|
|
237
|
-
options,
|
|
238
|
-
provider,
|
|
239
|
-
recordIds,
|
|
240
|
-
scope,
|
|
241
|
-
});
|
|
242
|
-
let deletion;
|
|
380
|
+
const releaseProtectionLease = await protectionStore?.acquireLease();
|
|
243
381
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
382
|
+
const current = await revalidateSessionCleanup({
|
|
383
|
+
expectedFingerprint,
|
|
384
|
+
options,
|
|
385
|
+
protectionStore,
|
|
386
|
+
provider,
|
|
387
|
+
recordIds,
|
|
247
388
|
scope,
|
|
248
|
-
shouldCancel,
|
|
249
|
-
store: current.store,
|
|
250
389
|
});
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
390
|
+
if (current.plan.ids.length === 0) {
|
|
391
|
+
return {
|
|
392
|
+
deletion: null,
|
|
393
|
+
plan: current.plan,
|
|
394
|
+
preflight: current.preflight,
|
|
395
|
+
skippedProtections: current.skippedProtections,
|
|
396
|
+
verification: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
let deletion;
|
|
400
|
+
try {
|
|
401
|
+
deletion = await provider.executeSessionDeletion({
|
|
402
|
+
onProgress,
|
|
403
|
+
plan: current.plan,
|
|
404
|
+
scope,
|
|
405
|
+
shouldCancel,
|
|
406
|
+
store: current.store,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error && typeof error === "object") error.cleanupPlan ??= current.plan;
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
onProgress({
|
|
413
|
+
canCancel: false,
|
|
414
|
+
message: "Checking that cleanup completed",
|
|
415
|
+
phase: "verification",
|
|
416
|
+
progress: 94,
|
|
267
417
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
418
|
+
let verification;
|
|
419
|
+
try {
|
|
420
|
+
verification = await provider.verifySessionDeletion({
|
|
421
|
+
plan: current.plan,
|
|
422
|
+
scope,
|
|
423
|
+
store: current.store,
|
|
424
|
+
});
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error && typeof error === "object") {
|
|
427
|
+
error.backupDirectory ??= deletion.backupDirectory;
|
|
428
|
+
error.cleanupPlan ??= current.plan;
|
|
429
|
+
error.deletionResult ??= deletion;
|
|
430
|
+
error.mutationStarted ??= true;
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
274
433
|
}
|
|
275
|
-
|
|
434
|
+
return {
|
|
435
|
+
deletion,
|
|
436
|
+
plan: current.plan,
|
|
437
|
+
preflight: current.preflight,
|
|
438
|
+
skippedProtections: current.skippedProtections,
|
|
439
|
+
verification,
|
|
440
|
+
};
|
|
441
|
+
} finally {
|
|
442
|
+
await releaseProtectionLease?.();
|
|
276
443
|
}
|
|
277
|
-
return {
|
|
278
|
-
deletion,
|
|
279
|
-
plan: current.plan,
|
|
280
|
-
preflight: current.preflight,
|
|
281
|
-
verification,
|
|
282
|
-
};
|
|
283
444
|
}
|
|
284
445
|
|
|
285
446
|
async function deleteBackups(provider, options, backupDirectories) {
|
|
@@ -327,12 +488,14 @@ async function restoreAndCheck({ backupDirectory, options, plan, provider, onPro
|
|
|
327
488
|
}
|
|
328
489
|
}
|
|
329
490
|
|
|
330
|
-
function baseResult({ plan, preflight, requestedIds, scope }) {
|
|
491
|
+
function baseResult({ plan, preflight, requestedIds, scope, skippedProtections = [] }) {
|
|
331
492
|
return {
|
|
332
493
|
affectedSessionCount: plan.ids.length,
|
|
333
|
-
childSessionCount: Math.max(0, plan.ids.length - requestedIds.length),
|
|
494
|
+
childSessionCount: plan.childCount ?? Math.max(0, plan.ids.length - requestedIds.length),
|
|
334
495
|
cleanupMode: scope === "deep" ? "thorough" : "standard",
|
|
335
496
|
requestedSessionCount: requestedIds.length,
|
|
497
|
+
skippedProtectionCount: skippedProtections.length,
|
|
498
|
+
skippedProtections,
|
|
336
499
|
transcriptBytes: preflight.transcriptBytes ?? plan.transcriptBytes ?? 0,
|
|
337
500
|
transcriptFileCount: preflight.transcriptFileCount ?? plan.transcriptFileCount ?? 0,
|
|
338
501
|
};
|
|
@@ -350,10 +513,10 @@ function safeCleanupError(error, backupRetained) {
|
|
|
350
513
|
);
|
|
351
514
|
}
|
|
352
515
|
|
|
353
|
-
async function runSessionCleanupUnlocked({ onProgress, options, provider, recordIds, scope, signal }) {
|
|
516
|
+
async function runSessionCleanupUnlocked({ onProgress, options, protectionStore, provider, recordIds, scope, signal }) {
|
|
354
517
|
let prepared;
|
|
355
518
|
try {
|
|
356
|
-
prepared = await prepareSessionCleanup({ options, provider, recordIds, scope });
|
|
519
|
+
prepared = await prepareSessionCleanup({ options, protectionStore, provider, recordIds, scope });
|
|
357
520
|
} catch (error) {
|
|
358
521
|
if (error?.code === SESSION_CLEANUP_REVIEW_REQUIRED) {
|
|
359
522
|
throw cleanupReviewError("The selected session data changed before cleanup. Find matching sessions again and retry.");
|
|
@@ -365,7 +528,20 @@ async function runSessionCleanupUnlocked({ onProgress, options, provider, record
|
|
|
365
528
|
preflight: prepared.preflight,
|
|
366
529
|
requestedIds: prepared.requestedIds,
|
|
367
530
|
scope: prepared.scope,
|
|
531
|
+
skippedProtections: prepared.skippedProtections,
|
|
368
532
|
});
|
|
533
|
+
if (prepared.plan.ids.length === 0) {
|
|
534
|
+
return {
|
|
535
|
+
...base,
|
|
536
|
+
deletedSessionCount: 0,
|
|
537
|
+
deletedTranscriptCount: 0,
|
|
538
|
+
recovery: { attempted: false, backupRetained: false, completed: false },
|
|
539
|
+
skippedTranscriptCount: 0,
|
|
540
|
+
status: "protected",
|
|
541
|
+
unrecognizedLocationCount: 0,
|
|
542
|
+
verification: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
369
545
|
let backupDirectory = null;
|
|
370
546
|
let deletionResult = null;
|
|
371
547
|
let execution = null;
|
|
@@ -375,6 +551,7 @@ async function runSessionCleanupUnlocked({ onProgress, options, provider, record
|
|
|
375
551
|
expectedFingerprint: prepared.fingerprint,
|
|
376
552
|
onProgress,
|
|
377
553
|
options,
|
|
554
|
+
protectionStore,
|
|
378
555
|
provider,
|
|
379
556
|
recordIds: prepared.requestedIds,
|
|
380
557
|
scope: prepared.scope,
|