session-steward 0.1.1 → 0.2.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/server.mjs CHANGED
@@ -11,10 +11,12 @@ import { classifyInstalledVersion } from "./version-support.mjs";
11
11
 
12
12
  const {
13
13
  assertDeepCleanupSupported,
14
+ deleteSessionDeletionBackup,
14
15
  formatSessionForJson,
15
16
  executeSessionDeletion,
16
17
  fingerprintSessionDeletion,
17
18
  diagnoseStorageCompatibility,
19
+ getSessionOverview,
18
20
  getSessionRecord,
19
21
  listSessions,
20
22
  loadDeletionStore,
@@ -32,6 +34,9 @@ const MAX_SAVED_PLANS = 20;
32
34
  const MAX_SAVED_OPERATIONS = 50;
33
35
  const PLAN_RECORD_SAMPLE_LIMIT = 20;
34
36
  const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
37
+ const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
38
+ const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
39
+ const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
35
40
  const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
36
41
  const staticAssets = new Map([
37
42
  ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
@@ -164,6 +169,30 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
164
169
  return Math.min(parsed, maximum);
165
170
  }
166
171
 
172
+ function getInactiveBeforeMs(value) {
173
+ if (value === null || value === "") {
174
+ return null;
175
+ }
176
+
177
+ const days = Number(value);
178
+
179
+ if (!ALLOWED_INACTIVE_DAYS.has(days)) {
180
+ throw new Error("Last activity must be 30, 60, or 90 days.");
181
+ }
182
+
183
+ return Date.now() - days * 24 * 60 * 60 * 1000;
184
+ }
185
+
186
+ function getArchiveStatus(value) {
187
+ const status = value || "all";
188
+
189
+ if (!ALLOWED_ARCHIVE_STATUSES.has(status)) {
190
+ throw new Error("Session status must be all, active, or archived.");
191
+ }
192
+
193
+ return status;
194
+ }
195
+
167
196
  function getLocalRequestOrigin({ hostHeader, server }) {
168
197
  if (typeof hostHeader !== "string") {
169
198
  return null;
@@ -196,7 +225,15 @@ function requireMutationAuthorization({ request, requestUrl, token }) {
196
225
  }
197
226
  }
198
227
 
199
- function summarizePlan(plan, preflight) {
228
+ function summarizePlan(plan, preflight, scope) {
229
+ const relatedRecordCount = plan.historyMatchCount
230
+ + plan.sessionIndexMatchCount
231
+ + plan.logRowCount
232
+ + plan.spawnEdgeCount
233
+ + (scope === "deep"
234
+ ? plan.goalRowCount + plan.memoryRowCount + preflight.desktopStateMatchCount
235
+ : 0);
236
+
200
237
  return {
201
238
  availableDiskBytes: preflight.availableDiskBytes,
202
239
  childCount: plan.childCount,
@@ -208,6 +245,7 @@ function summarizePlan(plan, preflight) {
208
245
  logRowCount: plan.logRowCount,
209
246
  memoryRowCount: plan.memoryRowCount,
210
247
  missingTranscriptCount: plan.missingTranscriptPaths.length,
248
+ newestLinkedActivityAtMs: plan.newestLinkedActivityAtMs,
211
249
  recordSamples: plan.records.slice(0, PLAN_RECORD_SAMPLE_LIMIT).map((record) => ({
212
250
  displayName: record.displayName,
213
251
  id: record.id,
@@ -215,7 +253,9 @@ function summarizePlan(plan, preflight) {
215
253
  sessionIndexMatchCount: plan.sessionIndexMatchCount,
216
254
  sessionCount: plan.ids.length,
217
255
  spawnEdgeCount: plan.spawnEdgeCount,
218
- transcriptCount: plan.transcriptPaths.length,
256
+ relatedRecordCount,
257
+ transcriptBytes: plan.transcriptBytes,
258
+ transcriptCount: plan.transcriptFileCount,
219
259
  };
220
260
  }
221
261
 
@@ -235,9 +275,9 @@ function summarizeVerification(verification) {
235
275
 
236
276
  function summarizeDeletionResult(result) {
237
277
  return {
238
- backupDirectory: result.backupDirectory,
239
278
  deletedSessionCount: result.deletedIds.length,
240
279
  deletedTranscriptCount: result.deletedTranscriptPaths.length,
280
+ recoveryBackupDeleted: false,
241
281
  skippedTranscriptCount: result.skippedTranscriptPaths.length,
242
282
  };
243
283
  }
@@ -245,7 +285,9 @@ function summarizeDeletionResult(result) {
245
285
  function publicOperation(operation) {
246
286
  return {
247
287
  backupDirectory: operation.backupDirectory ?? null,
288
+ backupDeleteError: operation.backupDeleteError ?? null,
248
289
  canCancel: Boolean(operation.canCancel),
290
+ canDeleteBackup: Boolean(operation.canDeleteBackup),
249
291
  canRestore: Boolean(operation.canRestore),
250
292
  cancelRequested: Boolean(operation.cancelRequested),
251
293
  error: operation.error ?? null,
@@ -291,6 +333,57 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
291
333
  const deletionPlans = new Map();
292
334
  const operations = new Map();
293
335
  const activeTasks = new Set();
336
+ let overviewCache = null;
337
+
338
+ function invalidateSessionOverview() {
339
+ overviewCache = null;
340
+ }
341
+
342
+ async function readSessionOverview({ refresh = false } = {}) {
343
+ const activeCodexHome = settings.getHome("codex");
344
+
345
+ if (
346
+ !refresh
347
+ && overviewCache?.codexHome === activeCodexHome
348
+ && overviewCache.expiresAtMs > Date.now()
349
+ ) {
350
+ return overviewCache.overview;
351
+ }
352
+
353
+ const overview = await getSessionOverview({ codexHome: activeCodexHome });
354
+ overviewCache = {
355
+ codexHome: activeCodexHome,
356
+ expiresAtMs: Date.now() + SESSION_OVERVIEW_TTL_MS,
357
+ overview,
358
+ };
359
+ return overview;
360
+ }
361
+
362
+ async function removeOperationBackups(operation, additionalDirectories = []) {
363
+ const candidates = [...new Set([
364
+ ...(operation.backupDirectories ?? []),
365
+ operation.backupDirectory,
366
+ ...additionalDirectories,
367
+ ].filter(Boolean))]
368
+ .sort((left, right) => right.length - left.length);
369
+ const remaining = [];
370
+
371
+ for (const backupDirectory of candidates) {
372
+ try {
373
+ await deleteSessionDeletionBackup({
374
+ backupDirectory,
375
+ codexHome: operation.codexHome,
376
+ });
377
+ } catch {
378
+ remaining.push(backupDirectory);
379
+ }
380
+ }
381
+
382
+ operation.backupDirectories = remaining;
383
+ operation.backupDirectory = remaining[0] ?? null;
384
+ operation.canDeleteBackup = remaining.length > 0;
385
+ return remaining.length === 0;
386
+ }
294
387
 
295
388
  function getDeletionPlan(planId) {
296
389
  const savedPlan = typeof planId === "string" ? deletionPlans.get(planId) : null;
@@ -380,6 +473,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
380
473
  store: currentStore,
381
474
  });
382
475
  operation.backupDirectory = result.backupDirectory;
476
+ operation.backupDirectories = [result.backupDirectory];
383
477
  operation.result = summarizeDeletionResult(result);
384
478
  operation.canCancel = false;
385
479
  operation.message = "Checking that cleanup completed";
@@ -395,8 +489,14 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
395
489
 
396
490
  if (verification.complete) {
397
491
  operation.message = "Cleanup completed";
492
+ if (await removeOperationBackups(operation)) {
493
+ operation.result.recoveryBackupDeleted = true;
494
+ } else {
495
+ operation.backupDeleteError = "Cleanup completed, but its recovery backup could not be removed.";
496
+ }
398
497
  operation.status = "completed";
399
498
  } else {
499
+ operation.canDeleteBackup = true;
400
500
  operation.canRestore = true;
401
501
  operation.error = "Cleanup finished, but some selected items remain. You can restore the recovery backup.";
402
502
  operation.message = "Cleanup needs attention";
@@ -404,6 +504,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
404
504
  }
405
505
  } catch (error) {
406
506
  operation.backupDirectory = error?.backupDirectory ?? null;
507
+ operation.backupDirectories = operation.backupDirectory ? [operation.backupDirectory] : [];
407
508
  operation.canCancel = false;
408
509
  operation.errorCode = error?.code ?? null;
409
510
  operation.progress = error?.cancelled ? operation.progress : 100;
@@ -411,14 +512,19 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
411
512
  if (error?.cancelled) {
412
513
  operation.error = null;
413
514
  operation.message = "Cleanup cancelled before session data changed";
515
+ if (!(await removeOperationBackups(operation))) {
516
+ operation.backupDeleteError = "Cleanup was cancelled, but its temporary backup could not be removed.";
517
+ }
414
518
  operation.status = "cancelled";
415
519
  } else {
520
+ operation.canDeleteBackup = Boolean(operation.backupDirectory);
416
521
  operation.canRestore = Boolean(operation.backupDirectory);
417
522
  operation.error = error instanceof Error ? error.message : "Cleanup could not be completed.";
418
523
  operation.message = "Cleanup could not be completed";
419
524
  operation.status = "failed";
420
525
  }
421
526
  } finally {
527
+ invalidateSessionOverview();
422
528
  savedPlan.consumed = true;
423
529
  deletionPlans.delete(savedPlan.id);
424
530
  operation.finishedAtMs = Date.now();
@@ -431,7 +537,10 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
431
537
  const id = randomBytes(18).toString("base64url");
432
538
  const operation = {
433
539
  backupDirectory: null,
540
+ backupDirectories: [],
541
+ backupDeleteError: null,
434
542
  canCancel: true,
543
+ canDeleteBackup: false,
435
544
  canRestore: false,
436
545
  cancelRequested: false,
437
546
  codexHome: savedPlan.codexHome,
@@ -457,7 +566,9 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
457
566
 
458
567
  function startRestoreOperation(operation) {
459
568
  operation.canCancel = false;
569
+ operation.canDeleteBackup = false;
460
570
  operation.canRestore = false;
571
+ operation.backupDeleteError = null;
461
572
  operation.error = null;
462
573
  operation.errorCode = null;
463
574
  operation.message = "Restore queued";
@@ -467,20 +578,38 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
467
578
  activeOperationId = operation.id;
468
579
  const task = (async () => {
469
580
  try {
470
- operation.restoreResult = await restoreSessionDeletionBackup({
581
+ const restoreResult = await restoreSessionDeletionBackup({
471
582
  backupDirectory: operation.backupDirectory,
472
583
  codexHome: operation.codexHome,
473
584
  onProgress: (update) => Object.assign(operation, update),
474
585
  });
586
+ operation.restoreResult = restoreResult;
475
587
  operation.message = "Recovery backup restored";
476
588
  operation.progress = 100;
589
+ if (await removeOperationBackups(operation, [restoreResult.safetyBackupDirectory])) {
590
+ operation.restoreResult = {
591
+ ...restoreResult,
592
+ recoveryBackupsDeleted: true,
593
+ safetyBackupDirectory: null,
594
+ };
595
+ } else {
596
+ operation.backupDeleteError = "The sessions were restored, but temporary recovery files could not be removed.";
597
+ }
477
598
  operation.status = "restored";
478
599
  } catch (error) {
600
+ if (error?.safetyBackupDirectory) {
601
+ operation.backupDirectories = [...new Set([
602
+ ...(operation.backupDirectories ?? []),
603
+ error.safetyBackupDirectory,
604
+ ])];
605
+ }
606
+ operation.canDeleteBackup = true;
479
607
  operation.canRestore = true;
480
608
  operation.error = error instanceof Error ? error.message : "The recovery backup could not be restored.";
481
609
  operation.message = "Restore could not be completed";
482
610
  operation.status = "restore-failed";
483
611
  } finally {
612
+ invalidateSessionOverview();
484
613
  operation.finishedAtMs = Date.now();
485
614
  if (activeOperationId === operation.id) activeOperationId = null;
486
615
  }
@@ -552,6 +681,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
552
681
  ? await settings.resetProviderHome(providerId)
553
682
  : await settings.setProviderHome(providerId, (await readJsonBody(request)).home);
554
683
  deletionPlans.clear();
684
+ invalidateSessionOverview();
555
685
  sendJson(response, 200, { provider });
556
686
  } finally {
557
687
  mutationInProgress = false;
@@ -576,15 +706,28 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
576
706
  return;
577
707
  }
578
708
 
709
+ if (request.method === "GET" && requestUrl.pathname === "/api/session-overview") {
710
+ const overview = await readSessionOverview({
711
+ refresh: requestUrl.searchParams.get("refresh") === "true",
712
+ });
713
+ sendJson(response, 200, { overview });
714
+ return;
715
+ }
716
+
579
717
  if (request.method === "GET" && requestUrl.pathname === "/api/sessions") {
580
718
  const result = await listSessions({
719
+ archiveStatus: getArchiveStatus(requestUrl.searchParams.get("archiveStatus")),
581
720
  codexHome: settings.getHome("codex"),
721
+ inactiveBeforeMs: getInactiveBeforeMs(requestUrl.searchParams.get("inactiveDays")),
582
722
  includeInternals: requestUrl.searchParams.get("includeInternals") === "true",
583
723
  includeSupporting: requestUrl.searchParams.get("includeSupporting") === "true",
584
724
  page: getPositiveInteger(requestUrl.searchParams.get("page"), 1),
585
725
  pageSize: getPositiveInteger(requestUrl.searchParams.get("pageSize"), 25, 100),
586
726
  search: requestUrl.searchParams.get("search"),
587
727
  sort: requestUrl.searchParams.get("sort"),
728
+ workspace: requestUrl.searchParams.has("workspace")
729
+ ? requestUrl.searchParams.get("workspace")
730
+ : undefined,
588
731
  });
589
732
  sendJson(response, 200, {
590
733
  ...result,
@@ -640,7 +783,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
640
783
  deletionPlans.set(id, savedPlan);
641
784
  sendJson(response, 200, {
642
785
  plan: {
643
- ...summarizePlan(plan, preflight),
786
+ ...summarizePlan(plan, preflight, scope),
644
787
  expiresAtMs,
645
788
  id,
646
789
  },
@@ -674,18 +817,20 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
674
817
  return;
675
818
  }
676
819
 
677
- const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore)?$/u.exec(requestUrl.pathname);
820
+ const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore|\/backup)?$/u.exec(requestUrl.pathname);
678
821
 
679
822
  if (operationRoute) {
680
823
  const operation = getOperation(decodeURIComponent(operationRoute[1]));
681
- const restoreRoute = Boolean(operationRoute[2]);
824
+ const operationAction = operationRoute[2] ?? "";
825
+ const restoreRoute = operationAction === "/restore";
826
+ const backupRoute = operationAction === "/backup";
682
827
 
683
- if (request.method === "GET" && !restoreRoute) {
828
+ if (request.method === "GET" && !operationAction) {
684
829
  sendJson(response, 200, { operation: publicOperation(operation) });
685
830
  return;
686
831
  }
687
832
 
688
- if (request.method === "DELETE" && !restoreRoute) {
833
+ if (request.method === "DELETE" && !operationAction) {
689
834
  requireMutationAuthorization({ request, requestUrl, token: mutationToken });
690
835
  const cancelAccepted = operation.status === "queued" || (
691
836
  operation.status === "running" && operation.canCancel
@@ -698,6 +843,33 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
698
843
  return;
699
844
  }
700
845
 
846
+ if (request.method === "DELETE" && backupRoute) {
847
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
848
+
849
+ if (!operation.canDeleteBackup || !operation.backupDirectory) {
850
+ throw new Error("A recovery backup is not available for deletion.");
851
+ }
852
+
853
+ if (mutationInProgress || activeOperationId) {
854
+ sendJson(response, 409, { error: "Wait for the current cleanup to finish before deleting its backup." });
855
+ return;
856
+ }
857
+
858
+ mutationInProgress = true;
859
+ try {
860
+ if (!(await removeOperationBackups(operation))) {
861
+ throw new Error("The recovery backup could not be deleted.");
862
+ }
863
+ operation.backupDeleteError = null;
864
+ operation.canRestore = false;
865
+ if (operation.result) operation.result.recoveryBackupDeleted = true;
866
+ sendJson(response, 200, { operation: publicOperation(operation) });
867
+ } finally {
868
+ mutationInProgress = false;
869
+ }
870
+ return;
871
+ }
872
+
701
873
  if (request.method === "POST" && restoreRoute) {
702
874
  requireMutationAuthorization({ request, requestUrl, token: mutationToken });
703
875
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "session-steward",
3
- "version": "0.1.1",
4
- "description": "Safely review, back up, and remove local Codex sessions with a browser UI or terminal CLI.",
3
+ "version": "0.2.0",
4
+ "description": "A local Codex session manager for safely reviewing and deleting old sessions with a browser UI or terminal CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Mallik Cheripally",
7
7
  "type": "module",
@@ -18,23 +18,24 @@
18
18
  "keywords": [
19
19
  "codex",
20
20
  "codex-cli",
21
- "codex-sessions",
22
21
  "openai-codex",
23
- "openai",
24
- "chatgpt",
22
+ "codex-sessions",
23
+ "codex-session-manager",
24
+ "codex-session-cleanup",
25
+ "codex-cleanup",
26
+ "codex-history",
27
+ "delete-codex-sessions",
25
28
  "session-manager",
26
29
  "session-cleanup",
30
+ "chatgpt-desktop",
31
+ "chatgpt-history",
27
32
  "ai-coding-agent",
28
- "cli",
29
- "backup",
30
- "privacy",
31
- "local-first"
33
+ "chatgpt-app-cleanup"
32
34
  ],
33
35
  "os": ["darwin", "linux"],
34
36
  "files": [
35
37
  "bin",
36
38
  "dist",
37
- "docs",
38
39
  "lib",
39
40
  "LICENSE",
40
41
  "README.md"
@@ -48,6 +49,7 @@
48
49
  "scripts": {
49
50
  "build": "vite build",
50
51
  "benchmark:discovery": "node --expose-gc test/benchmarks/codex-discovery.mjs",
52
+ "benchmark:overview": "node --expose-gc test/benchmarks/codex-overview.mjs",
51
53
  "benchmark:scale": "node --expose-gc test/benchmarks/codex-list.mjs",
52
54
  "benchmark:transcripts": "node --expose-gc test/benchmarks/codex-transcripts.mjs",
53
55
  "prepack": "npm run build",