session-steward 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -139,6 +139,12 @@ async function pathExists(targetPath) {
139
139
  }
140
140
  }
141
141
 
142
+ function isContainedPath(rootDirectory, candidatePath) {
143
+ if (!candidatePath) return false;
144
+ const relativePath = path.relative(path.resolve(rootDirectory), path.resolve(candidatePath));
145
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
146
+ }
147
+
142
148
  async function readFirstLine(filePath) {
143
149
  const stream = createReadStream(filePath, {
144
150
  encoding: "utf8",
@@ -414,8 +420,11 @@ function deriveDisplayName({
414
420
 
415
421
  function getCodexPaths(codexHomeInput) {
416
422
  const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
423
+ const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
424
+ const sessionsDirectory = path.join(codexHome, "sessions");
417
425
 
418
426
  return {
427
+ archivedSessionsDirectory,
419
428
  codexHome,
420
429
  desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
421
430
  desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
@@ -424,8 +433,9 @@ function getCodexPaths(codexHomeInput) {
424
433
  logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
425
434
  memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
426
435
  sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
427
- sessionsDirectory: path.join(codexHome, "sessions"),
436
+ sessionsDirectory,
428
437
  stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
438
+ transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
429
439
  };
430
440
  }
431
441
 
@@ -584,21 +594,7 @@ export async function loadSessionStore({ codexHome }) {
584
594
  from thread_spawn_edges
585
595
  `,
586
596
  );
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
- }
597
+ const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
602
598
 
603
599
  const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
604
600
 
@@ -698,7 +694,7 @@ export async function loadSessionStore({ codexHome }) {
698
694
  const record = {
699
695
  agentNickname: transcriptHeader.agentNickname,
700
696
  agentRole: transcriptHeader.agentRole,
701
- archived: false,
697
+ archived: isContainedPath(paths.archivedSessionsDirectory, transcriptHeader.filePath),
702
698
  childThreadIds: childIdsByParentId.get(transcriptId) ?? [],
703
699
  createdAtMs: transcriptHeader.timestampMs,
704
700
  cwd: transcriptHeader.cwd,
@@ -898,25 +894,40 @@ export async function assertDeepCleanupSupported({ codexHome }) {
898
894
 
899
895
  const DEFAULT_PAGE_SIZE = 25;
900
896
  const MAX_PAGE_SIZE = 100;
897
+ const OVERVIEW_STAT_BATCH_SIZE = 24;
901
898
  const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
899
+ const SESSION_CREATED_SQL = "coalesce(nullif(t.created_at_ms, 0), nullif(t.created_at, 0) * 1000, 0)";
900
+ const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updated_at, 0) * 1000, 0)";
901
+ const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
902
902
 
903
903
  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
904
  const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
907
905
 
908
906
  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`;
907
+ created: `${SESSION_CREATED_SQL} desc, t.id asc`,
908
+ cwd: `lower(coalesce(t.cwd, '')) asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
909
+ name: `${name} asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
910
+ updated: `${SESSION_ACTIVITY_SQL} desc, t.id asc`,
911
+ }[sort] ?? `${SESSION_ACTIVITY_SQL} desc, t.id asc`;
914
912
  }
915
913
 
916
- function getSessionConditions({ includeInternals, includeSupporting, search }) {
914
+ function getSessionConditions({
915
+ archiveStatus,
916
+ inactiveBeforeMs,
917
+ includeInternals,
918
+ includeSupporting,
919
+ search,
920
+ workspace,
921
+ }) {
917
922
  const conditions = [];
918
923
  const parameters = [];
919
924
 
925
+ if (archiveStatus === "active") {
926
+ conditions.push("coalesce(t.archived, 0) = 0");
927
+ } else if (archiveStatus === "archived") {
928
+ conditions.push("coalesce(t.archived, 0) <> 0");
929
+ }
930
+
920
931
  if (!includeInternals) {
921
932
  conditions.push(`not exists (
922
933
  select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
@@ -928,6 +939,16 @@ function getSessionConditions({ includeInternals, includeSupporting, search }) {
928
939
  parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
929
940
  }
930
941
 
942
+ if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
943
+ conditions.push(`${SESSION_ACTIVITY_SQL} > 0 and ${SESSION_ACTIVITY_SQL} <= ?`);
944
+ parameters.push(Math.trunc(inactiveBeforeMs));
945
+ }
946
+
947
+ if (typeof workspace === "string") {
948
+ conditions.push("coalesce(t.cwd, '') = ?");
949
+ parameters.push(workspace);
950
+ }
951
+
931
952
  const normalizedSearch = normalizeText(search).toLowerCase();
932
953
 
933
954
  if (normalizedSearch) {
@@ -957,8 +978,8 @@ const SESSION_COLUMNS = `
957
978
  t.agent_role,
958
979
  t.archived,
959
980
  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,
981
+ ${SESSION_CREATED_SQL} as created_at_ms,
982
+ ${SESSION_ACTIVITY_SQL} as updated_at_ms,
962
983
  (
963
984
  select edge.parent_thread_id
964
985
  from thread_spawn_edges edge
@@ -1061,19 +1082,21 @@ async function getStoreAvailability(paths) {
1061
1082
  };
1062
1083
  }
1063
1084
 
1064
- async function indexTranscriptHeaders(sessionsDirectory) {
1085
+ async function indexTranscriptHeaders(transcriptDirectories) {
1065
1086
  const headersById = new Map();
1066
1087
 
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;
1088
+ for (const transcriptDirectory of transcriptDirectories) {
1089
+ for await (const transcriptFile of findTranscriptFiles(transcriptDirectory)) {
1090
+ try {
1091
+ const header = await parseTranscriptHeader(transcriptFile);
1092
+ const current = header?.id ? headersById.get(String(header.id)) : null;
1071
1093
 
1072
- if (header?.id && (!current || header.filePath > current.filePath)) {
1073
- headersById.set(String(header.id), { ...header, id: String(header.id) });
1094
+ if (header?.id && (!current || header.filePath > current.filePath)) {
1095
+ headersById.set(String(header.id), { ...header, id: String(header.id) });
1096
+ }
1097
+ } catch {
1098
+ continue;
1074
1099
  }
1075
- } catch {
1076
- continue;
1077
1100
  }
1078
1101
  }
1079
1102
 
@@ -1126,7 +1149,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1126
1149
 
1127
1150
  const [availability, transcriptHeaders] = await Promise.all([
1128
1151
  getStoreAvailability(paths),
1129
- indexTranscriptHeaders(paths.sessionsDirectory),
1152
+ indexTranscriptHeaders(paths.transcriptDirectories),
1130
1153
  ]);
1131
1154
  const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
1132
1155
  const pendingIds = [...selectedIds];
@@ -1218,7 +1241,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1218
1241
  recordsById.set(id, {
1219
1242
  agentNickname: header.agentNickname,
1220
1243
  agentRole: header.agentRole,
1221
- archived: false,
1244
+ archived: isContainedPath(paths.archivedSessionsDirectory, header.filePath),
1222
1245
  childThreadIds: [...(childIdsByParentId.get(id) ?? [])].sort(),
1223
1246
  createdAtMs: header.timestampMs,
1224
1247
  cwd: header.cwd,
@@ -1306,20 +1329,30 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1306
1329
  }
1307
1330
 
1308
1331
  export async function listSessions({
1332
+ archiveStatus = "all",
1309
1333
  codexHome,
1334
+ inactiveBeforeMs = null,
1310
1335
  includeInternals = false,
1311
1336
  includeSupporting = false,
1312
1337
  page = 1,
1313
1338
  pageSize = DEFAULT_PAGE_SIZE,
1314
1339
  search = "",
1315
1340
  sort = "updated",
1341
+ workspace,
1316
1342
  }) {
1317
1343
  const paths = getCodexPaths(codexHome);
1318
1344
  const boundedPageSize = Number.isFinite(pageSize)
1319
1345
  ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
1320
1346
  : DEFAULT_PAGE_SIZE;
1321
1347
  const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1322
- const conditions = getSessionConditions({ includeInternals, includeSupporting, search });
1348
+ const conditions = getSessionConditions({
1349
+ archiveStatus,
1350
+ inactiveBeforeMs,
1351
+ includeInternals,
1352
+ includeSupporting,
1353
+ search,
1354
+ workspace,
1355
+ });
1323
1356
  const countRow = queryRows(
1324
1357
  paths.stateDatabasePath,
1325
1358
  `select count(*) as count from threads t ${conditions.sql}`,
@@ -1347,6 +1380,115 @@ export async function listSessions({
1347
1380
  };
1348
1381
  }
1349
1382
 
1383
+ async function measureTranscriptStorage(transcriptDirectories) {
1384
+ let transcriptBytes = 0;
1385
+ let transcriptFileCount = 0;
1386
+ let unreadableFileCount = 0;
1387
+ let pendingPaths = [];
1388
+
1389
+ const measurePendingPaths = async () => {
1390
+ const pathsToMeasure = pendingPaths;
1391
+ pendingPaths = [];
1392
+ const results = await Promise.all(pathsToMeasure.map(async (filePath) => {
1393
+ try {
1394
+ return await fs.stat(filePath);
1395
+ } catch {
1396
+ return null;
1397
+ }
1398
+ }));
1399
+
1400
+ for (const stats of results) {
1401
+ if (!stats?.isFile()) {
1402
+ unreadableFileCount += 1;
1403
+ continue;
1404
+ }
1405
+
1406
+ transcriptBytes += stats.size;
1407
+ transcriptFileCount += 1;
1408
+ }
1409
+ };
1410
+
1411
+ for (const transcriptDirectory of transcriptDirectories) {
1412
+ for await (const transcriptPath of findTranscriptFiles(transcriptDirectory)) {
1413
+ pendingPaths.push(transcriptPath);
1414
+
1415
+ if (pendingPaths.length >= OVERVIEW_STAT_BATCH_SIZE) {
1416
+ await measurePendingPaths();
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ if (pendingPaths.length > 0) {
1422
+ await measurePendingPaths();
1423
+ }
1424
+
1425
+ return {
1426
+ transcriptBytes,
1427
+ transcriptFileCount,
1428
+ unreadableFileCount,
1429
+ };
1430
+ }
1431
+
1432
+ export async function getSessionOverview({ codexHome }) {
1433
+ const paths = getCodexPaths(codexHome);
1434
+ const supportingPattern = `${SUPPORTING_THREAD_PREFIX}%`;
1435
+ const overviewRow = queryRows(
1436
+ paths.stateDatabasePath,
1437
+ `select
1438
+ count(*) as total,
1439
+ coalesce(sum(case when coalesce(t.archived, 0) <> 0 then 1 else 0 end), 0) as archived_count,
1440
+ coalesce(sum(case when coalesce(t.archived, 0) = 0 then 1 else 0 end), 0) as active_count,
1441
+ count(children.child_thread_id) as subagent_count,
1442
+ coalesce(sum(case
1443
+ when children.child_thread_id is null
1444
+ and not (coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ?)
1445
+ then 1
1446
+ else 0
1447
+ end), 0) as primary_session_count,
1448
+ coalesce(sum(case
1449
+ when coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ? then 1
1450
+ else 0
1451
+ end), 0) as supporting_count,
1452
+ coalesce(sum(case when ${SESSION_ACTIVITY_SQL} = 0 then 1 else 0 end), 0) as unknown_activity_count
1453
+ from threads t
1454
+ left join (
1455
+ select distinct child_thread_id from thread_spawn_edges
1456
+ ) children on children.child_thread_id = t.id`,
1457
+ [supportingPattern, supportingPattern],
1458
+ )[0] ?? {};
1459
+ const workspaceRows = queryRows(
1460
+ paths.stateDatabasePath,
1461
+ `select
1462
+ coalesce(t.cwd, '') as path,
1463
+ count(*) as session_count,
1464
+ max(${SESSION_ACTIVITY_SQL}) as last_activity_at_ms
1465
+ from threads t
1466
+ group by coalesce(t.cwd, '')
1467
+ order by
1468
+ case when coalesce(t.cwd, '') = '' then 1 else 0 end,
1469
+ last_activity_at_ms desc,
1470
+ lower(coalesce(t.cwd, '')) asc`,
1471
+ );
1472
+ const storage = await measureTranscriptStorage(paths.transcriptDirectories);
1473
+
1474
+ return {
1475
+ activeSessionCount: Number(overviewRow.active_count ?? 0),
1476
+ archivedSessionCount: Number(overviewRow.archived_count ?? 0),
1477
+ calculatedAtMs: Date.now(),
1478
+ primarySessionCount: Number(overviewRow.primary_session_count ?? 0),
1479
+ sessionCount: Number(overviewRow.total ?? 0),
1480
+ subagentCount: Number(overviewRow.subagent_count ?? 0),
1481
+ supportingCount: Number(overviewRow.supporting_count ?? 0),
1482
+ unknownActivityCount: Number(overviewRow.unknown_activity_count ?? 0),
1483
+ workspaces: workspaceRows.map((row) => ({
1484
+ lastActivityAtMs: toTimestampMs(row.last_activity_at_ms),
1485
+ path: row.path ?? "",
1486
+ sessionCount: Number(row.session_count ?? 0),
1487
+ })),
1488
+ ...storage,
1489
+ };
1490
+ }
1491
+
1350
1492
  export async function getSessionRecord({ codexHome, id }) {
1351
1493
  const paths = getCodexPaths(codexHome);
1352
1494
  const rows = queryRows(
@@ -1359,6 +1501,7 @@ export async function getSessionRecord({ codexHome, id }) {
1359
1501
  }
1360
1502
 
1361
1503
  export function filterAndSortSessions({
1504
+ archiveStatus = "all",
1362
1505
  includeInternals,
1363
1506
  records,
1364
1507
  search,
@@ -1366,6 +1509,14 @@ export function filterAndSortSessions({
1366
1509
  }) {
1367
1510
  const normalizedSearch = normalizeText(search).toLowerCase();
1368
1511
  const filteredRecords = records.filter((record) => {
1512
+ if (archiveStatus === "active" && record.archived) {
1513
+ return false;
1514
+ }
1515
+
1516
+ if (archiveStatus === "archived" && !record.archived) {
1517
+ return false;
1518
+ }
1519
+
1369
1520
  if (!includeInternals && record.isSubagent) {
1370
1521
  return false;
1371
1522
  }
@@ -1477,6 +1628,33 @@ export async function planSessionDeletion({ recordIds, store }) {
1477
1628
  .map((record) => record.rolloutPath)
1478
1629
  .filter(Boolean);
1479
1630
  const deletionIdSet = new Set(deletionIds);
1631
+ const requestedIdSet = new Set(recordIds);
1632
+ const newestLinkedActivityAtMs = selectedRecords.reduce((newest, record) => {
1633
+ if (requestedIdSet.has(record.id)) {
1634
+ return newest;
1635
+ }
1636
+
1637
+ return Math.max(newest, record.updatedAtMs ?? 0);
1638
+ }, 0);
1639
+ let transcriptBytes = 0;
1640
+ let transcriptFileCount = 0;
1641
+
1642
+ for (const transcriptBatch of batches([...new Set(transcriptPaths)], OVERVIEW_STAT_BATCH_SIZE)) {
1643
+ const results = await Promise.all(transcriptBatch.map(async (transcriptPath) => {
1644
+ try {
1645
+ return await fs.stat(transcriptPath);
1646
+ } catch (error) {
1647
+ if (error?.code === "ENOENT") return null;
1648
+ throw error;
1649
+ }
1650
+ }));
1651
+
1652
+ for (const stats of results) {
1653
+ if (!stats?.isFile()) continue;
1654
+ transcriptBytes += stats.size;
1655
+ transcriptFileCount += 1;
1656
+ }
1657
+ }
1480
1658
  const [historyMatches, sessionIndexMatches] = await Promise.all([
1481
1659
  inspectJsonlMatches(
1482
1660
  store.historyPath,
@@ -1522,9 +1700,12 @@ export async function planSessionDeletion({ recordIds, store }) {
1522
1700
  logRowCount,
1523
1701
  memoryRowCount,
1524
1702
  missingTranscriptPaths,
1703
+ newestLinkedActivityAtMs,
1525
1704
  records: selectedRecords,
1526
1705
  sessionIndexMatchCount: sessionIndexMatches.count,
1527
1706
  spawnEdgeCount,
1707
+ transcriptBytes,
1708
+ transcriptFileCount,
1528
1709
  transcriptPaths,
1529
1710
  };
1530
1711
  }
@@ -2043,6 +2224,23 @@ function resolveContainedPath(rootDirectory, candidatePath) {
2043
2224
  return resolved;
2044
2225
  }
2045
2226
 
2227
+ export async function deleteSessionDeletionBackup({ backupDirectory, codexHome }) {
2228
+ const backupRoot = path.join(codexHome, "session-steward-backups");
2229
+ const resolvedBackupDirectory = resolveContainedPath(backupRoot, backupDirectory);
2230
+
2231
+ if (resolvedBackupDirectory === path.resolve(backupRoot)) {
2232
+ throw new Error("Choose one recovery backup to delete.");
2233
+ }
2234
+
2235
+ await fs.rm(resolvedBackupDirectory, {
2236
+ force: true,
2237
+ maxRetries: 3,
2238
+ recursive: true,
2239
+ retryDelay: 25,
2240
+ });
2241
+ return { backupDirectory: resolvedBackupDirectory };
2242
+ }
2243
+
2046
2244
  async function atomicCopyFile(sourcePath, destinationPath) {
2047
2245
  await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2048
2246
  const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;