spiracha 2.1.0 → 2.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.
Files changed (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -1,12 +1,13 @@
1
1
  import { constants, Database } from 'bun:sqlite';
2
2
  import { closeSync, openSync, readdirSync, readSync, type Stats, statSync } from 'node:fs';
3
- import { rename, rm } from 'node:fs/promises';
3
+ import { realpath, rename, rm, stat } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { StringDecoder } from 'node:string_decoder';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import type {
9
9
  DashboardSummary,
10
+ DashboardThreadSummary,
10
11
  DeleteProjectResult,
11
12
  DeleteThreadsResult,
12
13
  DynamicToolRow,
@@ -14,11 +15,12 @@ import type {
14
15
  ThreadBrowseData,
15
16
  ThreadListEntry,
16
17
  } from './codex-browser-types';
17
- import { getCachedParsedCodexTranscript, getThreadRolloutLoadState } from './codex-thread-cache';
18
+ import { getCachedCodexTranscriptStats, getThreadRolloutLoadState } from './codex-thread-cache';
18
19
  import type { ThreadRelations, ThreadRow } from './codex-thread-types';
19
20
  import { DEFAULT_CODEX_DIR, DEFAULT_DB_PATH } from './codex-thread-types';
20
21
  import { mapWithConcurrency } from './concurrency';
21
- import { cleanInlineTitle, getPortablePathBasename } from './shared';
22
+ import { getPortablePathBasename } from './portable-path';
23
+ import { cleanInlineTitle } from './shared';
22
24
  import { runWithSqliteRetry } from './sqlite-retry';
23
25
  import { invalidateCacheByPrefix } from './ui-cache';
24
26
 
@@ -40,7 +42,76 @@ const FALLBACK_STATS_HEAD_READ_LIMIT_BYTES = 512 * 1024;
40
42
  const FALLBACK_STATS_TAIL_READ_LIMIT_BYTES = 512 * 1024;
41
43
  const FALLBACK_STATS_RECORD_PATTERN = /"type"\s*:\s*"(?:agent_message|message|token_count|turn_context)"/u;
42
44
  const THREAD_ID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/iu;
43
- const sessionFileIndexCache = new Map<string, Map<string, string>>();
45
+ const THREAD_ROW_COLUMNS = `
46
+ id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
47
+ sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
48
+ git_sha, git_branch, git_origin_url, cli_version, first_user_message, agent_nickname,
49
+ agent_role, memory_mode, model, reasoning_effort, agent_path, created_at_ms,
50
+ updated_at_ms, thread_source, preview
51
+ `;
52
+ const PROJECT_CWD_FILTER = `
53
+ typeof(cwd) = 'text'
54
+ AND (
55
+ RTRIM(cwd, '/\\') = ?1
56
+ OR SUBSTR(RTRIM(cwd, '/\\'), -(LENGTH(?1) + 1)) = '/' || ?1
57
+ OR SUBSTR(RTRIM(cwd, '/\\'), -(LENGTH(?1) + 1)) = '\\' || ?1
58
+ )
59
+ `;
60
+
61
+ type ActivityTimestampedThread = {
62
+ id: string;
63
+ rollout_path: string;
64
+ updated_at: number;
65
+ updated_at_ms: number | null;
66
+ };
67
+
68
+ type DashboardThreadCandidate = DashboardThreadSummary & Pick<ThreadRow, 'first_user_message' | 'rollout_path'>;
69
+
70
+ type ProjectAggregateRow = {
71
+ archived_thread_count: number;
72
+ cwd: string;
73
+ last_updated_at_ms: number | null;
74
+ model: string | null;
75
+ thread_count: number;
76
+ total_tokens: number;
77
+ };
78
+
79
+ type ThreadGoalRow = {
80
+ created_at_ms: number;
81
+ goal_id: string;
82
+ objective: string;
83
+ status: string;
84
+ time_used_seconds: number;
85
+ token_budget: number | null;
86
+ tokens_used: number;
87
+ updated_at_ms: number;
88
+ };
89
+
90
+ type ProjectSummaryAccumulator = {
91
+ archivedThreadCount: number;
92
+ cwdPaths: Set<string>;
93
+ lastUpdatedAtMs: number | null;
94
+ modelNames: Set<string>;
95
+ name: string;
96
+ threadCount: number;
97
+ totalTokens: number;
98
+ };
99
+
100
+ type ProjectSummaryMap = Map<string, ProjectSummaryAccumulator>;
101
+ type SessionFileIndexCacheEntry = {
102
+ fingerprint: string;
103
+ sessionFilesByThreadId: Map<string, string>;
104
+ };
105
+
106
+ type FallbackThreadRowCacheEntry = {
107
+ fingerprint: string;
108
+ row: ThreadRow | null;
109
+ sessionMeta: FallbackSessionMeta | null;
110
+ };
111
+
112
+ const sessionFileIndexCache = new Map<string, SessionFileIndexCacheEntry>();
113
+ const sessionIndexEntriesCache = new Map<string, { entries: SessionIndexEntry[]; fingerprint: string }>();
114
+ const fallbackThreadRowCache = new Map<string, FallbackThreadRowCacheEntry>();
44
115
  let sessionIndexMutationQueue = Promise.resolve();
45
116
 
46
117
  type SessionIndexEntry = {
@@ -55,6 +126,7 @@ type FallbackSessionMeta = {
55
126
  agent_role?: string;
56
127
  cli_version?: string;
57
128
  cwd?: string;
129
+ dynamic_tools?: unknown;
58
130
  forked_from_id?: string;
59
131
  id?: string;
60
132
  model_provider?: string;
@@ -128,7 +200,7 @@ const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
128
200
  }
129
201
  };
130
202
 
131
- const toTimestampMs = (thread: ThreadRow) => {
203
+ const toTimestampMs = (thread: Pick<ThreadRow, 'updated_at' | 'updated_at_ms'>) => {
132
204
  return thread.updated_at_ms ?? thread.updated_at * 1000;
133
205
  };
134
206
 
@@ -217,11 +289,15 @@ export const resolveCodexThreadDbPath = () => {
217
289
  throw new Error(`Unable to open Codex thread database. Tried: ${candidates.join(', ')}`);
218
290
  };
219
291
 
220
- const readAllThreads = (dbPath: string): ThreadRow[] => {
292
+ const readThreads = (dbPath: string, projectName: string | null = null): ThreadRow[] => {
221
293
  return withReadonlyDb(dbPath, (db) => {
294
+ const projectFilter = projectName ? `WHERE ${PROJECT_CWD_FILTER}` : '';
222
295
  return db
223
- .query('SELECT * FROM threads ORDER BY COALESCE(updated_at_ms, updated_at * 1000) DESC, id DESC')
224
- .all() as ThreadRow[];
296
+ .query(
297
+ `SELECT ${THREAD_ROW_COLUMNS} FROM threads ${projectFilter}
298
+ ORDER BY COALESCE(updated_at_ms, updated_at * 1000) DESC, id DESC`,
299
+ )
300
+ .all(...(projectName ? [projectName] : [])) as ThreadRow[];
225
301
  });
226
302
  };
227
303
 
@@ -230,6 +306,34 @@ const resolveCodexDirFromDbPath = (dbPath: string) => {
230
306
  return path.basename(dbDir) === 'sqlite' ? path.dirname(dbDir) : dbDir;
231
307
  };
232
308
 
309
+ const resolveCodexRolloutPath = (dbPath: string, rolloutPath: string) =>
310
+ path.isAbsolute(rolloutPath) ? rolloutPath : path.join(resolveCodexDirFromDbPath(dbPath), rolloutPath);
311
+
312
+ const assertSafeCodexRolloutPaths = async (dbPath: string, rolloutPaths: string[]): Promise<void> => {
313
+ const codexDir = path.resolve(resolveCodexDirFromDbPath(dbPath));
314
+ const canonicalCodexDir = await realpath(codexDir).catch(() => codexDir);
315
+
316
+ await Promise.all(
317
+ rolloutPaths.map(async (rolloutPath) => {
318
+ const resolvedPath = path.resolve(resolveCodexRolloutPath(dbPath, rolloutPath));
319
+ const canonicalPath = await realpath(resolvedPath).catch(() => resolvedPath);
320
+ const relativePath = path.relative(canonicalCodexDir, canonicalPath);
321
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
322
+ throw new Error(`Unsafe Codex rollout path: ${rolloutPath}`);
323
+ }
324
+ }),
325
+ );
326
+ };
327
+
328
+ export class CodexThreadNotFoundError extends Error {
329
+ readonly code = 'CODEX_THREAD_NOT_FOUND';
330
+
331
+ constructor(threadId: string) {
332
+ super(`Thread not found: ${threadId}`);
333
+ this.name = 'CodexThreadNotFoundError';
334
+ }
335
+ }
336
+
233
337
  const parseJsonlObject = <T>(line: string): T | null => {
234
338
  try {
235
339
  return JSON.parse(line) as T;
@@ -281,8 +385,11 @@ const readJsonlObjects = <T>(filePath: string, onRecord: (record: T) => void) =>
281
385
  }
282
386
 
283
387
  emitJsonlLine(pending + decoder.end(), onRecord);
284
- } catch {
285
- return;
388
+ } catch (error) {
389
+ if ((error as { code?: unknown }).code === 'ENOENT') {
390
+ return;
391
+ }
392
+ throw error;
286
393
  } finally {
287
394
  if (descriptor !== null) {
288
395
  closeSync(descriptor);
@@ -299,13 +406,39 @@ const collectJsonlObjects = <T>(filePath: string): T[] => {
299
406
  };
300
407
 
301
408
  const readSessionIndexEntries = (codexDir: string): SessionIndexEntry[] => {
302
- return collectJsonlObjects<SessionIndexEntry>(path.join(codexDir, 'session_index.jsonl')).filter(
409
+ const sessionIndexPath = path.join(codexDir, 'session_index.jsonl');
410
+ let fingerprint = 'missing';
411
+ try {
412
+ const metadata = statSync(sessionIndexPath);
413
+ fingerprint = `${metadata.size}:${metadata.mtimeMs}`;
414
+ } catch {}
415
+ const cached = sessionIndexEntriesCache.get(codexDir);
416
+ if (cached?.fingerprint === fingerprint) {
417
+ return cached.entries;
418
+ }
419
+
420
+ const entries = collectJsonlObjects<SessionIndexEntry>(sessionIndexPath).filter(
303
421
  (entry) => typeof entry.id === 'string' && entry.id.length > 0,
304
422
  );
423
+ sessionIndexEntriesCache.set(codexDir, { entries, fingerprint });
424
+ return entries;
305
425
  };
306
426
 
307
427
  const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string> => {
428
+ const ambiguousThreadIds = new Set<string>();
308
429
  const sessionFiles = new Map<string, string>();
430
+ const recordSessionFile = (fileName: string, filePath: string) => {
431
+ const threadId = THREAD_ID_PATTERN.exec(fileName)?.[1];
432
+ if (!threadId || ambiguousThreadIds.has(threadId)) {
433
+ return;
434
+ }
435
+ if (sessionFiles.has(threadId)) {
436
+ sessionFiles.delete(threadId);
437
+ ambiguousThreadIds.add(threadId);
438
+ } else {
439
+ sessionFiles.set(threadId, filePath);
440
+ }
441
+ };
309
442
  const visit = (directory: string) => {
310
443
  const entries = (() => {
311
444
  try {
@@ -329,10 +462,7 @@ const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string>
329
462
  continue;
330
463
  }
331
464
 
332
- const threadId = THREAD_ID_PATTERN.exec(entry.name)?.[1];
333
- if (threadId && !sessionFiles.has(threadId)) {
334
- sessionFiles.set(threadId, entryPath);
335
- }
465
+ recordSessionFile(entry.name, entryPath);
336
466
  }
337
467
  };
338
468
 
@@ -340,6 +470,36 @@ const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string>
340
470
  return sessionFiles;
341
471
  };
342
472
 
473
+ const getSessionFileIndexFingerprint = (sessionsDir: string) => {
474
+ const toFingerprintPart = (targetPath: string) => {
475
+ try {
476
+ const metadata = statSync(targetPath);
477
+ return `${metadata.size}:${metadata.mtimeMs}`;
478
+ } catch {
479
+ return 'missing';
480
+ }
481
+ };
482
+
483
+ return `${toFingerprintPart(path.join(path.dirname(sessionsDir), 'session_index.jsonl'))}:${toFingerprintPart(
484
+ sessionsDir,
485
+ )}`;
486
+ };
487
+
488
+ const getSessionFilesByThreadId = (sessionsDir: string) => {
489
+ const fingerprint = getSessionFileIndexFingerprint(sessionsDir);
490
+ const cached = sessionFileIndexCache.get(sessionsDir);
491
+ if (cached?.fingerprint === fingerprint) {
492
+ return cached.sessionFilesByThreadId;
493
+ }
494
+
495
+ const sessionFilesByThreadId = collectSessionFilesByThreadId(sessionsDir);
496
+ sessionFileIndexCache.set(sessionsDir, {
497
+ fingerprint,
498
+ sessionFilesByThreadId,
499
+ });
500
+ return sessionFilesByThreadId;
501
+ };
502
+
343
503
  const findSessionFileByThreadId = (sessionsDir: string, threadId: string): string | null => {
344
504
  const lookup = (sessionFilesByThreadId: Map<string, string>) => {
345
505
  const sessionFile = sessionFilesByThreadId.get(threadId);
@@ -354,15 +514,7 @@ const findSessionFileByThreadId = (sessionsDir: string, threadId: string): strin
354
514
  }
355
515
  };
356
516
 
357
- const cached = sessionFileIndexCache.get(sessionsDir);
358
- const cachedMatch = cached ? lookup(cached) : null;
359
- if (cachedMatch) {
360
- return cachedMatch;
361
- }
362
-
363
- const refreshed = collectSessionFilesByThreadId(sessionsDir);
364
- sessionFileIndexCache.set(sessionsDir, refreshed);
365
- return lookup(refreshed);
517
+ return lookup(getSessionFilesByThreadId(sessionsDir));
366
518
  };
367
519
 
368
520
  const readSessionMetaLine = (sessionFile: string): string | null => {
@@ -430,6 +582,32 @@ const objectOrNull = (value: unknown) => {
430
582
  : null;
431
583
  };
432
584
 
585
+ const parseFallbackDynamicTools = (sessionMeta: FallbackSessionMeta, threadId: string): DynamicToolRow[] => {
586
+ if (!Array.isArray(sessionMeta.dynamic_tools)) {
587
+ return [];
588
+ }
589
+
590
+ return sessionMeta.dynamic_tools.flatMap((value, position) => {
591
+ const tool = objectOrNull(value);
592
+ if (!tool) {
593
+ return [];
594
+ }
595
+
596
+ return [
597
+ {
598
+ deferLoading: tool.deferLoading === true || tool.defer_loading === true,
599
+ description: stringOrNull(tool.description) ?? '',
600
+ inputSchema: (objectOrNull(tool.inputSchema) ??
601
+ objectOrNull(tool.input_schema)) as DynamicToolRow['inputSchema'],
602
+ name: stringOrNull(tool.name) ?? 'unknown',
603
+ namespace: stringOrNull(tool.namespace),
604
+ position,
605
+ threadId,
606
+ },
607
+ ];
608
+ });
609
+ };
610
+
433
611
  const isFallbackSubagent = (sessionMeta: FallbackSessionMeta) => {
434
612
  return Boolean(
435
613
  sessionMeta.thread_source === 'subagent' ||
@@ -521,8 +699,8 @@ const readFallbackRolloutStatsHead = (sessionFile: string, stats: FallbackRollou
521
699
  return position;
522
700
  }
523
701
 
524
- decoder.end();
525
- return position;
702
+ const partialLine = pending + decoder.end();
703
+ return Math.max(0, position - Buffer.byteLength(partialLine, 'utf8'));
526
704
  } catch {
527
705
  return 0;
528
706
  } finally {
@@ -596,14 +774,14 @@ const readFallbackRolloutStatsTail = (
596
774
  }
597
775
  };
598
776
 
599
- const readFallbackRolloutStats = (sessionFile: string): FallbackRolloutStats => {
777
+ const readFallbackRolloutStats = (sessionFile: string, knownFileStats?: Stats): FallbackRolloutStats => {
600
778
  const stats: FallbackRolloutStats = {
601
779
  model: null,
602
780
  tokensUsed: 0,
603
781
  };
604
782
 
605
783
  try {
606
- const fileStats = statSync(sessionFile);
784
+ const fileStats = knownFileStats ?? statSync(sessionFile);
607
785
  if (!fileStats.isFile()) {
608
786
  return stats;
609
787
  }
@@ -622,18 +800,14 @@ const buildFallbackThreadRow = (
622
800
  sessionFile: string,
623
801
  sessionMeta: FallbackSessionMeta,
624
802
  rolloutStats: FallbackRolloutStats,
803
+ rolloutMtimeMs: number,
625
804
  ): ThreadRow | null => {
626
805
  const cwd = stringOrNull(sessionMeta.cwd);
627
806
  if (!cwd) {
628
807
  return null;
629
808
  }
630
809
 
631
- let mtimeMs = Date.now();
632
- try {
633
- mtimeMs = statSync(sessionFile).mtimeMs;
634
- } catch {}
635
-
636
- const updatedAtMs = parseIsoMs(entry.updated_at, mtimeMs);
810
+ const updatedAtMs = parseIsoMs(entry.updated_at, rolloutMtimeMs);
637
811
  const createdAtMs = parseIsoMs(sessionMeta.timestamp, updatedAtMs);
638
812
  const title = entry.thread_name?.trim() || path.basename(sessionFile, '.jsonl');
639
813
  const source = stringOrNull(sessionMeta.source) ?? 'session_file';
@@ -676,8 +850,17 @@ const readFallbackThreadRow = (
676
850
  sessionFile: string,
677
851
  options: FallbackThreadRowOptions = {},
678
852
  ): ThreadRow | null => {
679
- const sessionMeta = readFallbackSessionMeta(sessionFile);
853
+ let fileStats: Stats;
854
+ try {
855
+ fileStats = statSync(sessionFile);
856
+ } catch {
857
+ return null;
858
+ }
859
+ const fingerprint = `${fileStats.size}:${fileStats.mtimeMs}:${entry.thread_name ?? ''}:${entry.updated_at ?? ''}`;
860
+ const cached = fallbackThreadRowCache.get(sessionFile);
861
+ const sessionMeta = cached?.fingerprint === fingerprint ? cached.sessionMeta : readFallbackSessionMeta(sessionFile);
680
862
  if (!sessionMeta) {
863
+ fallbackThreadRowCache.set(sessionFile, { fingerprint, row: null, sessionMeta: null });
681
864
  return null;
682
865
  }
683
866
 
@@ -690,7 +873,19 @@ const readFallbackThreadRow = (
690
873
  return null;
691
874
  }
692
875
 
693
- return buildFallbackThreadRow(entry, sessionFile, sessionMeta, readFallbackRolloutStats(sessionFile));
876
+ if (cached?.fingerprint === fingerprint) {
877
+ return cached.row;
878
+ }
879
+
880
+ const row = buildFallbackThreadRow(
881
+ entry,
882
+ sessionFile,
883
+ sessionMeta,
884
+ readFallbackRolloutStats(sessionFile, fileStats),
885
+ fileStats.mtimeMs,
886
+ );
887
+ fallbackThreadRowCache.set(sessionFile, { fingerprint, row, sessionMeta });
888
+ return row;
694
889
  };
695
890
 
696
891
  const readFallbackThreadRows = (
@@ -700,7 +895,7 @@ const readFallbackThreadRows = (
700
895
  options: ReadFallbackThreadRowsOptions = {},
701
896
  ): ThreadRow[] => {
702
897
  const codexDir = resolveCodexDirFromDbPath(dbPath);
703
- const sessionFilesByThreadId = collectSessionFilesByThreadId(path.join(codexDir, 'sessions'));
898
+ const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
704
899
  const fallbackThreads: ThreadRow[] = [];
705
900
 
706
901
  for (const entry of readSessionIndexEntries(codexDir)) {
@@ -758,36 +953,55 @@ const mergeFallbackThreadRows = (dbPath: string, threads: ThreadRow[], projectNa
758
953
  });
759
954
  };
760
955
 
761
- const applyRolloutActivityTimestamps = (threads: ThreadRow[]) => {
762
- return threads
763
- .map((thread) => {
764
- let rolloutUpdatedAtMs = toTimestampMs(thread);
765
- try {
766
- rolloutUpdatedAtMs = Math.max(rolloutUpdatedAtMs, statSync(thread.rollout_path).mtimeMs);
767
- } catch {}
956
+ const applyRolloutActivityTimestamps = async <T extends ActivityTimestampedThread>(
957
+ dbPath: string,
958
+ threads: T[],
959
+ ): Promise<T[]> => {
960
+ const activeThreads = await mapWithConcurrency(threads, THREAD_LIST_IO_CONCURRENCY, async (thread): Promise<T> => {
961
+ const rolloutPath = resolveCodexRolloutPath(dbPath, thread.rollout_path);
962
+ const normalizedThread =
963
+ rolloutPath === thread.rollout_path ? thread : { ...thread, rollout_path: rolloutPath };
964
+ let rolloutUpdatedAtMs = toTimestampMs(thread);
965
+ try {
966
+ rolloutUpdatedAtMs = Math.max(rolloutUpdatedAtMs, (await stat(rolloutPath)).mtimeMs);
967
+ } catch {}
768
968
 
769
- if (rolloutUpdatedAtMs <= toTimestampMs(thread)) {
770
- return thread;
771
- }
969
+ if (rolloutUpdatedAtMs <= toTimestampMs(thread)) {
970
+ return normalizedThread;
971
+ }
772
972
 
773
- return {
774
- ...thread,
775
- updated_at: Math.floor(rolloutUpdatedAtMs / 1000),
776
- updated_at_ms: Math.floor(rolloutUpdatedAtMs),
777
- };
778
- })
779
- .sort((left, right) => {
780
- const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
781
- if (updatedDifference !== 0) {
782
- return updatedDifference;
783
- }
973
+ return {
974
+ ...normalizedThread,
975
+ updated_at: Math.floor(rolloutUpdatedAtMs / 1000),
976
+ updated_at_ms: Math.floor(rolloutUpdatedAtMs),
977
+ };
978
+ });
784
979
 
785
- return right.id.localeCompare(left.id);
786
- });
980
+ return activeThreads.sort((left, right) => {
981
+ const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
982
+ if (updatedDifference !== 0) {
983
+ return updatedDifference;
984
+ }
985
+
986
+ return right.id.localeCompare(left.id);
987
+ });
787
988
  };
788
989
 
789
- const buildDashboardRecentThreads = (threads: ThreadRow[]) => {
790
- const bestThreadByProject = new Map<string, ThreadRow>();
990
+ const compactDashboardThread = (thread: DashboardThreadCandidate): DashboardThreadSummary => {
991
+ return {
992
+ cwd: thread.cwd,
993
+ id: thread.id,
994
+ model: thread.model,
995
+ preview: cleanInlineTitle(thread.preview || thread.first_user_message || ''),
996
+ title: cleanInlineTitle(thread.title),
997
+ tokens_used: thread.tokens_used,
998
+ updated_at: thread.updated_at,
999
+ updated_at_ms: thread.updated_at_ms,
1000
+ };
1001
+ };
1002
+
1003
+ const buildDashboardRecentThreads = (threads: DashboardThreadCandidate[]) => {
1004
+ const bestThreadByProject = new Map<string, DashboardThreadCandidate>();
791
1005
  for (const thread of threads) {
792
1006
  const project = getPortablePathBasename(thread.cwd);
793
1007
  if (!project) {
@@ -812,31 +1026,12 @@ const buildDashboardRecentThreads = (threads: ThreadRow[]) => {
812
1026
  .slice(0, 5)
813
1027
  .map((thread) => ({
814
1028
  project: getPortablePathBasename(thread.cwd),
815
- thread: compactThreadListRow(thread),
1029
+ thread: compactDashboardThread(thread),
816
1030
  }));
817
1031
  };
818
1032
 
819
- const filterThreadsByProject = (threads: ThreadRow[], projectName: string | null) => {
820
- if (!projectName) {
821
- return threads;
822
- }
823
-
824
- return threads.filter((thread) => getPortablePathBasename(thread.cwd) === projectName);
825
- };
826
-
827
1033
  const buildProjectSummaryMap = (threads: ThreadRow[]) => {
828
- const projectMap = new Map<
829
- string,
830
- {
831
- archivedThreadCount: number;
832
- cwdPaths: Set<string>;
833
- lastUpdatedAtMs: number | null;
834
- modelNames: Set<string>;
835
- name: string;
836
- threadCount: number;
837
- totalTokens: number;
838
- }
839
- >();
1034
+ const projectMap: ProjectSummaryMap = new Map();
840
1035
 
841
1036
  for (const thread of threads) {
842
1037
  const projectName = getPortablePathBasename(thread.cwd);
@@ -867,7 +1062,37 @@ const buildProjectSummaryMap = (threads: ThreadRow[]) => {
867
1062
  return projectMap;
868
1063
  };
869
1064
 
870
- const mapProjectSummaries = (projectMap: ReturnType<typeof buildProjectSummaryMap>): ProjectSummary[] => {
1065
+ const mergeProjectAggregateRows = (projectMap: ProjectSummaryMap, rows: ProjectAggregateRow[]) => {
1066
+ for (const row of rows) {
1067
+ const projectName = getPortablePathBasename(row.cwd);
1068
+ if (!projectName) {
1069
+ continue;
1070
+ }
1071
+
1072
+ const current = projectMap.get(projectName) ?? {
1073
+ archivedThreadCount: 0,
1074
+ cwdPaths: new Set<string>(),
1075
+ lastUpdatedAtMs: null,
1076
+ modelNames: new Set<string>(),
1077
+ name: projectName,
1078
+ threadCount: 0,
1079
+ totalTokens: 0,
1080
+ };
1081
+ current.archivedThreadCount += Number(row.archived_thread_count);
1082
+ current.cwdPaths.add(row.cwd);
1083
+ current.lastUpdatedAtMs = Math.max(current.lastUpdatedAtMs ?? 0, Number(row.last_updated_at_ms ?? 0));
1084
+ if (row.model) {
1085
+ current.modelNames.add(row.model);
1086
+ }
1087
+ current.threadCount += Number(row.thread_count);
1088
+ current.totalTokens += Number(row.total_tokens);
1089
+ projectMap.set(projectName, current);
1090
+ }
1091
+
1092
+ return projectMap;
1093
+ };
1094
+
1095
+ const mapProjectSummaries = (projectMap: ProjectSummaryMap): ProjectSummary[] => {
871
1096
  return [...projectMap.values()]
872
1097
  .map((project) => {
873
1098
  return {
@@ -1023,12 +1248,19 @@ const getSessionFilesForThreadIds = (dbPath: string, threadIds: string[]) => {
1023
1248
  return sessionFile ? [sessionFile] : [];
1024
1249
  }
1025
1250
 
1026
- const sessionFilesByThreadId = collectSessionFilesByThreadId(path.join(codexDir, 'sessions'));
1251
+ const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
1027
1252
  return threadIds
1028
1253
  .map((threadId) => sessionFilesByThreadId.get(threadId))
1029
1254
  .filter((value): value is string => Boolean(value));
1030
1255
  };
1031
1256
 
1257
+ const validateSessionFileDeletionTargets = async (dbPath: string, threadIds: string[]): Promise<void> => {
1258
+ const dbSessionFiles = withReadonlyDb(dbPath, (db) =>
1259
+ getThreadDeleteTargets(db, threadIds).map((target) => target.rollout_path),
1260
+ );
1261
+ await assertSafeCodexRolloutPaths(dbPath, [...dbSessionFiles, ...getSessionFilesForThreadIds(dbPath, threadIds)]);
1262
+ };
1263
+
1032
1264
  const filterSessionIndexLines = (lines: string[], threadIds: Set<string>) => {
1033
1265
  const removedThreadIds: string[] = [];
1034
1266
  const retainedLines: string[] = [];
@@ -1051,13 +1283,52 @@ const filterSessionIndexLines = (lines: string[], threadIds: Set<string>) => {
1051
1283
  return { removedThreadIds, retainedLines };
1052
1284
  };
1053
1285
 
1054
- const writeSessionIndexLines = async (sessionIndexPath: string, codexDir: string, retainedLines: string[]) => {
1286
+ export const mergeSessionIndexLinesForRewrite = (
1287
+ initialLines: string[],
1288
+ retainedLines: string[],
1289
+ currentLines: string[],
1290
+ deletedThreadIds: Set<string>,
1291
+ ) => {
1292
+ const initial = new Set(initialLines.map((line) => line.trim()).filter(Boolean));
1293
+ const merged = retainedLines.map((line) => line.trim()).filter(Boolean);
1294
+ const included = new Set(merged);
1295
+
1296
+ for (const line of currentLines) {
1297
+ const trimmed = line.trim();
1298
+ if (!trimmed || initial.has(trimmed) || included.has(trimmed)) {
1299
+ continue;
1300
+ }
1301
+ const entry = parseJsonlObject<SessionIndexEntry>(trimmed);
1302
+ if (entry?.id && deletedThreadIds.has(entry.id)) {
1303
+ continue;
1304
+ }
1305
+ merged.push(trimmed);
1306
+ included.add(trimmed);
1307
+ }
1308
+
1309
+ return merged;
1310
+ };
1311
+
1312
+ const writeSessionIndexLines = async (
1313
+ sessionIndexPath: string,
1314
+ codexDir: string,
1315
+ initialLines: string[],
1316
+ retainedLines: string[],
1317
+ deletedThreadIds: Set<string>,
1318
+ ) => {
1055
1319
  const tempSessionIndexPath = path.join(
1056
1320
  codexDir,
1057
1321
  `.session_index.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
1058
1322
  );
1059
1323
  try {
1060
- await Bun.write(tempSessionIndexPath, retainedLines.length > 0 ? `${retainedLines.join('\n')}\n` : '');
1324
+ const currentLines = (await Bun.file(sessionIndexPath).text()).split(/\r?\n/u);
1325
+ const mergedLines = mergeSessionIndexLinesForRewrite(
1326
+ initialLines,
1327
+ retainedLines,
1328
+ currentLines,
1329
+ deletedThreadIds,
1330
+ );
1331
+ await Bun.write(tempSessionIndexPath, mergedLines.length > 0 ? `${mergedLines.join('\n')}\n` : '');
1061
1332
  await rename(tempSessionIndexPath, sessionIndexPath);
1062
1333
  } catch (error) {
1063
1334
  await rm(tempSessionIndexPath, { force: true });
@@ -1084,7 +1355,7 @@ const removeSessionIndexEntries = async (codexDir: string, threadIds: string[])
1084
1355
  return [];
1085
1356
  }
1086
1357
 
1087
- await writeSessionIndexLines(sessionIndexPath, codexDir, retainedLines);
1358
+ await writeSessionIndexLines(sessionIndexPath, codexDir, lines, retainedLines, uniqueThreadIds);
1088
1359
  return uniqueValues(removedThreadIds);
1089
1360
  };
1090
1361
 
@@ -1098,7 +1369,7 @@ const removeSessionIndexEntries = async (codexDir: string, threadIds: string[])
1098
1369
 
1099
1370
  const listFallbackThreadIdsForProject = (dbPath: string, existingThreadIds: Set<string>, projectName: string) => {
1100
1371
  const codexDir = resolveCodexDirFromDbPath(dbPath);
1101
- const sessionFilesByThreadId = collectSessionFilesByThreadId(path.join(codexDir, 'sessions'));
1372
+ const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
1102
1373
  const fallbackThreadIds: string[] = [];
1103
1374
 
1104
1375
  for (const entry of readSessionIndexEntries(codexDir)) {
@@ -1127,20 +1398,56 @@ const deleteSessionIndexEntriesForThreads = async (
1127
1398
  deleteSessionFiles: boolean,
1128
1399
  ) => {
1129
1400
  const codexDir = resolveCodexDirFromDbPath(dbPath);
1401
+ const fallbackSessionFiles = deleteSessionFiles ? getSessionFilesForThreadIds(dbPath, threadIds) : [];
1402
+ const deletedSessionFiles = deleteSessionFiles
1403
+ ? await deleteThreadSessionFiles([
1404
+ ...dbDeletedSessionFiles.map((sessionFile) => resolveCodexRolloutPath(dbPath, sessionFile)),
1405
+ ...fallbackSessionFiles,
1406
+ ])
1407
+ : [];
1130
1408
  const removedThreadIds = await removeSessionIndexEntries(codexDir, threadIds);
1131
- const fallbackSessionFiles = deleteSessionFiles ? getSessionFilesForThreadIds(dbPath, removedThreadIds) : [];
1132
1409
 
1133
1410
  return {
1134
- deletedSessionFiles: deleteSessionFiles
1135
- ? await deleteThreadSessionFiles([...dbDeletedSessionFiles, ...fallbackSessionFiles])
1136
- : [],
1411
+ deletedSessionFiles,
1137
1412
  deletedThreadIds: removedThreadIds,
1138
1413
  };
1139
1414
  };
1140
1415
 
1141
- export const listCodexProjects = (dbPath: string): ProjectSummary[] => {
1416
+ const readProjectAggregateRows = (db: Database) => {
1417
+ return db
1418
+ .query(`
1419
+ SELECT
1420
+ cwd,
1421
+ model,
1422
+ SUM(CASE WHEN archived <> 0 THEN 1 ELSE 0 END) AS archived_thread_count,
1423
+ MAX(COALESCE(updated_at_ms, updated_at * 1000)) AS last_updated_at_ms,
1424
+ COUNT(*) AS thread_count,
1425
+ COALESCE(SUM(tokens_used), 0) AS total_tokens
1426
+ FROM threads
1427
+ WHERE typeof(cwd) = 'text' AND TRIM(cwd) <> ''
1428
+ GROUP BY cwd, model
1429
+ `)
1430
+ .all() as ProjectAggregateRow[];
1431
+ };
1432
+
1433
+ const readDbThreadIds = (db: Database) => {
1434
+ const rows = db.query('SELECT id FROM threads').all() as Array<{ id: string }>;
1435
+ return new Set(rows.map((row) => row.id));
1436
+ };
1437
+
1438
+ const readProjectSummaryDatabaseData = (dbPath: string) => {
1439
+ return withReadonlyDb(dbPath, (db) => ({
1440
+ existingThreadIds: readDbThreadIds(db),
1441
+ projectAggregates: readProjectAggregateRows(db),
1442
+ }));
1443
+ };
1444
+
1445
+ export const listCodexProjects = async (dbPath: string): Promise<ProjectSummary[]> => {
1446
+ const database = readProjectSummaryDatabaseData(dbPath);
1447
+ const fallbackThreads = readFallbackThreadRows(dbPath, database.existingThreadIds);
1448
+
1142
1449
  return mapProjectSummaries(
1143
- buildProjectSummaryMap(applyRolloutActivityTimestamps(mergeFallbackThreadRows(dbPath, readAllThreads(dbPath)))),
1450
+ mergeProjectAggregateRows(buildProjectSummaryMap(fallbackThreads), database.projectAggregates),
1144
1451
  );
1145
1452
  };
1146
1453
 
@@ -1162,12 +1469,8 @@ export const listProjectThreads = async (
1162
1469
  projectName: string,
1163
1470
  options: ListProjectThreadsOptions = {},
1164
1471
  ): Promise<ThreadListEntry[]> => {
1165
- const threads = mergeFallbackThreadRows(
1166
- dbPath,
1167
- filterThreadsByProject(readAllThreads(dbPath), projectName),
1168
- projectName,
1169
- );
1170
- const activeThreads = applyRolloutActivityTimestamps(threads);
1472
+ const threads = mergeFallbackThreadRows(dbPath, readThreads(dbPath, projectName), projectName);
1473
+ const activeThreads = await applyRolloutActivityTimestamps(dbPath, threads);
1171
1474
  const entries = await mapWithConcurrency(activeThreads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
1172
1475
  const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
1173
1476
 
@@ -1199,16 +1502,16 @@ export const listProjectThreads = async (
1199
1502
  };
1200
1503
  }
1201
1504
 
1202
- const transcript = await getCachedParsedCodexTranscript(thread.rollout_path);
1505
+ const stats = await getCachedCodexTranscriptStats(thread.rollout_path);
1203
1506
 
1204
1507
  return {
1205
1508
  project: projectName,
1206
1509
  rolloutSizeBytes: rollout.fileSizeBytes,
1207
1510
  stats: {
1208
1511
  deferred: false,
1209
- execCommandCount: transcript.stats.execCommandCount,
1210
- toolCallCount: transcript.stats.toolCallCount,
1211
- webSearchEventCount: transcript.stats.webSearchEventCount,
1512
+ execCommandCount: stats.execCommandCount,
1513
+ toolCallCount: stats.toolCallCount,
1514
+ webSearchEventCount: stats.webSearchEventCount,
1212
1515
  },
1213
1516
  thread: compactThreadListRow(thread),
1214
1517
  };
@@ -1223,48 +1526,130 @@ export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBro
1223
1526
  const dbThread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
1224
1527
  const thread = dbThread ?? readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true }) ?? null;
1225
1528
  if (!thread) {
1226
- throw new Error(`Thread not found: ${threadId}`);
1529
+ throw new CodexThreadNotFoundError(threadId);
1227
1530
  }
1531
+ const normalizedThread = {
1532
+ ...thread,
1533
+ rollout_path: resolveCodexRolloutPath(dbPath, thread.rollout_path),
1534
+ };
1228
1535
 
1229
1536
  const dynamicTools =
1230
1537
  dbThread && existingTableNames.has('thread_dynamic_tools')
1538
+ ? (
1539
+ db
1540
+ .query(
1541
+ 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
1542
+ )
1543
+ .all(threadId) as Array<Record<string, number | string | null>>
1544
+ ).map((row) => parseDynamicToolRow(row))
1545
+ : parseFallbackDynamicTools(readFallbackSessionMeta(thread.rollout_path) ?? {}, threadId);
1546
+ const goals =
1547
+ dbThread && existingTableNames.has('thread_goals')
1231
1548
  ? (db
1232
1549
  .query(
1233
- 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
1550
+ 'SELECT goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms FROM thread_goals WHERE thread_id = ? ORDER BY updated_at_ms DESC, goal_id ASC',
1234
1551
  )
1235
- .all(threadId) as Array<Record<string, number | string | null>>)
1552
+ .all(threadId) as ThreadGoalRow[])
1236
1553
  : [];
1237
1554
 
1238
1555
  return {
1239
- dynamicTools: dynamicTools.map((row) => parseDynamicToolRow(row)),
1240
- project: getPortablePathBasename(thread.cwd),
1556
+ dynamicTools,
1557
+ goals: goals.map((goal) => ({
1558
+ createdAtMs: goal.created_at_ms,
1559
+ goalId: goal.goal_id,
1560
+ objective: goal.objective,
1561
+ status: goal.status,
1562
+ timeUsedSeconds: goal.time_used_seconds,
1563
+ tokenBudget: goal.token_budget,
1564
+ tokensUsed: goal.tokens_used,
1565
+ updatedAtMs: goal.updated_at_ms,
1566
+ })),
1567
+ project: getPortablePathBasename(normalizedThread.cwd),
1241
1568
  relations: getRelationsForThread(db, threadId, existingTableNames),
1242
- thread,
1569
+ thread: normalizedThread,
1243
1570
  };
1244
1571
  });
1245
1572
  };
1246
1573
 
1247
- export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
1248
- const threads = applyRolloutActivityTimestamps(mergeFallbackThreadRows(dbPath, readAllThreads(dbPath)));
1249
- const projects = mapProjectSummaries(buildProjectSummaryMap(threads));
1250
- const threadsWithRelations = withReadonlyDb(dbPath, (db) => {
1251
- if (!getExistingTableNames(db).has('thread_spawn_edges')) {
1252
- return 0;
1253
- }
1574
+ type DashboardDatabaseTotals = {
1575
+ archived_threads: number;
1576
+ total_threads: number;
1577
+ total_tokens: number;
1578
+ };
1579
+
1580
+ const readDashboardDatabaseData = (dbPath: string) => {
1581
+ return withReadonlyDb(dbPath, (db) => {
1582
+ const totals = db
1583
+ .query(`
1584
+ SELECT
1585
+ SUM(CASE WHEN archived <> 0 THEN 1 ELSE 0 END) AS archived_threads,
1586
+ COUNT(*) AS total_threads,
1587
+ COALESCE(SUM(tokens_used), 0) AS total_tokens
1588
+ FROM threads
1589
+ `)
1590
+ .get() as DashboardDatabaseTotals;
1591
+ const recentCandidates = db
1592
+ .query(`
1593
+ SELECT
1594
+ id,
1595
+ rollout_path,
1596
+ cwd,
1597
+ title,
1598
+ preview,
1599
+ first_user_message,
1600
+ model,
1601
+ tokens_used,
1602
+ updated_at,
1603
+ updated_at_ms
1604
+ FROM threads
1605
+ WHERE typeof(cwd) = 'text' AND TRIM(cwd) <> ''
1606
+ `)
1607
+ .all() as DashboardThreadCandidate[];
1608
+ const existingTableNames = getExistingTableNames(db);
1609
+ const relationCount = existingTableNames.has('thread_spawn_edges')
1610
+ ? (
1611
+ db
1612
+ .query(`
1613
+ SELECT COUNT(*) AS count
1614
+ FROM (
1615
+ SELECT parent_thread_id AS thread_id FROM thread_spawn_edges
1616
+ UNION
1617
+ SELECT child_thread_id AS thread_id FROM thread_spawn_edges
1618
+ )
1619
+ `)
1620
+ .get() as { count: number }
1621
+ ).count
1622
+ : 0;
1254
1623
 
1255
- const rows = db.query('SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges').all() as Array<{
1256
- child_thread_id: string;
1257
- parent_thread_id: string;
1258
- }>;
1259
- const relatedThreadIds = new Set(rows.flatMap((row) => [row.parent_thread_id, row.child_thread_id]));
1260
- return relatedThreadIds.size;
1624
+ return {
1625
+ existingThreadIds: readDbThreadIds(db),
1626
+ projectAggregates: readProjectAggregateRows(db),
1627
+ recentCandidates,
1628
+ relationCount: Number(relationCount),
1629
+ totals,
1630
+ };
1261
1631
  });
1632
+ };
1633
+
1634
+ export const getCodexDashboardSummary = async (dbPath: string): Promise<DashboardSummary> => {
1635
+ const database = readDashboardDatabaseData(dbPath);
1636
+ const fallbackThreads = readFallbackThreadRows(dbPath, database.existingThreadIds);
1637
+ const recentCandidates = await applyRolloutActivityTimestamps(dbPath, [
1638
+ ...database.recentCandidates,
1639
+ ...fallbackThreads,
1640
+ ]);
1641
+ const projects = mapProjectSummaries(
1642
+ mergeProjectAggregateRows(buildProjectSummaryMap(fallbackThreads), database.projectAggregates),
1643
+ );
1644
+ const fallbackArchivedThreads = fallbackThreads.filter((thread) => Boolean(thread.archived)).length;
1645
+ const archivedThreads = Number(database.totals.archived_threads ?? 0) + fallbackArchivedThreads;
1646
+ const totalThreads = Number(database.totals.total_threads) + fallbackThreads.length;
1262
1647
 
1263
1648
  return {
1264
- activeThreads: threads.filter((thread) => !thread.archived).length,
1265
- archivedThreads: threads.filter((thread) => Boolean(thread.archived)).length,
1266
- recentThreads: buildDashboardRecentThreads(threads),
1267
- threadsWithRelations,
1649
+ activeThreads: totalThreads - archivedThreads,
1650
+ archivedThreads,
1651
+ recentThreads: buildDashboardRecentThreads(recentCandidates),
1652
+ threadsWithRelations: database.relationCount,
1268
1653
  topProjectsByThreadCount: [...projects]
1269
1654
  .sort((left, right) => {
1270
1655
  if (left.threadCount !== right.threadCount) {
@@ -1276,8 +1661,9 @@ export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
1276
1661
  .slice(0, 5),
1277
1662
  topProjectsByTokens: projects.slice(0, 5),
1278
1663
  totalProjects: projects.length,
1279
- totalThreads: threads.length,
1280
- totalTokens: threads.reduce((sum, thread) => sum + thread.tokens_used, 0),
1664
+ totalThreads,
1665
+ totalTokens:
1666
+ Number(database.totals.total_tokens) + fallbackThreads.reduce((sum, thread) => sum + thread.tokens_used, 0),
1281
1667
  };
1282
1668
  };
1283
1669
 
@@ -1287,6 +1673,9 @@ export const deleteCodexThread = async (
1287
1673
  options: DeleteThreadOptions = {},
1288
1674
  ): Promise<DeleteThreadsResult> => {
1289
1675
  const threadIds = [threadId];
1676
+ if (options.deleteSessionFiles) {
1677
+ await validateSessionFileDeletionTargets(dbPath, threadIds);
1678
+ }
1290
1679
  const result = withWritableDb(dbPath, (db) => {
1291
1680
  return deleteThreadIds(db, threadIds);
1292
1681
  });
@@ -1314,6 +1703,9 @@ export const deleteCodexThreads = async (
1314
1703
  options: DeleteThreadOptions = {},
1315
1704
  ): Promise<DeleteThreadsResult> => {
1316
1705
  const uniqueThreadIds = uniqueValues(threadIds);
1706
+ if (options.deleteSessionFiles) {
1707
+ await validateSessionFileDeletionTargets(dbPath, uniqueThreadIds);
1708
+ }
1317
1709
  const result = withWritableDb(dbPath, (db) => {
1318
1710
  return deleteThreadIds(db, uniqueThreadIds);
1319
1711
  });
@@ -1340,14 +1732,25 @@ export const deleteCodexProject = async (
1340
1732
  projectName: string,
1341
1733
  options: DeleteProjectOptions = {},
1342
1734
  ): Promise<DeleteProjectResult> => {
1343
- const existingThreadIds = new Set(readAllThreads(dbPath).map((thread) => thread.id));
1735
+ const existingThreadIds = new Set(
1736
+ withReadonlyDb(dbPath, (db) =>
1737
+ (db.query('SELECT id FROM threads').all() as Array<{ id: string }>).map(({ id }) => id),
1738
+ ),
1739
+ );
1344
1740
  const fallbackThreadIds = listFallbackThreadIdsForProject(dbPath, existingThreadIds, projectName);
1741
+ const projectThreadIds = withReadonlyDb(dbPath, (db) =>
1742
+ (
1743
+ db.query(`SELECT id FROM threads WHERE ${PROJECT_CWD_FILTER}`).all(projectName) as Array<{
1744
+ id: string;
1745
+ }>
1746
+ ).map(({ id }) => id),
1747
+ );
1748
+ const allThreadIds = [...projectThreadIds, ...fallbackThreadIds];
1749
+ if (options.deleteSessionFiles) {
1750
+ await validateSessionFileDeletionTargets(dbPath, allThreadIds);
1751
+ }
1345
1752
  const result = withWritableDb(dbPath, (db) => {
1346
- const threads = db.query('SELECT id, cwd FROM threads').all() as Array<{ cwd: string; id: string }>;
1347
- const threadIds = threads
1348
- .filter((thread) => getPortablePathBasename(thread.cwd) === projectName)
1349
- .map((thread) => thread.id);
1350
- const deleted = deleteThreadIds(db, threadIds);
1753
+ const deleted = deleteThreadIds(db, projectThreadIds);
1351
1754
 
1352
1755
  return {
1353
1756
  ...deleted,
@@ -1374,7 +1777,7 @@ export const deleteCodexProject = async (
1374
1777
  };
1375
1778
 
1376
1779
  export const listScopedThreads = (dbPath: string, projectName: string | null): ThreadRow[] => {
1377
- return mergeFallbackThreadRows(dbPath, filterThreadsByProject(readAllThreads(dbPath), projectName), projectName);
1780
+ return mergeFallbackThreadRows(dbPath, readThreads(dbPath, projectName), projectName);
1378
1781
  };
1379
1782
 
1380
1783
  export const invalidateCodexUiCaches = async () => {