sidekick-shared 0.25.0 → 0.26.1

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 (119) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +50 -3
  3. package/dist/accountChanges.js +22 -3
  4. package/dist/accountRegistry.d.ts +22 -1
  5. package/dist/accountRegistry.js +37 -14
  6. package/dist/accountStatus.d.ts +9 -1
  7. package/dist/accountStatus.js +3 -3
  8. package/dist/accounts.d.ts +3 -1
  9. package/dist/accounts.js +84 -64
  10. package/dist/aggregation/EventAggregator.d.ts +15 -2
  11. package/dist/aggregation/EventAggregator.js +54 -9
  12. package/dist/aggregation/costProvenance.d.ts +14 -0
  13. package/dist/aggregation/costProvenance.js +35 -0
  14. package/dist/aggregation/snapshot.d.ts +13 -2
  15. package/dist/aggregation/snapshot.js +57 -6
  16. package/dist/aggregation/types.d.ts +30 -1
  17. package/dist/analytics/compactionLedger.js +15 -5
  18. package/dist/analytics/qualityScore.js +2 -1
  19. package/dist/browser.d.ts +11 -1
  20. package/dist/browser.js +29 -1
  21. package/dist/codexProfiles.d.ts +3 -1
  22. package/dist/codexProfiles.js +4 -2
  23. package/dist/codexQuota.d.ts +2 -0
  24. package/dist/codexQuota.js +11 -66
  25. package/dist/failingTools.d.ts +37 -0
  26. package/dist/failingTools.js +70 -0
  27. package/dist/formatters/sessionDump.js +13 -7
  28. package/dist/formatting.d.ts +13 -0
  29. package/dist/formatting.js +35 -0
  30. package/dist/historicalStore.d.ts +47 -0
  31. package/dist/historicalStore.js +312 -0
  32. package/dist/index.d.ts +46 -12
  33. package/dist/index.js +112 -24
  34. package/dist/modelContext.js +8 -3
  35. package/dist/modelInfo.js +45 -27
  36. package/dist/node.d.ts +19 -0
  37. package/dist/node.js +47 -1
  38. package/dist/observedContextWindows.d.ts +7 -0
  39. package/dist/observedContextWindows.js +17 -2
  40. package/dist/observedSessionCollector.d.ts +3 -8
  41. package/dist/observedSessionCollector.js +10 -27
  42. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  43. package/dist/parsers/sessionActivityDetector.js +95 -17
  44. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  45. package/dist/parsers/sessionPathResolver.js +13 -10
  46. package/dist/peakHours.d.ts +34 -3
  47. package/dist/peakHours.js +92 -7
  48. package/dist/pricingCatalog.d.ts +7 -0
  49. package/dist/pricingCatalog.js +8 -5
  50. package/dist/providers/claudeCode.js +11 -96
  51. package/dist/providers/codex.d.ts +2 -0
  52. package/dist/providers/codex.js +94 -259
  53. package/dist/providers/detect.d.ts +2 -0
  54. package/dist/providers/detect.js +29 -24
  55. package/dist/providers/factory.d.ts +6 -0
  56. package/dist/providers/openCode.d.ts +2 -0
  57. package/dist/providers/openCode.js +105 -121
  58. package/dist/providers/rolloutWalker.d.ts +53 -0
  59. package/dist/providers/rolloutWalker.js +209 -0
  60. package/dist/providers/types.d.ts +30 -0
  61. package/dist/quota.d.ts +30 -3
  62. package/dist/quota.js +35 -3
  63. package/dist/quotaHistory.d.ts +1 -1
  64. package/dist/quotaHistory.js +36 -35
  65. package/dist/quotaPresentation.js +17 -5
  66. package/dist/quotaResolver.d.ts +83 -0
  67. package/dist/quotaResolver.js +234 -0
  68. package/dist/quotaSnapshots.d.ts +15 -2
  69. package/dist/quotaSnapshots.js +25 -3
  70. package/dist/quotaThresholds.d.ts +60 -0
  71. package/dist/quotaThresholds.js +90 -0
  72. package/dist/report/htmlReportGenerator.js +10 -4
  73. package/dist/report/sessionReportInputs.d.ts +23 -0
  74. package/dist/report/sessionReportInputs.js +34 -0
  75. package/dist/schemas/index.d.ts +2 -0
  76. package/dist/schemas/index.js +10 -1
  77. package/dist/schemas/quota.d.ts +139 -0
  78. package/dist/schemas/quota.js +17 -2
  79. package/dist/schemas/quotaHistory.d.ts +1 -0
  80. package/dist/schemas/quotaHistory.js +1 -1
  81. package/dist/schemas/sessionEvent.d.ts +6 -0
  82. package/dist/schemas/sessionEvent.js +1 -0
  83. package/dist/schemas/stateFile.d.ts +198 -0
  84. package/dist/schemas/stateFile.js +77 -0
  85. package/dist/schemas/transcript.js +2 -0
  86. package/dist/sessionFingerprint.d.ts +25 -0
  87. package/dist/sessionFingerprint.js +46 -0
  88. package/dist/sessionPreviews.d.ts +7 -1
  89. package/dist/sessionPreviews.js +15 -3
  90. package/dist/sessionStats.d.ts +51 -0
  91. package/dist/sessionStats.js +166 -0
  92. package/dist/stateFile.d.ts +113 -0
  93. package/dist/stateFile.js +160 -0
  94. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  95. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  96. package/dist/statusline/formatter.d.ts +13 -0
  97. package/dist/statusline/formatter.js +48 -6
  98. package/dist/statusline/index.d.ts +10 -2
  99. package/dist/statusline/index.js +19 -1
  100. package/dist/tokenSummary.d.ts +62 -0
  101. package/dist/tokenSummary.js +72 -0
  102. package/dist/transcript.d.ts +5 -0
  103. package/dist/transcript.js +2 -0
  104. package/dist/types/observedSessionV1.d.ts +2 -0
  105. package/dist/types/observedSessionV1.js +75 -18
  106. package/dist/types/sessionEvent.d.ts +2 -0
  107. package/dist/usage/billingBlocks.d.ts +85 -0
  108. package/dist/usage/billingBlocks.js +153 -0
  109. package/dist/usage/importSessionHistory.d.ts +53 -0
  110. package/dist/usage/importSessionHistory.js +88 -0
  111. package/dist/usage/usageEvents.d.ts +92 -0
  112. package/dist/usage/usageEvents.js +322 -0
  113. package/dist/usage/usageReports.d.ts +76 -0
  114. package/dist/usage/usageReports.js +208 -0
  115. package/dist/watchers/factory.d.ts +6 -0
  116. package/dist/watchers/factory.js +30 -25
  117. package/dist/zaiQuotaApi.d.ts +2 -0
  118. package/dist/zaiQuotaApi.js +2 -0
  119. package/package.json +1 -1
