sidekick-shared 0.27.6 → 0.28.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 (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,1034 @@
1
+ /**
2
+ * Codex CLI session provider for the shared package.
3
+ *
4
+ * Implements SessionProviderBase for monitoring Codex CLI sessions.
5
+ * Codex stores sessions as JSONL rollout files in:
6
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
7
+ * with an optional SQLite index at ~/.codex/state.sqlite.
8
+ *
9
+ * Ported from sidekick-vscode/src/services/providers/CodexSessionProvider.ts
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { StringDecoder } from 'string_decoder';
14
+ import { readSessionContextSnapshot } from '../context/sessionContext';
15
+ import { readSessionFileStats } from '../sessionStats';
16
+ import { CodexRolloutParser } from '../parsers/codexParser';
17
+ import { CodexDatabase, spawnParentFromSource } from './codexDatabase';
18
+ import { cachedSubagentStats } from '../parsers/subagentStatsCache';
19
+ import { DEFAULT_ROLLOUT_WALK_MAX_DEPTH, extractRolloutSessionId as extractSessionId, isRolloutFile, walkRolloutFiles, } from './rolloutWalker';
20
+ import { getCodexSessionsDirs } from '../codexPaths';
21
+ import { cwdMatches, getSessionsDirs, listCodexSessionFilesAsync, normalizePath, readFirstLines, readSessionCwd, readSessionCwdAsync, readSessionMeta, } from './codexRollouts';
22
+ import { DirectoryListingCache, splitWatchedPath } from './directoryListingCache';
23
+ import { ProviderDiagnosticTracker, diagnosticFromRuntimeStatus } from './diagnostics';
24
+ import { getModelContextWindowSize } from '../modelContext';
25
+ import { recordObservedContextWindow } from '../observedContextWindows';
26
+ import { extractNormalizedUsage, normalizeProviderUsage } from '../usageNormalization';
27
+ import { addUsageToSubagent } from '../usage/subagentUsage';
28
+ // ---------------------------------------------------------------------------
29
+ // Helper functions
30
+ // ---------------------------------------------------------------------------
31
+ /** Truncate a string to maxLen with ellipsis. */
32
+ function truncate(text, maxLen) {
33
+ const trimmed = text.trim().replace(/\s+/g, ' ');
34
+ if (trimmed.length > maxLen) {
35
+ return trimmed.substring(0, maxLen - 3) + '...';
36
+ }
37
+ return trimmed;
38
+ }
39
+ /** Every rollout under a directory, newest first (one capped walk). */
40
+ function findRolloutFiles(dir) {
41
+ return walkRolloutFiles([dir]);
42
+ }
43
+ function hasRolloutFile(dir) {
44
+ return walkRolloutFiles([dir], { limit: 1 }).length > 0;
45
+ }
46
+ /** Every rollout across the monitored homes, newest first, deduplicated by path. */
47
+ function findRolloutFilesInConfiguredHomes(options = {}) {
48
+ return walkRolloutFiles(getSessionsDirs(), options);
49
+ }
50
+ /**
51
+ * Locates the rollout file for a Codex session id by walking
52
+ * `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`, then
53
+ * `<home>/archived_sessions/`, where Codex moves the rollouts of archived
54
+ * threads.
55
+ *
56
+ * Filename-only match, case-insensitive on the id — no file contents are read
57
+ * and files are only stat'ed once their name matches, so the walk stays cheap
58
+ * even over a large history. When the same id exists in more than one home,
59
+ * the most recently modified copy wins. Returns `null` for an unknown, empty,
60
+ * or whitespace id. Consumers who want the database's answer instead can query
61
+ * the already-public `CodexDatabase` (requires the external `sqlite3` binary).
62
+ */
63
+ export function findCodexRolloutFile(sessionId, options = {}) {
64
+ const normalizedId = sessionId.trim().toLowerCase();
65
+ if (!normalizedId)
66
+ return null;
67
+ const sessionsDirs = options.codexHome
68
+ ? getCodexSessionsDirs([options.codexHome])
69
+ : getSessionsDirs();
70
+ // Name-only filter, one walk across the homes, newest modification first.
71
+ return walkRolloutFiles(sessionsDirs, { sessionId: normalizedId })[0]?.path ?? null;
72
+ }
73
+ /** Pick the best sessions directory for UI/search entrypoints. */
74
+ function getSessionsDir() {
75
+ const dirs = getCodexSessionsDirs(undefined, { includeArchived: false });
76
+ for (const dir of dirs) {
77
+ if (hasRolloutFile(dir)) {
78
+ return dir;
79
+ }
80
+ }
81
+ return dirs[0] ?? path.join(process.cwd(), '.codex', 'sessions');
82
+ }
83
+ /** Pick the home that owns the preferred sessions directory. */
84
+ function getCodexHome() {
85
+ return path.dirname(getSessionsDir());
86
+ }
87
+ /**
88
+ * Check if text looks like system-injected context rather than a real user prompt.
89
+ * Codex prepends AGENTS.md, environment_context, permissions, and collaboration_mode
90
+ * as user/developer messages before the actual user prompt.
91
+ */
92
+ function isSystemInjection(text) {
93
+ const t = text.trimStart();
94
+ return t.startsWith('<') || t.startsWith('#');
95
+ }
96
+ /**
97
+ * Read the first user message text from a rollout file.
98
+ * Reads up to 20 lines to find a real user message (lines can be 10KB+ each).
99
+ * Skips system-injected context (AGENTS.md, environment_context, etc.).
100
+ */
101
+ function extractFirstUserMessage(rolloutPath) {
102
+ try {
103
+ return extractFirstUserMessageFromLines(readFirstLines(rolloutPath, 20));
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function extractFirstUserMessageFromLines(lines) {
110
+ for (const line of lines) {
111
+ const trimmed = line.trim();
112
+ if (!trimmed)
113
+ continue;
114
+ try {
115
+ const parsed = JSON.parse(trimmed);
116
+ // Check response_item with user message
117
+ if (parsed.type === 'response_item') {
118
+ const payload = parsed.payload;
119
+ if (payload.role === 'user') {
120
+ const content = payload.content;
121
+ if (typeof content === 'string' && content.trim() && !isSystemInjection(content)) {
122
+ return truncate(content, 60);
123
+ }
124
+ if (Array.isArray(content)) {
125
+ for (const part of content) {
126
+ const p = part;
127
+ if (p.text?.trim() && !isSystemInjection(p.text)) {
128
+ return truncate(p.text, 60);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ // Check event_msg with user_message
135
+ if (parsed.type === 'event_msg') {
136
+ const payload = parsed.payload;
137
+ if (payload.type === 'user_message' && payload.message?.trim()) {
138
+ return truncate(payload.message, 60);
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ // Skip malformed lines
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ async function extractFirstUserMessageAsync(rolloutPath) {
149
+ let handle = null;
150
+ try {
151
+ handle = await fs.promises.open(rolloutPath, 'r');
152
+ const buffer = Buffer.alloc(256 * 1024);
153
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
154
+ return extractFirstUserMessageFromLines(buffer.toString('utf8', 0, bytesRead).split('\n').slice(0, 20));
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ finally {
160
+ await handle?.close().catch(() => undefined);
161
+ }
162
+ }
163
+ /** Extract searchable text from a rollout line. */
164
+ function extractSearchableText(line) {
165
+ const parts = [];
166
+ switch (line.type) {
167
+ case 'session_meta': {
168
+ const payload = line.payload;
169
+ if (payload.cwd)
170
+ parts.push(payload.cwd);
171
+ if (payload.source)
172
+ parts.push(payload.source);
173
+ if (payload.base_instructions?.text)
174
+ parts.push(payload.base_instructions.text);
175
+ if (payload.git?.branch)
176
+ parts.push(payload.git.branch);
177
+ if (payload.git?.commit)
178
+ parts.push(payload.git.commit);
179
+ return parts.join(' ');
180
+ }
181
+ case 'response_item': {
182
+ const item = line.payload;
183
+ if (item.type === 'message') {
184
+ parts.push(extractTextFromUnknownContent(item.content));
185
+ }
186
+ if (item.type === 'reasoning') {
187
+ const summary = item.summary;
188
+ if (Array.isArray(summary)) {
189
+ for (const entry of summary) {
190
+ if (typeof entry.text === 'string')
191
+ parts.push(entry.text);
192
+ }
193
+ }
194
+ }
195
+ if (item.type === 'function_call') {
196
+ if (typeof item.name === 'string')
197
+ parts.push(item.name);
198
+ if (typeof item.arguments === 'string')
199
+ parts.push(item.arguments);
200
+ }
201
+ if (item.type === 'function_call_output' && typeof item.output === 'string') {
202
+ parts.push(item.output);
203
+ }
204
+ if (item.type === 'local_shell_call') {
205
+ const action = item.action;
206
+ const command = action?.command;
207
+ if (Array.isArray(command))
208
+ parts.push(command.map(String).join(' '));
209
+ if (typeof action?.workdir === 'string')
210
+ parts.push(action.workdir);
211
+ }
212
+ if (item.type === 'custom_tool_call') {
213
+ if (typeof item.name === 'string')
214
+ parts.push(item.name);
215
+ if (typeof item.input === 'string')
216
+ parts.push(item.input);
217
+ }
218
+ if (item.type === 'custom_tool_call_output' && typeof item.output === 'string') {
219
+ parts.push(item.output);
220
+ }
221
+ return parts.filter(Boolean).join(' ');
222
+ }
223
+ case 'event_msg': {
224
+ const event = line.payload;
225
+ for (const key of [
226
+ 'type',
227
+ 'message',
228
+ 'result',
229
+ 'stdout',
230
+ 'stderr',
231
+ 'summary',
232
+ 'error',
233
+ 'code',
234
+ 'tool_name',
235
+ 'server_name',
236
+ ]) {
237
+ if (typeof event[key] === 'string')
238
+ parts.push(event[key]);
239
+ }
240
+ const command = event.command;
241
+ if (Array.isArray(command))
242
+ parts.push(command.map(String).join(' '));
243
+ if (event.arguments && typeof event.arguments === 'object')
244
+ parts.push(JSON.stringify(event.arguments));
245
+ return parts.filter(Boolean).join(' ');
246
+ }
247
+ case 'compacted': {
248
+ const payload = line.payload;
249
+ return payload.summary || '';
250
+ }
251
+ case 'turn_context': {
252
+ const payload = line.payload;
253
+ for (const key of ['model', 'cwd', 'approval_policy', 'sandbox_policy', 'effort']) {
254
+ if (typeof payload[key] === 'string')
255
+ parts.push(payload[key]);
256
+ }
257
+ return parts.join(' ');
258
+ }
259
+ default:
260
+ return '';
261
+ }
262
+ }
263
+ function extractTextFromUnknownContent(content) {
264
+ if (typeof content === 'string')
265
+ return content;
266
+ if (!Array.isArray(content))
267
+ return '';
268
+ return content
269
+ .map((part) => {
270
+ const p = part;
271
+ return typeof p.text === 'string' ? p.text : '';
272
+ })
273
+ .filter(Boolean)
274
+ .join(' ');
275
+ }
276
+ // ---------------------------------------------------------------------------
277
+ // CodexReader — incremental JSONL reader wrapping CodexRolloutParser
278
+ // ---------------------------------------------------------------------------
279
+ /**
280
+ * Incremental JSONL reader for Codex rollout files.
281
+ * Tracks byte position and feeds lines through CodexRolloutParser.
282
+ */
283
+ class CodexReader {
284
+ rolloutPath;
285
+ onContextWindowLimit;
286
+ onRateLimits;
287
+ onTokenUsage;
288
+ parser;
289
+ filePosition = 0;
290
+ /** Cursor after the last complete line, safe to persist across restarts. */
291
+ committedPosition = 0;
292
+ lineBuffer = '';
293
+ _wasTruncated = false;
294
+ decoder = new StringDecoder('utf8');
295
+ constructor(rolloutPath, onContextWindowLimit, onRateLimits, onTokenUsage) {
296
+ this.rolloutPath = rolloutPath;
297
+ this.onContextWindowLimit = onContextWindowLimit;
298
+ this.onRateLimits = onRateLimits;
299
+ this.onTokenUsage = onTokenUsage;
300
+ this.parser = new CodexRolloutParser();
301
+ }
302
+ /** Get the parser's session metadata. */
303
+ getSessionMeta() {
304
+ return this.parser.getSessionMeta();
305
+ }
306
+ readNew(endPosition) {
307
+ this._wasTruncated = false;
308
+ const events = [];
309
+ try {
310
+ if (!fs.existsSync(this.rolloutPath))
311
+ return [];
312
+ const stats = fs.statSync(this.rolloutPath);
313
+ const currentSize = Math.min(stats.size, endPosition ?? stats.size);
314
+ // Handle truncation
315
+ if (currentSize < this.filePosition) {
316
+ this._wasTruncated = true;
317
+ this.filePosition = 0;
318
+ this.committedPosition = 0;
319
+ this.lineBuffer = '';
320
+ this.parser.reset();
321
+ this.decoder = new StringDecoder('utf8');
322
+ }
323
+ // No new content
324
+ if (currentSize <= this.filePosition)
325
+ return [];
326
+ // Read new bytes
327
+ const fd = fs.openSync(this.rolloutPath, 'r');
328
+ const bufferSize = currentSize - this.filePosition;
329
+ const buffer = Buffer.alloc(bufferSize);
330
+ let bytesRead = 0;
331
+ try {
332
+ bytesRead = fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
333
+ }
334
+ finally {
335
+ fs.closeSync(fd);
336
+ }
337
+ const bytes = buffer.subarray(0, bytesRead);
338
+ const newline = bytes.lastIndexOf(0x0a);
339
+ if (newline >= 0)
340
+ this.committedPosition = this.filePosition + newline + 1;
341
+ const chunk = this.decoder.write(bytes);
342
+ this.filePosition += bytesRead;
343
+ // Process lines
344
+ const text = this.lineBuffer + chunk;
345
+ const lines = text.split('\n');
346
+ // Last element may be incomplete
347
+ this.lineBuffer = lines.pop() || '';
348
+ for (const line of lines) {
349
+ const trimmed = line.trim();
350
+ if (!trimmed)
351
+ continue;
352
+ try {
353
+ const parsed = JSON.parse(trimmed);
354
+ events.push(...this.parser.convertLine(parsed));
355
+ }
356
+ catch {
357
+ // Skip malformed JSON lines
358
+ }
359
+ }
360
+ // Propagate model_context_window from token_count events to the provider.
361
+ // The model comes along so the value can be persisted per model — the
362
+ // window Codex reports is tier-specific, not the published maximum.
363
+ const mcw = this.parser.getModelContextWindow();
364
+ if (mcw && this.onContextWindowLimit) {
365
+ this.onContextWindowLimit(mcw, this.parser.getCurrentModel() ?? undefined);
366
+ }
367
+ // Propagate rate_limits from token_count events to the provider
368
+ const rateLimits = this.parser.getLastRateLimits();
369
+ if (rateLimits && this.onRateLimits) {
370
+ this.onRateLimits(rateLimits);
371
+ }
372
+ // Propagate last token usage snapshot to the provider
373
+ const lastUsage = this.parser.getLastTokenUsage();
374
+ if (lastUsage && this.onTokenUsage) {
375
+ const normalized = normalizeProviderUsage({
376
+ semantics: 'openai',
377
+ provider: 'openai',
378
+ source: 'codex-reader-snapshot',
379
+ model: this.parser.getCurrentModel() || undefined,
380
+ inputTokens: lastUsage.input_tokens,
381
+ outputTokens: lastUsage.output_tokens,
382
+ cacheReadTokens: lastUsage.cached_input_tokens,
383
+ reasoningTokens: lastUsage.reasoning_output_tokens,
384
+ });
385
+ this.onTokenUsage({
386
+ inputTokens: normalized.uncachedInputTokens,
387
+ outputTokens: normalized.outputTokens,
388
+ cacheWriteTokens: normalized.cacheWriteTokens,
389
+ cacheReadTokens: normalized.cacheReadTokens,
390
+ reasoningTokens: normalized.reasoningTokens,
391
+ model: this.parser.getCurrentModel() || 'unknown',
392
+ timestamp: new Date(),
393
+ });
394
+ }
395
+ }
396
+ catch {
397
+ // Skip read errors
398
+ }
399
+ return events;
400
+ }
401
+ readAll() {
402
+ this.reset();
403
+ return this.readNew();
404
+ }
405
+ reset() {
406
+ this.filePosition = 0;
407
+ this.committedPosition = 0;
408
+ this.lineBuffer = '';
409
+ this.parser.reset();
410
+ this.decoder = new StringDecoder('utf8');
411
+ this._wasTruncated = false;
412
+ }
413
+ exists() {
414
+ return fs.existsSync(this.rolloutPath);
415
+ }
416
+ flush() {
417
+ this.lineBuffer += this.decoder.end();
418
+ // Process any remaining data in the line buffer
419
+ if (this.lineBuffer.trim()) {
420
+ try {
421
+ const parsed = JSON.parse(this.lineBuffer.trim());
422
+ this.parser.convertLine(parsed);
423
+ }
424
+ catch {
425
+ // Not a complete line yet
426
+ }
427
+ }
428
+ this.decoder = new StringDecoder('utf8');
429
+ }
430
+ getPosition() {
431
+ return this.committedPosition;
432
+ }
433
+ seekTo(position) {
434
+ // Rebuild model, turn, and cumulative-token context before resuming.
435
+ // The prefix is parsed only; its events are already in the consumer snapshot.
436
+ this.reset();
437
+ if (position > 0)
438
+ this.readNew(position);
439
+ }
440
+ wasTruncated() {
441
+ return this._wasTruncated;
442
+ }
443
+ }
444
+ // ---------------------------------------------------------------------------
445
+ // CodexProvider — full SessionProviderBase implementation
446
+ // ---------------------------------------------------------------------------
447
+ /**
448
+ * Session provider for Codex CLI.
449
+ *
450
+ * Uses SQLite database as optional index, with file-system scanning
451
+ * of the YYYY/MM/DD directory structure as primary discovery method.
452
+ */
453
+ export class CodexProvider {
454
+ id = 'codex';
455
+ displayName = 'Codex CLI';
456
+ db = null;
457
+ dbInitialized = false;
458
+ dbStatus = { available: false, kind: 'db_missing' };
459
+ diagnostics;
460
+ dynamicContextWindowLimit = null;
461
+ lastRateLimitsData = null;
462
+ lastTokenUsageData = null;
463
+ /** Dated rollout-directory listings reused across async enumerations. */
464
+ directoryCache = new DirectoryListingCache();
465
+ constructor(options = {}) {
466
+ this.diagnostics = new ProviderDiagnosticTracker(this.id, options);
467
+ }
468
+ // --- Database ---
469
+ /** Lazy-initialize database connection. */
470
+ ensureDb() {
471
+ if (this.dbInitialized)
472
+ return this.db;
473
+ this.dbInitialized = true;
474
+ const codexHome = getCodexHome();
475
+ const db = new CodexDatabase(codexHome);
476
+ if (db.isAvailable() && db.open()) {
477
+ this.db = db;
478
+ }
479
+ if (typeof db.getRuntimeStatus === 'function') {
480
+ this.dbStatus = db.getRuntimeStatus();
481
+ }
482
+ return this.db;
483
+ }
484
+ async ensureDbAsync() {
485
+ if (this.db)
486
+ return this.db;
487
+ return new CodexDatabase(getCodexHome());
488
+ }
489
+ /**
490
+ * Cache an async-created database only after an operation proved it usable.
491
+ * Sync paths treat a cached `db` as "open() succeeded", so adopting an
492
+ * unproven instance would silently disable their file fallbacks.
493
+ */
494
+ adoptDbIfAvailable(db) {
495
+ if (this.db || !db.getRuntimeStatus().available)
496
+ return;
497
+ this.db = db;
498
+ this.dbInitialized = true;
499
+ }
500
+ getRuntimeStatus() {
501
+ const db = this.ensureDb();
502
+ return db && typeof db.getRuntimeStatus === 'function' ? db.getRuntimeStatus() : this.dbStatus;
503
+ }
504
+ getLastOperationStatus() {
505
+ return this.diagnostics.getLastOperationStatus();
506
+ }
507
+ // --- Path resolution ---
508
+ getSessionDirectory(workspacePath) {
509
+ // Codex doesn't use workspace-encoded directories like Claude Code.
510
+ // All sessions live under ~/.codex/sessions/ in date-based subdirs.
511
+ // We return the sessions base dir; session matching is done by CWD in the data.
512
+ const db = this.ensureDb();
513
+ if (db) {
514
+ const thread = db.getMostRecentThread(workspacePath);
515
+ if (thread?.rollout_path) {
516
+ this.recordDatabaseFallback('getSessionDirectory', true);
517
+ return path.dirname(thread.rollout_path);
518
+ }
519
+ }
520
+ const directory = getSessionsDir();
521
+ this.recordDatabaseFallback('getSessionDirectory', true);
522
+ return directory;
523
+ }
524
+ discoverSessionDirectory(workspacePath) {
525
+ // DB: find latest rollout directory for this workspace
526
+ const db = this.ensureDb();
527
+ if (db) {
528
+ const thread = db.getMostRecentThread(workspacePath);
529
+ if (thread?.rollout_path && fs.existsSync(thread.rollout_path)) {
530
+ this.recordDatabaseFallback('discoverSessionDirectory', true);
531
+ return path.dirname(thread.rollout_path);
532
+ }
533
+ }
534
+ // File scan: look for any rollout file whose session_meta.cwd matches
535
+ // (newest first; the cwd read is cached per path).
536
+ const files = findRolloutFilesInConfiguredHomes();
537
+ if (files.length === 0) {
538
+ this.recordDatabaseFallback('discoverSessionDirectory', true);
539
+ return null;
540
+ }
541
+ for (const file of files) {
542
+ const cwd = readSessionCwd(file.path);
543
+ if (cwd && cwdMatches(cwd, workspacePath)) {
544
+ this.recordDatabaseFallback('discoverSessionDirectory', true);
545
+ return path.dirname(file.path);
546
+ }
547
+ }
548
+ // Fallback: return the most relevant base sessions dir if one exists
549
+ const directory = getSessionsDir();
550
+ this.recordDatabaseFallback('discoverSessionDirectory', true);
551
+ return directory;
552
+ }
553
+ // --- Session discovery ---
554
+ findActiveSession(workspacePath) {
555
+ // DB first
556
+ const db = this.ensureDb();
557
+ if (db) {
558
+ const thread = db.getMostRecentThread(workspacePath);
559
+ if (thread?.rollout_path && fs.existsSync(thread.rollout_path)) {
560
+ this.recordDatabaseFallback('findActiveSession', true);
561
+ return thread.rollout_path;
562
+ }
563
+ }
564
+ // File scan fallback (newest first; cwd reads are cached per path)
565
+ for (const file of findRolloutFilesInConfiguredHomes()) {
566
+ const cwd = readSessionCwd(file.path);
567
+ if (cwd && cwdMatches(cwd, workspacePath)) {
568
+ this.recordDatabaseFallback('findActiveSession', true);
569
+ return file.path;
570
+ }
571
+ }
572
+ this.recordDatabaseFallback('findActiveSession', true);
573
+ return null;
574
+ }
575
+ findAllSessions(workspacePath) {
576
+ // DB first
577
+ const db = this.ensureDb();
578
+ if (db) {
579
+ const threads = db.getThreadsByCwd(workspacePath);
580
+ const dbPaths = threads
581
+ .filter((t) => t.rollout_path && fs.existsSync(t.rollout_path))
582
+ .map((t) => t.rollout_path);
583
+ if (dbPaths.length > 0) {
584
+ this.recordDatabaseFallback('findAllSessions', true);
585
+ return dbPaths;
586
+ }
587
+ }
588
+ // File scan (newest first; cwd reads are cached per path)
589
+ const sessions = findRolloutFilesInConfiguredHomes()
590
+ .filter((f) => {
591
+ const cwd = readSessionCwd(f.path);
592
+ return cwd !== null && cwdMatches(cwd, workspacePath);
593
+ })
594
+ .map((f) => f.path);
595
+ this.recordDatabaseFallback('findAllSessions', true);
596
+ return sessions;
597
+ }
598
+ findSessionById(_workspacePath, sessionId) {
599
+ try {
600
+ const found = findCodexRolloutFile(sessionId);
601
+ this.recordDatabaseFallback('findSessionById', true);
602
+ return found;
603
+ }
604
+ catch {
605
+ this.recordDatabaseFallback('findSessionById', false);
606
+ return null;
607
+ }
608
+ }
609
+ /** Backward-compatible alias for findAllSessions. */
610
+ findSessionFiles(workspacePath) {
611
+ return this.findAllSessions(workspacePath);
612
+ }
613
+ findSessionsInDirectory(dir) {
614
+ return findRolloutFiles(dir).map((f) => f.path);
615
+ }
616
+ listAllSessionFiles() {
617
+ const files = findRolloutFilesInConfiguredHomes();
618
+ this.recordDatabaseFallback('listAllSessionFiles', true);
619
+ return files;
620
+ }
621
+ async listSessionFilesAsync(workspacePath, options = {}) {
622
+ const results = await listCodexSessionFilesAsync(workspacePath, options, this.directoryCache);
623
+ // Filesystem enumeration bypasses the database, so record that here as
624
+ // the sync listing paths do; otherwise `getLastOperationStatus()` keeps
625
+ // describing whichever operation ran before this one.
626
+ this.recordDatabaseFallback('listSessionFilesAsync', true);
627
+ return results;
628
+ }
629
+ /**
630
+ * Resolve a recursive-watch event under a sessions root with one stat.
631
+ * Rollouts are matched by name at any depth the walker would visit; a
632
+ * dated directory appearing or vanishing needs a full walk.
633
+ */
634
+ async statWatchedSessionFile(root, relativePath, workspacePath) {
635
+ const segments = splitWatchedPath(relativePath);
636
+ if (!segments)
637
+ return { status: 'unknown' };
638
+ const fullPath = path.join(root, ...segments);
639
+ const name = segments[segments.length - 1];
640
+ if (segments.length - 1 > DEFAULT_ROLLOUT_WALK_MAX_DEPTH)
641
+ return { status: 'ignored' };
642
+ if (!isRolloutFile(name)) {
643
+ this.directoryCache.invalidate(fullPath);
644
+ try {
645
+ const stat = await fs.promises.stat(fullPath);
646
+ return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
647
+ }
648
+ catch {
649
+ return { status: 'unknown' };
650
+ }
651
+ }
652
+ const sessionId = extractSessionId(name);
653
+ const stat = await this.directoryCache.refreshFile(fullPath);
654
+ if (!stat || stat.sizeBytes <= 0)
655
+ return { status: 'missing', path: fullPath, sessionId };
656
+ const file = {
657
+ path: fullPath,
658
+ mtime: new Date(stat.mtimeMs),
659
+ sizeBytes: stat.sizeBytes,
660
+ sessionId,
661
+ };
662
+ if (workspacePath) {
663
+ const cwd = await readSessionCwdAsync(fullPath);
664
+ if (!cwd || !cwdMatches(cwd, workspacePath))
665
+ return { status: 'ignored' };
666
+ file.workspacePath = cwd;
667
+ }
668
+ return { status: 'present', file };
669
+ }
670
+ getAllProjectFolders(workspacePath) {
671
+ const folders = [];
672
+ const seenCwds = new Map();
673
+ // DB first
674
+ const db = this.ensureDb();
675
+ if (db) {
676
+ const cwdStats = db.getAllDistinctCwds();
677
+ const sessionsDir = getSessionsDir();
678
+ for (const stat of cwdStats) {
679
+ seenCwds.set(stat.cwd, {
680
+ dir: sessionsDir,
681
+ name: stat.cwd,
682
+ encodedName: stat.cwd,
683
+ sessionCount: stat.count,
684
+ lastModified: new Date(stat.lastUpdated),
685
+ });
686
+ }
687
+ }
688
+ // File scan supplement. When the index answered, only rollouts written
689
+ // after its last update can be missing from it, so older files are not
690
+ // opened; the cwd read itself is cached per path either way.
691
+ const indexedUpTo = db ? db.getDbMtime() : 0;
692
+ for (const file of findRolloutFilesInConfiguredHomes()) {
693
+ if (db && indexedUpTo > 0 && file.mtime.getTime() <= indexedUpTo)
694
+ continue;
695
+ const cwd = readSessionCwd(file.path);
696
+ if (!cwd)
697
+ continue;
698
+ const existing = seenCwds.get(cwd);
699
+ if (existing) {
700
+ // Update mtime if newer
701
+ if (file.mtime > existing.lastModified) {
702
+ existing.lastModified = file.mtime;
703
+ }
704
+ }
705
+ else {
706
+ seenCwds.set(cwd, {
707
+ dir: path.dirname(file.path),
708
+ name: cwd,
709
+ encodedName: cwd,
710
+ sessionCount: 1,
711
+ lastModified: file.mtime,
712
+ });
713
+ }
714
+ }
715
+ folders.push(...seenCwds.values());
716
+ // Sort: current workspace first, then by recency
717
+ const normalizedWorkspace = workspacePath ? normalizePath(workspacePath) : null;
718
+ folders.sort((a, b) => {
719
+ if (normalizedWorkspace) {
720
+ const aMatch = cwdMatches(a.name, normalizedWorkspace);
721
+ const bMatch = cwdMatches(b.name, normalizedWorkspace);
722
+ if (aMatch && !bMatch)
723
+ return -1;
724
+ if (!aMatch && bMatch)
725
+ return 1;
726
+ }
727
+ return b.lastModified.getTime() - a.lastModified.getTime();
728
+ });
729
+ this.recordDatabaseFallback('getAllProjectFolders', true);
730
+ return folders;
731
+ }
732
+ // --- File identification ---
733
+ isSessionFile(filename) {
734
+ return isRolloutFile(filename);
735
+ }
736
+ getSessionId(sessionPath) {
737
+ return extractSessionId(path.basename(sessionPath));
738
+ }
739
+ encodeWorkspacePath(workspacePath) {
740
+ // Codex doesn't encode workspace paths in directory names
741
+ return workspacePath;
742
+ }
743
+ /** Thread title or first user message from the SQLite index; null without it. */
744
+ extractSessionLabelFromDb(sessionPath) {
745
+ const db = this.ensureDb();
746
+ if (!db)
747
+ return null;
748
+ const sessionId = this.getSessionId(sessionPath);
749
+ const thread = db.getThread(sessionId);
750
+ if (thread?.title) {
751
+ this.recordDatabaseFallback('extractSessionLabel', true);
752
+ return truncate(thread.title, 60);
753
+ }
754
+ if (thread?.first_user_message) {
755
+ this.recordDatabaseFallback('extractSessionLabel', true);
756
+ return truncate(thread.first_user_message, 60);
757
+ }
758
+ return null;
759
+ }
760
+ extractSessionLabel(sessionPath) {
761
+ const fromDb = this.extractSessionLabelFromDb(sessionPath);
762
+ if (fromDb)
763
+ return fromDb;
764
+ // File fallback: parse first user message
765
+ const label = extractFirstUserMessage(sessionPath);
766
+ this.recordDatabaseFallback('extractSessionLabel', true);
767
+ return label;
768
+ }
769
+ async extractSessionLabelsAsync(sessionPaths) {
770
+ const results = new Map();
771
+ const byId = new Map(sessionPaths.map((sessionPath) => [this.getSessionId(sessionPath), sessionPath]));
772
+ const db = await this.ensureDbAsync();
773
+ const threads = await db.getThreadsByIdsAsync([...byId.keys()]);
774
+ for (const thread of threads) {
775
+ const sessionPath = byId.get(thread.id);
776
+ if (!sessionPath)
777
+ continue;
778
+ const label = thread.title ?? thread.first_user_message;
779
+ if (label)
780
+ results.set(sessionPath, truncate(label, 60));
781
+ }
782
+ this.dbStatus = db.getRuntimeStatus();
783
+ this.adoptDbIfAvailable(db);
784
+ for (const sessionPath of sessionPaths) {
785
+ if (!results.has(sessionPath)) {
786
+ results.set(sessionPath, await extractFirstUserMessageAsync(sessionPath));
787
+ await new Promise((resolve) => setImmediate(resolve));
788
+ }
789
+ }
790
+ this.recordDatabaseFallback('extractSessionLabelsAsync', true);
791
+ return results;
792
+ }
793
+ // --- Data reading ---
794
+ createReader(sessionPath) {
795
+ return new CodexReader(sessionPath, (limit, modelId) => {
796
+ this.dynamicContextWindowLimit = limit;
797
+ if (modelId) {
798
+ // Fire-and-forget: persist so historical/static views keep this
799
+ // tier-specific window instead of falling back to the catalog maximum.
800
+ void recordObservedContextWindow(modelId, limit).catch(() => {
801
+ /* non-fatal; the in-memory limit above still applies */
802
+ });
803
+ }
804
+ }, (limits) => {
805
+ this.lastRateLimitsData = limits;
806
+ }, (usage) => {
807
+ this.lastTokenUsageData = usage;
808
+ });
809
+ }
810
+ // --- Subagent support ---
811
+ scanSubagents(_sessionDir, sessionId) {
812
+ // Codex tracks forked sessions via forked_from_id in session_meta.
813
+ // Use DB when available, fall back to filesystem scanning.
814
+ const subagents = [];
815
+ // DB approach: spawned subagents (parent recorded in `threads.source`),
816
+ // plus forked children on older schemas that carry `forked_from_id`.
817
+ const db = this.ensureDb();
818
+ if (db) {
819
+ const seen = new Set();
820
+ const children = [
821
+ ...db.getSpawnedChildThreads(sessionId),
822
+ ...db.getThreadsByForkedFromId(sessionId),
823
+ ].filter((thread) => !seen.has(thread.id) && seen.add(thread.id));
824
+ for (const thread of children) {
825
+ const built = cachedSubagentStats(thread.rollout_path, () => this.buildSubagentStats(thread.id, thread.rollout_path, thread.title || thread.agent_nickname));
826
+ if (built) {
827
+ subagents.push(thread.agent_role && !built.agentType
828
+ ? { ...built, agentType: thread.agent_role }
829
+ : built);
830
+ }
831
+ }
832
+ if (children.length > 0)
833
+ return subagents;
834
+ }
835
+ // Filesystem fallback: scan recent rollout files for a matching
836
+ // forked_from_id. The walk stops after the 50 newest-dated files instead
837
+ // of enumerating the whole history and slicing afterwards.
838
+ for (const file of findRolloutFilesInConfiguredHomes({ limit: 50 })) {
839
+ const meta = readSessionMeta(file.path);
840
+ if (meta &&
841
+ (meta.forked_from_id === sessionId || spawnParentFromSource(meta.source) === sessionId)) {
842
+ const childId = extractSessionId(path.basename(file.path));
843
+ // `source` is a plain string for top-level sessions but an object
844
+ // (`{ subagent: { thread_spawn: … } }`) for spawned children.
845
+ const description = typeof meta.source === 'string' ? meta.source : undefined;
846
+ const stats = cachedSubagentStats(file.path, () => this.buildSubagentStats(childId, file.path, description));
847
+ if (stats)
848
+ subagents.push(stats);
849
+ }
850
+ }
851
+ return subagents;
852
+ }
853
+ /**
854
+ * Builds SubagentStats for a forked session by reading its rollout file.
855
+ */
856
+ buildSubagentStats(agentId, rolloutPath, description) {
857
+ if (!rolloutPath || !fs.existsSync(rolloutPath))
858
+ return null;
859
+ const stats = {
860
+ agentId,
861
+ description: description || undefined,
862
+ toolCalls: [],
863
+ inputTokens: 0,
864
+ outputTokens: 0,
865
+ };
866
+ try {
867
+ const parser = new CodexRolloutParser();
868
+ const content = fs.readFileSync(rolloutPath, 'utf8');
869
+ const lines = content.split('\n');
870
+ for (const line of lines) {
871
+ const trimmed = line.trim();
872
+ if (!trimmed)
873
+ continue;
874
+ try {
875
+ const parsed = JSON.parse(trimmed);
876
+ // Track timestamps
877
+ if (parsed.timestamp) {
878
+ const ts = new Date(parsed.timestamp);
879
+ if (!stats.startTime || ts < stats.startTime)
880
+ stats.startTime = ts;
881
+ if (!stats.endTime || ts > stats.endTime)
882
+ stats.endTime = ts;
883
+ }
884
+ const events = parser.convertLine(parsed);
885
+ for (const event of events) {
886
+ // Collect tool calls
887
+ if (event.type === 'assistant' && Array.isArray(event.message.content)) {
888
+ for (const block of event.message.content) {
889
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
890
+ stats.toolCalls.push({
891
+ name: block.name,
892
+ input: block.input || {},
893
+ timestamp: new Date(event.timestamp),
894
+ });
895
+ }
896
+ }
897
+ }
898
+ // Accumulate tokens from usage events (duplicate token_count events
899
+ // are already dropped by the parser).
900
+ const usage = extractNormalizedUsage(event);
901
+ if (usage)
902
+ addUsageToSubagent(stats, usage);
903
+ }
904
+ }
905
+ catch {
906
+ /* skip malformed lines */
907
+ }
908
+ }
909
+ if (stats.startTime && stats.endTime) {
910
+ stats.durationMs = stats.endTime.getTime() - stats.startTime.getTime();
911
+ }
912
+ }
913
+ catch {
914
+ /* skip unreadable files */
915
+ }
916
+ return stats;
917
+ }
918
+ // --- Cross-session search ---
919
+ searchInSession(sessionPath, query, maxResults) {
920
+ const results = [];
921
+ const queryLower = query.toLowerCase();
922
+ try {
923
+ const content = fs.readFileSync(sessionPath, 'utf8');
924
+ const lines = content.split('\n');
925
+ for (const line of lines) {
926
+ if (results.length >= maxResults)
927
+ break;
928
+ if (!line.trim())
929
+ continue;
930
+ const lineLower = line.toLowerCase();
931
+ if (!lineLower.includes(queryLower))
932
+ continue;
933
+ try {
934
+ const parsed = JSON.parse(line);
935
+ const text = extractSearchableText(parsed);
936
+ if (!text)
937
+ continue;
938
+ const textLower = text.toLowerCase();
939
+ const matchIdx = textLower.indexOf(queryLower);
940
+ if (matchIdx < 0)
941
+ continue;
942
+ const start = Math.max(0, matchIdx - 40);
943
+ const end = Math.min(text.length, matchIdx + query.length + 40);
944
+ const snippet = (start > 0 ? '...' : '') +
945
+ text.substring(start, end) +
946
+ (end < text.length ? '...' : '');
947
+ results.push({
948
+ sessionPath,
949
+ line: snippet.replace(/\n/g, ' '),
950
+ eventType: parsed.type,
951
+ timestamp: parsed.timestamp || '',
952
+ projectPath: readSessionCwd(sessionPath) || sessionPath,
953
+ });
954
+ }
955
+ catch {
956
+ // Skip malformed JSON
957
+ }
958
+ }
959
+ }
960
+ catch {
961
+ // Skip unreadable files
962
+ }
963
+ return results;
964
+ }
965
+ getProjectsBaseDir() {
966
+ return getSessionsDir();
967
+ }
968
+ getWatchRoots() {
969
+ return getSessionsDirs();
970
+ }
971
+ // --- Stats ---
972
+ readSessionStats(sessionPath) {
973
+ // One reader pass computes stats and the label: the SQLite thread title is
974
+ // used when the index is available, otherwise the first user prompt comes
975
+ // from the events already read, so the rollout is never opened twice.
976
+ return readSessionFileStats(this, sessionPath, {
977
+ resolveLabel: () => this.extractSessionLabelFromDb(sessionPath),
978
+ });
979
+ }
980
+ readSessionContextSnapshot(sessionPath, options = {}) {
981
+ return readSessionContextSnapshot(this, sessionPath, options);
982
+ }
983
+ // --- Optional methods ---
984
+ getContextWindowLimit(modelId) {
985
+ // Prefer actual model_context_window reported by token_count events
986
+ if (this.dynamicContextWindowLimit)
987
+ return this.dynamicContextWindowLimit;
988
+ return getModelContextWindowSize(modelId);
989
+ }
990
+ getCurrentUsageSnapshot(_sessionPath) {
991
+ // Return the last token usage snapshot received via the CodexReader callback.
992
+ // This is populated in real-time as token_count events arrive during active monitoring.
993
+ return this.lastTokenUsageData;
994
+ }
995
+ computeContextSize(usage) {
996
+ // Codex usage is normalized to the disjoint TokenUsage shape at the reader
997
+ // boundary, so reconstruct the full prompt/context from uncached + cached.
998
+ return usage.inputTokens + usage.cacheReadTokens;
999
+ }
1000
+ // --- Rate limits (for vscode wrapper to build QuotaState) ---
1001
+ /** Get the last rate limits data received from token_count events. */
1002
+ getLastRateLimits() {
1003
+ return this.lastRateLimitsData;
1004
+ }
1005
+ // --- Lifecycle ---
1006
+ dispose() {
1007
+ this.db?.close();
1008
+ this.db = null;
1009
+ this.dbInitialized = false;
1010
+ this.dbStatus = { available: false, kind: 'db_missing' };
1011
+ this.dynamicContextWindowLimit = null;
1012
+ this.lastRateLimitsData = null;
1013
+ this.lastTokenUsageData = null;
1014
+ this.directoryCache.invalidate();
1015
+ }
1016
+ recordDatabaseFallback(operation, usable) {
1017
+ if (!this.dbInitialized) {
1018
+ this.diagnostics.available(operation);
1019
+ return;
1020
+ }
1021
+ const status = this.getRuntimeStatus();
1022
+ const diagnostic = diagnosticFromRuntimeStatus(this.id, 'query', status);
1023
+ if (!diagnostic) {
1024
+ this.diagnostics.available(operation);
1025
+ return;
1026
+ }
1027
+ this.diagnostics.degraded(operation, status, {
1028
+ kind: diagnostic.kind,
1029
+ severity: diagnostic.severity,
1030
+ phase: diagnostic.phase,
1031
+ message: diagnostic.message,
1032
+ }, usable);
1033
+ }
1034
+ }