sidekick-shared 0.24.5 → 0.26.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 (157) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +205 -52
  3. package/dist/accountChangeSignal.d.ts +3 -0
  4. package/dist/accountChangeSignal.js +19 -0
  5. package/dist/accountChanges.d.ts +21 -0
  6. package/dist/accountChanges.js +170 -0
  7. package/dist/accountRegistry.d.ts +25 -2
  8. package/dist/accountRegistry.js +41 -13
  9. package/dist/accountStatus.d.ts +9 -1
  10. package/dist/accountStatus.js +3 -3
  11. package/dist/accounts.d.ts +3 -1
  12. package/dist/accounts.js +84 -64
  13. package/dist/aggregation/EventAggregator.d.ts +15 -2
  14. package/dist/aggregation/EventAggregator.js +54 -9
  15. package/dist/aggregation/costProvenance.d.ts +14 -0
  16. package/dist/aggregation/costProvenance.js +35 -0
  17. package/dist/aggregation/snapshot.d.ts +13 -2
  18. package/dist/aggregation/snapshot.js +57 -6
  19. package/dist/aggregation/types.d.ts +30 -1
  20. package/dist/analytics/compactionLedger.js +15 -5
  21. package/dist/analytics/qualityScore.js +2 -1
  22. package/dist/browser.d.ts +19 -4
  23. package/dist/browser.js +43 -1
  24. package/dist/codexProfiles.d.ts +3 -1
  25. package/dist/codexProfiles.js +4 -2
  26. package/dist/codexQuota.d.ts +2 -0
  27. package/dist/codexQuota.js +11 -66
  28. package/dist/codexQuotaWatcher.d.ts +6 -0
  29. package/dist/codexQuotaWatcher.js +101 -47
  30. package/dist/failingTools.d.ts +37 -0
  31. package/dist/failingTools.js +70 -0
  32. package/dist/formatters/sessionDump.js +13 -7
  33. package/dist/formatting.d.ts +13 -0
  34. package/dist/formatting.js +35 -0
  35. package/dist/historicalStore.d.ts +47 -0
  36. package/dist/historicalStore.js +312 -0
  37. package/dist/index.d.ts +67 -23
  38. package/dist/index.js +138 -26
  39. package/dist/modelAliases.d.ts +13 -0
  40. package/dist/modelAliases.js +58 -0
  41. package/dist/modelCatalog.d.ts +32 -0
  42. package/dist/modelCatalog.js +145 -0
  43. package/dist/modelContext.d.ts +36 -3
  44. package/dist/modelContext.js +109 -34
  45. package/dist/modelInfo.d.ts +30 -1
  46. package/dist/modelInfo.js +116 -26
  47. package/dist/multiProviderQuotaService.d.ts +8 -0
  48. package/dist/multiProviderQuotaService.js +64 -3
  49. package/dist/node.d.ts +23 -4
  50. package/dist/node.js +50 -1
  51. package/dist/observedContextWindows.d.ts +7 -0
  52. package/dist/observedContextWindows.js +17 -2
  53. package/dist/observedSessionCollector.d.ts +72 -6
  54. package/dist/observedSessionCollector.js +440 -47
  55. package/dist/parsers/codexHistory.d.ts +4 -2
  56. package/dist/parsers/codexHistory.js +9 -2
  57. package/dist/parsers/sessionActivityDetector.d.ts +28 -0
  58. package/dist/parsers/sessionActivityDetector.js +110 -15
  59. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  60. package/dist/parsers/sessionPathResolver.js +13 -10
  61. package/dist/peakHours.d.ts +34 -3
  62. package/dist/peakHours.js +92 -7
  63. package/dist/pricingCatalog.d.ts +7 -0
  64. package/dist/pricingCatalog.js +8 -5
  65. package/dist/providerIds.d.ts +11 -0
  66. package/dist/providerIds.js +9 -0
  67. package/dist/providerQuota.d.ts +3 -1
  68. package/dist/providerQuota.js +3 -0
  69. package/dist/providers/claudeCode.d.ts +10 -1
  70. package/dist/providers/claudeCode.js +211 -131
  71. package/dist/providers/codex.d.ts +20 -1
  72. package/dist/providers/codex.js +308 -244
  73. package/dist/providers/codexDatabase.d.ts +9 -0
  74. package/dist/providers/codexDatabase.js +107 -2
  75. package/dist/providers/detect.d.ts +2 -0
  76. package/dist/providers/detect.js +29 -24
  77. package/dist/providers/diagnostics.d.ts +13 -0
  78. package/dist/providers/diagnostics.js +88 -0
  79. package/dist/providers/factory.d.ts +20 -0
  80. package/dist/providers/factory.js +57 -0
  81. package/dist/providers/openCode.d.ts +22 -1
  82. package/dist/providers/openCode.js +460 -126
  83. package/dist/providers/openCodeDatabase.d.ts +10 -0
  84. package/dist/providers/openCodeDatabase.js +69 -0
  85. package/dist/providers/rolloutWalker.d.ts +53 -0
  86. package/dist/providers/rolloutWalker.js +209 -0
  87. package/dist/providers/types.d.ts +82 -2
  88. package/dist/providers/types.js +4 -0
  89. package/dist/quota.d.ts +34 -4
  90. package/dist/quota.js +37 -9
  91. package/dist/quotaHistory.d.ts +3 -2
  92. package/dist/quotaHistory.js +36 -35
  93. package/dist/quotaPoller.d.ts +13 -1
  94. package/dist/quotaPoller.js +65 -3
  95. package/dist/quotaResolver.d.ts +81 -0
  96. package/dist/quotaResolver.js +213 -0
  97. package/dist/quotaSnapshots.d.ts +15 -2
  98. package/dist/quotaSnapshots.js +25 -3
  99. package/dist/quotaThresholds.d.ts +60 -0
  100. package/dist/quotaThresholds.js +90 -0
  101. package/dist/report/htmlReportGenerator.js +10 -4
  102. package/dist/report/sessionReportInputs.d.ts +23 -0
  103. package/dist/report/sessionReportInputs.js +34 -0
  104. package/dist/schemas/accountManager.d.ts +2 -2
  105. package/dist/schemas/accountManager.js +2 -4
  106. package/dist/schemas/accountStatus.d.ts +3 -0
  107. package/dist/schemas/accountStatus.js +1 -0
  108. package/dist/schemas/index.d.ts +3 -0
  109. package/dist/schemas/index.js +16 -2
  110. package/dist/schemas/observedSessionV1.d.ts +27 -26
  111. package/dist/schemas/observedSessionV1.js +6 -4
  112. package/dist/schemas/quota.d.ts +141 -2
  113. package/dist/schemas/quota.js +20 -8
  114. package/dist/schemas/quotaHistory.d.ts +3 -2
  115. package/dist/schemas/quotaHistory.js +1 -1
  116. package/dist/schemas/sessionEvent.d.ts +6 -0
  117. package/dist/schemas/sessionEvent.js +1 -0
  118. package/dist/schemas/stateFile.d.ts +198 -0
  119. package/dist/schemas/stateFile.js +77 -0
  120. package/dist/schemas/tokenEstimation.d.ts +1 -1
  121. package/dist/schemas/transcript.js +2 -0
  122. package/dist/sessionFingerprint.d.ts +25 -0
  123. package/dist/sessionFingerprint.js +46 -0
  124. package/dist/sessionMonitor.d.ts +15 -0
  125. package/dist/sessionMonitor.js +142 -0
  126. package/dist/sessionPreviews.d.ts +33 -2
  127. package/dist/sessionPreviews.js +333 -6
  128. package/dist/sessionStats.d.ts +51 -0
  129. package/dist/sessionStats.js +166 -0
  130. package/dist/stateFile.d.ts +113 -0
  131. package/dist/stateFile.js +160 -0
  132. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  133. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  134. package/dist/statusline/formatter.d.ts +13 -0
  135. package/dist/statusline/formatter.js +48 -6
  136. package/dist/statusline/index.d.ts +10 -2
  137. package/dist/statusline/index.js +19 -1
  138. package/dist/tokenSummary.d.ts +62 -0
  139. package/dist/tokenSummary.js +72 -0
  140. package/dist/transcript.d.ts +5 -0
  141. package/dist/transcript.js +2 -0
  142. package/dist/types/observedSessionV1.d.ts +8 -2
  143. package/dist/types/observedSessionV1.js +88 -25
  144. package/dist/types/sessionEvent.d.ts +2 -0
  145. package/dist/usage/billingBlocks.d.ts +85 -0
  146. package/dist/usage/billingBlocks.js +153 -0
  147. package/dist/usage/importSessionHistory.d.ts +53 -0
  148. package/dist/usage/importSessionHistory.js +88 -0
  149. package/dist/usage/usageEvents.d.ts +92 -0
  150. package/dist/usage/usageEvents.js +322 -0
  151. package/dist/usage/usageReports.d.ts +76 -0
  152. package/dist/usage/usageReports.js +208 -0
  153. package/dist/watchers/factory.d.ts +6 -0
  154. package/dist/watchers/factory.js +30 -25
  155. package/dist/zaiQuotaApi.d.ts +2 -0
  156. package/dist/zaiQuotaApi.js +2 -0
  157. package/package.json +22 -1