@@ -48,11 +48,12 @@ exports.findCodexRolloutFile = findCodexRolloutFile;
48
48
  const fs = __importStar(require("fs"));
49
49
  const path = __importStar(require("path"));
50
50
  const string_decoder_1 = require("string_decoder");
51
- const EventAggregator_1 = require("../aggregation/EventAggregator");
52
51
  const sessionContext_1 = require("../context/sessionContext");
52
+ const sessionStats_1 = require("../sessionStats");
53
53
  const codexParser_1 = require("../parsers/codexParser");
54
54
  const codexDatabase_1 = require("./codexDatabase");
55
55
  const codexProfiles_1 = require("../codexProfiles");
56
+ const rolloutWalker_1 = require("./rolloutWalker");
56
57
  const diagnostics_1 = require("./diagnostics");
57
58
  const modelContext_1 = require("../modelContext");
58
59
  const observedContextWindows_1 = require("../observedContextWindows");
@@ -73,28 +74,6 @@ function getCodexHomes() {
73
74
  function getSessionsDirs() {
74
75
  return getCodexHomes().map((home) => path.join(home, 'sessions'));
75
76
  }
76
- /** Test if a filename is a Codex rollout file. */
77
- function isRolloutFile(filename) {
78
- return filename.startsWith('rollout-') && filename.endsWith('.jsonl');
79
- }
80
- /**
81
- * Extract session UUID from a rollout filename.
82
- * Format: rollout-<timestamp>-<uuid>.jsonl -> <uuid>
83
- */
84
- function extractSessionId(filename) {
85
- const base = path.basename(filename, '.jsonl');
86
- // rollout-YYYYMMDD-HHMMSS-<uuid> or rollout-<timestamp>-<uuid>
87
- const parts = base.split('-');
88
- // The UUID is typically the last 5 segments (8-4-4-4-12)
89
- if (parts.length >= 6) {
90
- const possibleUuid = parts.slice(-5).join('-');
91
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(possibleUuid)) {
92
- return possibleUuid;
93
- }
94
- }
95
- // Fallback: use everything after "rollout-"
96
- return base.replace(/^rollout-/, '');
97
- }
98
77
  /** Truncate a string to maxLen with ellipsis. */
