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/cli.mjs CHANGED
@@ -5,23 +5,25 @@ import process, { stdin as input, stdout as output } from "node:process";
5
5
 
6
6
  import { getProvider } from "./providers/index.mjs";
7
7
 
8
- const {
9
- assertDeepCleanupSupported,
10
- executeSessionDeletion,
11
- formatSessionForJson,
12
- getSessionRecord,
13
- listSessions,
14
- loadDeletionStore,
15
- planSessionDeletion,
16
- verifySessionDeletion,
17
- } = getProvider("codex");
8
+ function providerOptions(providerId, home) {
9
+ return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
10
+ }
18
11
 
19
12
  const PAGE_SIZE = 20;
13
+ const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
14
+ const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
15
+ const ALLOWED_CLEANUP_MODES = new Set(["standard", "thorough"]);
20
16
  const HELP_TEXT = `
21
17
  Commands
22
18
  search <text> Set the active search filter
23
19
  search Clear the active search filter
24
- sort <updated|created|name|cwd>
20
+ workspace <path> Show one exact workspace
21
+ workspace Clear the workspace filter
22
+ inactive <30|60|90> Show sessions last active that many days ago
23
+ inactive Clear the inactivity filter
24
+ archive <all|active|archived> Filter sessions by archive status
25
+ archive Clear the archive filter
26
+ sort <updated|created|name|cwd|size>
25
27
  Change sort order
26
28
  inspect <index|id-prefix> Show session details
27
29
  delete <selector> [...] Delete one or more sessions
@@ -29,6 +31,12 @@ Commands
29
31
  next Next page
30
32
  prev Previous page
31
33
  internals Toggle subagent visibility
34
+ supporting Toggle supporting-session visibility
35
+ cleanup <standard|thorough> Choose the cleanup level
36
+ overview Show storage and workspace totals
37
+ backups Show retained recovery backups
38
+ restore <index|backup-id> Restore a recovery backup
39
+ delete-backup <index|id> Permanently remove a recovery backup
32
40
  refresh Reload sessions from sqlite and disk
33
41
  help Show this help
34
42
  quit Exit
@@ -40,6 +48,29 @@ Selectors
40
48
  delete 1 4-6 019dd26c
41
49
  `.trim();
42
50
 