@@ -57,9 +57,11 @@ const os = __importStar(require("os"));
57
57
  const path = __importStar(require("path"));
58
58
  const child_process_1 = require("child_process");
59
59
  const sessionContext_1 = require("../context/sessionContext");
60
+ const sessionStats_1 = require("../sessionStats");
60
61
  const openCodeParser_1 = require("../parsers/openCodeParser");
61
62
  const openCodeDatabase_1 = require("./openCodeDatabase");
62
63
  const zaiQuota_1 = require("../zaiQuota");
64
+ const diagnostics_1 = require("./diagnostics");
63
65
  const modelContext_1 = require("../modelContext");
64
66
  // ---------------------------------------------------------------------------
65
67
  // Helper functions
@@ -169,6 +171,25 @@ function makeDbSessionPath(dataDir, projectId, sessionId) {
169
171
  function isDbSessionPath(sessionPath) {
170
172
  return sessionPath.includes(path.sep + DB_SESSION_PREFIX + path.sep);
171
173
  }
174
+ /**
175
+ * Whether a session recorded in `sessionDirectory` belongs to `workspacePath`.
176
+ *
177
+ * A session started inside the workspace (the workspace itself or any
178
+ * subdirectory, e.g. a monorepo package) matches. A session started in a
179
+ * *parent* of the workspace does not: a sub-package workspace must not list
180
+ * every session of the enclosing repo as its own.
181
+ */
182
+ function workspaceMatches(sessionDirectory, workspacePath) {
183
+ const session = path
184
+ .resolve(sessionDirectory)
185
+ .replace(/[\\/]+$/, '')
186
+ .toLowerCase();
187
+ const workspace = path
188
+ .resolve(workspacePath)
189
+ .replace(/[\\/]+$/, '')
190
+ .toLowerCase();
191
+ return session === workspace || session.startsWith(workspace + path.sep);
192
+ }
172
193
  /** Extract project ID from a synthetic DB session path. */
173
194
  function extractProjectIdFromDbPath(sessionPath) {
174
195
  const prefix = path.sep + DB_SESSION_PREFIX + path.sep;
@@ -310,18 +331,15 @@ function normalizePath(input) {
310
331
  return path.resolve(input);
311
332
  }
312
333
  }
313
- // ---------------------------------------------------------------------------
314
- // File-based reader (legacy fallback)
315
- // ---------------------------------------------------------------------------
316
- /**
317
- * Incremental reader for file-based OpenCode sessions.
318
- *
319
- * Tracks which message IDs have been seen and reads new messages
320
- * plus their parts on each readNew() call.
321
- */
334
+ function messageCreatedMs(message) {
335
+ const created = message.time?.created;
336
+ const ms = typeof created === 'number' ? created : Date.parse(String(created ?? ''));
337
+ return Number.isFinite(ms) ? ms : 0;
338
+ }
322
339
  class OpenCodeFileReader {
323
340
  sessionId;
324
341
  seenMessages = new Map();
342
+ pendingSkip = 0;
325
343
  storageBase;
326
344
  constructor(sessionId) {
327
345
  this.sessionId = sessionId;
@@ -338,47 +356,47 @@ class OpenCodeFileReader {
338
356
  catch {
339
357
  return [];
340
358
  }
341
- const newEvents = [];
359
+ const entries = [];
342
360
  for (const file of messageFiles) {
343
361
  const msgId = path.basename(file, '.json');
344
362
  const messagePath = path.join(messageDir, file);
345
363
  const message = readJsonSafe(messagePath);
346
364
  if (!message)
347
365
  continue;
348
- let messageMtimeMs = 0;
366
+ let mtimeMs = 0;
349
367
  try {
350
- messageMtimeMs = fs.statSync(messagePath).mtimeMs;
368
+ mtimeMs = fs.statSync(messagePath).mtimeMs;
351
369
  }
352
370
  catch {
353
- messageMtimeMs = 0;
371
+ mtimeMs = 0;
354
372
  }
355
- const partDir = path.join(this.storageBase, 'part', msgId);
356
- let parts = [];
357
- if (fs.existsSync(partDir)) {
358
- try {
359
- parts = fs
360
- .readdirSync(partDir)
361
- .filter((f) => f.endsWith('.json'))
362
- .map((f) => readJsonSafe(path.join(partDir, f)))
363
- .filter((p) => p !== null);
364
- }
365
- catch {
366
- // Skip unreadable part directories
367
- }
373
+ const parts = this.readParts(msgId);
374
+ entries.push({ msgId, message, mtimeMs, parts, partIds: new Set(parts.map((p) => p.id)) });
375
+ }
376
+ if (this.pendingSkip > 0) {
377
+ // A position for this reader is a message count (see getPosition()), so a
378
+ // seek marks the oldest N messages as already seen without emitting them.
379
+ const ordered = [...entries].sort((a, b) => messageCreatedMs(a.message) - messageCreatedMs(b.message) ||
380
+ a.msgId.localeCompare(b.msgId));
381
+ for (const entry of ordered.slice(0, this.pendingSkip)) {
382
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
368
383
  }
369
- const partIds = new Set(parts.map((part) => part.id));
370
- const previous = this.seenMessages.get(msgId);
384
+ this.pendingSkip = 0;
385
+ }
386
+ const newEvents = [];
387
+ for (const entry of entries) {
388
+ const previous = this.seenMessages.get(entry.msgId);
371
389
  const isNewMessage = !previous;
372
390
  let hasNewParts = false;
373
391
  if (previous) {
374
- if (previous.mtimeMs !== messageMtimeMs) {
392
+ if (previous.mtimeMs !== entry.mtimeMs) {
375
393
  hasNewParts = true;
376
394
  }
377
- else if (partIds.size !== previous.partIds.size) {
395
+ else if (entry.partIds.size !== previous.partIds.size) {
378
396
  hasNewParts = true;
379
397
  }
380
398
  else {
381
- for (const partId of partIds) {
399
+ for (const partId of entry.partIds) {
382
400
  if (!previous.partIds.has(partId)) {
383
401
  hasNewParts = true;
384
402
  break;
@@ -387,19 +405,36 @@ class OpenCodeFileReader {
387
405
  }
388
406
  }
389
407
  if (isNewMessage || hasNewParts) {
390
- newEvents.push(...(0, openCodeParser_1.convertOpenCodeMessage)(message, parts));
391
- this.seenMessages.set(msgId, { partIds, mtimeMs: messageMtimeMs });
408
+ newEvents.push(...(0, openCodeParser_1.convertOpenCodeMessage)(entry.message, entry.parts));
409
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
392
410
  }
393
411
  }
394
412
  // Sort by timestamp
395
413
  return newEvents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
396
414
  }
415
+ readParts(msgId) {
416
+ const partDir = path.join(this.storageBase, 'part', msgId);
417
+ if (!fs.existsSync(partDir))
418
+ return [];
419
+ try {
420
+ return fs
421
+ .readdirSync(partDir)
422
+ .filter((f) => f.endsWith('.json'))
423
+ .map((f) => readJsonSafe(path.join(partDir, f)))
424
+ .filter((p) => p !== null);
425
+ }
426
+ catch {
427
+ // Skip unreadable part directories
428
+ return [];
429
+ }
430
+ }
397
431
  readAll() {
398
432
  this.reset();
399
433
  return this.readNew();
400
434
  }
401
435
  reset() {
402
436
  this.seenMessages.clear();
437
+ this.pendingSkip = 0;
403
438
  }
404
439
  exists() {
405
440
  return fs.existsSync(path.join(this.storageBase, 'message', this.sessionId));
@@ -410,8 +445,10 @@ class OpenCodeFileReader {
410
445
  getPosition() {
411
446
  return this.seenMessages.size;
412
447
  }
413
- seekTo(_position) {
414
- // File-backed reader does not support seeking — full replay required
448
+ seekTo(position) {
449
+ // Positions are message counts (getPosition()), so the next readNew() marks
450
+ // the oldest `position` messages as seen and emits only what follows.
451
+ this.pendingSkip = Number.isFinite(position) && position > 0 ? Math.floor(position) : 0;
415
452
  }
416
453
  wasTruncated() {
417
454
  return false; // JSON files don't truncate
@@ -426,11 +463,15 @@ class OpenCodeFileReader {
426
463
  * Uses time_updated timestamps to track which data has been seen,
427
464
  * querying only for newer messages/parts on each readNew() call.
428
465
  */
466
+ /** How long a database-backed reader trusts its last session-row check. */
467
+ const DB_EXISTS_MEMO_MS = 30_000;
429
468
  class OpenCodeDbReader {
430
469
  sessionId;
431
470
  db;
432
471
  lastTimeUpdated = 0;
433
472
  hasReadOnce = false;
473
+ existsCheckedAtMs = 0;
474
+ existsResult = true;
434
475
  constructor(sessionId, db) {
435
476
  this.sessionId = sessionId;
436
477
  this.db = db;
@@ -616,10 +657,22 @@ class OpenCodeDbReader {
616
657
  this.hasReadOnce = false;
617
658
  }
618
659
  exists() {
619
- // DB-backed sessions are durable rows rather than ephemeral files.
620
- // Treat transient sqlite timeout/read failures as "still exists" so
621
- // SessionMonitor does not flap into discovery mode.
622
- return true;
660
+ // DB-backed sessions are durable rows rather than ephemeral files. The row
661
+ // check costs a sqlite3 subprocess, so it is memoised, and a transient
662
+ // query failure still reports "exists" so SessionMonitor does not flap
663
+ // into discovery mode.
664
+ const now = Date.now();
665
+ if (now - this.existsCheckedAtMs < DB_EXISTS_MEMO_MS)
666
+ return this.existsResult;
667
+ this.existsCheckedAtMs = now;
668
+ try {
669
+ const row = this.db.getSession(this.sessionId);
670
+ this.existsResult = row !== null || this.db.getRuntimeStatus().kind === 'query_failed';
671
+ }
672
+ catch {
673
+ this.existsResult = true;
674
+ }
675
+ return this.existsResult;
623
676
  }
624
677
  flush() {
625
678
  // No-op for DB reading
@@ -650,10 +703,14 @@ class OpenCodeProvider {
650
703
  db = null;
651
704
  dbInitialized = false;
652
705
  dbStatus = { available: false, kind: 'db_missing' };
706
+ diagnostics;
653
707
  /** Cache of session metadata populated during listing */
654
708
  sessionMetaCache = new Map();
655
709
  /** Runtime-reported context window limit (overrides static map when set). */
656
710
  dynamicContextWindowLimit = null;
711
+ constructor(options = {}) {
712
+ this.diagnostics = new diagnostics_1.ProviderDiagnosticTracker(this.id, options);
713
+ }
657
714
  /** Lazy-initialize the database connection. */
658
715
  ensureDb() {
659
716
  if (this.dbInitialized)
@@ -667,12 +724,31 @@ class OpenCodeProvider {
667
724
  this.dbStatus = db.getRuntimeStatus();
668
725
  return this.db;
669
726
  }
727
+ async ensureDbAsync() {
728
+ if (this.db)
729
+ return this.db;
730
+ return new openCodeDatabase_1.OpenCodeDatabase(getOpenCodeDataDir());
731
+ }
732
+ /**
733
+ * Cache an async-created database only after an operation proved it usable.
734
+ * Sync paths treat a cached `db` as "open() succeeded", so adopting an
735
+ * unproven instance would cut them off from the file-storage fallback.
736
+ */
737
+ adoptDbIfAvailable(db) {
738
+ if (this.db || !db.getRuntimeStatus().available)
739
+ return;
740
+ this.db = db;
741
+ this.dbInitialized = true;
742
+ }
670
743
  getRuntimeStatus() {
671
744
  const db = this.ensureDb();
672
745
  if (db)
673
746
  return db.getRuntimeStatus();
674
747
  return this.dbStatus;
675
748
  }
749
+ getLastOperationStatus() {
750
+ return this.diagnostics.getLastOperationStatus();
751
+ }
676
752
  canMonitorDirectory(dir) {
677
753
  if (fs.existsSync(dir))
678
754
  return true;
@@ -738,8 +814,10 @@ class OpenCodeProvider {
738
814
  const db = this.ensureDb();
739
815
  const dbStatus = this.getRuntimeStatus();
740
816
  const projectId = resolveProjectId(workspacePath, db, dbStatus);
741
- if (!projectId)
817
+ if (!projectId) {
818
+ this.recordDatabaseStatus('findActiveSession', dbStatus.kind === 'db_missing');
742
819
  return null;
820
+ }
743
821
  // DB primary
744
822
  if (db) {
745
823
  const session = db.getMostRecentSession(projectId);
@@ -749,14 +827,18 @@ class OpenCodeProvider {
749
827
  title: session.title,
750
828
  timeUpdated: session.time_updated,
751
829
  });
830
+ this.recordDatabaseStatus('findActiveSession', true);
752
831
  return syntheticPath;
753
832
  }
754
833
  }
755
834
  if (dbStatus.kind !== 'db_missing') {
835
+ this.recordDatabaseStatus('findActiveSession', dbStatus.available);
756
836
  return null;
757
837
  }
758
838
  // File fallback
759
- return this.findActiveSessionFromFiles(projectId);
839
+ const result = this.findActiveSessionFromFiles(projectId);
840
+ this.recordDatabaseStatus('findActiveSession', true);
841
+ return result;
760
842
  }
761
843
  findActiveSessionFromFiles(projectId) {
762
844
  const sessionDir = path.join(getStorageDir(), 'session', projectId);
@@ -817,18 +899,64 @@ class OpenCodeProvider {
817
899
  const db = this.ensureDb();
818
900
  const dbStatus = this.getRuntimeStatus();
819
901
  const projectId = resolveProjectId(workspacePath, db, dbStatus);
820
- if (!projectId)
902
+ if (!projectId) {
903
+ this.recordDatabaseStatus('findAllSessions', dbStatus.kind === 'db_missing');
821
904
  return [];
905
+ }
822
906
  // DB primary
823
907
  if (db) {
824
- return this.mapDbSessions(projectId);
908
+ const sessions = this.mapDbSessions(projectId);
909
+ this.recordDatabaseStatus('findAllSessions', true);
910
+ return sessions;
825
911
  }
826
912
  if (dbStatus.kind !== 'db_missing') {
913
+ this.recordDatabaseStatus('findAllSessions', dbStatus.available);
827
914
  return [];
828
915
  }
829
916
  // File fallback
830
917
  const sessionDir = path.join(getStorageDir(), 'session', projectId);
831
- return this.findSessionsInDirectoryFromFiles(sessionDir);
918
+ const sessions = this.findSessionsInDirectoryFromFiles(sessionDir);
919
+ this.recordDatabaseStatus('findAllSessions', true);
920
+ return sessions;
921
+ }
922
+ findSessionById(workspacePath, sessionId) {
923
+ const normalizedId = sessionId.trim();
924
+ if (!normalizedId || path.basename(normalizedId) !== normalizedId)
925
+ return null;
926
+ try {
927
+ const db = this.ensureDb();
928
+ if (db) {
929
+ const session = db.getSession(normalizedId);
930
+ if (session && workspaceMatches(session.directory, workspacePath)) {
931
+ const syntheticPath = makeDbSessionPath(getOpenCodeDataDir(), session.project_id, session.id);
932
+ this.sessionMetaCache.set(syntheticPath, {
933
+ title: session.title,
934
+ timeUpdated: session.time_updated,
935
+ });
936
+ this.recordDatabaseStatus('findSessionById', true);
937
+ return syntheticPath;
938
+ }
939
+ }
940
+ const projectId = resolveProjectId(workspacePath, db, this.getRuntimeStatus());
941
+ if (projectId) {
942
+ const candidate = path.join(getStorageDir(), 'session', projectId, `${normalizedId}.json`);
943
+ try {
944
+ if (fs.statSync(candidate).isFile()) {
945
+ this.recordDatabaseStatus('findSessionById', true);
946
+ return candidate;
947
+ }
948
+ }
949
+ catch {
950
+ // Missing legacy session is a normal miss.
951
+ }
952
+ }
953
+ this.recordDatabaseStatus('findSessionById', true);
954
+ return null;
955
+ }
956
+ catch {
957
+ this.recordDatabaseStatus('findSessionById', false);
958
+ return null;
959
+ }
832
960
  }
833
961
  findSessionsInDirectory(dir) {
834
962
  const db = this.ensureDb();
@@ -837,7 +965,9 @@ class OpenCodeProvider {
837
965
  if (db && dir.includes(path.sep + DB_SESSION_PREFIX + path.sep)) {
838
966
  const projectId = extractProjectIdFromDbPath(dir + path.sep + 'dummy.json');
839
967
  if (projectId) {
840
- return this.mapDbSessions(projectId);
968
+ const sessions = this.mapDbSessions(projectId);
969
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
970
+ return sessions;
841
971
  }
842
972
  }
843
973
  // For a DB-backed directory path ending with the project ID,
@@ -845,14 +975,19 @@ class OpenCodeProvider {
845
975
  if (db) {
846
976
  const dirName = path.basename(dir);
847
977
  if (db.hasProject(dirName)) {
848
- return this.mapDbSessions(dirName);
978
+ const sessions = this.mapDbSessions(dirName);
979
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
980
+ return sessions;
849
981
  }
850
982
  }
851
983
  if (dbStatus.kind !== 'db_missing') {
984
+ this.recordDatabaseStatus('findSessionsInDirectory', dbStatus.available);
852
985
  return [];
853
986
  }
854
987
  // File fallback
855
- return this.findSessionsInDirectoryFromFiles(dir);
988
+ const sessions = this.findSessionsInDirectoryFromFiles(dir);
989
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
990
+ return sessions;
856
991
  }
857
992
  findSessionsInDirectoryFromFiles(dir) {
858
993
  try {
@@ -879,6 +1014,159 @@ class OpenCodeProvider {
879
1014
  return [];
880
1015
  }
881
1016
  }
1017
+ listAllSessionFiles() {
1018
+ try {
1019
+ const db = this.ensureDb();
1020
+ if (db) {
1021
+ const dataDir = getOpenCodeDataDir();
1022
+ const files = db.getAllSessions().map((session) => {
1023
+ const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
1024
+ this.sessionMetaCache.set(syntheticPath, {
1025
+ title: session.title,
1026
+ timeUpdated: session.time_updated,
1027
+ });
1028
+ return {
1029
+ path: syntheticPath,
1030
+ mtime: new Date(session.time_updated),
1031
+ label: session.title ?? undefined,
1032
+ sizeBytes: 0,
1033
+ sessionId: session.id,
1034
+ workspacePath: session.directory,
1035
+ createdAt: new Date(session.time_created),
1036
+ };
1037
+ });
1038
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1039
+ return files;
1040
+ }
1041
+ if (this.getRuntimeStatus().kind !== 'db_missing') {
1042
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1043
+ return [];
1044
+ }
1045
+ const root = path.join(getStorageDir(), 'session');
1046
+ const results = [];
1047
+ let projectDirs = [];
1048
+ try {
1049
+ projectDirs = fs.readdirSync(root, { withFileTypes: true });
1050
+ }
1051
+ catch {
1052
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1053
+ return [];
1054
+ }
1055
+ for (const entry of projectDirs) {
1056
+ if (!entry.isDirectory())
1057
+ continue;
1058
+ for (const sessionPath of this.findSessionsInDirectoryFromFiles(path.join(root, entry.name))) {
1059
+ try {
1060
+ const stats = fs.statSync(sessionPath);
1061
+ results.push({
1062
+ path: sessionPath,
1063
+ mtime: stats.mtime,
1064
+ sizeBytes: stats.size,
1065
+ sessionId: path.basename(sessionPath, '.json'),
1066
+ });
1067
+ }
1068
+ catch {
1069
+ // Session vanished between listing and stat.
1070
+ }
1071
+ }
1072
+ }
1073
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1074
+ return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1075
+ }
1076
+ catch {
1077
+ this.recordDatabaseStatus('listAllSessionFiles', false);
1078
+ return [];
1079
+ }
1080
+ }
1081
+ async listSessionFilesAsync(workspacePath) {
1082
+ const db = await this.ensureDbAsync();
1083
+ const sessions = await db.getAllSessionsAsync();
1084
+ this.dbStatus = db.getRuntimeStatus();
1085
+ this.adoptDbIfAvailable(db);
1086
+ if (this.dbStatus.available) {
1087
+ const dataDir = getOpenCodeDataDir();
1088
+ const files = [];
1089
+ for (const session of sessions) {
1090
+ if (workspacePath && !workspaceMatches(session.directory, workspacePath))
1091
+ continue;
1092
+ const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
1093
+ this.sessionMetaCache.set(syntheticPath, {
1094
+ title: session.title,
1095
+ timeUpdated: session.time_updated,
1096
+ });
1097
+ files.push({
1098
+ path: syntheticPath,
1099
+ mtime: new Date(session.time_updated),
1100
+ label: session.title ?? undefined,
1101
+ sizeBytes: 0,
1102
+ sessionId: session.id,
1103
+ workspacePath: session.directory,
1104
+ createdAt: new Date(session.time_created),
1105
+ });
1106
+ }
1107
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1108
+ return files;
1109
+ }
1110
+ const root = path.join(getStorageDir(), 'session');
1111
+ const results = [];
1112
+ let projectDirs = [];
1113
+ try {
1114
+ projectDirs = await fs.promises.readdir(root, { withFileTypes: true });
1115
+ }
1116
+ catch {
1117
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1118
+ return [];
1119
+ }
1120
+ for (const project of projectDirs) {
1121
+ if (!project.isDirectory())
1122
+ continue;
1123
+ let entries = [];
1124
+ try {
1125
+ entries = await fs.promises.readdir(path.join(root, project.name), {
1126
+ withFileTypes: true,
1127
+ });
1128
+ }
1129
+ catch {
1130
+ continue;
1131
+ }
1132
+ for (const entry of entries) {
1133
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
1134
+ continue;
1135
+ const sessionPath = path.join(root, project.name, entry.name);
1136
+ try {
1137
+ const [stat, raw] = await Promise.all([
1138
+ fs.promises.stat(sessionPath),
1139
+ workspacePath ? fs.promises.readFile(sessionPath, 'utf8') : Promise.resolve(null),
1140
+ ]);
1141
+ let session = null;
1142
+ if (raw !== null) {
1143
+ try {
1144
+ session = JSON.parse(raw);
1145
+ }
1146
+ catch {
1147
+ continue;
1148
+ }
1149
+ const directory = session.directory;
1150
+ if (directory && !workspaceMatches(directory, workspacePath))
1151
+ continue;
1152
+ }
1153
+ results.push({
1154
+ path: sessionPath,
1155
+ mtime: stat.mtime,
1156
+ sizeBytes: stat.size,
1157
+ sessionId: path.basename(entry.name, '.json'),
1158
+ workspacePath: session?.directory,
1159
+ });
1160
+ }
1161
+ catch {
1162
+ // Skip inaccessible sessions.
1163
+ }
1164
+ await new Promise((resolve) => setImmediate(resolve));
1165
+ }
1166
+ }
1167
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1168
+ return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1169
+ }
882
1170
  getAllProjectFolders(workspacePath) {
883
1171
  const db = this.ensureDb();
884
1172
  const folders = [];
@@ -917,14 +1205,19 @@ class OpenCodeProvider {
917
1205
  }
918
1206
  return b.lastModified.getTime() - a.lastModified.getTime();
919
1207
  });
920
- if (folders.length > 0)
1208
+ if (folders.length > 0) {
1209
+ this.recordDatabaseStatus('getAllProjectFolders', true);
921
1210
  return folders;
1211
+ }
922
1212
  }
923
1213
  if (dbStatus.kind !== 'db_missing') {
1214
+ this.recordDatabaseStatus('getAllProjectFolders', dbStatus.available);
924
1215
  return [];
925
1216
  }
926
1217
  // File fallback
927
- return this.getAllProjectFoldersFromFiles(workspacePath);
1218
+ const fallback = this.getAllProjectFoldersFromFiles(workspacePath);
1219
+ this.recordDatabaseStatus('getAllProjectFolders', true);
1220
+ return fallback;
928
1221
  }
929
1222
  getAllProjectFoldersFromFiles(workspacePath) {
930
1223
  const folders = [];
@@ -1019,23 +1312,79 @@ class OpenCodeProvider {
1019
1312
  const db = this.ensureDb();
1020
1313
  return resolveProjectId(workspacePath, db, this.getRuntimeStatus()) || workspacePath;
1021
1314
  }
1022
- extractSessionLabel(sessionPath) {
1023
- const db = this.ensureDb();
1024
- const sessionId = this.getSessionId(sessionPath);
1315
+ /** Session title from the metadata cache or the database; null without either. */
1316
+ extractSessionLabelFromMetadata(sessionPath) {
1025
1317
  // Check metadata cache first
1026
1318
  const cached = this.sessionMetaCache.get(sessionPath);
1027
1319
  if (cached?.title) {
1028
1320
  return truncateTitle(cached.title);
1029
1321
  }
1030
1322
  // DB lookup
1323
+ const db = this.ensureDb();
1031
1324
  if (db) {
1032
- const session = db.getSession(sessionId);
1325
+ const session = db.getSession(this.getSessionId(sessionPath));
1033
1326
  if (session?.title) {
1327
+ this.recordDatabaseStatus('extractSessionLabel', true);
1034
1328
  return truncateTitle(session.title);
1035
1329
  }
1036
1330
  }
1331
+ return null;
1332
+ }
1333
+ extractSessionLabel(sessionPath) {
1334
+ const fromMetadata = this.extractSessionLabelFromMetadata(sessionPath);
1335
+ if (fromMetadata)
1336
+ return fromMetadata;
1037
1337
  // File fallback
1038
- return this.extractSessionLabelFromFiles(sessionPath, sessionId);
1338
+ const sessionId = this.getSessionId(sessionPath);
1339
+ const label = this.extractSessionLabelFromFiles(sessionPath, sessionId);
1340
+ this.recordDatabaseStatus('extractSessionLabel', label !== null || !isDbSessionPath(sessionPath));
1341
+ return label;
1342
+ }
1343
+ async extractSessionLabelsAsync(sessionPaths) {
1344
+ const results = new Map();
1345
+ const unresolved = [];
1346
+ for (const sessionPath of sessionPaths) {
1347
+ const cached = this.sessionMetaCache.get(sessionPath);
1348
+ if (cached)
1349
+ results.set(sessionPath, cached.title ? truncateTitle(cached.title) : null);
1350
+ else
1351
+ unresolved.push(sessionPath);
1352
+ }
1353
+ if (unresolved.length > 0 && (!this.dbInitialized || this.dbStatus.available)) {
1354
+ const db = await this.ensureDbAsync();
1355
+ const byId = new Map(unresolved.map((item) => [this.getSessionId(item), item]));
1356
+ const sessions = await db.getSessionsByIdsAsync([...byId.keys()]);
1357
+ this.dbStatus = db.getRuntimeStatus();
1358
+ this.adoptDbIfAvailable(db);
1359
+ for (const session of sessions) {
1360
+ const sessionPath = byId.get(session.id);
1361
+ if (!sessionPath)
1362
+ continue;
1363
+ this.sessionMetaCache.set(sessionPath, {
1364
+ title: session.title,
1365
+ timeUpdated: session.time_updated,
1366
+ });
1367
+ results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
1368
+ }
1369
+ }
1370
+ for (const sessionPath of unresolved) {
1371
+ if (results.has(sessionPath))
1372
+ continue;
1373
+ if (isDbSessionPath(sessionPath)) {
1374
+ results.set(sessionPath, null);
1375
+ continue;
1376
+ }
1377
+ try {
1378
+ const session = JSON.parse(await fs.promises.readFile(sessionPath, 'utf8'));
1379
+ results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
1380
+ }
1381
+ catch {
1382
+ results.set(sessionPath, null);
1383
+ }
1384
+ await new Promise((resolve) => setImmediate(resolve));
1385
+ }
1386
+ this.recordDatabaseStatus('extractSessionLabelsAsync', true);
1387
+ return results;
1039
1388
  }
1040
1389
  extractSessionLabelFromFiles(sessionPath, sessionId) {
1041
1390
  if (isDbSessionPath(sessionPath))
@@ -1343,80 +1692,18 @@ class OpenCodeProvider {
1343
1692
  getProjectsBaseDir() {
1344
1693
  return path.join(getStorageDir(), 'session');
1345
1694
  }
1695
+ getWatchRoots() {
1696
+ return [getOpenCodeDataDir(), this.getProjectsBaseDir()];
1697
+ }
1346
1698
  // --- Stats ---
1347
1699
  readSessionStats(sessionPath) {
1348
- const sessionId = path.basename(sessionPath, '.json');
1349
- const db = this.ensureDb();
1350
- let messageCount = 0;
1351
- let startTime = '';
1352
- let endTime = '';
1353
- const tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
1354
- const modelUsage = {};
1355
- const toolUsage = {};
1356
- let reportedCost = 0;
1357
- if (db) {
1358
- const messages = db.getMessagesForSession(sessionId);
1359
- const parts = db.getPartsForSession(sessionId);
1360
- for (const msg of messages) {
1361
- try {
1362
- const data = JSON.parse(msg.data);
1363
- const role = data.role;
1364
- if (role === 'assistant' || role === 'user')
1365
- messageCount++;
1366
- const msgTokens = data.tokens;
1367
- const cache = msgTokens?.cache;
1368
- if (msgTokens) {
1369
- tokens.input += msgTokens.input || 0;
1370
- tokens.output += msgTokens.output || 0;
1371
- tokens.cacheRead += cache?.read || 0;
1372
- tokens.cacheWrite += cache?.write || 0;
1373
- }
1374
- if (data.cost)
1375
- reportedCost += data.cost || 0;
1376
- const modelId = data.modelID || 'unknown';
1377
- if (role === 'assistant' && msgTokens) {
1378
- if (!modelUsage[modelId])
1379
- modelUsage[modelId] = { calls: 0, tokens: 0 };
1380
- modelUsage[modelId].calls++;
1381
- modelUsage[modelId].tokens +=
1382
- (msgTokens.input || 0) + (msgTokens.output || 0);
1383
- }
1384
- }
1385
- catch {
1386
- /* skip */
1387
- }
1388
- if (!startTime)
1389
- startTime = new Date(msg.time_created).toISOString();
1390
- endTime = new Date(msg.time_updated || msg.time_created).toISOString();
1391
- }
1392
- for (const part of parts) {
1393
- try {
1394
- const data = JSON.parse(part.data);
1395
- if (data.type === 'tool' || data.type === 'tool-invocation') {
1396
- const toolName = data.tool || 'unknown';
1397
- toolUsage[toolName] = (toolUsage[toolName] || 0) + 1;
1398
- }
1399
- }
1400
- catch {
1401
- /* skip */
1402
- }
1403
- }
1404
- }
1405
- return {
1406
- providerId: 'opencode',
1407
- sessionId,
1408
- filePath: sessionPath,
1409
- label: this.extractSessionLabel(sessionPath),
1410
- startTime,
1411
- endTime,
1412
- messageCount,
1413
- tokens,
1414
- modelUsage,
1415
- toolUsage,
1416
- compactionEstimate: 0,
1417
- truncationCount: 0,
1418
- reportedCost,
1419
- };
1700
+ // One reader pass (database-backed or file-backed) feeds the shared
1701
+ // aggregator, so compaction and truncation counts are real instead of the
1702
+ // hardcoded zeros the direct SQLite read returned, and a session that
1703
+ // cannot be read reports `availability: 'unavailable'` instead of zeros.
1704
+ return (0, sessionStats_1.readSessionFileStats)(this, sessionPath, {
1705
+ resolveLabel: () => this.extractSessionLabelFromMetadata(sessionPath),
1706
+ });
1420
1707
  }
1421
1708
  readSessionContextSnapshot(sessionPath, options = {}) {
1422
1709
  return (0, sessionContext_1.readSessionContextSnapshot)(this, sessionPath, options);
@@ -1453,6 +1740,37 @@ class OpenCodeProvider {
1453
1740
  }
1454
1741
  return null;
1455
1742
  }
1743
+ async getSessionMetadataAsync(sessionPath) {
1744
+ if (!isDbSessionPath(sessionPath)) {
1745
+ try {
1746
+ return { mtime: (await fs.promises.stat(sessionPath)).mtime };
1747
+ }
1748
+ catch {
1749
+ return null;
1750
+ }
1751
+ }
1752
+ const cached = this.sessionMetaCache.get(sessionPath);
1753
+ try {
1754
+ const db = await this.ensureDbAsync();
1755
+ const sessionId = this.getSessionId(sessionPath);
1756
+ const session = (await db.getSessionsByIdsAsync([sessionId]))[0];
1757
+ this.dbStatus = db.getRuntimeStatus();
1758
+ this.adoptDbIfAvailable(db);
1759
+ if (session) {
1760
+ this.sessionMetaCache.set(sessionPath, {
1761
+ title: session.title,
1762
+ timeUpdated: session.time_updated,
1763
+ });
1764
+ this.recordDatabaseStatus('getSessionMetadataAsync', true);
1765
+ return { mtime: new Date(session.time_updated) };
1766
+ }
1767
+ this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
1768
+ }
1769
+ catch {
1770
+ this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
1771
+ }
1772
+ return cached ? { mtime: new Date(cached.timeUpdated) } : null;
1773
+ }
1456
1774
  /**
1457
1775
  * Returns the latest assistant message's token snapshot for context window sizing.
1458
1776
  * Cumulative session totals are derived from readNew() events processed by SessionMonitor.
@@ -1599,9 +1917,25 @@ class OpenCodeProvider {
1599
1917
  this.db?.close();
1600
1918
  this.db = null;
1601
1919
  this.dbInitialized = false;
1920
+ this.dbStatus = { available: false, kind: 'db_missing' };
1602
1921
  this.sessionMetaCache.clear();
1603
1922
  this.dynamicContextWindowLimit = null;
1604
1923
  }
1924
+ recordDatabaseStatus(operation, usable) {
1925
+ const status = this.db?.getRuntimeStatus() ?? this.dbStatus;
1926
+ const diagnostic = (0, diagnostics_1.diagnosticFromRuntimeStatus)(this.id, 'query', status);
1927
+ if (!diagnostic || status.kind === 'db_missing') {
1928
+ // A genuinely absent OpenCode DB is expected for legacy file storage.
1929
+ this.diagnostics.available(operation);
1930
+ return;
1931
+ }
1932
+ this.diagnostics.degraded(operation, status, {
1933
+ kind: diagnostic.kind,
1934
+ severity: diagnostic.severity,
1935
+ phase: diagnostic.phase,
1936
+ message: diagnostic.message,
1937
+ }, usable);
1938
+ }
1605
1939
  // --- z.ai (GLM Coding Plan) compatibility quota derivation ---
1606
1940
  /**
1607
1941
  * Derives a z.ai coding-plan `QuotaState` from OpenCode assistant turns