99
78
  function truncate(text, maxLen) {
100
79
  const trimmed = text.trim().replace(/\s+/g, ' ');
@@ -103,102 +82,16 @@ function truncate(text, maxLen) {
103
82
  }
104
83
  return trimmed;
105
84
  }
106
- /**
107
- * Recursively find all rollout files under a directory.
108
- * Handles the YYYY/MM/DD subdirectory structure.
109
- */
85
+ /** Every rollout under a directory, newest first (one capped walk). */
110
86
  function findRolloutFiles(dir) {
111
- const results = [];
112
- try {
113
- if (!fs.existsSync(dir))
114
- return results;
115
- const entries = fs.readdirSync(dir, { withFileTypes: true });
116
- for (const entry of entries) {
117
- const fullPath = path.join(dir, entry.name);
118
- if (entry.isDirectory()) {
119
- results.push(...findRolloutFiles(fullPath));
120
- }
121
- else if (entry.isFile() && isRolloutFile(entry.name)) {
122
- try {
123
- const stats = fs.statSync(fullPath);
124
- if (stats.size > 0) {
125
- results.push({ path: fullPath, mtime: stats.mtime });
126
- }
127
- }
128
- catch {
129
- // Skip inaccessible files
130
- }
131
- }
132
- }
133
- }
134
- catch {
135
- // Skip inaccessible directories
136
- }
137
- return results;
87
+ return (0, rolloutWalker_1.walkRolloutFiles)([dir]);
138
88
  }
139
89
  function hasRolloutFile(dir) {
140
- try {
141
- if (!fs.existsSync(dir))
142
- return false;
143
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
144
- const fullPath = path.join(dir, entry.name);
145
- if (entry.isDirectory() && hasRolloutFile(fullPath))
146
- return true;
147
- if (entry.isFile() && isRolloutFile(entry.name))
148
- return true;
149
- }
150
- }
151
- catch {
152
- return false;
153
- }
154
- return false;
90
+ return (0, rolloutWalker_1.walkRolloutFiles)([dir], { limit: 1 }).length > 0;
155
91
  }
