session-steward 0.1.1 → 0.3.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
@@ -5,25 +5,10 @@ import { promises as fs } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
- import { getProvider } from "./providers/index.mjs";
8
+ import { getProvider, listProviders } from "./providers/index.mjs";
9
9
  import { createProviderSettings } from "./settings.mjs";
10
10
  import { classifyInstalledVersion } from "./version-support.mjs";
11
11
 
12
- const {
13
- assertDeepCleanupSupported,
14
- formatSessionForJson,
15
- executeSessionDeletion,
16
- fingerprintSessionDeletion,
17
- diagnoseStorageCompatibility,
18
- getSessionRecord,
19
- listSessions,
20
- loadDeletionStore,
21
- planSessionDeletion,
22
- preflightSessionDeletion,
23
- restoreSessionDeletionBackup,
24
- verifySessionDeletion,
25
- } = getProvider("codex");
26
-
27
12
  const MAX_BODY_BYTES = 64 * 1024;
28
13
  const ALLOWED_SCOPES = new Set(["core", "deep"]);
29
14
  const PLAN_TTL_MS = 10 * 60 * 1000;
@@ -32,6 +17,10 @@ const MAX_SAVED_PLANS = 20;
32
17
  const MAX_SAVED_OPERATIONS = 50;
33
18
  const PLAN_RECORD_SAMPLE_LIMIT = 20;
34
19
  const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
20
+ const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
21
+ const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
22
+ const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
23
+ const ALLOWED_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
35
24
  const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