51
+ const CLI_HELP_TEXT = `
52
+ Usage: session-steward-cli [options]
53
+
54
+ Options
55
+ --provider <codex|claude-code> Choose the session provider
56
+ --codex-home <path> Use another Codex session folder for this run
57
+ --claude-home <path> Use another Claude session folder for this run
58
+ --json Print sessions as JSON
59
+ --include-internals Include subagent sessions
60
+ --include-supporting Include supporting sessions
61
+ --cleanup <standard|thorough> Choose the interactive cleanup level
62
+ --overview Print storage and workspace totals
63
+ --backups Print retained recovery backups
64
+ --search <text> Search names, workspaces, and session IDs
65
+ --workspace <path> Show one exact workspace
66
+ --inactive-days <30|60|90> Show sessions last active at least this long ago
67
+ --archive-status <status> Show all, active, or archived sessions
68
+ --sort <updated|created|name|cwd|size>
69
+ Choose the session order
70
+ --limit <number> Limit JSON results
71
+ -h, --help Show this help
72
+ `.trim();
73
+
43
74
  function truncate(value, maxLength) {
44
75
  if (value.length <= maxLength) {
45
76
  return value;
@@ -101,6 +132,31 @@ function absoluteTime(timestampMs) {
101
132
  return new Date(timestampMs).toLocaleString();
102
133
  }
103
134
 
135
+ function formatBytes(bytes) {
136
+ if (!Number.isFinite(bytes) || bytes < 0) return "-";
137
+ if (bytes < 1024) return `${bytes} B`;
138
+
139
+ const units = ["KB", "MB", "GB", "TB"];
140
+ let value = bytes;
141
+ let unit = "B";
142
+
143
+ for (const nextUnit of units) {
144
+ value /= 1024;
145
+ unit = nextUnit;
146
+ if (value < 1024) break;
147
+ }
148
+
149
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
150
+ }
151
+
152
+ function cleanupScope(cleanupMode) {
153
+ return cleanupMode === "thorough" ? "deep" : "core";
154
+ }
155
+
156
+ function cleanupLabel(scope) {
157
+ return scope === "deep" ? "Thorough" : scope === "core" ? "Standard" : "Unknown";
158
+ }
159
+
104
160
  function getCwdDisplay(record) {
105
161
  if (!record.cwd) {
106
162
  return "-";
@@ -138,26 +194,32 @@ function getMarkerText(record) {
138
194
  return markers.join(", ");
139
195
  }
140
196
 
141
- function printScreen({ codexHome, result, search, showInternals, sort }) {
197
+ function printScreen({ archiveStatus, cleanupMode, inactiveDays, providerHome, providerName, result, search, showInternals, showSupporting, sort, workspace }) {
142
198
  if (output.isTTY) {
143
199
  output.write("\x1Bc");
144
200
  }
145
201
 
146
202
  output.write("Session Steward\n");
147
203
  output.write(
148
- `Home: ${codexHome} | Sessions: ${result.total} | Page: ${result.page}/${result.pageCount}\n`,
204
+ `${providerName} home: ${providerHome} | Sessions: ${result.total} | Page: ${result.page}/${result.pageCount}\n`,
205
+ );
206
+ output.write(
207
+ `Sort: ${sort} | Search: ${search || "-"} | Cleanup: ${cleanupLabel(cleanupScope(cleanupMode))}\n`,
149
208
  );
150
209
  output.write(
151
- `Sort: ${sort} | Search: ${search || "-"} | Internals: ${showInternals ? "shown" : "hidden"}\n`,
210
+ `Subagents: ${showInternals ? "shown" : "hidden"} | Supporting: ${showSupporting ? "shown" : "hidden"}\n`,
152
211
  );
153
212
  output.write(
154
- "Commands: search | sort | inspect | delete | page | next | prev | internals | refresh | help | quit\n\n",
213
+ `Last active: ${inactiveDays ? `${inactiveDays}+ days ago` : "any time"} | Workspace: ${workspace || "all"} | Status: ${archiveStatus}\n`,
214
+ );
215
+ output.write(
216
+ "Commands: search | workspace | inactive | archive | sort | inspect | delete | overview | backups | help | quit\n\n",
155
217
  );
156
218
 
157
219
  output.write(
158
- `${pad("#", 4)} ${pad("Name", 64)} ${pad("Updated", 8)} ${pad("Cwd", 20)} Markers\n`,
220
+ `${pad("#", 4)} ${pad("Name", 52)} ${pad("Updated", 8)} ${pad("Size", 10)} ${pad("Cwd", 18)} Markers\n`,
159
221
  );
160
- output.write(`${"-".repeat(4)} ${"-".repeat(64)} ${"-".repeat(8)} ${"-".repeat(20)} ${"-".repeat(24)}\n`);
222
+ output.write(`${"-".repeat(4)} ${"-".repeat(52)} ${"-".repeat(8)} ${"-".repeat(10)} ${"-".repeat(18)} ${"-".repeat(20)}\n`);
161
223
 
162
224
  if (result.records.length === 0) {
163
225
  output.write("No sessions match the current view.\n");
@@ -167,7 +229,7 @@ function printScreen({ codexHome, result, search, showInternals, sort }) {
167
229
  result.records.forEach((record, index) => {
168
230
  const rowNumber = index + 1;
169
231
  output.write(
170
- `${pad(String(rowNumber), 4)} ${pad(truncate(record.displayName, 64), 64)} ${pad(relativeTime(record.updatedAtMs), 8)} ${pad(truncate(getCwdDisplay(record), 20), 20)} ${truncate(getMarkerText(record), 24)}\n`,
232
+ `${pad(String(rowNumber), 4)} ${pad(truncate(record.displayName, 52), 52)} ${pad(relativeTime(record.updatedAtMs), 8)} ${pad(formatBytes(record.transcriptBytes), 10)} ${pad(truncate(getCwdDisplay(record), 18), 18)} ${truncate(getMarkerText(record), 20)}\n`,
171
233
  );
172
234
  });
173
235
  }
@@ -202,12 +264,10 @@ function parseSelectors(selectors, records) {
202
264
  const rowIndex = Number.parseInt(selector, 10) - 1;
203
265
  const record = records[rowIndex];
204
266
 
205
- if (!record) {
206
- throw new Error(`No session exists at row ${selector}.`);
267
+ if (record) {
268
+ resolvedIds.add(record.id);
269
+ continue;
207
270
  }
208
-
209
- resolvedIds.add(record.id);
210
- continue;
211
271
  }
212
272
 
213
273
  if (/^\d+-\d+$/u.test(selector)) {
@@ -215,21 +275,19 @@ function parseSelectors(selectors, records) {
215
275
  const start = Number.parseInt(startText, 10);
216
276
  const end = Number.parseInt(endText, 10);
217
277
 
218
- if (start > end) {
219
- throw new Error(`Invalid range: ${selector}.`);
220
- }
221
-
222
- for (let index = start; index <= end; index += 1) {
223
- const record = records[index - 1];
278
+ if (startText.length <= 4 && endText.length <= 4) {
279
+ if (start > end) {
280
+ throw new Error(`Invalid range: ${selector}.`);
281
+ }
224
282
 
225
- if (!record) {
226
- throw new Error(`No session exists at row ${index}.`);
283
+ for (let index = start; index <= end; index += 1) {
284
+ const record = records[index - 1];
285
+ if (!record) throw new Error(`No session exists at row ${index}.`);
286
+ resolvedIds.add(record.id);
227
287
  }
228
288
 
229
- resolvedIds.add(record.id);
289
+ continue;
230
290
  }
231
-
232
- continue;
233
291
  }
234
292
 
235
293
  const matches = records.filter((record) => record.id.startsWith(selector));
@@ -257,6 +315,7 @@ function printInspect(record, deletionPlan) {
257
315
  output.write(`Created: ${absoluteTime(record.createdAtMs)}\n`);
258
316
  output.write(`Cwd: ${record.cwd || "-"}\n`);
259
317
  output.write(`Transcript: ${record.rolloutPath || "-"}\n`);
318
+ output.write(`Transcript size: ${formatBytes(record.transcriptBytes)}\n`);
260
319
  output.write(`Title source: ${record.titleSource}\n`);
261
320
  output.write(`Parent: ${record.parentThreadId || "-"}\n`);
262
321
  output.write(`Children: ${record.childThreadIds.length}\n`);
@@ -272,7 +331,9 @@ function printInspect(record, deletionPlan) {
272
331
  output.write(`Delete log rows: ${deletionPlan.logRowCount}\n`);
273
332
  }
274
333
 
275
- function printDeletionPreview(plan) {
334
+ function printDeletionPreview(plan, preflight, scope) {
335
+ const fileCount = preflight.transcriptFileCount ?? plan.transcriptFileCount;
336
+ const sessionBytes = preflight.transcriptBytes ?? plan.transcriptBytes;
276
337
  output.write("\nDelete preview\n");
277
338
  output.write("--------------\n");
278
339
  output.write(`Sessions: ${plan.ids.length}\n`);
@@ -282,6 +343,10 @@ function printDeletionPreview(plan) {
282
343
  output.write(`History rows: ${plan.historyMatchCount}\n`);
283
344
  output.write(`Spawn edges: ${plan.spawnEdgeCount}\n`);
284
345
  output.write(`Log rows: ${plan.logRowCount}\n`);
346
+ output.write(`Cleanup: ${cleanupLabel(scope)}\n`);
347
+ output.write(`Files: ${fileCount}\n`);
348
+ output.write(`Session data: ${formatBytes(sessionBytes)}\n`);
349
+ output.write(`Temporary backup space needed: ${formatBytes(preflight.estimatedBackupBytes)}\n`);
285
350
 
286
351
  for (const record of plan.records.slice(0, 20)) {
287
352
  output.write(`- ${record.displayName} (${record.id})\n`);
@@ -301,20 +366,146 @@ function printHelp() {
301
366
  }
302
367
 
303
368
  function validateSort(value) {
304
- return ["updated", "created", "name", "cwd"].includes(value)
369
+ return ["updated", "created", "name", "cwd", "size"].includes(value)
305
370
  ? value
306
371
  : "updated";
307
372
  }
308
373
 
374
+ function validateInactiveDays(value) {
375
+ if (value === undefined || value === null || value === "") {
376
+ return null;
377
+ }
378
+
379
+ const days = Number(value);
380
+
381
+ if (!ALLOWED_INACTIVE_DAYS.has(days)) {
382
+ throw new Error("Inactive days must be 30, 60, or 90.");
383
+ }
384
+
385
+ return days;
386
+ }
387
+
388
+ function validateArchiveStatus(value) {
389
+ const status = value || "all";
390
+
391
+ if (!ALLOWED_ARCHIVE_STATUSES.has(status)) {
392
+ throw new Error("Archive status must be all, active, or archived.");
393
+ }
394
+
395
+ return status;
396
+ }
397
+
398
+ function validateCleanupMode(value) {
399
+ const cleanupMode = value || "standard";
400
+
401
+ if (!ALLOWED_CLEANUP_MODES.has(cleanupMode)) {
402
+ throw new Error("Cleanup must be standard or thorough.");
403
+ }
404
+
405
+ return cleanupMode;
406
+ }
407
+
408
+ async function writeJsonValue(value) {
409
+ const content = `${JSON.stringify(value, null, 2)}\n`;
410
+ if (!output.write(content)) await once(output, "drain");
411
+ }
412
+
413
+ async function loadOverview(state) {
414
+ return state.provider.getSessionOverview({
415
+ ...providerOptions(state.provider.id, state.providerHome),
416
+ refresh: true,
417
+ });
418
+ }
419
+
420
+ function printOverview(overview, providerName) {
421
+ output.write(`\n${providerName} overview\n`);
422
+ output.write(`${"-".repeat(providerName.length + 9)}\n`);
423
+ output.write(`On disk: ${formatBytes(overview.transcriptBytes)} in ${overview.transcriptFileCount} files\n`);
424
+ output.write(`All sessions: ${overview.sessionCount}\n`);
425
+ output.write(`Primary sessions: ${overview.primarySessionCount}\n`);
426
+ output.write(`Subagent sessions: ${overview.subagentCount}\n`);
427
+ output.write(`Supporting sessions: ${overview.supportingCount}\n`);
428
+
429
+ if (Number.isFinite(overview.cliSessionCount)) {
430
+ output.write(`CLI sessions: ${overview.cliSessionCount}\n`);
431
+ }
432
+
433
+ if (Number.isFinite(overview.desktopSessionCount)) {
434
+ output.write(`Desktop sessions: ${overview.desktopSessionCount}\n`);
435
+ }
436
+
437
+ if (overview.workspaces.length === 0) return;
438
+ output.write("\nWorkspaces\n");
439
+ output.write(`${pad("Sessions", 10)} ${pad("Size", 10)} Workspace\n`);
440
+
441
+ for (const workspace of overview.workspaces) {
442
+ output.write(
443
+ `${pad(String(workspace.sessionCount), 10)} ${pad(formatBytes(workspace.transcriptBytes), 10)} ${workspace.path || "Unknown workspace"}\n`,
444
+ );
445
+ }
446
+ }
447
+
448
+ async function loadBackups(state) {
449
+ return state.provider.listSessionDeletionBackups(
450
+ providerOptions(state.provider.id, state.providerHome),
451
+ );
452
+ }
453
+
454
+ function printBackups(backups) {
455
+ output.write("\nRecovery backups\n");
456
+ output.write("----------------\n");
457
+
458
+ if (backups.length === 0) {
459
+ output.write("No recovery backups are stored.\n");
460
+ return;
461
+ }
462
+
463
+ output.write(`${pad("#", 4)} ${pad("Created", 22)} ${pad("Cleanup", 10)} ${pad("Size", 10)} ${pad("Files", 7)} Status\n`);
464
+
465
+ backups.forEach((backup, index) => {
466
+ output.write(
467
+ `${pad(String(index + 1), 4)} ${pad(truncate(absoluteTime(backup.createdAtMs), 22), 22)} ${pad(cleanupLabel(backup.scope), 10)} ${pad(formatBytes(backup.bytes), 10)} ${pad(String(backup.fileCount), 7)} ${backup.restorable ? "Ready to restore" : "Manual recovery only"}\n`,
468
+ );
469
+ output.write(` ${backup.id}\n`);
470
+ });
471
+ }
472
+
473
+ function resolveBackupSelector(selector, backups) {
474
+ if (!selector) throw new Error("Choose a recovery backup by number or backup id.");
475
+
476
+ if (/^\d+$/u.test(selector)) {
477
+ const backup = backups[Number.parseInt(selector, 10) - 1];
478
+ if (!backup) throw new Error(`No recovery backup exists at row ${selector}.`);
479
+ return backup;
480
+ }
481
+
482
+ const exactMatch = backups.find((backup) => backup.id === selector);
483
+ if (exactMatch) return exactMatch;
484
+ const prefixMatches = backups.filter((backup) => backup.id.startsWith(selector));
485
+ if (prefixMatches.length === 0) throw new Error(`No recovery backup id starts with "${selector}".`);
486
+ if (prefixMatches.length > 1) throw new Error(`Recovery backup id prefix "${selector}" is ambiguous.`);
487
+ return prefixMatches[0];
488
+ }
489
+
490
+ function getInactiveBeforeMs(days) {
491
+ return days === null ? null : Date.now() - days * 24 * 60 * 60 * 1000;
492
+ }
493
+
309
494
  async function refreshPage(state) {
310
- state.result = await listSessions({
311
- codexHome: state.codexHome,
495
+ state.result = await state.provider.listSessions({
496
+ archiveStatus: state.archiveStatus,
497
+ ...providerOptions(state.provider.id, state.providerHome),
498
+ inactiveBeforeMs: getInactiveBeforeMs(state.inactiveDays),
312
499
  includeInternals: state.showInternals,
500
+ includeSupporting: state.showSupporting,
313
501
  page: state.page,
314
502
  pageSize: PAGE_SIZE,
503
+ refresh: state.forceRefresh,
315
504
  search: state.search,
316
505
  sort: state.sort,
506
+ workspace: state.workspace,
317
507
  });
508
+ state.forceRefresh = false;
318
509
  state.page = state.result.page;
319
510
  }
320
511
 
@@ -323,24 +514,30 @@ async function printJson(options) {
323
514
  if (!output.write(chunk)) await once(output, "drain");
324
515
  };
325
516
  const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : Infinity;
517
+ const inactiveBeforeMs = getInactiveBeforeMs(validateInactiveDays(options.inactiveDays));
518
+ const archiveStatus = validateArchiveStatus(options.archiveStatus);
326
519
  let page = 1;
327
520
  let written = 0;
328
521
  let first = true;
329
522
  await writeChunk("[\n");
330
523
 
331
524
  while (written < limit) {
332
- const result = await listSessions({
333
- codexHome: options.codexHome,
525
+ const result = await options.provider.listSessions({
526
+ archiveStatus,
527
+ ...providerOptions(options.provider.id, options.providerHome),
528
+ inactiveBeforeMs,
334
529
  includeInternals: Boolean(options.includeInternals),
530
+ includeSupporting: Boolean(options.includeSupporting),
335
531
  page,
336
532
  pageSize: Math.min(100, limit - written),
337
533
  search: options.search || "",
338
534
  sort: validateSort(options.sort || "updated"),
535
+ workspace: options.workspace,
339
536
  });
340
537
 
341
538
  for (const record of result.records) {
342
539
  if (written >= limit) break;
343
- await writeChunk(`${first ? "" : ",\n"}${JSON.stringify(formatSessionForJson(record), null, 2)}`);
540
+ await writeChunk(`${first ? "" : ",\n"}${JSON.stringify(options.provider.formatSessionForJson(record), null, 2)}`);
344
541
  first = false;
345
542
  written += 1;
346
543
  }
@@ -362,11 +559,17 @@ async function runInteractive(state) {
362
559
  while (true) {
363
560
  await refreshPage(state);
364
561
  printScreen({
365
- codexHome: state.codexHome,
562
+ archiveStatus: state.archiveStatus,
563
+ cleanupMode: state.cleanupMode,
564
+ inactiveDays: state.inactiveDays,
565
+ providerHome: state.providerHome,
566
+ providerName: state.provider.displayName,
366
567
  result: state.result,
367
568
  search: state.search,
368
569
  showInternals: state.showInternals,
570
+ showSupporting: state.showSupporting,
369
571
  sort: state.sort,
572
+ workspace: state.workspace,
370
573
  });
371
574
 
372
575
  const commandInput = await rl.question("\nsession-steward> ");
@@ -387,6 +590,7 @@ async function runInteractive(state) {
387
590
  }
388
591
 
389
592
  if (["refresh", "r"].includes(command.name)) {
593
+ state.forceRefresh = true;
390
594
  state.page = 1;
391
595
  continue;
392
596
  }
@@ -397,12 +601,161 @@ async function runInteractive(state) {
397
601
  continue;
398
602
  }
399
603
 
604
+ if (["supporting", "toggle-supporting"].includes(command.name)) {
605
+ state.showSupporting = !state.showSupporting;
606
+ state.page = 1;
607
+ continue;
608
+ }
609
+
610
+ if (command.name === "cleanup") {
611
+ try {
612
+ state.cleanupMode = validateCleanupMode(command.args[0]);
613
+ } catch (error) {
614
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
615
+ await pause(rl);
616
+ }
617
+ continue;
618
+ }
619
+
620
+ if (command.name === "overview") {
621
+ try {
622
+ printOverview(await loadOverview(state), state.provider.displayName);
623
+ } catch (error) {
624
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
625
+ }
626
+ await pause(rl);
627
+ continue;
628
+ }
629
+
630
+ if (command.name === "backups") {
631
+ try {
632
+ printBackups(await loadBackups(state));
633
+ } catch (error) {
634
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
635
+ }
636
+ await pause(rl);
637
+ continue;
638
+ }
639
+
640
+ if (command.name === "restore") {
641
+ try {
642
+ const backups = await loadBackups(state);
643
+ const backup = resolveBackupSelector(command.args[0], backups);
644
+
645
+ if (!backup.restorable) {
646
+ throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
647
+ }
648
+
649
+ output.write(`\nRestore backup from ${absoluteTime(backup.createdAtMs)} (${formatBytes(backup.bytes)}).\n`);
650
+ output.write("Current files are saved before the restore begins.\n");
651
+ const response = await rl.question('\nType "RESTORE" to confirm: ');
652
+
653
+ if (response.trim() !== "RESTORE") {
654
+ output.write("Restore cancelled.\n");
655
+ await pause(rl);
656
+ continue;
657
+ }
658
+
659
+ const restoreResult = await state.provider.restoreSessionDeletionBackup({
660
+ backupDirectory: backup.backupDirectory,
661
+ ...providerOptions(state.provider.id, state.providerHome),
662
+ onProgress: ({ message }) => {
663
+ if (message) output.write(`${message}...\n`);
664
+ },
665
+ });
666
+ const cleanupDirectories = [
667
+ restoreResult.safetyBackupDirectory,
668
+ backup.backupDirectory,
669
+ ].filter(Boolean);
670
+ const retainedDirectories = [];
671
+
672
+ for (const backupDirectory of cleanupDirectories) {
673
+ try {
674
+ await state.provider.deleteSessionDeletionBackup({
675
+ backupDirectory,
676
+ ...providerOptions(state.provider.id, state.providerHome),
677
+ });
678
+ } catch {
679
+ retainedDirectories.push(backupDirectory);
680
+ }
681
+ }
682
+
683
+ state.provider.invalidateSessionCache?.(providerOptions(state.provider.id, state.providerHome));
684
+ const restoredCount = restoreResult.restoredFileCount ?? restoreResult.restoredEntryCount ?? 0;
685
+ output.write(`Restored and verified ${restoredCount} session data files.\n`);
686
+ if (retainedDirectories.length > 0) {
687
+ output.write(`Restore completed, but recovery files remain at ${retainedDirectories.join(", ")}.\n`);
688
+ }
689
+ state.page = 1;
690
+ state.forceRefresh = true;
691
+ } catch (error) {
692
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
693
+ }
694
+ await pause(rl);
695
+ continue;
696
+ }
697
+
698
+ if (command.name === "delete-backup") {
699
+ try {
700
+ const backups = await loadBackups(state);
701
+ const backup = resolveBackupSelector(command.args[0], backups);
702
+ output.write(`\nDelete recovery backup from ${absoluteTime(backup.createdAtMs)} (${formatBytes(backup.bytes)}).\n`);
703
+ output.write("You will no longer be able to restore from it.\n");
704
+ const response = await rl.question('\nType "DELETE BACKUP" to confirm: ');
705
+
706
+ if (response.trim() !== "DELETE BACKUP") {
707
+ output.write("Backup deletion cancelled.\n");
708
+ await pause(rl);
709
+ continue;
710
+ }
711
+
712
+ await state.provider.deleteSessionDeletionBackup({
713
+ backupDirectory: backup.backupDirectory,
714
+ ...providerOptions(state.provider.id, state.providerHome),
715
+ });
716
+ output.write(`Deleted recovery backup ${backup.id}.\n`);
717
+ } catch (error) {
718
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
719
+ }
720
+ await pause(rl);
721
+ continue;
722
+ }
723
+
400
724
  if (["search", "s"].includes(command.name)) {
401
725
  state.search = command.args.join(" ");
402
726
  state.page = 1;
403
727
  continue;
404
728
  }
405
729
 
730
+ if (["workspace", "cwd"].includes(command.name)) {
731
+ state.workspace = command.args.length > 0 ? command.args.join(" ") : undefined;
732
+ state.page = 1;
733
+ continue;
734
+ }
735
+
736
+ if (["inactive", "inactive-days"].includes(command.name)) {
737
+ try {
738
+ state.inactiveDays = validateInactiveDays(command.args[0]);
739
+ state.page = 1;
740
+ state.forceRefresh = true;
741
+ } catch (error) {
742
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
743
+ await pause(rl);
744
+ }
745
+ continue;
746
+ }
747
+
748
+ if (["archive", "archive-status"].includes(command.name)) {
749
+ try {
750
+ state.archiveStatus = validateArchiveStatus(command.args[0]);
751
+ state.page = 1;
752
+ } catch (error) {
753
+ output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
754
+ await pause(rl);
755
+ }
756
+ continue;
757
+ }
758
+
406
759
  if (command.name === "sort") {
407
760
  state.sort = validateSort(command.args[0] ?? "updated");
408
761
  state.page = 1;
@@ -433,12 +786,12 @@ async function runInteractive(state) {
433
786
  throw new Error("`inspect` accepts exactly one selector.");
434
787
  }
435
788
 
436
- const record = await getSessionRecord({ codexHome: state.codexHome, id: sessionIds[0] });
437
- const deletionStore = await loadDeletionStore({
438
- codexHome: state.codexHome,
789
+ const record = await state.provider.getSessionRecord({ ...providerOptions(state.provider.id, state.providerHome), id: sessionIds[0] });
790
+ const deletionStore = await state.provider.loadDeletionStore({
791
+ ...providerOptions(state.provider.id, state.providerHome),
439
792
  recordIds: sessionIds,
440
793
  });
441
- const deletionPlan = await planSessionDeletion({
794
+ const deletionPlan = await state.provider.planSessionDeletion({
442
795
  recordIds: sessionIds,
443
796
  store: deletionStore,
444
797
  });
@@ -455,19 +808,30 @@ async function runInteractive(state) {
455
808
  if (command.name === "delete") {
456
809
  try {
457
810
  const sessionIds = parseSelectors(command.args, state.result.records);
458
- await assertDeepCleanupSupported({ codexHome: state.codexHome });
459
- const deletionStore = await loadDeletionStore({
460
- codexHome: state.codexHome,
811
+ if (sessionIds.length === 0) throw new Error("Choose at least one session to delete.");
812
+ const scope = cleanupScope(state.cleanupMode);
813
+ if (scope === "deep") {
814
+ await state.provider.assertDeepCleanupSupported(
815
+ providerOptions(state.provider.id, state.providerHome),
816
+ );
817
+ }
818
+ const deletionStore = await state.provider.loadDeletionStore({
819
+ ...providerOptions(state.provider.id, state.providerHome),
461
820
  recordIds: sessionIds,
462
821
  });
463
- const deletionPlan = await planSessionDeletion({
822
+ const deletionPlan = await state.provider.planSessionDeletion({
464
823
  recordIds: sessionIds,
465
824
  store: deletionStore,
466
825
  });
826
+ const preflight = await state.provider.preflightSessionDeletion({
827
+ plan: deletionPlan,
828
+ scope,
829
+ store: deletionStore,
830
+ });
467
831
 
468
- printDeletionPreview(deletionPlan);
832
+ printDeletionPreview(deletionPlan, preflight, scope);
469
833
  output.write(
470
- "\nClose the selected Codex sessions before continuing. Active-session detection is unavailable.\n",
834
+ `\nClose the selected ${state.provider.displayName} sessions before continuing.\n`,
471
835
  );
472
836
 
473
837
  const confirmationToken =
@@ -499,7 +863,7 @@ async function runInteractive(state) {
499
863
  let result;
500
864
 
501
865
  try {
502
- result = await executeSessionDeletion({
866
+ result = await state.provider.executeSessionDeletion({
503
867
  onProgress: ({ canCancel: nextCanCancel, message }) => {
504
868
  canCancel = nextCanCancel;
505
869
  if (message !== lastMessage) {
@@ -508,21 +872,30 @@ async function runInteractive(state) {
508
872
  }
509
873
  },
510
874
  plan: deletionPlan,
875
+ scope,
511
876
  shouldCancel: () => cancelRequested,
512
877
  store: deletionStore,
513
878
  });
514
879
  } finally {
515
880
  process.off("SIGINT", handleInterrupt);
516
881
  }
517
- const verification = await verifySessionDeletion({
882
+ const verification = await state.provider.verifySessionDeletion({
518
883
  plan: deletionPlan,
519
- scope: "deep",
884
+ scope,
520
885
  store: deletionStore,
521
886
  });
522
887
 
523
888
  if (verification.complete) {
889
+ try {
890
+ await state.provider.deleteSessionDeletionBackup({
891
+ backupDirectory: result.backupDirectory,
892
+ ...providerOptions(state.provider.id, state.providerHome),
893
+ });
894
+ } catch {
895
+ output.write(`Cleanup completed, but its recovery backup remains at ${result.backupDirectory}.\n`);
896
+ }
524
897
  output.write(
525
- `Deleted and verified ${result.deletedIds.length} sessions and ${result.deletedTranscriptPaths.length} transcripts.\n`,
898
+ `Deleted and verified ${result.deletedIds.length} sessions and ${result.deletedTranscriptPaths.length} session paths.\n`,
526
899
  );
527
900
  } else {
528
901
  output.write(
@@ -536,7 +909,11 @@ async function runInteractive(state) {
536
909
  );
537
910
  }
538
911
 
912
+ state.provider.invalidateSessionCache?.(
913
+ providerOptions(state.provider.id, state.providerHome),
914
+ );
539
915
  state.page = 1;
916
+ state.forceRefresh = true;
540
917
  } catch (error) {
541
918
  output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
542
919
  await pause(rl);
@@ -555,24 +932,50 @@ async function runInteractive(state) {
555
932
 
556
933
  export async function runCli(options) {
557
934
  if (options.help) {
558
- output.write(
559
- "Usage: session-steward-cli [--codex-home <path>] [--json] [--include-internals] [--search <text>] [--sort <updated|created|name|cwd>] [--limit <n>]\n",
560
- );
935
+ output.write(`${CLI_HELP_TEXT}\n`);
936
+ return;
937
+ }
938
+
939
+ const provider = options.provider ?? getProvider(options.providerId || "codex");
940
+ const providerHome = path.resolve(options.providerHome);
941
+
942
+ if (options.overview && options.backups) {
943
+ throw new Error("Choose either overview or backups.");
944
+ }
945
+
946
+ if (options.overview) {
947
+ const overview = await loadOverview({ provider, providerHome });
948
+ if (options.json) await writeJsonValue(overview);
949
+ else printOverview(overview, provider.displayName);
950
+ return;
951
+ }
952
+
953
+ if (options.backups) {
954
+ const backups = await loadBackups({ provider, providerHome });
955
+ if (options.json) await writeJsonValue(backups);
956
+ else printBackups(backups);
561
957
  return;
562
958
  }
563
959
 
564
960
  if (options.json) {
565
- await printJson(options);
961
+ await printJson({ ...options, provider, providerHome });
566
962
  return;
567
963
  }
568
964
 
569
965
  const state = {
570
- codexHome: path.resolve(options.codexHome),
966
+ archiveStatus: validateArchiveStatus(options.archiveStatus),
967
+ cleanupMode: validateCleanupMode(options.cleanup),
968
+ provider,
969
+ providerHome,
970
+ inactiveDays: validateInactiveDays(options.inactiveDays),
971
+ forceRefresh: false,
571
972
  page: 1,
572
973
  result: null,
573
974
  search: options.search || "",
574
975
  showInternals: Boolean(options.includeInternals),
976
+ showSupporting: Boolean(options.includeSupporting),
575
977
  sort: validateSort(options.sort || "updated"),
978
+ workspace: options.workspace,
576
979
  };
577
980
 
578
981
  await runInteractive(state);