156
- function findRolloutFilesInConfiguredHomes() {
157
- const results = [];
158
- const seen = new Set();
159
- for (const sessionsDir of getSessionsDirs()) {
160
- for (const file of findRolloutFiles(sessionsDir)) {
161
- if (seen.has(file.path))
162
- continue;
163
- seen.add(file.path);
164
- results.push(file);
165
- }
166
- }
167
- return results;
168
- }
169
- async function findRolloutFilesAsync(dir) {
170
- const results = [];
171
- let entries;
172
- try {
173
- entries = await fs.promises.readdir(dir, { withFileTypes: true });
174
- }
175
- catch {
176
- return results;
177
- }
178
- for (const entry of entries) {
179
- const fullPath = path.join(dir, entry.name);
180
- if (entry.isDirectory()) {
181
- results.push(...(await findRolloutFilesAsync(fullPath)));
182
- }
183
- else if (entry.isFile() && isRolloutFile(entry.name)) {
184
- try {
185
- const stats = await fs.promises.stat(fullPath);
186
- if (stats.size > 0) {
187
- results.push({
188
- path: fullPath,
189
- mtime: stats.mtime,
190
- sizeBytes: stats.size,
191
- sessionId: extractSessionId(entry.name),
192
- });
193
- }
194
- }
195
- catch {
196
- // Skip inaccessible files.
197
- }
198
- }
199
- await new Promise((resolve) => setImmediate(resolve));
200
- }
201
- return results;
92
+ /** Every rollout across the monitored homes, newest first, deduplicated by path. */
93
+ function findRolloutFilesInConfiguredHomes(options = {}) {
94
+ return (0, rolloutWalker_1.walkRolloutFiles)(getSessionsDirs(), options);
202
95
  }
203
96
  /**
204
97
  * Locates the rollout file for a Codex session id by walking
@@ -218,41 +111,8 @@ function findCodexRolloutFile(sessionId, options = {}) {
218
111
  const sessionsDirs = options.codexHome
219
112
  ? [path.join(options.codexHome, 'sessions')]
220
113
  : getSessionsDirs();
221
- let best = null;
222
- for (const dir of sessionsDirs) {
223
- for (const candidate of findRolloutFilesById(dir, normalizedId)) {
224
- if (!best || candidate.mtimeMs > best.mtimeMs)
225
- best = candidate;
226
- }
227
- }
228
- return best?.path ?? null;
229
- }
230
- function findRolloutFilesById(dir, normalizedId) {
231
- const results = [];
232
- try {
233
- if (!fs.existsSync(dir))
234
- return results;
235
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
236
- const fullPath = path.join(dir, entry.name);
237
- if (entry.isDirectory()) {
238
- results.push(...findRolloutFilesById(fullPath, normalizedId));
239
- }
240
- else if (entry.isFile() &&
241
- isRolloutFile(entry.name) &&
242
- extractSessionId(entry.name).toLowerCase() === normalizedId) {
243
- try {
244
- results.push({ path: fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs });
245
- }
246
- catch {
247
- // Skip files that vanish between readdir and stat.
248
- }
249
- }
250
- }
251
- }
252
- catch {
253
- // Skip inaccessible directories.
254
- }
255
- return results;
114
+ // Name-only filter, one walk across the homes, newest modification first.
115
+ return (0, rolloutWalker_1.walkRolloutFiles)(sessionsDirs, { sessionId: normalizedId })[0]?.path ?? null;
256
116
  }
257
117
  /** Pick the best sessions directory for UI/search entrypoints. */
258
118
  function getSessionsDir() {
@@ -368,15 +228,28 @@ async function readSessionMetaAsync(rolloutPath) {
368
228
  await handle?.close().catch(() => undefined);
369
229
  }
370
230
  }
371
- /** Read the CWD from a rollout file's session_meta. Cached. */
231
+ /**
232
+ * Read the CWD from a rollout file's session_meta. Cached per path, bounded
233
+ * so a long-lived host watching a large history cannot grow it without limit
234
+ * (insertion order doubles as an eviction order).
235
+ */
236
+ const CWD_CACHE_LIMIT = 5000;
372
237
  const cwdCache = new Map();
238
+ function rememberCwd(rolloutPath, cwd) {
239
+ if (!cwdCache.has(rolloutPath) && cwdCache.size >= CWD_CACHE_LIMIT) {
240
+ const oldest = cwdCache.keys().next().value;
241
+ if (oldest !== undefined)
242
+ cwdCache.delete(oldest);
243
+ }
244
+ cwdCache.set(rolloutPath, cwd);
245
+ }
373
246
  function readSessionCwd(rolloutPath) {
374
247
  const cached = cwdCache.get(rolloutPath);
375
248
  if (cached !== undefined)
376
249
  return cached;
377
250
  const meta = readSessionMeta(rolloutPath);
378
251
  const cwd = meta?.cwd || null;
379
- cwdCache.set(rolloutPath, cwd);
252
+ rememberCwd(rolloutPath, cwd);
380
253
  return cwd;
381
254
  }
382
255
  /** Async twin of readSessionCwd, sharing its cache so repeat listings do no content reads. */
@@ -386,7 +259,7 @@ async function readSessionCwdAsync(rolloutPath) {
386
259
  return cached;
387
260
  const meta = await readSessionMetaAsync(rolloutPath);
388
261
  const cwd = meta?.cwd || null;
389
- cwdCache.set(rolloutPath, cwd);
262
+ rememberCwd(rolloutPath, cwd);
390
263
  return cwd;
391
264
  }
392
265
  /** Check if a session's CWD matches a workspace path. */
@@ -833,16 +706,15 @@ class CodexProvider {
833
706
  }
834
707
  }
835
708
  // File scan: look for any rollout file whose session_meta.cwd matches
709
+ // (newest first; the cwd read is cached per path).
836
710
  const files = findRolloutFilesInConfiguredHomes();
837
711
  if (files.length === 0) {
838
712
  this.recordDatabaseFallback('discoverSessionDirectory', true);
839
713
  return null;
840
714
  }