36
25
  const staticAssets = new Map([
37
26
  ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
@@ -46,22 +35,43 @@ function readCommandVersion(command, args) {
46
35
  }
47
36
 
48
37
  async function getInstalledProductVersions() {
49
- const versions = { chatgptDesktop: null, codexCli: readCommandVersion("codex", ["--version"]) };
38
+ const versions = {
39
+ chatgptDesktop: null,
40
+ claudeCli: readCommandVersion("claude", ["--version"]),
41
+ claudeDesktop: null,
42
+ codexCli: readCommandVersion("codex", ["--version"]),
43
+ };
50
44
 
51
45
  if (process.platform !== "darwin") {
52
46
  return versions;
53
47
  }
54
48
 
55
49
  const chatGptInfoPath = "/Applications/ChatGPT.app/Contents/Info.plist";
50
+ const claudeInfoPath = "/Applications/Claude.app/Contents/Info.plist";
56
51
  try {
57
52
  await fs.access(chatGptInfoPath);
58
53
  versions.chatgptDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", chatGptInfoPath]);
59
54
  } catch {
60
55
  }
56
+ try {
57
+ await fs.access(claudeInfoPath);
58
+ versions.claudeDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", claudeInfoPath]);
59
+ } catch {
60
+ }
61
61
 
62
62
  return versions;
63
63
  }
64
64
 
65
+ function providerOptions(providerId, home) {
66
+ return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
67
+ }
68
+
69
+ function resolveProviderId(value) {
70
+ const providerId = value || "codex";
71
+ getProvider(providerId);
72
+ return providerId;
73
+ }
74
+
65
75
  function getStaticAsset(requestPath) {
66
76
  const knownAsset = staticAssets.get(requestPath);
67
77
 
@@ -164,6 +174,40 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
164
174
  return Math.min(parsed, maximum);
165
175
  }
166
176
 
177
+ function getInactiveBeforeMs(value) {
178
+ if (value === null || value === "") {
179
+ return null;
180
+ }
181
+
182
+ const days = Number(value);
183
+
184
+ if (!ALLOWED_INACTIVE_DAYS.has(days)) {
185
+ throw new Error("Last activity must be 30, 60, or 90 days.");
186
+ }
187
+
188
+ return Date.now() - days * 24 * 60 * 60 * 1000;
189
+ }
190
+
191
+ function getArchiveStatus(value) {
192
+ const status = value || "all";
193
+
194
+ if (!ALLOWED_ARCHIVE_STATUSES.has(status)) {
195
+ throw new Error("Session status must be all, active, or archived.");
196
+ }
197
+
198
+ return status;
199
+ }
200
+
201
+ function getSort(value) {
202
+ const sort = value || "updated";
203
+
204
+ if (!ALLOWED_SORTS.has(sort)) {
205
+ throw new Error("Session order is not available.");
206
+ }
207
+
208
+ return sort;
209
+ }
210
+
167
211
  function getLocalRequestOrigin({ hostHeader, server }) {
168
212
  if (typeof hostHeader !== "string") {
169
213
  return null;
@@ -196,7 +240,15 @@ function requireMutationAuthorization({ request, requestUrl, token }) {
196
240
  }
197
241
  }
198
242
 
199
- function summarizePlan(plan, preflight) {
243
+ function summarizePlan(plan, preflight, scope) {
244
+ const relatedRecordCount = plan.historyMatchCount
245
+ + plan.sessionIndexMatchCount
246
+ + plan.logRowCount
247
+ + plan.spawnEdgeCount
248
+ + (scope === "deep"
249
+ ? plan.goalRowCount + plan.memoryRowCount + preflight.desktopStateMatchCount
250
+ : 0);
251
+
200
252
  return {
201
253
  availableDiskBytes: preflight.availableDiskBytes,
202
254
  childCount: plan.childCount,
@@ -208,6 +260,7 @@ function summarizePlan(plan, preflight) {
208
260
  logRowCount: plan.logRowCount,
209
261
  memoryRowCount: plan.memoryRowCount,
210
262
  missingTranscriptCount: plan.missingTranscriptPaths.length,
263
+ newestLinkedActivityAtMs: plan.newestLinkedActivityAtMs,
211
264
  recordSamples: plan.records.slice(0, PLAN_RECORD_SAMPLE_LIMIT).map((record) => ({
212
265
  displayName: record.displayName,
213
266
  id: record.id,
@@ -215,7 +268,9 @@ function summarizePlan(plan, preflight) {
215
268
  sessionIndexMatchCount: plan.sessionIndexMatchCount,
216
269
  sessionCount: plan.ids.length,
217
270
  spawnEdgeCount: plan.spawnEdgeCount,
218
- transcriptCount: plan.transcriptPaths.length,
271
+ relatedRecordCount,
272
+ transcriptBytes: preflight.transcriptBytes ?? plan.transcriptBytes,
273
+ transcriptCount: preflight.transcriptFileCount ?? plan.transcriptFileCount,
219
274
  };
220
275
  }
221
276
 
@@ -235,9 +290,9 @@ function summarizeVerification(verification) {
235
290
 
236
291
  function summarizeDeletionResult(result) {
237
292
  return {
238
- backupDirectory: result.backupDirectory,
239
293
  deletedSessionCount: result.deletedIds.length,
240
294
  deletedTranscriptCount: result.deletedTranscriptPaths.length,
295
+ recoveryBackupDeleted: false,
241
296
  skippedTranscriptCount: result.skippedTranscriptPaths.length,
242
297
  };
243
298
  }
@@ -245,7 +300,9 @@ function summarizeDeletionResult(result) {
245
300
  function publicOperation(operation) {
246
301
  return {
247
302
  backupDirectory: operation.backupDirectory ?? null,
303
+ backupDeleteError: operation.backupDeleteError ?? null,
248
304
  canCancel: Boolean(operation.canCancel),
305
+ canDeleteBackup: Boolean(operation.canDeleteBackup),
249
306
  canRestore: Boolean(operation.canRestore),
250
307
  cancelRequested: Boolean(operation.cancelRequested),
251
308
  error: operation.error ?? null,
@@ -276,14 +333,17 @@ function removeExpiredEntries(entries, ttlMs, maximum) {
276
333
  }
277
334
  }
278
335
 
279
- export async function startLocalServer({ codexHome, configDirectory, port = 0 }) {
336
+ export async function startLocalServer({ claudeHome, codexHome, configDirectory, port = 0 }) {
280
337
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
281
338
  throw new Error("port must be an integer between 0 and 65535.");
282
339
  }
283
340
 
284
341
  const settings = await createProviderSettings({
285
342
  configDirectory,
286
- providerHomeOverrides: codexHome === undefined ? {} : { codex: codexHome },
343
+ providerHomeOverrides: {
344
+ ...(codexHome === undefined ? {} : { codex: codexHome }),
345
+ ...(claudeHome === undefined ? {} : { "claude-code": claudeHome }),
346
+ },
287
347
  });
288
348
  const mutationToken = randomBytes(32).toString("base64url");
289
349
  let mutationInProgress = false;
@@ -291,6 +351,63 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
291
351
  const deletionPlans = new Map();
292
352
  const operations = new Map();
293
353
  const activeTasks = new Set();
354
+ let overviewCache = null;
355
+
356
+ function invalidateSessionOverview() {
357
+ overviewCache = null;
358
+ }
359
+
360
+ async function readSessionOverview({ providerId, refresh = false } = {}) {
361
+ const home = settings.getHome(providerId);
362
+
363
+ if (
364
+ !refresh
365
+ && overviewCache?.providerId === providerId
366
+ && overviewCache?.home === home
367
+ && overviewCache.expiresAtMs > Date.now()
368
+ ) {
369
+ return overviewCache.overview;
370
+ }
371
+
372
+ const overview = await getProvider(providerId).getSessionOverview({
373
+ ...providerOptions(providerId, home),
374
+ refresh,
375
+ });
376
+ overviewCache = {
377
+ home,
378
+ providerId,
379
+ expiresAtMs: Date.now() + SESSION_OVERVIEW_TTL_MS,
380
+ overview,
381
+ };
382
+ return overview;
383
+ }
384
+
385
+ async function removeOperationBackups(operation, additionalDirectories = []) {
386
+ const provider = getProvider(operation.providerId);
387
+ const candidates = [...new Set([
388
+ ...(operation.backupDirectories ?? []),
389
+ operation.backupDirectory,
390
+ ...additionalDirectories,
391
+ ].filter(Boolean))]
392
+ .sort((left, right) => right.length - left.length);
393
+ const remaining = [];
394
+
395
+ for (const backupDirectory of candidates) {
396
+ try {
397
+ await provider.deleteSessionDeletionBackup({
398
+ backupDirectory,
399
+ ...providerOptions(operation.providerId, operation.home),
400
+ });
401
+ } catch {
402
+ remaining.push(backupDirectory);
403
+ }
404
+ }
405
+
406
+ operation.backupDirectories = remaining;
407
+ operation.backupDirectory = remaining[0] ?? null;
408
+ operation.canDeleteBackup = remaining.length > 0;
409
+ return remaining.length === 0;
410
+ }
294
411
 
295
412
  function getDeletionPlan(planId) {
296
413
  const savedPlan = typeof planId === "string" ? deletionPlans.get(planId) : null;
@@ -310,10 +427,10 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
310
427
  );
311
428
  }
312
429
 
313
- if (savedPlan.codexHome !== settings.getHome("codex")) {
430
+ if (savedPlan.home !== settings.getHome(savedPlan.providerId)) {
314
431
  deletionPlans.delete(planId);
315
432
  throw codedError(
316
- "The Codex session folder changed. Review the selection again.",
433
+ `The ${getProvider(savedPlan.providerId).displayName} session folder changed. Review the selection again.`,
317
434
  PLAN_REVIEW_REQUIRED,
318
435
  );
319
436
  }
@@ -334,11 +451,13 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
334
451
  operation.progress = 2;
335
452
 
336
453
  try {
454
+ const provider = getProvider(savedPlan.providerId);
455
+ const options = providerOptions(savedPlan.providerId, savedPlan.home);
337
456
  let currentStore;
338
457
 
339
458
  try {
340
- currentStore = await loadDeletionStore({
341
- codexHome: savedPlan.codexHome,
459
+ currentStore = await provider.loadDeletionStore({
460
+ ...options,
342
461
  recordIds: savedPlan.requestedIds,
343
462
  });
344
463
  } catch (error) {
@@ -351,11 +470,11 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
351
470
  throw error;
352
471
  }
353
472
 
354
- const currentPlan = await planSessionDeletion({
473
+ const currentPlan = await provider.planSessionDeletion({
355
474
  recordIds: savedPlan.requestedIds,
356
475
  store: currentStore,
357
476
  });
358
- const currentFingerprint = await fingerprintSessionDeletion({
477
+ const currentFingerprint = await provider.fingerprintSessionDeletion({
359
478
  plan: currentPlan,
360
479
  scope: savedPlan.scope,
361
480
  store: currentStore,
@@ -368,11 +487,17 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
368
487
  );
369
488
  }
370
489
 
490
+ await provider.preflightSessionDeletion({
491
+ plan: currentPlan,
492
+ scope: savedPlan.scope,
493
+ store: currentStore,
494
+ });
495
+
371
496
  if (savedPlan.scope === "deep") {
372
- await assertDeepCleanupSupported({ codexHome: savedPlan.codexHome });
497
+ await provider.assertDeepCleanupSupported(options);
373
498
  }
374
499
 
375
- const result = await executeSessionDeletion({
500
+ const result = await provider.executeSessionDeletion({
376
501
  onProgress: (update) => Object.assign(operation, update),
377
502
  plan: currentPlan,
378
503
  scope: savedPlan.scope,
@@ -380,12 +505,13 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
380
505
  store: currentStore,
381
506
  });
382
507
  operation.backupDirectory = result.backupDirectory;
508
+ operation.backupDirectories = [result.backupDirectory];
383
509
  operation.result = summarizeDeletionResult(result);
384
510
  operation.canCancel = false;
385
511
  operation.message = "Checking that cleanup completed";
386
512
  operation.phase = "verification";
387
513
  operation.progress = 94;
388
- const verification = await verifySessionDeletion({
514
+ const verification = await provider.verifySessionDeletion({
389
515
  plan: currentPlan,
390
516
  scope: savedPlan.scope,
391
517
  store: currentStore,
@@ -395,8 +521,14 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
395
521
 
396
522
  if (verification.complete) {
397
523
  operation.message = "Cleanup completed";
524
+ if (await removeOperationBackups(operation)) {
525
+ operation.result.recoveryBackupDeleted = true;
526
+ } else {
527
+ operation.backupDeleteError = "Cleanup completed, but its recovery backup could not be removed.";
528
+ }
398
529
  operation.status = "completed";
399
530
  } else {
531
+ operation.canDeleteBackup = true;
400
532
  operation.canRestore = true;
401
533
  operation.error = "Cleanup finished, but some selected items remain. You can restore the recovery backup.";
402
534
  operation.message = "Cleanup needs attention";
@@ -404,6 +536,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
404
536
  }
405
537
  } catch (error) {
406
538
  operation.backupDirectory = error?.backupDirectory ?? null;
539
+ operation.backupDirectories = operation.backupDirectory ? [operation.backupDirectory] : [];
407
540
  operation.canCancel = false;
408
541
  operation.errorCode = error?.code ?? null;
409
542
  operation.progress = error?.cancelled ? operation.progress : 100;
@@ -411,14 +544,22 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
411
544
  if (error?.cancelled) {
412
545
  operation.error = null;
413
546
  operation.message = "Cleanup cancelled before session data changed";
547
+ if (!(await removeOperationBackups(operation))) {
548
+ operation.backupDeleteError = "Cleanup was cancelled, but its temporary backup could not be removed.";
549
+ }
414
550
  operation.status = "cancelled";
415
551
  } else {
552
+ operation.canDeleteBackup = Boolean(operation.backupDirectory);
416
553
  operation.canRestore = Boolean(operation.backupDirectory);
417
554
  operation.error = error instanceof Error ? error.message : "Cleanup could not be completed.";
418
555
  operation.message = "Cleanup could not be completed";
419
556
  operation.status = "failed";
420
557
  }
421
558
  } finally {
559
+ getProvider(savedPlan.providerId).invalidateSessionCache?.(
560
+ providerOptions(savedPlan.providerId, savedPlan.home),
561
+ );
562
+ invalidateSessionOverview();
422
563
  savedPlan.consumed = true;
423
564
  deletionPlans.delete(savedPlan.id);
424
565
  operation.finishedAtMs = Date.now();
@@ -431,10 +572,14 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
431
572
  const id = randomBytes(18).toString("base64url");
432
573
  const operation = {
433
574
  backupDirectory: null,
575
+ backupDirectories: [],
576
+ backupDeleteError: null,
434
577
  canCancel: true,
578
+ canDeleteBackup: false,
435
579
  canRestore: false,
436
580
  cancelRequested: false,
437
- codexHome: savedPlan.codexHome,
581
+ home: savedPlan.home,
582
+ providerId: savedPlan.providerId,
438
583
  createdAtMs: Date.now(),
439
584
  error: null,
440
585
  errorCode: null,
@@ -457,7 +602,9 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
457
602
 
458
603
  function startRestoreOperation(operation) {
459
604
  operation.canCancel = false;
605
+ operation.canDeleteBackup = false;
460
606
  operation.canRestore = false;
607
+ operation.backupDeleteError = null;
461
608
  operation.error = null;
462
609
  operation.errorCode = null;
463
610
  operation.message = "Restore queued";
@@ -467,20 +614,41 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
467
614
  activeOperationId = operation.id;
468
615
  const task = (async () => {
469
616
  try {
470
- operation.restoreResult = await restoreSessionDeletionBackup({
617
+ const restoreResult = await getProvider(operation.providerId).restoreSessionDeletionBackup({
471
618
  backupDirectory: operation.backupDirectory,
472
- codexHome: operation.codexHome,
619
+ ...providerOptions(operation.providerId, operation.home),
473
620
  onProgress: (update) => Object.assign(operation, update),
474
621
  });
622
+ operation.restoreResult = restoreResult;
475
623
  operation.message = "Recovery backup restored";
476
624
  operation.progress = 100;
625
+ if (await removeOperationBackups(operation, [restoreResult.safetyBackupDirectory])) {
626
+ operation.restoreResult = {
627
+ ...restoreResult,
628
+ recoveryBackupsDeleted: true,
629
+ safetyBackupDirectory: null,
630
+ };
631
+ } else {
632
+ operation.backupDeleteError = "The sessions were restored, but temporary recovery files could not be removed.";
633
+ }
477
634
  operation.status = "restored";
478
635
  } catch (error) {
636
+ if (error?.safetyBackupDirectory) {
637
+ operation.backupDirectories = [...new Set([
638
+ ...(operation.backupDirectories ?? []),
639
+ error.safetyBackupDirectory,
640
+ ])];
641
+ }
642
+ operation.canDeleteBackup = true;
479
643
  operation.canRestore = true;
480
644
  operation.error = error instanceof Error ? error.message : "The recovery backup could not be restored.";
481
645
  operation.message = "Restore could not be completed";
482
646
  operation.status = "restore-failed";
483
647
  } finally {
648
+ getProvider(operation.providerId).invalidateSessionCache?.(
649
+ providerOptions(operation.providerId, operation.home),
650
+ );
651
+ invalidateSessionOverview();
484
652
  operation.finishedAtMs = Date.now();
485
653
  if (activeOperationId === operation.id) activeOperationId = null;
486
654
  }
@@ -527,7 +695,34 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
527
695
  }
528
696
 
529
697
  if (request.method === "GET" && requestUrl.pathname === "/api/config") {
530
- sendJson(response, 200, { mutationToken, providers: settings.getAll() });
698
+ sendJson(response, 200, {
699
+ activeProviderId: settings.getActiveProviderId(),
700
+ mutationToken,
701
+ providerOrder: listProviders().map(({ id }) => id),
702
+ providers: settings.getAll(),
703
+ });
704
+ return;
705
+ }
706
+
707
+ if (request.method === "PUT" && requestUrl.pathname === "/api/settings/active-provider") {
708
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
709
+
710
+ if (mutationInProgress || activeOperationId) {
711
+ sendJson(response, 409, { error: "Wait for the current change to finish before switching providers." });
712
+ return;
713
+ }
714
+
715
+ mutationInProgress = true;
716
+
717
+ try {
718
+ const activeProviderId = await settings.setActiveProviderId(
719
+ (await readJsonBody(request)).providerId,
720
+ );
721
+ sendJson(response, 200, { activeProviderId });
722
+ } finally {
723
+ mutationInProgress = false;
724
+ }
725
+
531
726
  return;
532
727
  }
533
728
 
@@ -552,6 +747,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
552
747
  ? await settings.resetProviderHome(providerId)
553
748
  : await settings.setProviderHome(providerId, (await readJsonBody(request)).home);
554
749
  deletionPlans.clear();
750
+ invalidateSessionOverview();
555
751
  sendJson(response, 200, { provider });
556
752
  } finally {
557
753
  mutationInProgress = false;
@@ -561,7 +757,10 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
561
757
  }
562
758
 
563
759
  if (request.method === "GET" && requestUrl.pathname === "/api/compatibility") {
564
- const diagnostic = await diagnoseStorageCompatibility({ codexHome: settings.getHome("codex") });
760
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
761
+ const provider = getProvider(providerId);
762
+ const home = settings.getHome(providerId);
763
+ const diagnostic = await provider.diagnoseStorageCompatibility(providerOptions(providerId, home));
565
764
  const currentVersions = await getInstalledProductVersions();
566
765
  const versionSupport = Object.fromEntries(
567
766
  Object.entries(diagnostic.builtFor).map(([product, supportedVersions]) => [
@@ -572,64 +771,91 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
572
771
  }),
573
772
  ]),
574
773
  );
575
- sendJson(response, 200, { ...diagnostic, currentVersions, versionSupport });
774
+ sendJson(response, 200, { ...diagnostic, currentVersions, providerId, versionSupport });
775
+ return;
776
+ }
777
+
778
+ if (request.method === "GET" && requestUrl.pathname === "/api/session-overview") {
779
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
780
+ const overview = await readSessionOverview({
781
+ providerId,
782
+ refresh: requestUrl.searchParams.get("refresh") === "true",
783
+ });
784
+ sendJson(response, 200, { overview });
576
785
  return;
577
786
  }
578
787
 
579
788
  if (request.method === "GET" && requestUrl.pathname === "/api/sessions") {
580
- const result = await listSessions({
581
- codexHome: settings.getHome("codex"),
789
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
790
+ const provider = getProvider(providerId);
791
+ const result = await provider.listSessions({
792
+ archiveStatus: getArchiveStatus(requestUrl.searchParams.get("archiveStatus")),
793
+ ...providerOptions(providerId, settings.getHome(providerId)),
794
+ inactiveBeforeMs: getInactiveBeforeMs(requestUrl.searchParams.get("inactiveDays")),
582
795
  includeInternals: requestUrl.searchParams.get("includeInternals") === "true",
583
796
  includeSupporting: requestUrl.searchParams.get("includeSupporting") === "true",
584
797
  page: getPositiveInteger(requestUrl.searchParams.get("page"), 1),
585
798
  pageSize: getPositiveInteger(requestUrl.searchParams.get("pageSize"), 25, 100),
799
+ refresh: requestUrl.searchParams.get("refresh") === "true",
586
800
  search: requestUrl.searchParams.get("search"),
587
- sort: requestUrl.searchParams.get("sort"),
801
+ sort: getSort(requestUrl.searchParams.get("sort")),
802
+ workspace: requestUrl.searchParams.has("workspace")
803
+ ? requestUrl.searchParams.get("workspace")
804
+ : undefined,
588
805
  });
589
806
  sendJson(response, 200, {
590
807
  ...result,
591
- records: result.records.map(formatSessionForJson),
808
+ records: result.records.map(provider.formatSessionForJson),
592
809
  });
593
810
  return;
594
811
  }
595
812
 
596
813
  if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
814
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
815
+ const provider = getProvider(providerId);
597
816
  const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
598
- const record = await getSessionRecord({ codexHome: settings.getHome("codex"), id });
817
+ const record = await provider.getSessionRecord({
818
+ ...providerOptions(providerId, settings.getHome(providerId)),
819
+ id,
820
+ });
599
821
 
600
822
  if (!record) {
601
823
  sendJson(response, 404, { error: "Session not found." });
602
824
  return;
603
825
  }
604
826
 
605
- sendJson(response, 200, { record: formatSessionForJson(record) });
827
+ sendJson(response, 200, { record: provider.formatSessionForJson(record) });
606
828
  return;
607
829
  }
608
830
 
609
831
  if (request.method === "POST" && requestUrl.pathname === "/api/deletion-plans") {
610
832
  const body = await readJsonBody(request);
833
+ const providerId = resolveProviderId(body.providerId);
834
+ const provider = getProvider(providerId);
611
835
  const ids = normalizeIds(body.ids);
612
836
  const scope = getScope(body.scope);
613
- const activeCodexHome = settings.getHome("codex");
837
+ const home = settings.getHome(providerId);
838
+ const options = providerOptions(providerId, home);
614
839
 
615
840
  if (scope === "deep") {
616
- await assertDeepCleanupSupported({ codexHome: activeCodexHome });
841
+ await provider.assertDeepCleanupSupported(options);
617
842
  }
618
843
 
619
- const store = await loadDeletionStore({
620
- codexHome: activeCodexHome,
844
+ const store = await provider.loadDeletionStore({
845
+ ...options,
621
846
  recordIds: ids,
622
847
  });
623
- const plan = await planSessionDeletion({ recordIds: ids, store });
624
- const preflight = await preflightSessionDeletion({ plan, store });
848
+ const plan = await provider.planSessionDeletion({ recordIds: ids, store });
849
+ const preflight = await provider.preflightSessionDeletion({ plan, scope, store });
625
850
  const id = randomBytes(18).toString("base64url");
626
851
  const expiresAtMs = Date.now() + PLAN_TTL_MS;
627
852
  const savedPlan = {
628
- codexHome: activeCodexHome,
853
+ home,
629
854
  consumed: false,
630
855
  expiresAtMs,
631
- fingerprint: await fingerprintSessionDeletion({ plan, scope, store }),
856
+ fingerprint: await provider.fingerprintSessionDeletion({ plan, scope, store }),
632
857
  id,
858
+ providerId,
633
859
  requestedIds: ids,
634
860
  scope,
635
861
  };
@@ -638,16 +864,18 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
638
864
  deletionPlans.delete(deletionPlans.keys().next().value);
639
865
  }
640
866
  deletionPlans.set(id, savedPlan);
867
+ const warnings = preflight.activeThreadDetection === "unavailable"
868
+ ? [`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`]
869
+ : [];
641
870
  sendJson(response, 200, {
642
871
  plan: {
643
- ...summarizePlan(plan, preflight),
872
+ ...summarizePlan(plan, preflight, scope),
644
873
  expiresAtMs,
645
874
  id,
875
+ warnings,
646
876
  },
647
877
  scope,
648
- warnings: preflight.activeThreadDetection === "unavailable"
649
- ? ["The current Codex runtime cannot identify an active session. Confirm it is safe to delete the selected sessions."]
650
- : [],
878
+ warnings,
651
879
  });
652
880
  return;
653
881
  }
@@ -674,18 +902,20 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
674
902
  return;
675
903
  }
676
904
 
677
- const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore)?$/u.exec(requestUrl.pathname);
905
+ const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore|\/backup)?$/u.exec(requestUrl.pathname);
678
906
 
679
907
  if (operationRoute) {
680
908
  const operation = getOperation(decodeURIComponent(operationRoute[1]));
681
- const restoreRoute = Boolean(operationRoute[2]);
909
+ const operationAction = operationRoute[2] ?? "";
910
+ const restoreRoute = operationAction === "/restore";
911
+ const backupRoute = operationAction === "/backup";
682
912
 
683
- if (request.method === "GET" && !restoreRoute) {
913
+ if (request.method === "GET" && !operationAction) {
684
914
  sendJson(response, 200, { operation: publicOperation(operation) });
685
915
  return;
686
916
  }
687
917
 
688
- if (request.method === "DELETE" && !restoreRoute) {
918
+ if (request.method === "DELETE" && !operationAction) {
689
919
  requireMutationAuthorization({ request, requestUrl, token: mutationToken });
690
920
  const cancelAccepted = operation.status === "queued" || (
691
921
  operation.status === "running" && operation.canCancel
@@ -698,6 +928,33 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
698
928
  return;
699
929
  }
700
930
 
931
+ if (request.method === "DELETE" && backupRoute) {
932
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
933
+
934
+ if (!operation.canDeleteBackup || !operation.backupDirectory) {
935
+ throw new Error("A recovery backup is not available for deletion.");
936
+ }
937
+
938
+ if (mutationInProgress || activeOperationId) {
939
+ sendJson(response, 409, { error: "Wait for the current cleanup to finish before deleting its backup." });
940
+ return;
941
+ }
942
+
943
+ mutationInProgress = true;
944
+ try {
945
+ if (!(await removeOperationBackups(operation))) {
946
+ throw new Error("The recovery backup could not be deleted.");
947
+ }
948
+ operation.backupDeleteError = null;
949
+ operation.canRestore = false;
950
+ if (operation.result) operation.result.recoveryBackupDeleted = true;
951
+ sendJson(response, 200, { operation: publicOperation(operation) });
952
+ } finally {
953
+ mutationInProgress = false;
954
+ }
955
+ return;
956
+ }
957
+
701
958
  if (request.method === "POST" && restoreRoute) {
702
959
  requireMutationAuthorization({ request, requestUrl, token: mutationToken });
703
960