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.
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
7
 
8
+ import { measurePath } from "../../storage/files.mjs";
8
9
  import {
9
10
  inspectJsonlMatches,
10
11
  readJsonlEntries,
@@ -42,6 +43,27 @@ function normalizeDisplayName(value) {
42
43
  return normalizeText(value);
43
44
  }
44
45
 
46
+ function cleanDerivedTitle(value) {
47
+ const original = normalizeDisplayName(value);
48
+
49
+ if (!original) {
50
+ return "";
51
+ }
52
+
53
+ let cleaned = original.replace(/^\[\d+\]\s+(?:user|assistant):\s*/u, "");
54
+ const nextRoleMarker = cleaned.search(/\s\[\d+\]\s+(?:user|assistant):\s*/u);
55
+
56
+ if (nextRoleMarker >= 0) {
57
+ cleaned = cleaned.slice(0, nextRoleMarker);
58
+ }
59
+
60
+ cleaned = normalizeDisplayName(
61
+ cleaned.replace(/\[([^\[\]]+?)\]\([^()]*?\)/gu, "$1"),
62
+ );
63
+
64
+ return cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? cleaned : original;
65
+ }
66
+
45
67
  function toTimestampMs(value) {
46
68
  if (typeof value === "number" && Number.isFinite(value)) {
47
69
  return value;
@@ -139,6 +161,12 @@ async function pathExists(targetPath) {
139
161
  }
140
162
  }
141
163
 
164
+ function isContainedPath(rootDirectory, candidatePath) {
165
+ if (!candidatePath) return false;
166
+ const relativePath = path.relative(path.resolve(rootDirectory), path.resolve(candidatePath));
167
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
168
+ }
169
+
142
170
  async function readFirstLine(filePath) {
143
171
  const stream = createReadStream(filePath, {
144
172
  encoding: "utf8",
@@ -362,7 +390,7 @@ function deriveDisplayName({
362
390
  if (sessionIndexEntry?.threadName && (!sqliteTitle || sqliteTitleLooksPrompt)) {
363
391
  return {
364
392
  source: "session_index",
365
- value: sessionIndexEntry.threadName,
393
+ value: cleanDerivedTitle(sessionIndexEntry.threadName),
366
394
  };
367
395
  }
368
396
 
@@ -372,21 +400,21 @@ function deriveDisplayName({
372
400
  ) {
373
401
  return {
374
402
  source: "transcript_thread_name",
375
- value: transcriptFallback.latestThreadName,
403
+ value: cleanDerivedTitle(transcriptFallback.latestThreadName),
376
404
  };
377
405
  }
378
406
 
379
407
  if (sqliteTitle) {
380
408
  return {
381
409
  source: "sqlite_title",
382
- value: sqliteTitle,
410
+ value: cleanDerivedTitle(sqliteTitle),
383
411
  };
384
412
  }
385
413
 
386
414
  if (sqliteFirstUserMessage) {
387
415
  return {
388
416
  source: "sqlite_first_user_message",
389
- value: sqliteFirstUserMessage,
417
+ value: cleanDerivedTitle(sqliteFirstUserMessage),
390
418
  };
391
419
  }
392
420
 
@@ -395,14 +423,14 @@ function deriveDisplayName({
395
423
  if (historyEntry?.text) {
396
424
  return {
397
425
  source: "history_first_user_message",
398
- value: historyEntry.text,
426
+ value: cleanDerivedTitle(historyEntry.text),
399
427
  };
400
428
  }
401
429
 
402
430
  if (transcriptFallback?.firstUserMessage) {
403
431
  return {
404
432
  source: "transcript_first_user_message",
405
- value: transcriptFallback.firstUserMessage,
433
+ value: cleanDerivedTitle(transcriptFallback.firstUserMessage),
406
434
  };
407
435
  }
408
436
 
@@ -414,8 +442,11 @@ function deriveDisplayName({
414
442
 
415
443
  function getCodexPaths(codexHomeInput) {
416
444
  const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
445
+ const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
446
+ const sessionsDirectory = path.join(codexHome, "sessions");
417
447
 
418
448
  return {
449
+ archivedSessionsDirectory,
419
450
  codexHome,
420
451
  desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
421
452
  desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
@@ -424,8 +455,9 @@ function getCodexPaths(codexHomeInput) {
424
455
  logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
425
456
  memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
426
457
  sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
427
- sessionsDirectory: path.join(codexHome, "sessions"),
458
+ sessionsDirectory,
428
459
  stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
460
+ transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
429
461
  };
430
462
  }
431
463
 
@@ -442,7 +474,7 @@ const COMPATIBILITY_PROFILE = {
442
474
  id: "local-store-2026-07",
443
475
  builtFor: {
444
476
  chatgptDesktop: ["26.727.40816"],
445
- codexCli: ["0.144.1"],
477
+ codexCli: ["0.144.1", "0.146.0"],
446
478
  },
447
479
  };
448
480
 
@@ -584,21 +616,7 @@ export async function loadSessionStore({ codexHome }) {
584
616
  from thread_spawn_edges
585
617
  `,
586
618
  );
587
- const transcriptHeaders = new Map();
588
-
589
- for await (const transcriptFile of findTranscriptFiles(paths.sessionsDirectory)) {
590
- try {
591
- const header = await parseTranscriptHeader(transcriptFile);
592
-
593
- const current = header?.id ? transcriptHeaders.get(header.id) : null;
594
-
595
- if (header?.id && (!current || header.filePath > current.filePath)) {
596
- transcriptHeaders.set(header.id, header);
597
- }
598
- } catch {
599
- continue;
600
- }
601
- }
619
+ const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
602
620
 
603
621
  const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
604
622
 
@@ -698,7 +716,7 @@ export async function loadSessionStore({ codexHome }) {
698
716
  const record = {
699
717
  agentNickname: transcriptHeader.agentNickname,
700
718
  agentRole: transcriptHeader.agentRole,
701
- archived: false,
719
+ archived: isContainedPath(paths.archivedSessionsDirectory, transcriptHeader.filePath),
702
720
  childThreadIds: childIdsByParentId.get(transcriptId) ?? [],
703
721
  createdAtMs: transcriptHeader.timestampMs,
704
722
  cwd: transcriptHeader.cwd,
@@ -898,25 +916,118 @@ export async function assertDeepCleanupSupported({ codexHome }) {
898
916
 
899
917
  const DEFAULT_PAGE_SIZE = 25;
900
918
  const MAX_PAGE_SIZE = 100;
919
+ const OVERVIEW_STAT_BATCH_SIZE = 24;
920
+ const SESSION_SIZE_CACHE_TTL_MS = 45 * 1000;
921
+ const SESSION_SIZE_STAT_CONCURRENCY = 64;
901
922
  const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
923
+ const SESSION_CREATED_SQL = "coalesce(nullif(t.created_at_ms, 0), nullif(t.created_at, 0) * 1000, 0)";
924
+ const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updated_at, 0) * 1000, 0)";
925
+ const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
926
+ const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
927
+ const sessionSizeCache = new Map();
928
+
929
+ async function buildSessionSizeIndex(paths) {
930
+ const rows = queryRows(
931
+ paths.stateDatabasePath,
932
+ "select id, rollout_path from threads",
933
+ );
934
+ const sizes = new Map();
935
+ let nextIndex = 0;
936
+
937
+ const measureNext = async () => {
938
+ while (nextIndex < rows.length) {
939
+ const row = rows[nextIndex];
940
+ nextIndex += 1;
941
+ const id = String(row.id);
942
+
943
+ if (!row.rollout_path) {
944
+ sizes.set(id, null);
945
+ continue;
946
+ }
947
+
948
+ try {
949
+ const stats = await fs.stat(row.rollout_path);
950
+ sizes.set(id, stats.isFile() ? stats.size : null);
951
+ } catch {
952
+ sizes.set(id, null);
953
+ }
954
+ }
955
+ };
956
+
957
+ await Promise.all(
958
+ Array.from(
959
+ { length: Math.min(SESSION_SIZE_STAT_CONCURRENCY, rows.length) },
960
+ measureNext,
961
+ ),
962
+ );
963
+ return sizes;
964
+ }
965
+
966
+ async function getSessionSizeIndex(paths, { refresh = false } = {}) {
967
+ const cached = sessionSizeCache.get(paths.codexHome);
968
+
969
+ if (!refresh && cached?.expiresAtMs > Date.now()) {
970
+ return cached.promise;
971
+ }
972
+
973
+ const promise = buildSessionSizeIndex(paths).catch((error) => {
974
+ if (sessionSizeCache.get(paths.codexHome)?.promise === promise) {
975
+ sessionSizeCache.delete(paths.codexHome);
976
+ }
977
+ throw error;
978
+ });
979
+ sessionSizeCache.set(paths.codexHome, {
980
+ expiresAtMs: Date.now() + SESSION_SIZE_CACHE_TTL_MS,
981
+ promise,
982
+ });
983
+ return promise;
984
+ }
985
+
986
+ export function invalidateSessionCache({ codexHome }) {
987
+ sessionSizeCache.delete(getCodexPaths(codexHome).codexHome);
988
+ }
989
+
990
+ function compareSessionIdsBySize(leftId, rightId, sizes) {
991
+ const leftSize = sizes.get(leftId);
992
+ const rightSize = sizes.get(rightId);
993
+ const leftKnown = Number.isFinite(leftSize);
994
+ const rightKnown = Number.isFinite(rightSize);
995
+
996
+ if (leftKnown !== rightKnown) {
997
+ return leftKnown ? -1 : 1;
998
+ }
999
+
1000
+ return (rightSize ?? 0) - (leftSize ?? 0) || leftId.localeCompare(rightId);
1001
+ }
902
1002
 
903
1003
  function getSessionOrder(sort) {
904
- const updated = "coalesce(t.updated_at_ms, t.updated_at * 1000, 0)";
905
- const created = "coalesce(t.created_at_ms, t.created_at * 1000, 0)";
906
1004
  const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
907
1005
 
908
1006
  return {
909
- created: `${created} desc, t.id asc`,
910
- cwd: `lower(coalesce(t.cwd, '')) asc, ${updated} desc, t.id asc`,
911
- name: `${name} asc, ${updated} desc, t.id asc`,
912
- updated: `${updated} desc, t.id asc`,
913
- }[sort] ?? `${updated} desc, t.id asc`;
1007
+ created: `${SESSION_CREATED_SQL} desc, t.id asc`,
1008
+ cwd: `lower(coalesce(t.cwd, '')) asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1009
+ name: `${name} asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1010
+ updated: `${SESSION_ACTIVITY_SQL} desc, t.id asc`,
1011
+ }[sort] ?? `${SESSION_ACTIVITY_SQL} desc, t.id asc`;
914
1012
  }
915
1013
 
916
- function getSessionConditions({ includeInternals, includeSupporting, search }) {
1014
+ function getSessionConditions({
1015
+ archiveStatus,
1016
+ inactiveBeforeMs,
1017
+ includeInternals,
1018
+ includeSupporting,
1019
+ search,
1020
+ workspace,
1021
+ }) {
917
1022
  const conditions = [];
918
1023
  const parameters = [];
919
1024
 
1025
+ if (archiveStatus === "active") {
1026
+ conditions.push("coalesce(t.archived, 0) = 0");
1027
+ } else if (archiveStatus === "archived") {
1028
+ conditions.push("coalesce(t.archived, 0) <> 0");
1029
+ }
1030
+
920
1031
  if (!includeInternals) {
921
1032
  conditions.push(`not exists (
922
1033
  select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
@@ -928,6 +1039,16 @@ function getSessionConditions({ includeInternals, includeSupporting, search }) {
928
1039
  parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
929
1040
  }
930
1041
 
1042
+ if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
1043
+ conditions.push(`${SESSION_ACTIVITY_SQL} > 0 and ${SESSION_ACTIVITY_SQL} <= ?`);
1044
+ parameters.push(Math.trunc(inactiveBeforeMs));
1045
+ }
1046
+
1047
+ if (typeof workspace === "string") {
1048
+ conditions.push("coalesce(t.cwd, '') = ?");
1049
+ parameters.push(workspace);
1050
+ }
1051
+
931
1052
  const normalizedSearch = normalizeText(search).toLowerCase();
932
1053
 
933
1054
  if (normalizedSearch) {
@@ -957,8 +1078,8 @@ const SESSION_COLUMNS = `
957
1078
  t.agent_role,
958
1079
  t.archived,
959
1080
  t.is_pinned,
960
- coalesce(t.created_at_ms, t.created_at * 1000, 0) as created_at_ms,
961
- coalesce(t.updated_at_ms, t.updated_at * 1000, 0) as updated_at_ms,
1081
+ ${SESSION_CREATED_SQL} as created_at_ms,
1082
+ ${SESSION_ACTIVITY_SQL} as updated_at_ms,
962
1083
  (
963
1084
  select edge.parent_thread_id
964
1085
  from thread_spawn_edges edge
@@ -993,18 +1114,22 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
993
1114
  const records = [];
994
1115
 
995
1116
  for (const threadRow of threadRows) {
996
- const title = normalizeDisplayName(threadRow.title ?? "");
997
- const firstUserMessage = getMeaningfulUserText(threadRow.first_user_message ?? "");
1117
+ const title = cleanDerivedTitle(threadRow.title ?? "");
1118
+ const firstUserMessage = cleanDerivedTitle(
1119
+ getMeaningfulUserText(threadRow.first_user_message ?? ""),
1120
+ );
998
1121
  const displayName = title || firstUserMessage || `Untitled ${String(threadRow.id).slice(0, 8)}`;
999
1122
  const rolloutPath = threadRow.rollout_path ?? "";
1000
1123
  let transcriptHeader = null;
1124
+ let transcriptBytes = null;
1001
1125
 
1002
1126
  if (rolloutPath) {
1003
- try {
1004
- transcriptHeader = await parseTranscriptHeader(rolloutPath);
1005
- } catch {
1006
- transcriptHeader = null;
1007
- }
1127
+ const [header, stats] = await Promise.all([
1128
+ parseTranscriptHeader(rolloutPath).catch(() => null),
1129
+ fs.stat(rolloutPath).catch(() => null),
1130
+ ]);
1131
+ transcriptHeader = header;
1132
+ transcriptBytes = stats?.isFile() ? stats.size : null;
1008
1133
  }
1009
1134
 
1010
1135
  const parentThreadId = threadRow.parent_thread_id ?? transcriptHeader?.parentThreadId ?? null;
@@ -1030,6 +1155,7 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1030
1155
  rolloutPath,
1031
1156
  title: threadRow.title ?? "",
1032
1157
  titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
1158
+ transcriptBytes,
1033
1159
  updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
1034
1160
  });
1035
1161
  }
@@ -1061,19 +1187,21 @@ async function getStoreAvailability(paths) {
1061
1187
  };
1062
1188
  }
1063
1189
 
1064
- async function indexTranscriptHeaders(sessionsDirectory) {
1190
+ async function indexTranscriptHeaders(transcriptDirectories) {
1065
1191
  const headersById = new Map();
1066
1192
 
1067
- for await (const transcriptFile of findTranscriptFiles(sessionsDirectory)) {
1068
- try {
1069
- const header = await parseTranscriptHeader(transcriptFile);
1070
- const current = header?.id ? headersById.get(String(header.id)) : null;
1193
+ for (const transcriptDirectory of transcriptDirectories) {
1194
+ for await (const transcriptFile of findTranscriptFiles(transcriptDirectory)) {
1195
+ try {
1196
+ const header = await parseTranscriptHeader(transcriptFile);
1197
+ const current = header?.id ? headersById.get(String(header.id)) : null;
1071
1198
 
1072
- if (header?.id && (!current || header.filePath > current.filePath)) {
1073
- headersById.set(String(header.id), { ...header, id: String(header.id) });
1199
+ if (header?.id && (!current || header.filePath > current.filePath)) {
1200
+ headersById.set(String(header.id), { ...header, id: String(header.id) });
1201
+ }
1202
+ } catch {
1203
+ continue;
1074
1204
  }
1075
- } catch {
1076
- continue;
1077
1205
  }
1078
1206
  }
1079
1207
 
@@ -1126,7 +1254,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1126
1254
 
1127
1255
  const [availability, transcriptHeaders] = await Promise.all([
1128
1256
  getStoreAvailability(paths),
1129
- indexTranscriptHeaders(paths.sessionsDirectory),
1257
+ indexTranscriptHeaders(paths.transcriptDirectories),
1130
1258
  ]);
1131
1259
  const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
1132
1260
  const pendingIds = [...selectedIds];
@@ -1218,7 +1346,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1218
1346
  recordsById.set(id, {
1219
1347
  agentNickname: header.agentNickname,
1220
1348
  agentRole: header.agentRole,
1221
- archived: false,
1349
+ archived: isContainedPath(paths.archivedSessionsDirectory, header.filePath),
1222
1350
  childThreadIds: [...(childIdsByParentId.get(id) ?? [])].sort(),
1223
1351
  createdAtMs: header.timestampMs,
1224
1352
  cwd: header.cwd,
@@ -1306,20 +1434,68 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1306
1434
  }
1307
1435
 
1308
1436
  export async function listSessions({
1437
+ archiveStatus = "all",
1309
1438
  codexHome,
1439
+ inactiveBeforeMs = null,
1310
1440
  includeInternals = false,
1311
1441
  includeSupporting = false,
1312
1442
  page = 1,
1313
1443
  pageSize = DEFAULT_PAGE_SIZE,
1444
+ refresh = false,
1314
1445
  search = "",
1315
1446
  sort = "updated",
1447
+ workspace,
1316
1448
  }) {
1317
1449
  const paths = getCodexPaths(codexHome);
1318
1450
  const boundedPageSize = Number.isFinite(pageSize)
1319
1451
  ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
1320
1452
  : DEFAULT_PAGE_SIZE;
1321
1453
  const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1322
- const conditions = getSessionConditions({ includeInternals, includeSupporting, search });
1454
+ const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
1455
+ const conditions = getSessionConditions({
1456
+ archiveStatus,
1457
+ inactiveBeforeMs,
1458
+ includeInternals,
1459
+ includeSupporting,
1460
+ search,
1461
+ workspace,
1462
+ });
1463
+ if (resolvedSort === "size") {
1464
+ const sizes = await getSessionSizeIndex(paths, { refresh });
1465
+ const matchingIds = queryRows(
1466
+ paths.stateDatabasePath,
1467
+ `select t.id from threads t ${conditions.sql}`,
1468
+ conditions.parameters,
1469
+ ).map((row) => String(row.id));
1470
+ matchingIds.sort((left, right) => compareSessionIdsBySize(left, right, sizes));
1471
+ const total = matchingIds.length;
1472
+ const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1473
+ const currentPage = Math.min(requestedPage, pageCount);
1474
+ const pageIds = matchingIds.slice(
1475
+ (currentPage - 1) * boundedPageSize,
1476
+ currentPage * boundedPageSize,
1477
+ );
1478
+ const threadRows = pageIds.length > 0
1479
+ ? queryRows(
1480
+ paths.stateDatabasePath,
1481
+ `select ${SESSION_COLUMNS}
1482
+ from threads t
1483
+ where t.id in (${placeholders(pageIds)})`,
1484
+ pageIds,
1485
+ )
1486
+ : [];
1487
+ const rowsById = new Map(threadRows.map((row) => [String(row.id), row]));
1488
+ const orderedRows = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
1489
+
1490
+ return {
1491
+ page: currentPage,
1492
+ pageCount,
1493
+ pageSize: boundedPageSize,
1494
+ records: await formatPagedThreadRows(paths.stateDatabasePath, orderedRows),
1495
+ total,
1496
+ };
1497
+ }
1498
+
1323
1499
  const countRow = queryRows(
1324
1500
  paths.stateDatabasePath,
1325
1501
  `select count(*) as count from threads t ${conditions.sql}`,
@@ -1333,7 +1509,7 @@ export async function listSessions({
1333
1509
  `select ${SESSION_COLUMNS}
1334
1510
  from threads t
1335
1511
  ${conditions.sql}
1336
- order by ${getSessionOrder(sort)}
1512
+ order by ${getSessionOrder(resolvedSort)}
1337
1513
  limit ? offset ?`,
1338
1514
  [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1339
1515
  );
@@ -1347,6 +1523,131 @@ export async function listSessions({
1347
1523
  };
1348
1524
  }
1349
1525
 
1526
+ async function measureTranscriptStorage(transcriptDirectories) {
1527
+ let transcriptBytes = 0;
1528
+ let transcriptFileCount = 0;
1529
+ let unreadableFileCount = 0;
1530
+ let pendingPaths = [];
1531
+
1532
+ const measurePendingPaths = async () => {
1533
+ const pathsToMeasure = pendingPaths;
1534
+ pendingPaths = [];
1535
+ const results = await Promise.all(pathsToMeasure.map(async (filePath) => {
1536
+ try {
1537
+ return await fs.stat(filePath);
1538
+ } catch {
1539
+ return null;
1540
+ }
1541
+ }));
1542
+
1543
+ for (const stats of results) {
1544
+ if (!stats?.isFile()) {
1545
+ unreadableFileCount += 1;
1546
+ continue;
1547
+ }
1548
+
1549
+ transcriptBytes += stats.size;
1550
+ transcriptFileCount += 1;
1551
+ }
1552
+ };
1553
+
1554
+ for (const transcriptDirectory of transcriptDirectories) {
1555
+ for await (const transcriptPath of findTranscriptFiles(transcriptDirectory)) {
1556
+ pendingPaths.push(transcriptPath);
1557
+
1558
+ if (pendingPaths.length >= OVERVIEW_STAT_BATCH_SIZE) {
1559
+ await measurePendingPaths();
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ if (pendingPaths.length > 0) {
1565
+ await measurePendingPaths();
1566
+ }
1567
+
1568
+ return {
1569
+ transcriptBytes,
1570
+ transcriptFileCount,
1571
+ unreadableFileCount,
1572
+ };
1573
+ }
1574
+
1575
+ export async function getSessionOverview({ codexHome, refresh = false }) {
1576
+ const paths = getCodexPaths(codexHome);
1577
+ const supportingPattern = `${SUPPORTING_THREAD_PREFIX}%`;
1578
+ const overviewRow = queryRows(
1579
+ paths.stateDatabasePath,
1580
+ `select
1581
+ count(*) as total,
1582
+ coalesce(sum(case when coalesce(t.archived, 0) <> 0 then 1 else 0 end), 0) as archived_count,
1583
+ coalesce(sum(case when coalesce(t.archived, 0) = 0 then 1 else 0 end), 0) as active_count,
1584
+ count(children.child_thread_id) as subagent_count,
1585
+ coalesce(sum(case
1586
+ when children.child_thread_id is null
1587
+ and not (coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ?)
1588
+ then 1
1589
+ else 0
1590
+ end), 0) as primary_session_count,
1591
+ coalesce(sum(case
1592
+ when coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ? then 1
1593
+ else 0
1594
+ end), 0) as supporting_count,
1595
+ coalesce(sum(case when ${SESSION_ACTIVITY_SQL} = 0 then 1 else 0 end), 0) as unknown_activity_count
1596
+ from threads t
1597
+ left join (
1598
+ select distinct child_thread_id from thread_spawn_edges
1599
+ ) children on children.child_thread_id = t.id`,
1600
+ [supportingPattern, supportingPattern],
1601
+ )[0] ?? {};
1602
+ const workspaceSessionRows = queryRows(
1603
+ paths.stateDatabasePath,
1604
+ `select
1605
+ t.id,
1606
+ coalesce(t.cwd, '') as path,
1607
+ ${SESSION_ACTIVITY_SQL} as last_activity_at_ms
1608
+ from threads t`,
1609
+ );
1610
+ const [sizes, storage] = await Promise.all([
1611
+ getSessionSizeIndex(paths, { refresh }),
1612
+ measureTranscriptStorage(paths.transcriptDirectories),
1613
+ ]);
1614
+ const workspaces = new Map();
1615
+
1616
+ for (const row of workspaceSessionRows) {
1617
+ const workspacePath = row.path ?? "";
1618
+ const current = workspaces.get(workspacePath) ?? {
1619
+ lastActivityAtMs: 0,
1620
+ path: workspacePath,
1621
+ sessionCount: 0,
1622
+ transcriptBytes: 0,
1623
+ };
1624
+ current.lastActivityAtMs = Math.max(
1625
+ current.lastActivityAtMs,
1626
+ toTimestampMs(row.last_activity_at_ms),
1627
+ );
1628
+ current.sessionCount += 1;
1629
+ const transcriptBytes = sizes.get(String(row.id));
1630
+ if (Number.isFinite(transcriptBytes)) current.transcriptBytes += transcriptBytes;
1631
+ workspaces.set(workspacePath, current);
1632
+ }
1633
+
1634
+ return {
1635
+ activeSessionCount: Number(overviewRow.active_count ?? 0),
1636
+ archivedSessionCount: Number(overviewRow.archived_count ?? 0),
1637
+ calculatedAtMs: Date.now(),
1638
+ primarySessionCount: Number(overviewRow.primary_session_count ?? 0),
1639
+ sessionCount: Number(overviewRow.total ?? 0),
1640
+ subagentCount: Number(overviewRow.subagent_count ?? 0),
1641
+ supportingCount: Number(overviewRow.supporting_count ?? 0),
1642
+ unknownActivityCount: Number(overviewRow.unknown_activity_count ?? 0),
1643
+ workspaces: [...workspaces.values()].sort((left, right) =>
1644
+ Number(!left.path) - Number(!right.path)
1645
+ || right.lastActivityAtMs - left.lastActivityAtMs
1646
+ || left.path.localeCompare(right.path)),
1647
+ ...storage,
1648
+ };
1649
+ }
1650
+
1350
1651
  export async function getSessionRecord({ codexHome, id }) {
1351
1652
  const paths = getCodexPaths(codexHome);
1352
1653
  const rows = queryRows(
@@ -1359,6 +1660,7 @@ export async function getSessionRecord({ codexHome, id }) {
1359
1660
  }
1360
1661
 
1361
1662
  export function filterAndSortSessions({
1663
+ archiveStatus = "all",
1362
1664
  includeInternals,
1363
1665
  records,
1364
1666
  search,
@@ -1366,6 +1668,14 @@ export function filterAndSortSessions({
1366
1668
  }) {
1367
1669
  const normalizedSearch = normalizeText(search).toLowerCase();
1368
1670
  const filteredRecords = records.filter((record) => {
1671
+ if (archiveStatus === "active" && record.archived) {
1672
+ return false;
1673
+ }
1674
+
1675
+ if (archiveStatus === "archived" && !record.archived) {
1676
+ return false;
1677
+ }
1678
+
1369
1679
  if (!includeInternals && record.isSubagent) {
1370
1680
  return false;
1371
1681
  }
@@ -1394,6 +1704,12 @@ export function filterAndSortSessions({
1394
1704
  name: (left, right) =>
1395
1705
  left.displayName.localeCompare(right.displayName) ||
1396
1706
  right.updatedAtMs - left.updatedAtMs,
1707
+ size: (left, right) => {
1708
+ const leftKnown = Number.isFinite(left.transcriptBytes);
1709
+ const rightKnown = Number.isFinite(right.transcriptBytes);
1710
+ if (leftKnown !== rightKnown) return leftKnown ? -1 : 1;
1711
+ return (right.transcriptBytes ?? 0) - (left.transcriptBytes ?? 0);
1712
+ },
1397
1713
  updated: (left, right) =>
1398
1714
  right.updatedAtMs - left.updatedAtMs || left.displayName.localeCompare(right.displayName),
1399
1715
  };
@@ -1477,6 +1793,33 @@ export async function planSessionDeletion({ recordIds, store }) {
1477
1793
  .map((record) => record.rolloutPath)
1478
1794
  .filter(Boolean);
1479
1795
  const deletionIdSet = new Set(deletionIds);
1796
+ const requestedIdSet = new Set(recordIds);
1797
+ const newestLinkedActivityAtMs = selectedRecords.reduce((newest, record) => {
1798
+ if (requestedIdSet.has(record.id)) {
1799
+ return newest;
1800
+ }
1801
+
1802
+ return Math.max(newest, record.updatedAtMs ?? 0);
1803
+ }, 0);
1804
+ let transcriptBytes = 0;
1805
+ let transcriptFileCount = 0;
1806
+
1807
+ for (const transcriptBatch of batches([...new Set(transcriptPaths)], OVERVIEW_STAT_BATCH_SIZE)) {
1808
+ const results = await Promise.all(transcriptBatch.map(async (transcriptPath) => {
1809
+ try {
1810
+ return await fs.stat(transcriptPath);
1811
+ } catch (error) {
1812
+ if (error?.code === "ENOENT") return null;
1813
+ throw error;
1814
+ }
1815
+ }));
1816
+
1817
+ for (const stats of results) {
1818
+ if (!stats?.isFile()) continue;
1819
+ transcriptBytes += stats.size;
1820
+ transcriptFileCount += 1;
1821
+ }
1822
+ }
1480
1823
  const [historyMatches, sessionIndexMatches] = await Promise.all([
1481
1824
  inspectJsonlMatches(
1482
1825
  store.historyPath,
@@ -1522,9 +1865,12 @@ export async function planSessionDeletion({ recordIds, store }) {
1522
1865
  logRowCount,
1523
1866
  memoryRowCount,
1524
1867
  missingTranscriptPaths,
1868
+ newestLinkedActivityAtMs,
1525
1869
  records: selectedRecords,
1526
1870
  sessionIndexMatchCount: sessionIndexMatches.count,
1527
1871
  spawnEdgeCount,
1872
+ transcriptBytes,
1873
+ transcriptFileCount,
1528
1874
  transcriptPaths,
1529
1875
  };
1530
1876
  }
@@ -1532,19 +1878,19 @@ export async function planSessionDeletion({ recordIds, store }) {
1532
1878
  const BACKUP_MINIMUM_RESERVE_BYTES = 1024 * 1024;
1533
1879
  const BACKUP_RESERVE_RATIO = 0.05;
1534
1880
 
1535
- function getBackupSourcePaths({ plan, store }) {
1881
+ function getBackupSourcePaths({ plan, scope, store }) {
1536
1882
  const databasePaths = [
1537
1883
  store.stateDatabasePath,
1538
1884
  store.hasLogsDatabase ? store.logsDatabasePath : null,
1539
- store.hasMemoryDatabase ? store.memoryDatabasePath : null,
1540
- store.hasGoalsDatabase ? store.goalsDatabasePath : null,
1885
+ scope === "deep" && store.hasMemoryDatabase ? store.memoryDatabasePath : null,
1886
+ scope === "deep" && store.hasGoalsDatabase ? store.goalsDatabasePath : null,
1541
1887
  ].filter(Boolean);
1542
1888
 
1543
1889
  return [
1544
1890
  store.historyPath,
1545
1891
  store.sessionIndexPath,
1546
- store.hasDesktopState ? store.desktopStatePath : null,
1547
- store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1892
+ scope === "deep" && store.hasDesktopState ? store.desktopStatePath : null,
1893
+ scope === "deep" && store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1548
1894
  ...plan.transcriptPaths,
1549
1895
  ...databasePaths.flatMap((databasePath) => [
1550
1896
  databasePath,
@@ -1630,9 +1976,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1630
1976
  return hash.digest("hex");
1631
1977
  }
1632
1978
 
1633
- async function estimateBackupBytes({ plan, store }) {
1979
+ async function estimateBackupBytes({ plan, scope, store }) {
1634
1980
  let sourceBytes = 0;
1635
- const uniquePaths = new Set(getBackupSourcePaths({ plan, store }));
1981
+ const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
1636
1982
 
1637
1983
  for (const sourcePath of uniquePaths) {
1638
1984
  try {
@@ -1688,7 +2034,7 @@ function formatBytes(bytes) {
1688
2034
  return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
1689
2035
  }
1690
2036
 
1691
- export async function preflightSessionDeletion({ availableDiskBytes, plan, store }) {
2037
+ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
1692
2038
  const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
1693
2039
  const missingRequiredPaths = [];
1694
2040
 
@@ -1708,7 +2054,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, store
1708
2054
  store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
1709
2055
  ]);
1710
2056
  const [desktopState, desktopStateBackup] = desktopStates;
1711
- const backupEstimate = await estimateBackupBytes({ plan, store });
2057
+ const backupEstimate = await estimateBackupBytes({ plan, scope, store });
1712
2058
  const diskCapacityBytes = availableDiskBytes ?? await getAvailableDiskBytes(store.codexHome);
1713
2059
 
1714
2060
  if (diskCapacityBytes < backupEstimate.estimatedBackupBytes) {
@@ -1898,7 +2244,7 @@ export async function executeSessionDeletion({
1898
2244
  });
1899
2245
  if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
1900
2246
  const deletedIdSet = new Set(plan.ids);
1901
- const preflight = await preflightSessionDeletion({ plan, store });
2247
+ const preflight = await preflightSessionDeletion({ plan, scope, store });
1902
2248
  if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
1903
2249
  const backupDirectory = await createOperationBackup({
1904
2250
  onProgress,
@@ -2043,6 +2389,72 @@ function resolveContainedPath(rootDirectory, candidatePath) {
2043
2389
  return resolved;
2044
2390
  }
2045
2391
 
2392
+ export async function deleteSessionDeletionBackup({ backupDirectory, codexHome }) {
2393
+ const backupRoot = path.join(codexHome, "session-steward-backups");
2394
+ const resolvedBackupDirectory = resolveContainedPath(backupRoot, backupDirectory);
2395
+
2396
+ if (resolvedBackupDirectory === path.resolve(backupRoot)) {
2397
+ throw new Error("Choose one recovery backup to delete.");
2398
+ }
2399
+
2400
+ await fs.rm(resolvedBackupDirectory, {
2401
+ force: true,
2402
+ maxRetries: 3,
2403
+ recursive: true,
2404
+ retryDelay: 25,
2405
+ });
2406
+ return { backupDirectory: resolvedBackupDirectory };
2407
+ }
2408
+
2409
+ export async function listSessionDeletionBackups({ codexHome }) {
2410
+ const backupRoot = path.join(path.resolve(codexHome), "session-steward-backups");
2411
+ let entries;
2412
+
2413
+ try {
2414
+ entries = await fs.readdir(backupRoot, { withFileTypes: true });
2415
+ } catch (error) {
2416
+ if (error?.code === "ENOENT") return [];
2417
+ throw error;
2418
+ }
2419
+
2420
+ const backups = [];
2421
+
2422
+ for (const entry of entries) {
2423
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
2424
+ const backupDirectory = path.join(backupRoot, entry.name);
2425
+ let operation = null;
2426
+
2427
+ try {
2428
+ operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
2429
+ } catch (error) {
2430
+ if (!(error instanceof SyntaxError)) throw error;
2431
+ }
2432
+
2433
+ const [stats, measured] = await Promise.all([
2434
+ fs.stat(backupDirectory),
2435
+ measurePath(backupDirectory),
2436
+ ]);
2437
+ const restorable = operation?.version === 2 &&
2438
+ Array.isArray(operation.files) &&
2439
+ operation.files.length > 0;
2440
+
2441
+ backups.push({
2442
+ backupDirectory,
2443
+ bytes: measured.bytes,
2444
+ createdAtMs: toTimestampMs(operation?.createdAtMs) || stats.mtimeMs,
2445
+ fileCount: measured.fileCount,
2446
+ id: entry.name,
2447
+ itemCount: Array.isArray(operation?.files) ? operation.files.length : measured.fileCount,
2448
+ providerId: "codex",
2449
+ restorable,
2450
+ scope: operation?.scope === "core" || operation?.scope === "deep" ? operation.scope : null,
2451
+ sessionCount: Array.isArray(operation?.ids) ? operation.ids.length : null,
2452
+ });
2453
+ }
2454
+
2455
+ return backups.sort((left, right) => right.createdAtMs - left.createdAtMs || left.id.localeCompare(right.id));
2456
+ }
2457
+
2046
2458
  async function atomicCopyFile(sourcePath, destinationPath) {
2047
2459
  await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2048
2460
  const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;
@@ -2295,6 +2707,7 @@ export function formatSessionForJson(sessionRecord) {
2295
2707
  rolloutMissing: sessionRecord.rolloutMissing,
2296
2708
  rolloutPath: sessionRecord.rolloutPath,
2297
2709
  titleSource: sessionRecord.titleSource,
2710
+ transcriptBytes: sessionRecord.transcriptBytes,
2298
2711
  updatedAtMs: sessionRecord.updatedAtMs,
2299
2712
  };
2300
2713
  }