841
- // Sort by mtime descending
842
- files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
843
715
  for (const file of files) {
844
- const meta = readSessionMeta(file.path);
845
- if (meta && cwdMatches(meta.cwd, workspacePath)) {
716
+ const cwd = readSessionCwd(file.path);
717
+ if (cwd && cwdMatches(cwd, workspacePath)) {
846
718
  this.recordDatabaseFallback('discoverSessionDirectory', true);
847
719
  return path.dirname(file.path);
848
720
  }
@@ -863,12 +735,10 @@ class CodexProvider {
863
735
  return thread.rollout_path;
864
736
  }
865
737
  }
866
- // File scan fallback
867
- const files = findRolloutFilesInConfiguredHomes();
868
- files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
869
- for (const file of files) {
870
- const meta = readSessionMeta(file.path);
871
- if (meta && cwdMatches(meta.cwd, workspacePath)) {
738
+ // File scan fallback (newest first; cwd reads are cached per path)
739
+ for (const file of findRolloutFilesInConfiguredHomes()) {
740
+ const cwd = readSessionCwd(file.path);
741
+ if (cwd && cwdMatches(cwd, workspacePath)) {
872
742
  this.recordDatabaseFallback('findActiveSession', true);
873
743
  return file.path;
874
744
  }
@@ -889,13 +759,11 @@ class CodexProvider {
889
759
  return dbPaths;
890
760
  }
891
761
  }
892
- // File scan
893
- const files = findRolloutFilesInConfiguredHomes();
894
- files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
895
- const sessions = files
762
+ // File scan (newest first; cwd reads are cached per path)
763
+ const sessions = findRolloutFilesInConfiguredHomes()
896
764
  .filter((f) => {
897
- const meta = readSessionMeta(f.path);
898
- return meta && cwdMatches(meta.cwd, workspacePath);
765
+ const cwd = readSessionCwd(f.path);
766
+ return cwd !== null && cwdMatches(cwd, workspacePath);
899
767
  })
900
768
  .map((f) => f.path);
901
769
  this.recordDatabaseFallback('findAllSessions', true);
@@ -917,9 +785,7 @@ class CodexProvider {
917
785
  return this.findAllSessions(workspacePath);
918
786
  }
919
787
  findSessionsInDirectory(dir) {
920
- const files = findRolloutFiles(dir);
921
- files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
922
- return files.map((f) => f.path);
788
+ return findRolloutFiles(dir).map((f) => f.path);
923
789
  }
924
790
  listAllSessionFiles() {
925
791
  const files = findRolloutFilesInConfiguredHomes();
@@ -927,23 +793,23 @@ class CodexProvider {
927
793
  return files;
928
794
  }
929
795
  async listSessionFilesAsync(workspacePath) {
930
- const seen = new Set();
931
796
  const results = [];
932
- for (const sessionsDir of getSessionsDirs()) {
933
- for (const file of await findRolloutFilesAsync(sessionsDir)) {
934
- if (seen.has(file.path))
797
+ // One capped walk across the homes (deduplicated, newest first).
798
+ for (const file of await (0, rolloutWalker_1.walkRolloutFilesAsync)(getSessionsDirs())) {
799
+ const info = { ...file };
800
+ if (workspacePath) {
801
+ const cwd = await readSessionCwdAsync(file.path);
802
+ if (!cwd || !cwdMatches(cwd, workspacePath))
935
803
  continue;
936
- seen.add(file.path);
937
- if (workspacePath) {
938
- const cwd = await readSessionCwdAsync(file.path);
939
- if (!cwd || !cwdMatches(cwd, workspacePath))
940
- continue;
941
- file.workspacePath = cwd;
942
- }
943
- results.push(file);
804
+ info.workspacePath = cwd;
944
805
  }
806
+ results.push(info);
945
807
  }
946
- return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
808
+ // Filesystem enumeration bypasses the database, so record that here as
809
+ // the sync listing paths do; otherwise `getLastOperationStatus()` keeps
810
+ // describing whichever operation ran before this one.
811
+ this.recordDatabaseFallback('listSessionFilesAsync', true);
812
+ return results;
947
813
  }
948
814
  getAllProjectFolders(workspacePath) {
949
815
  const folders = [];
@@ -963,12 +829,17 @@ class CodexProvider {
963
829
  });
964
830
  }
965
831
  }
966
- // File scan supplement
832
+ // File scan supplement. When the index answered, only rollouts written
833
+ // after its last update can be missing from it, so older files are not
834
+ // opened; the cwd read itself is cached per path either way.
835
+ const indexedUpTo = db ? db.getDbMtime() : 0;
967
836
  for (const file of findRolloutFilesInConfiguredHomes()) {
968
- const meta = readSessionMeta(file.path);
969
- if (!meta?.cwd)
837
+ if (db && indexedUpTo > 0 && file.mtime.getTime() <= indexedUpTo)
838
+ continue;
839
+ const cwd = readSessionCwd(file.path);
840
+ if (!cwd)
970
841
  continue;
971
- const existing = seenCwds.get(meta.cwd);
842
+ const existing = seenCwds.get(cwd);
972
843
  if (existing) {
973
844
  // Update mtime if newer
974
845
  if (file.mtime > existing.lastModified) {
@@ -976,10 +847,10 @@ class CodexProvider {
976
847
  }
977
848
  }
978
849
  else {
979
- seenCwds.set(meta.cwd, {
850
+ seenCwds.set(cwd, {
980
851
  dir: path.dirname(file.path),
981
- name: meta.cwd,
982
- encodedName: meta.cwd,
852
+ name: cwd,
853
+ encodedName: cwd,
983
854
  sessionCount: 1,
984
855
  lastModified: file.mtime,
985
856
  });
@@ -1004,30 +875,36 @@ class CodexProvider {
1004
875
  }
1005
876
  // --- File identification ---
1006
877
  isSessionFile(filename) {
1007
- return isRolloutFile(filename);
878
+ return (0, rolloutWalker_1.isRolloutFile)(filename);
1008
879
  }
1009
880
  getSessionId(sessionPath) {
1010
- return extractSessionId(path.basename(sessionPath));
881
+ return (0, rolloutWalker_1.extractRolloutSessionId)(path.basename(sessionPath));
1011
882
  }
1012
883
  encodeWorkspacePath(workspacePath) {
1013
884
  // Codex doesn't encode workspace paths in directory names
1014
885
  return workspacePath;
1015
886
  }
1016
- extractSessionLabel(sessionPath) {
1017
- // DB first
887
+ /** Thread title or first user message from the SQLite index; null without it. */
888
+ extractSessionLabelFromDb(sessionPath) {
1018
889
  const db = this.ensureDb();
1019
- if (db) {
1020
- const sessionId = this.getSessionId(sessionPath);
1021
- const thread = db.getThread(sessionId);
1022
- if (thread?.title) {
1023
- this.recordDatabaseFallback('extractSessionLabel', true);
1024
- return truncate(thread.title, 60);
1025
- }
1026
- if (thread?.first_user_message) {
1027
- this.recordDatabaseFallback('extractSessionLabel', true);
1028
- return truncate(thread.first_user_message, 60);
1029
- }
890
+ if (!db)
891
+ return null;
892
+ const sessionId = this.getSessionId(sessionPath);
893
+ const thread = db.getThread(sessionId);
894
+ if (thread?.title) {
895
+ this.recordDatabaseFallback('extractSessionLabel', true);
896
+ return truncate(thread.title, 60);
897
+ }
898
+ if (thread?.first_user_message) {
899
+ this.recordDatabaseFallback('extractSessionLabel', true);
900
+ return truncate(thread.first_user_message, 60);
1030
901
  }
902
+ return null;
903
+ }
904
+ extractSessionLabel(sessionPath) {
905
+ const fromDb = this.extractSessionLabelFromDb(sessionPath);
906
+ if (fromDb)
907
+ return fromDb;
1031
908
  // File fallback: parse first user message
1032
909
  const label = extractFirstUserMessage(sessionPath);
1033
910
  this.recordDatabaseFallback('extractSessionLabel', true);
@@ -1091,15 +968,13 @@ class CodexProvider {
1091
968
  if (forkedThreads.length > 0)
1092
969
  return subagents;
1093
970
  }
1094
- // Filesystem fallback: scan all rollout files for matching forked_from_id.
1095
- // Limited to recent files (last 50) to avoid excessive I/O.
1096
- const files = findRolloutFilesInConfiguredHomes();
1097
- files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1098
- const filesToCheck = files.slice(0, 50);
1099
- for (const file of filesToCheck) {
971
+ // Filesystem fallback: scan recent rollout files for a matching
972
+ // forked_from_id. The walk stops after the 50 newest-dated files instead
973
+ // of enumerating the whole history and slicing afterwards.
974
+ for (const file of findRolloutFilesInConfiguredHomes({ limit: 50 })) {
1100
975
  const meta = readSessionMeta(file.path);
1101
976
  if (meta?.forked_from_id === sessionId) {
1102
- const childId = extractSessionId(path.basename(file.path));
977
+ const childId = (0, rolloutWalker_1.extractRolloutSessionId)(path.basename(file.path));
1103
978
  const stats = this.buildSubagentStats(childId, file.path, meta.source);
1104
979
  if (stats)
1105
980
  subagents.push(stats);
@@ -1227,52 +1102,12 @@ class CodexProvider {
1227
1102
  }
1228
1103
  // --- Stats ---
1229
1104
  readSessionStats(sessionPath) {
1230
- const sessionId = extractSessionId(path.basename(sessionPath));
1231
- const aggregator = new EventAggregator_1.EventAggregator({
1232
- providerId: 'codex',
1233
- computeContextSize: (usage) => usage.inputTokens,
1105
+ // One reader pass computes stats and the label: the SQLite thread title is
1106
+ // used when the index is available, otherwise the first user prompt comes
1107
+ // from the events already read, so the rollout is never opened twice.
1108
+ return (0, sessionStats_1.readSessionFileStats)(this, sessionPath, {
1109
+ resolveLabel: () => this.extractSessionLabelFromDb(sessionPath),
1234
1110
  });
1235
- try {
1236
- const reader = this.createReader(sessionPath);
1237
- for (const event of reader.readAll()) {
1238
- aggregator.processEvent(event);
1239
- }
1240
- }
1241
- catch (err) {
1242
- // Return an empty stats shell below. Surface the cause under DEBUG so a
1243
- // malformed rollout doesn't fail silently during dashboard refreshes.
1244
- if (process.env.DEBUG)
1245
- console.error(`CodexProvider.readSessionStats: ${err}`);
1246
- }
1247
- const metrics = aggregator.getMetrics();
1248
- const modelUsage = {};
1249
- for (const model of metrics.modelStats) {
1250
- modelUsage[model.model] = { calls: model.calls, tokens: model.tokens };
1251
- }
1252
- const toolUsage = {};
1253
- for (const tool of metrics.toolStats) {
1254
- toolUsage[tool.name] = tool.successCount + tool.failureCount + tool.pendingCount;
1255
- }
1256
- return {
1257
- providerId: 'codex',
1258
- sessionId,
1259
- filePath: sessionPath,
1260
- label: this.extractSessionLabel(sessionPath),
1261
- startTime: metrics.sessionStartTime || '',
1262
- endTime: metrics.lastEventTime || '',
1263
- messageCount: metrics.messageCount,
1264
- tokens: {
1265
- input: metrics.tokens.inputTokens,
1266
- output: metrics.tokens.outputTokens,
1267
- cacheWrite: metrics.tokens.cacheWriteTokens,
1268
- cacheRead: metrics.tokens.cacheReadTokens,
1269
- },
1270
- modelUsage,
1271
- toolUsage,
1272
- compactionEstimate: metrics.compactionCount,
1273
- truncationCount: metrics.truncationCount,
1274
- reportedCost: metrics.tokens.reportedCost,
1275
- };
1276
1111
  }
1277
1112
  readSessionContextSnapshot(sessionPath, options = {}) {
1278
1113
  return (0, sessionContext_1.readSessionContextSnapshot)(this, sessionPath, options);
@@ -4,6 +4,8 @@
4
4
  * without VS Code dependency (no vscode.workspace.getConfiguration).
5
5
  */
6
6
  import type { ProviderId } from './types';
7
+ /** Forget the memoised ranking. Test-only. */
8
+ export declare function _resetProviderDetectionCache(): void;
7
9
  /**
8
10
  * Returns all provider IDs whose data directories exist on the filesystem.
9
11
  * Ordered by most-recent activity first.
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports._resetProviderDetectionCache = _resetProviderDetectionCache;
41
42
  exports.getAllDetectedProviders = getAllDetectedProviders;
42
43
  exports.detectProvider = detectProvider;
43
44
  const fs = __importStar(require("fs"));
@@ -169,11 +170,25 @@ function getProviderPaths() {
169
170
  codexHomes,
170
171
  };
171
172
  }
173
+ /** Ranked detection results are reused within a process for this long. */
174
+ const DETECTION_CACHE_TTL_MS = 5_000;
175
+ let detectionCache = null;
176
+ /** Forget the memoised ranking. Test-only. */
177
+ function _resetProviderDetectionCache() {
178
+ detectionCache = null;
179
+ }
172
180
  /**
173
- * Returns all provider IDs whose data directories exist on the filesystem.
174
- * Ordered by most-recent activity first.
181
+ * Providers with data on disk, most recently active first.
182
+ *
183
+ * Detection walks every provider's session corpus (a recursive stat over
184
+ * `~/.claude/projects`, every Codex home, and the OpenCode data dir), so the
185
+ * ranking is memoised briefly: the CLI resolves the provider several times per
186
+ * command and the extension asks again on every settings-driven re-detect.
175
187
  */
176
- function getAllDetectedProviders() {
188
+ function rankDetectedProviders(now = Date.now()) {
189
+ if (detectionCache && now - detectionCache.at < DETECTION_CACHE_TTL_MS) {
190
+ return detectionCache.ranked;
191
+ }
177
192
  const { claudeBase, openCodeDbPath, openCodeStorageDir, codexHomes } = getProviderPaths();
178
193
  const hasClaude = fs.existsSync(claudeBase);
179
194
  const hasOpenCode = fs.existsSync(openCodeStorageDir) || fs.existsSync(openCodeDbPath);
@@ -186,7 +201,16 @@ function getAllDetectedProviders() {
186
201
  if (hasCodex)
187
202
  available.push({ id: 'codex', mtime: getCodexActivityMtime() });
188
203
  available.sort((a, b) => b.mtime - a.mtime);
189
- return available.map((a) => a.id);
204
+ const ranked = available.map((a) => a.id);
205
+ detectionCache = { at: now, ranked };
206
+ return ranked;
207
+ }
208
+ /**
209
+ * Returns all provider IDs whose data directories exist on the filesystem.
210
+ * Ordered by most-recent activity first.
211
+ */
212
+ function getAllDetectedProviders() {
213
+ return [...rankDetectedProviders()];
190
214
  }
191
215
  /**
192
216
  * Detects the most appropriate provider based on filesystem presence and recency.
@@ -195,24 +219,5 @@ function getAllDetectedProviders() {
195
219
  function detectProvider(override) {
196
220
  if (override && override !== 'auto')
197
221
  return override;
198
- const { claudeBase, openCodeDbPath, openCodeStorageDir, codexHomes } = getProviderPaths();
199
- const hasClaude = fs.existsSync(claudeBase);
200
- const hasOpenCode = fs.existsSync(openCodeStorageDir) || fs.existsSync(openCodeDbPath);
201
- const hasCodex = codexHomes.some((codexHome) => fs.existsSync(path.join(codexHome, 'sessions')) || hasCodexStateDb(codexHome));
202
- const available = [];
203
- if (hasClaude) {
204
- available.push({ id: 'claude-code', mtime: getClaudeActivityMtime(claudeBase) });
205
- }
206
- if (hasOpenCode) {
207
- available.push({ id: 'opencode', mtime: getOpenCodeActivityMtime() });
208
- }
209
- if (hasCodex) {
210
- available.push({ id: 'codex', mtime: getCodexActivityMtime() });
211
- }
212
- if (available.length === 0)
213
- return 'claude-code';
214
- if (available.length === 1)
215
- return available[0].id;
216
- available.sort((a, b) => b.mtime - a.mtime);
217
- return available[0].id;
222
+ return rankDetectedProviders()[0] ?? 'claude-code';
218
223
  }
@@ -5,6 +5,12 @@ export interface CreateSessionProvidersOptions {
5
5
  }
6
6
  export interface CreateSessionProvidersResult {
7
7
  providers: SessionProviderBase[];
8
+ /**
9
+ * Live, not a snapshot: the providers keep the emit closure and update
10
+ * entries in place (one per provider/kind/phase) as they run, so this array
11
+ * reflects the latest diagnostics whenever it is read. Copy it to freeze a
12
+ * point in time; use `onDiagnostic` to observe each emission.
13
+ */
8
14
  diagnostics: SessionProviderDiagnostic[];
9
15
  }
10
16
  /**
@@ -78,6 +78,8 @@ export declare class OpenCodeProvider implements SessionProviderBase {
78
78
  isSessionFile(filename: string): boolean;
79
79
  getSessionId(sessionPath: string): string;
80
80
  encodeWorkspacePath(workspacePath: string): string;
81
+ /** Session title from the metadata cache or the database; null without either. */
82
+ private extractSessionLabelFromMetadata;
81
83
  extractSessionLabel(sessionPath: string): string | null;
82
84
  extractSessionLabelsAsync(sessionPaths: readonly string[]): Promise<Map<string, string | null>>;
83
85
  private extractSessionLabelFromFiles;