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,2008 @@
1
+ /**
2
+ * @fileoverview OpenCode session provider for the shared package.
3
+ *
4
+ * Implements SessionProviderBase for monitoring OpenCode CLI sessions.
5
+ * Uses SQLite database as primary data source (opencode.db), with
6
+ * file-based scanning as fallback for older OpenCode installations.
7
+ *
8
+ * Ported from sidekick-vscode/src/services/providers/OpenCodeSessionProvider.ts
9
+ *
10
+ * OpenCode file storage layout (legacy):
11
+ * - Base: $XDG_DATA_HOME/opencode/ or ~/.local/share/opencode/
12
+ * - Sessions: storage/session/{projectID}/{sessionID}.json
13
+ * - Messages: storage/message/{sessionID}/{messageID}.json
14
+ * - Parts: storage/part/{messageID}/{partID}.json
15
+ *
16
+ * @module providers/openCode
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { execSync } from 'child_process';
22
+ import { readSessionContextSnapshot } from '../context/sessionContext';
23
+ import { readSessionFileStats } from '../sessionStats';
24
+ import { convertOpenCodeMessage, parseDbMessageData, parseDbPartData, normalizeToolName, normalizeToolInput, } from '../parsers/openCodeParser';
25
+ import { OpenCodeDatabase } from './openCodeDatabase';
26
+ import { normalizeProviderUsage } from '../usageNormalization';
27
+ import { addUsageToSubagent } from '../usage/subagentUsage';
28
+ import { accumulateZaiUsage, inferZaiQuotaState, parseZaiQuotaError, ZAI_PROVIDER_IDS, } from '../zaiQuota';
29
+ import { ProviderDiagnosticTracker, diagnosticFromRuntimeStatus } from './diagnostics';
30
+ import { getModelContextWindowSize } from '../modelContext';
31
+ // ---------------------------------------------------------------------------
32
+ // Helper functions
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Gets the OpenCode data directory.
36
+ *
37
+ * Resolves in priority order: `XDG_DATA_HOME` (if set), then whichever of
38
+ * the platform-default candidates actually contains an `opencode.db` file.
39
+ * OpenCode's CLI has historically used the Linux-style `~/.local/share/opencode`
40
+ * path even on macOS, so we probe both and pick whichever exists — falling
41
+ * back to the platform default when neither is present yet.
42
+ */
43
+ export function getOpenCodeDataDir() {
44
+ const xdg = process.env.XDG_DATA_HOME;
45
+ if (xdg)
46
+ return path.join(xdg, 'opencode');
47
+ const candidates = [path.join(os.homedir(), '.local', 'share', 'opencode')];
48
+ if (process.platform === 'darwin') {
49
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'opencode'));
50
+ }
51
+ else if (process.platform === 'win32') {
52
+ candidates.push(path.join(process.env.LOCALAPPDATA ||
53
+ process.env.APPDATA ||
54
+ path.join(os.homedir(), 'AppData', 'Local'), 'opencode'));
55
+ }
56
+ for (const candidate of candidates) {
57
+ try {
58
+ if (fs.existsSync(path.join(candidate, 'opencode.db')))
59
+ return candidate;
60
+ }
61
+ catch {
62
+ // ignore — try the next candidate.
63
+ }
64
+ }
65
+ // Nothing found yet — return the platform default so a fresh OpenCode
66
+ // install gets detected at the conventional location for this OS.
67
+ if (process.platform === 'darwin') {
68
+ return path.join(os.homedir(), 'Library', 'Application Support', 'opencode');
69
+ }
70
+ if (process.platform === 'win32') {
71
+ return path.join(process.env.LOCALAPPDATA ||
72
+ process.env.APPDATA ||
73
+ path.join(os.homedir(), 'AppData', 'Local'), 'opencode');
74
+ }
75
+ return path.join(os.homedir(), '.local', 'share', 'opencode');
76
+ }
77
+ /**
78
+ * Gets the storage base directory within OpenCode's data dir.
79
+ */
80
+ function getStorageDir() {
81
+ return path.join(getOpenCodeDataDir(), 'storage');
82
+ }
83
+ /**
84
+ * Pattern matching model IDs and generic agent names that don't convey type info.
85
+ */
86
+ const GENERIC_AGENT_RE = /^(gpt-|claude-|o[1-9]|gemini|default|agent|worker)/i;
87
+ /**
88
+ * Returns true if the raw agent type is a model ID or generic name
89
+ * that doesn't convey meaningful type information.
90
+ */
91
+ function isGenericAgentType(type) {
92
+ return GENERIC_AGENT_RE.test(type);
93
+ }
94
+ /**
95
+ * Detects a meaningful agent type from the description using keyword matching.
96
+ * Returns undefined if no keywords match.
97
+ */
98
+ function detectAgentTypeFromDescription(desc) {
99
+ const lower = desc.toLowerCase();
100
+ if (lower.includes('explore') || lower.includes('research') || lower.includes('investigate')) {
101
+ return 'Explore';
102
+ }
103
+ if (lower.includes('plan') || lower.includes('architect') || lower.includes('design')) {
104
+ return 'Plan';
105
+ }
106
+ if (lower.includes('task') ||
107
+ lower.includes('execute') ||
108
+ lower.includes('implement') ||
109
+ lower.includes('build')) {
110
+ return 'Task';
111
+ }
112
+ return undefined;
113
+ }
114
+ /**
115
+ * Normalizes a raw agent type. If the raw type is a model ID or generic name,
116
+ * falls back to keyword detection from the description.
117
+ */
118
+ function normalizeAgentType(rawType, description) {
119
+ if (!rawType)
120
+ return rawType;
121
+ if (!isGenericAgentType(rawType))
122
+ return rawType;
123
+ // Raw type is a model ID or generic name -- try to detect from description
124
+ if (description) {
125
+ return detectAgentTypeFromDescription(description) || rawType;
126
+ }
127
+ return rawType;
128
+ }
129
+ /** Prefix for synthetic DB session paths */
130
+ const DB_SESSION_PREFIX = 'db-sessions';
131
+ /** Build a synthetic session path for a DB-backed session. */
132
+ function makeDbSessionPath(dataDir, projectId, sessionId) {
133
+ return path.join(dataDir, DB_SESSION_PREFIX, projectId, `${sessionId}.json`);
134
+ }
135
+ /** Check if a path is a synthetic DB session path. */
136
+ function isDbSessionPath(sessionPath) {
137
+ return sessionPath.includes(path.sep + DB_SESSION_PREFIX + path.sep);
138
+ }
139
+ /**
140
+ * Whether a session recorded in `sessionDirectory` belongs to `workspacePath`.
141
+ *
142
+ * A session started inside the workspace (the workspace itself or any
143
+ * subdirectory, e.g. a monorepo package) matches. A session started in a
144
+ * *parent* of the workspace does not: a sub-package workspace must not list
145
+ * every session of the enclosing repo as its own.
146
+ */
147
+ function workspaceMatches(sessionDirectory, workspacePath) {
148
+ const session = path
149
+ .resolve(sessionDirectory)
150
+ .replace(/[\\/]+$/, '')
151
+ .toLowerCase();
152
+ const workspace = path
153
+ .resolve(workspacePath)
154
+ .replace(/[\\/]+$/, '')
155
+ .toLowerCase();
156
+ return session === workspace || session.startsWith(workspace + path.sep);
157
+ }
158
+ /** Extract project ID from a synthetic DB session path. */
159
+ function extractProjectIdFromDbPath(sessionPath) {
160
+ const prefix = path.sep + DB_SESSION_PREFIX + path.sep;
161
+ const idx = sessionPath.indexOf(prefix);
162
+ if (idx < 0)
163
+ return null;
164
+ const rest = sessionPath.substring(idx + prefix.length);
165
+ const slashIdx = rest.indexOf(path.sep);
166
+ return slashIdx > 0 ? rest.substring(0, slashIdx) : null;
167
+ }
168
+ /** Extract role from a DB message row payload. */
169
+ function extractRoleFromDbMessage(row) {
170
+ try {
171
+ const data = JSON.parse(row.data);
172
+ if (data.role === 'user' || data.role === 'assistant' || data.role === 'system') {
173
+ return data.role;
174
+ }
175
+ }
176
+ catch {
177
+ // Ignore malformed payloads
178
+ }
179
+ return 'unknown';
180
+ }
181
+ /** Extract parent message ID from a DB message row payload. */
182
+ function extractParentIdFromDbMessage(row) {
183
+ try {
184
+ const data = JSON.parse(row.data);
185
+ return typeof data.parentID === 'string' && data.parentID.length > 0 ? data.parentID : null;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ /**
192
+ * Resolves the OpenCode project ID for a workspace path.
193
+ *
194
+ * Tries DB first, then file-based project metadata, then git root commit hash.
195
+ */
196
+ function resolveProjectId(workspacePath, db, runtimeStatus) {
197
+ // Strategy 1: DB lookup
198
+ if (db) {
199
+ const dbProject = db.findProjectByWorktree(workspacePath);
200
+ if (dbProject)
201
+ return dbProject.id;
202
+ const byDirectory = db.findProjectBySessionDirectory(workspacePath);
203
+ if (byDirectory)
204
+ return byDirectory.id;
205
+ }
206
+ if (runtimeStatus.kind !== 'db_missing') {
207
+ return null;
208
+ }
209
+ // Strategy 2: scan project files to find matching path
210
+ return resolveProjectIdFromFiles(workspacePath);
211
+ }
212
+ function resolveProjectIdFromFiles(workspacePath) {
213
+ const workspaceResolved = normalizePath(workspacePath);
214
+ try {
215
+ const projectDir = path.join(getStorageDir(), 'project');
216
+ if (!fs.existsSync(projectDir))
217
+ return resolveProjectIdFromGit(workspacePath);
218
+ const files = fs.readdirSync(projectDir).filter((f) => f.endsWith('.json'));
219
+ const matches = [];
220
+ for (const file of files) {
221
+ try {
222
+ const project = JSON.parse(fs.readFileSync(path.join(projectDir, file), 'utf-8'));
223
+ if (project.path) {
224
+ const projectPath = normalizePath(project.path);
225
+ if (projectPath === workspaceResolved) {
226
+ matches.push({ id: project.id, path: projectPath });
227
+ continue;
228
+ }
229
+ if (workspaceResolved.startsWith(projectPath + path.sep)) {
230
+ matches.push({ id: project.id, path: projectPath });
231
+ continue;
232
+ }
233
+ if (projectPath.startsWith(workspaceResolved + path.sep)) {
234
+ matches.push({ id: project.id, path: projectPath });
235
+ }
236
+ }
237
+ }
238
+ catch {
239
+ // Skip malformed project files
240
+ }
241
+ }
242
+ if (matches.length > 0) {
243
+ matches.sort((a, b) => b.path.length - a.path.length);
244
+ return matches[0].id;
245
+ }
246
+ }
247
+ catch {
248
+ // Can't read project directory
249
+ }
250
+ return resolveProjectIdFromGit(workspacePath);
251
+ }
252
+ function resolveProjectIdFromGit(workspacePath) {
253
+ try {
254
+ const hash = execSync('git rev-list --max-parents=0 HEAD', {
255
+ cwd: workspacePath,
256
+ encoding: 'utf-8',
257
+ timeout: 4000,
258
+ killSignal: 'SIGKILL',
259
+ stdio: ['pipe', 'pipe', 'pipe'],
260
+ })
261
+ .trim()
262
+ .split('\n')[0];
263
+ if (hash && /^[a-f0-9]+$/i.test(hash)) {
264
+ return hash;
265
+ }
266
+ }
267
+ catch {
268
+ // Git not available or not a git repo
269
+ }
270
+ return null;
271
+ }
272
+ /**
273
+ * Safely reads and parses a JSON file, returning null on failure.
274
+ */
275
+ function readJsonSafe(filePath) {
276
+ try {
277
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ /** Truncate a title to 60 chars with ellipsis. */
284
+ function truncateTitle(title) {
285
+ const trimmed = title.trim();
286
+ if (trimmed.length > 60) {
287
+ return trimmed.substring(0, 57) + '...';
288
+ }
289
+ return trimmed;
290
+ }
291
+ function normalizePath(input) {
292
+ try {
293
+ return fs.realpathSync(input);
294
+ }
295
+ catch {
296
+ return path.resolve(input);
297
+ }
298
+ }
299
+ function messageCreatedMs(message) {
300
+ const created = message.time?.created;
301
+ const ms = typeof created === 'number' ? created : Date.parse(String(created ?? ''));
302
+ return Number.isFinite(ms) ? ms : 0;
303
+ }
304
+ class OpenCodeFileReader {
305
+ sessionId;
306
+ seenMessages = new Map();
307
+ pendingSkip = 0;
308
+ storageBase;
309
+ constructor(sessionId) {
310
+ this.sessionId = sessionId;
311
+ this.storageBase = getStorageDir();
312
+ }
313
+ readNew() {
314
+ const messageDir = path.join(this.storageBase, 'message', this.sessionId);
315
+ if (!fs.existsSync(messageDir))
316
+ return [];
317
+ let messageFiles;
318
+ try {
319
+ messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
320
+ }
321
+ catch {
322
+ return [];
323
+ }
324
+ const entries = [];
325
+ for (const file of messageFiles) {
326
+ const msgId = path.basename(file, '.json');
327
+ const messagePath = path.join(messageDir, file);
328
+ const message = readJsonSafe(messagePath);
329
+ if (!message)
330
+ continue;
331
+ let mtimeMs = 0;
332
+ try {
333
+ mtimeMs = fs.statSync(messagePath).mtimeMs;
334
+ }
335
+ catch {
336
+ mtimeMs = 0;
337
+ }
338
+ const parts = this.readParts(msgId);
339
+ entries.push({ msgId, message, mtimeMs, parts, partIds: new Set(parts.map((p) => p.id)) });
340
+ }
341
+ if (this.pendingSkip > 0) {
342
+ // A position for this reader is a message count (see getPosition()), so a
343
+ // seek marks the oldest N messages as already seen without emitting them.
344
+ const ordered = [...entries].sort((a, b) => messageCreatedMs(a.message) - messageCreatedMs(b.message) ||
345
+ a.msgId.localeCompare(b.msgId));
346
+ for (const entry of ordered.slice(0, this.pendingSkip)) {
347
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
348
+ }
349
+ this.pendingSkip = 0;
350
+ }
351
+ const newEvents = [];
352
+ for (const entry of entries) {
353
+ const previous = this.seenMessages.get(entry.msgId);
354
+ const isNewMessage = !previous;
355
+ let hasNewParts = false;
356
+ if (previous) {
357
+ if (previous.mtimeMs !== entry.mtimeMs) {
358
+ hasNewParts = true;
359
+ }
360
+ else if (entry.partIds.size !== previous.partIds.size) {
361
+ hasNewParts = true;
362
+ }
363
+ else {
364
+ for (const partId of entry.partIds) {
365
+ if (!previous.partIds.has(partId)) {
366
+ hasNewParts = true;
367
+ break;
368
+ }
369
+ }
370
+ }
371
+ }
372
+ if (isNewMessage || hasNewParts) {
373
+ newEvents.push(...convertOpenCodeMessage(entry.message, entry.parts));
374
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
375
+ }
376
+ }
377
+ // Sort by timestamp
378
+ return newEvents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
379
+ }
380
+ readParts(msgId) {
381
+ const partDir = path.join(this.storageBase, 'part', msgId);
382
+ if (!fs.existsSync(partDir))
383
+ return [];
384
+ try {
385
+ return fs
386
+ .readdirSync(partDir)
387
+ .filter((f) => f.endsWith('.json'))
388
+ .map((f) => readJsonSafe(path.join(partDir, f)))
389
+ .filter((p) => p !== null);
390
+ }
391
+ catch {
392
+ // Skip unreadable part directories
393
+ return [];
394
+ }
395
+ }
396
+ readAll() {
397
+ this.reset();
398
+ return this.readNew();
399
+ }
400
+ reset() {
401
+ this.seenMessages.clear();
402
+ this.pendingSkip = 0;
403
+ }
404
+ exists() {
405
+ return fs.existsSync(path.join(this.storageBase, 'message', this.sessionId));
406
+ }
407
+ flush() {
408
+ // No-op for JSON file reading
409
+ }
410
+ getPosition() {
411
+ return this.seenMessages.size;
412
+ }
413
+ seekTo(position) {
414
+ // Positions are message counts (getPosition()), so the next readNew() marks
415
+ // the oldest `position` messages as seen and emits only what follows.
416
+ this.pendingSkip = Number.isFinite(position) && position > 0 ? Math.floor(position) : 0;
417
+ }
418
+ wasTruncated() {
419
+ return false; // JSON files don't truncate
420
+ }
421
+ }
422
+ // ---------------------------------------------------------------------------
423
+ // DB-backed reader
424
+ // ---------------------------------------------------------------------------
425
+ /**
426
+ * Incremental reader for DB-backed OpenCode sessions.
427
+ *
428
+ * Uses time_updated timestamps to track which data has been seen,
429
+ * querying only for newer messages/parts on each readNew() call.
430
+ */
431
+ /** How long a database-backed reader trusts its last session-row check. */
432
+ const DB_EXISTS_MEMO_MS = 30_000;
433
+ class OpenCodeDbReader {
434
+ sessionId;
435
+ db;
436
+ lastTimeUpdated = 0;
437
+ hasReadOnce = false;
438
+ existsCheckedAtMs = 0;
439
+ existsResult = true;
440
+ constructor(sessionId, db) {
441
+ this.sessionId = sessionId;
442
+ this.db = db;
443
+ }
444
+ readNew() {
445
+ // On first call, load the full session history so that attaching to an
446
+ // existing session populates the dashboard with all prior events.
447
+ // This matches the behavior of OpenCodeFileReader and the Claude Code
448
+ // JSONL reader where the first readNew() returns all existing data.
449
+ if (!this.hasReadOnce) {
450
+ this.hasReadOnce = true;
451
+ return this.readAllInternal();
452
+ }
453
+ return this.readIncremental();
454
+ }
455
+ /**
456
+ * Reads all messages and parts for the session, converting them to events.
457
+ * Used for initial history load and readAll().
458
+ */
459
+ readAllInternal() {
460
+ const events = [];
461
+ const messages = this.db.getMessagesForSession(this.sessionId);
462
+ const parts = this.db.getPartsForSession(this.sessionId);
463
+ if (messages.length === 0) {
464
+ return [];
465
+ }
466
+ // Group parts by message_id
467
+ const partsByMessage = new Map();
468
+ for (const part of parts) {
469
+ const existing = partsByMessage.get(part.message_id);
470
+ if (existing) {
471
+ existing.push(part);
472
+ }
473
+ else {
474
+ partsByMessage.set(part.message_id, [part]);
475
+ }
476
+ }
477
+ // Sort messages by creation time
478
+ messages.sort((a, b) => a.time_created - b.time_created);
479
+ // Filter user messages that haven't been processed yet
480
+ const userMessageIds = messages
481
+ .filter((m) => extractRoleFromDbMessage(m) === 'user')
482
+ .map((m) => m.id);
483
+ const processedUserMessageIds = new Set(this.db.getProcessedUserMessageIds(this.sessionId, userMessageIds));
484
+ for (const msgRow of messages) {
485
+ try {
486
+ if (extractRoleFromDbMessage(msgRow) === 'user' &&
487
+ !processedUserMessageIds.has(msgRow.id)) {
488
+ continue;
489
+ }
490
+ const message = parseDbMessageData(msgRow);
491
+ const msgParts = (partsByMessage.get(msgRow.id) || [])
492
+ .map((row) => {
493
+ try {
494
+ return parseDbPartData(row);
495
+ }
496
+ catch {
497
+ return null;
498
+ }
499
+ })
500
+ .filter((p) => p !== null);
501
+ events.push(...convertOpenCodeMessage(message, msgParts));
502
+ }
503
+ catch {
504
+ // Skip malformed messages
505
+ }
506
+ }
507
+ // Set cursor to max time_updated across all results
508
+ let maxTimeUpdated = this.lastTimeUpdated;
509
+ for (const m of messages) {
510
+ if (m.time_updated > maxTimeUpdated)
511
+ maxTimeUpdated = m.time_updated;
512
+ }
513
+ for (const p of parts) {
514
+ if (p.time_updated > maxTimeUpdated)
515
+ maxTimeUpdated = p.time_updated;
516
+ }
517
+ this.lastTimeUpdated = maxTimeUpdated;
518
+ return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
519
+ }
520
+ /**
521
+ * Reads only messages/parts newer than the last cursor position.
522
+ * Used for subsequent polling calls after the initial history load.
523
+ */
524
+ readIncremental() {
525
+ const events = [];
526
+ // Get messages and parts that are newer than what we've seen
527
+ const messages = this.db.getMessagesNewerThan(this.sessionId, this.lastTimeUpdated);
528
+ const parts = this.db.getPartsNewerThan(this.sessionId, this.lastTimeUpdated);
529
+ if (messages.length === 0 && parts.length === 0) {
530
+ return [];
531
+ }
532
+ // Build set of affected message IDs from both message and part changes
533
+ const affectedMessageIds = new Set(messages.map((m) => m.id));
534
+ for (const part of parts) {
535
+ affectedMessageIds.add(part.message_id);
536
+ }
537
+ // If an assistant reply arrives, include its parent user message so
538
+ // queued user prompts are surfaced only once they are actually processed.
539
+ for (const msg of messages) {
540
+ const parentId = extractParentIdFromDbMessage(msg);
541
+ if (parentId) {
542
+ affectedMessageIds.add(parentId);
543
+ }
544
+ }
545
+ // Fetch missing message rows for part-only updates
546
+ const messageMap = new Map(messages.map((m) => [m.id, m]));
547
+ const missingMessageIds = [...affectedMessageIds].filter((id) => !messageMap.has(id));
548
+ if (missingMessageIds.length > 0) {
549
+ const missingRows = this.db.getMessagesByIds(this.sessionId, missingMessageIds);
550
+ for (const row of missingRows) {
551
+ messageMap.set(row.id, row);
552
+ }
553
+ const unresolved = missingMessageIds.filter((id) => !messageMap.has(id));
554
+ if (unresolved.length > 0) {
555
+ // Keep cursor unchanged so we can retry on next polling cycle.
556
+ return [];
557
+ }
558
+ }
559
+ const targetMessages = [...messageMap.values()];
560
+ const targetMessageIds = targetMessages.map((m) => m.id);
561
+ // Fetch complete part sets for all affected messages in one batched query
562
+ const allParts = this.db.getPartsForMessages(this.sessionId, targetMessageIds);
563
+ const partsByMessage = new Map();
564
+ for (const part of allParts) {
565
+ const existing = partsByMessage.get(part.message_id);
566
+ if (existing) {
567
+ existing.push(part);
568
+ }
569
+ else {
570
+ partsByMessage.set(part.message_id, [part]);
571
+ }
572
+ }
573
+ // Track max time_updated across all results
574
+ let maxTimeUpdated = this.lastTimeUpdated;
575
+ for (const m of messages) {
576
+ if (m.time_updated > maxTimeUpdated)
577
+ maxTimeUpdated = m.time_updated;
578
+ }
579
+ for (const p of parts) {
580
+ if (p.time_updated > maxTimeUpdated)
581
+ maxTimeUpdated = p.time_updated;
582
+ }
583
+ // Convert each message + its parts to events
584
+ // Sort messages by creation time
585
+ targetMessages.sort((a, b) => a.time_created - b.time_created);
586
+ const userMessageIds = targetMessages
587
+ .filter((m) => extractRoleFromDbMessage(m) === 'user')
588
+ .map((m) => m.id);
589
+ const processedUserMessageIds = new Set(this.db.getProcessedUserMessageIds(this.sessionId, userMessageIds));
590
+ for (const msgRow of targetMessages) {
591
+ try {
592
+ if (extractRoleFromDbMessage(msgRow) === 'user' &&
593
+ !processedUserMessageIds.has(msgRow.id)) {
594
+ continue;
595
+ }
596
+ const message = parseDbMessageData(msgRow);
597
+ const msgParts = (partsByMessage.get(msgRow.id) || [])
598
+ .map((row) => {
599
+ try {
600
+ return parseDbPartData(row);
601
+ }
602
+ catch {
603
+ return null;
604
+ }
605
+ })
606
+ .filter((p) => p !== null);
607
+ events.push(...convertOpenCodeMessage(message, msgParts));
608
+ }
609
+ catch {
610
+ // Skip malformed messages
611
+ }
612
+ }
613
+ this.lastTimeUpdated = maxTimeUpdated;
614
+ return events;
615
+ }
616
+ readAll() {
617
+ this.reset();
618
+ return this.readAllInternal();
619
+ }
620
+ reset() {
621
+ this.lastTimeUpdated = 0;
622
+ this.hasReadOnce = false;
623
+ }
624
+ exists() {
625
+ // DB-backed sessions are durable rows rather than ephemeral files. The row
626
+ // check costs a sqlite3 subprocess, so it is memoised, and a transient
627
+ // query failure still reports "exists" so SessionMonitor does not flap
628
+ // into discovery mode.
629
+ const now = Date.now();
630
+ if (now - this.existsCheckedAtMs < DB_EXISTS_MEMO_MS)
631
+ return this.existsResult;
632
+ this.existsCheckedAtMs = now;
633
+ try {
634
+ const row = this.db.getSession(this.sessionId);
635
+ this.existsResult = row !== null || this.db.getRuntimeStatus().kind === 'query_failed';
636
+ }
637
+ catch {
638
+ this.existsResult = true;
639
+ }
640
+ return this.existsResult;
641
+ }
642
+ flush() {
643
+ // No-op for DB reading
644
+ }
645
+ getPosition() {
646
+ return this.lastTimeUpdated;
647
+ }
648
+ seekTo(position) {
649
+ this.lastTimeUpdated = position;
650
+ this.hasReadOnce = true; // Skip full history load on next readNew()
651
+ }
652
+ wasTruncated() {
653
+ return false;
654
+ }
655
+ }
656
+ // ---------------------------------------------------------------------------
657
+ // Main provider
658
+ // ---------------------------------------------------------------------------
659
+ /**
660
+ * Session provider for OpenCode CLI.
661
+ *
662
+ * Uses SQLite database as primary data source, with file-based
663
+ * scanning as fallback for older OpenCode installations.
664
+ */
665
+ export class OpenCodeProvider {
666
+ id = 'opencode';
667
+ displayName = 'OpenCode';
668
+ db = null;
669
+ dbInitialized = false;
670
+ dbStatus = { available: false, kind: 'db_missing' };
671
+ diagnostics;
672
+ /** Cache of session metadata populated during listing */
673
+ sessionMetaCache = new Map();
674
+ /** Runtime-reported context window limit (overrides static map when set). */
675
+ dynamicContextWindowLimit = null;
676
+ constructor(options = {}) {
677
+ this.diagnostics = new ProviderDiagnosticTracker(this.id, options);
678
+ }
679
+ /** Lazy-initialize the database connection. */
680
+ ensureDb() {
681
+ if (this.dbInitialized)
682
+ return this.db;
683
+ this.dbInitialized = true;
684
+ const dataDir = getOpenCodeDataDir();
685
+ const db = new OpenCodeDatabase(dataDir);
686
+ if (db.open()) {
687
+ this.db = db;
688
+ }
689
+ this.dbStatus = db.getRuntimeStatus();
690
+ return this.db;
691
+ }
692
+ async ensureDbAsync() {
693
+ if (this.db)
694
+ return this.db;
695
+ return new OpenCodeDatabase(getOpenCodeDataDir());
696
+ }
697
+ /**
698
+ * Cache an async-created database only after an operation proved it usable.
699
+ * Sync paths treat a cached `db` as "open() succeeded", so adopting an
700
+ * unproven instance would cut them off from the file-storage fallback.
701
+ */
702
+ adoptDbIfAvailable(db) {
703
+ if (this.db || !db.getRuntimeStatus().available)
704
+ return;
705
+ this.db = db;
706
+ this.dbInitialized = true;
707
+ }
708
+ getRuntimeStatus() {
709
+ const db = this.ensureDb();
710
+ if (db)
711
+ return db.getRuntimeStatus();
712
+ return this.dbStatus;
713
+ }
714
+ getLastOperationStatus() {
715
+ return this.diagnostics.getLastOperationStatus();
716
+ }
717
+ canMonitorDirectory(dir) {
718
+ if (fs.existsSync(dir))
719
+ return true;
720
+ const db = this.ensureDb();
721
+ if (!db)
722
+ return false;
723
+ const projectId = extractProjectIdFromDbPath(dir + path.sep + 'dummy.json') || path.basename(dir);
724
+ return projectId.length > 0 && db.hasProject(projectId);
725
+ }
726
+ mapDbSessions(projectId) {
727
+ const db = this.ensureDb();
728
+ if (!db)
729
+ return [];
730
+ const sessions = db.getSessionsForProject(projectId);
731
+ const dataDir = getOpenCodeDataDir();
732
+ return sessions.map((session) => {
733
+ const syntheticPath = makeDbSessionPath(dataDir, projectId, session.id);
734
+ this.sessionMetaCache.set(syntheticPath, {
735
+ title: session.title,
736
+ timeUpdated: session.time_updated,
737
+ });
738
+ return syntheticPath;
739
+ });
740
+ }
741
+ // --- Path resolution ---
742
+ getSessionDirectory(workspacePath) {
743
+ const db = this.ensureDb();
744
+ const dbStatus = this.getRuntimeStatus();
745
+ const projectId = resolveProjectId(workspacePath, db, dbStatus);
746
+ if (projectId) {
747
+ // For DB sessions, return a synthetic directory path
748
+ if (db) {
749
+ return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX, projectId);
750
+ }
751
+ return path.join(getStorageDir(), 'session', projectId);
752
+ }
753
+ if (dbStatus.kind !== 'db_missing') {
754
+ return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX);
755
+ }
756
+ return path.join(getStorageDir(), 'session');
757
+ }
758
+ discoverSessionDirectory(workspacePath) {
759
+ const db = this.ensureDb();
760
+ const dbStatus = this.getRuntimeStatus();
761
+ const projectId = resolveProjectId(workspacePath, db, dbStatus);
762
+ if (!projectId)
763
+ return null;
764
+ // DB: check if project has sessions
765
+ if (db) {
766
+ if (db.hasProject(projectId)) {
767
+ return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX, projectId);
768
+ }
769
+ }
770
+ if (dbStatus.kind !== 'db_missing') {
771
+ return null;
772
+ }
773
+ // File fallback
774
+ const dir = path.join(getStorageDir(), 'session', projectId);
775
+ return fs.existsSync(dir) ? dir : null;
776
+ }
777
+ // --- Session discovery ---
778
+ findActiveSession(workspacePath) {
779
+ const db = this.ensureDb();
780
+ const dbStatus = this.getRuntimeStatus();
781
+ const projectId = resolveProjectId(workspacePath, db, dbStatus);
782
+ if (!projectId) {
783
+ this.recordDatabaseStatus('findActiveSession', dbStatus.kind === 'db_missing');
784
+ return null;
785
+ }
786
+ // DB primary
787
+ if (db) {
788
+ const session = db.getMostRecentSession(projectId);
789
+ if (session) {
790
+ const syntheticPath = makeDbSessionPath(getOpenCodeDataDir(), projectId, session.id);
791
+ this.sessionMetaCache.set(syntheticPath, {
792
+ title: session.title,
793
+ timeUpdated: session.time_updated,
794
+ });
795
+ this.recordDatabaseStatus('findActiveSession', true);
796
+ return syntheticPath;
797
+ }
798
+ }
799
+ if (dbStatus.kind !== 'db_missing') {
800
+ this.recordDatabaseStatus('findActiveSession', dbStatus.available);
801
+ return null;
802
+ }
803
+ // File fallback
804
+ const result = this.findActiveSessionFromFiles(projectId);
805
+ this.recordDatabaseStatus('findActiveSession', true);
806
+ return result;
807
+ }
808
+ findActiveSessionFromFiles(projectId) {
809
+ const sessionDir = path.join(getStorageDir(), 'session', projectId);
810
+ if (!fs.existsSync(sessionDir))
811
+ return null;
812
+ let bestPath = null;
813
+ let bestMtime = 0;
814
+ try {
815
+ const files = fs.readdirSync(sessionDir).filter((f) => f.endsWith('.json'));
816
+ for (const file of files) {
817
+ const fullPath = path.join(sessionDir, file);
818
+ try {
819
+ const stats = fs.statSync(fullPath);
820
+ if (stats.size > 0 && stats.mtime.getTime() > bestMtime) {
821
+ bestMtime = stats.mtime.getTime();
822
+ bestPath = fullPath;
823
+ }
824
+ }
825
+ catch {
826
+ // Skip
827
+ }
828
+ }
829
+ }
830
+ catch {
831
+ return null;
832
+ }
833
+ if (bestPath) {
834
+ const sessionId = path.basename(bestPath, '.json');
835
+ const messageDir = path.join(getStorageDir(), 'message', sessionId);
836
+ if (fs.existsSync(messageDir)) {
837
+ try {
838
+ const messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
839
+ for (const mf of messageFiles) {
840
+ try {
841
+ const mstat = fs.statSync(path.join(messageDir, mf));
842
+ if (mstat.mtime.getTime() > bestMtime) {
843
+ bestMtime = mstat.mtime.getTime();
844
+ }
845
+ }
846
+ catch {
847
+ // Skip
848
+ }
849
+ }
850
+ }
851
+ catch {
852
+ // Skip
853
+ }
854
+ }
855
+ return bestPath;
856
+ }
857
+ return null;
858
+ }
859
+ /** Backward-compatible alias for findAllSessions. */
860
+ findSessionFiles(workspacePath) {
861
+ return this.findAllSessions(workspacePath);
862
+ }
863
+ findAllSessions(workspacePath) {
864
+ const db = this.ensureDb();
865
+ const dbStatus = this.getRuntimeStatus();
866
+ const projectId = resolveProjectId(workspacePath, db, dbStatus);
867
+ if (!projectId) {
868
+ this.recordDatabaseStatus('findAllSessions', dbStatus.kind === 'db_missing');
869
+ return [];
870
+ }
871
+ // DB primary
872
+ if (db) {
873
+ const sessions = this.mapDbSessions(projectId);
874
+ this.recordDatabaseStatus('findAllSessions', true);
875
+ return sessions;
876
+ }
877
+ if (dbStatus.kind !== 'db_missing') {
878
+ this.recordDatabaseStatus('findAllSessions', dbStatus.available);
879
+ return [];
880
+ }
881
+ // File fallback
882
+ const sessionDir = path.join(getStorageDir(), 'session', projectId);
883
+ const sessions = this.findSessionsInDirectoryFromFiles(sessionDir);
884
+ this.recordDatabaseStatus('findAllSessions', true);
885
+ return sessions;
886
+ }
887
+ findSessionById(workspacePath, sessionId) {
888
+ const normalizedId = sessionId.trim();
889
+ if (!normalizedId || path.basename(normalizedId) !== normalizedId)
890
+ return null;
891
+ try {
892
+ const db = this.ensureDb();
893
+ if (db) {
894
+ const session = db.getSession(normalizedId);
895
+ if (session && workspaceMatches(session.directory, workspacePath)) {
896
+ const syntheticPath = makeDbSessionPath(getOpenCodeDataDir(), session.project_id, session.id);
897
+ this.sessionMetaCache.set(syntheticPath, {
898
+ title: session.title,
899
+ timeUpdated: session.time_updated,
900
+ });
901
+ this.recordDatabaseStatus('findSessionById', true);
902
+ return syntheticPath;
903
+ }
904
+ }
905
+ const projectId = resolveProjectId(workspacePath, db, this.getRuntimeStatus());
906
+ if (projectId) {
907
+ const candidate = path.join(getStorageDir(), 'session', projectId, `${normalizedId}.json`);
908
+ try {
909
+ if (fs.statSync(candidate).isFile()) {
910
+ this.recordDatabaseStatus('findSessionById', true);
911
+ return candidate;
912
+ }
913
+ }
914
+ catch {
915
+ // Missing legacy session is a normal miss.
916
+ }
917
+ }
918
+ this.recordDatabaseStatus('findSessionById', true);
919
+ return null;
920
+ }
921
+ catch {
922
+ this.recordDatabaseStatus('findSessionById', false);
923
+ return null;
924
+ }
925
+ }
926
+ findSessionsInDirectory(dir) {
927
+ const db = this.ensureDb();
928
+ const dbStatus = this.getRuntimeStatus();
929
+ // Check if this is a synthetic DB session directory
930
+ if (db && dir.includes(path.sep + DB_SESSION_PREFIX + path.sep)) {
931
+ const projectId = extractProjectIdFromDbPath(dir + path.sep + 'dummy.json');
932
+ if (projectId) {
933
+ const sessions = this.mapDbSessions(projectId);
934
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
935
+ return sessions;
936
+ }
937
+ }
938
+ // For a DB-backed directory path ending with the project ID,
939
+ // try to extract project ID from dir name
940
+ if (db) {
941
+ const dirName = path.basename(dir);
942
+ if (db.hasProject(dirName)) {
943
+ const sessions = this.mapDbSessions(dirName);
944
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
945
+ return sessions;
946
+ }
947
+ }
948
+ if (dbStatus.kind !== 'db_missing') {
949
+ this.recordDatabaseStatus('findSessionsInDirectory', dbStatus.available);
950
+ return [];
951
+ }
952
+ // File fallback
953
+ const sessions = this.findSessionsInDirectoryFromFiles(dir);
954
+ this.recordDatabaseStatus('findSessionsInDirectory', true);
955
+ return sessions;
956
+ }
957
+ findSessionsInDirectoryFromFiles(dir) {
958
+ try {
959
+ if (!fs.existsSync(dir))
960
+ return [];
961
+ return fs
962
+ .readdirSync(dir)
963
+ .filter((f) => f.endsWith('.json'))
964
+ .map((f) => {
965
+ const fullPath = path.join(dir, f);
966
+ try {
967
+ const stats = fs.statSync(fullPath);
968
+ return { path: fullPath, mtime: stats.mtime.getTime(), size: stats.size };
969
+ }
970
+ catch {
971
+ return null;
972
+ }
973
+ })
974
+ .filter((f) => f !== null && f.size > 0)
975
+ .sort((a, b) => b.mtime - a.mtime)
976
+ .map((f) => f.path);
977
+ }
978
+ catch {
979
+ return [];
980
+ }
981
+ }
982
+ listAllSessionFiles() {
983
+ try {
984
+ const db = this.ensureDb();
985
+ if (db) {
986
+ const dataDir = getOpenCodeDataDir();
987
+ const files = db.getAllSessions().map((session) => {
988
+ const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
989
+ this.sessionMetaCache.set(syntheticPath, {
990
+ title: session.title,
991
+ timeUpdated: session.time_updated,
992
+ });
993
+ return {
994
+ path: syntheticPath,
995
+ mtime: new Date(session.time_updated),
996
+ label: session.title ?? undefined,
997
+ sizeBytes: 0,
998
+ sessionId: session.id,
999
+ workspacePath: session.directory,
1000
+ createdAt: new Date(session.time_created),
1001
+ };
1002
+ });
1003
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1004
+ return files;
1005
+ }
1006
+ if (this.getRuntimeStatus().kind !== 'db_missing') {
1007
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1008
+ return [];
1009
+ }
1010
+ const root = path.join(getStorageDir(), 'session');
1011
+ const results = [];
1012
+ let projectDirs = [];
1013
+ try {
1014
+ projectDirs = fs.readdirSync(root, { withFileTypes: true });
1015
+ }
1016
+ catch {
1017
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1018
+ return [];
1019
+ }
1020
+ for (const entry of projectDirs) {
1021
+ if (!entry.isDirectory())
1022
+ continue;
1023
+ for (const sessionPath of this.findSessionsInDirectoryFromFiles(path.join(root, entry.name))) {
1024
+ try {
1025
+ const stats = fs.statSync(sessionPath);
1026
+ results.push({
1027
+ path: sessionPath,
1028
+ mtime: stats.mtime,
1029
+ sizeBytes: stats.size,
1030
+ sessionId: path.basename(sessionPath, '.json'),
1031
+ });
1032
+ }
1033
+ catch {
1034
+ // Session vanished between listing and stat.
1035
+ }
1036
+ }
1037
+ }
1038
+ this.recordDatabaseStatus('listAllSessionFiles', true);
1039
+ return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1040
+ }
1041
+ catch {
1042
+ this.recordDatabaseStatus('listAllSessionFiles', false);
1043
+ return [];
1044
+ }
1045
+ }
1046
+ async listSessionFilesAsync(workspacePath) {
1047
+ const db = await this.ensureDbAsync();
1048
+ const sessions = await db.getAllSessionsAsync();
1049
+ this.dbStatus = db.getRuntimeStatus();
1050
+ this.adoptDbIfAvailable(db);
1051
+ if (this.dbStatus.available) {
1052
+ const dataDir = getOpenCodeDataDir();
1053
+ const files = [];
1054
+ for (const session of sessions) {
1055
+ if (workspacePath && !workspaceMatches(session.directory, workspacePath))
1056
+ continue;
1057
+ const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
1058
+ this.sessionMetaCache.set(syntheticPath, {
1059
+ title: session.title,
1060
+ timeUpdated: session.time_updated,
1061
+ });
1062
+ files.push({
1063
+ path: syntheticPath,
1064
+ mtime: new Date(session.time_updated),
1065
+ label: session.title ?? undefined,
1066
+ sizeBytes: 0,
1067
+ sessionId: session.id,
1068
+ workspacePath: session.directory,
1069
+ createdAt: new Date(session.time_created),
1070
+ });
1071
+ }
1072
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1073
+ return files;
1074
+ }
1075
+ const root = path.join(getStorageDir(), 'session');
1076
+ const results = [];
1077
+ let projectDirs = [];
1078
+ try {
1079
+ projectDirs = await fs.promises.readdir(root, { withFileTypes: true });
1080
+ }
1081
+ catch {
1082
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1083
+ return [];
1084
+ }
1085
+ for (const project of projectDirs) {
1086
+ if (!project.isDirectory())
1087
+ continue;
1088
+ let entries = [];
1089
+ try {
1090
+ entries = await fs.promises.readdir(path.join(root, project.name), {
1091
+ withFileTypes: true,
1092
+ });
1093
+ }
1094
+ catch {
1095
+ continue;
1096
+ }
1097
+ for (const entry of entries) {
1098
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
1099
+ continue;
1100
+ const sessionPath = path.join(root, project.name, entry.name);
1101
+ try {
1102
+ const [stat, raw] = await Promise.all([
1103
+ fs.promises.stat(sessionPath),
1104
+ workspacePath ? fs.promises.readFile(sessionPath, 'utf8') : Promise.resolve(null),
1105
+ ]);
1106
+ let session = null;
1107
+ if (raw !== null) {
1108
+ try {
1109
+ session = JSON.parse(raw);
1110
+ }
1111
+ catch {
1112
+ continue;
1113
+ }
1114
+ const directory = session.directory;
1115
+ if (directory && !workspaceMatches(directory, workspacePath))
1116
+ continue;
1117
+ }
1118
+ results.push({
1119
+ path: sessionPath,
1120
+ mtime: stat.mtime,
1121
+ sizeBytes: stat.size,
1122
+ sessionId: path.basename(entry.name, '.json'),
1123
+ workspacePath: session?.directory,
1124
+ });
1125
+ }
1126
+ catch {
1127
+ // Skip inaccessible sessions.
1128
+ }
1129
+ await new Promise((resolve) => setImmediate(resolve));
1130
+ }
1131
+ }
1132
+ this.recordDatabaseStatus('listSessionFilesAsync', true);
1133
+ return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1134
+ }
1135
+ getAllProjectFolders(workspacePath) {
1136
+ const db = this.ensureDb();
1137
+ const folders = [];
1138
+ const dbStatus = this.getRuntimeStatus();
1139
+ // DB primary
1140
+ if (db) {
1141
+ const projects = db.getAllProjects();
1142
+ const stats = db.getProjectSessionStats();
1143
+ const statsMap = new Map(stats.map((s) => [s.projectId, s]));
1144
+ let currentProjectId = null;
1145
+ if (workspacePath) {
1146
+ currentProjectId = resolveProjectId(workspacePath, db, dbStatus);
1147
+ }
1148
+ const dataDir = getOpenCodeDataDir();
1149
+ for (const project of projects) {
1150
+ const projStats = statsMap.get(project.id);
1151
+ if (!projStats || projStats.sessionCount === 0)
1152
+ continue;
1153
+ folders.push({
1154
+ dir: path.join(dataDir, DB_SESSION_PREFIX, project.id),
1155
+ name: project.worktree || project.name || project.id,
1156
+ encodedName: project.id,
1157
+ sessionCount: projStats.sessionCount,
1158
+ lastModified: new Date(projStats.maxTimeUpdated),
1159
+ });
1160
+ }
1161
+ // Sort: current project first, then by recency
1162
+ folders.sort((a, b) => {
1163
+ if (currentProjectId) {
1164
+ const aIsCurrent = a.encodedName === currentProjectId;
1165
+ const bIsCurrent = b.encodedName === currentProjectId;
1166
+ if (aIsCurrent && !bIsCurrent)
1167
+ return -1;
1168
+ if (!aIsCurrent && bIsCurrent)
1169
+ return 1;
1170
+ }
1171
+ return b.lastModified.getTime() - a.lastModified.getTime();
1172
+ });
1173
+ if (folders.length > 0) {
1174
+ this.recordDatabaseStatus('getAllProjectFolders', true);
1175
+ return folders;
1176
+ }
1177
+ }
1178
+ if (dbStatus.kind !== 'db_missing') {
1179
+ this.recordDatabaseStatus('getAllProjectFolders', dbStatus.available);
1180
+ return [];
1181
+ }
1182
+ // File fallback
1183
+ const fallback = this.getAllProjectFoldersFromFiles(workspacePath);
1184
+ this.recordDatabaseStatus('getAllProjectFolders', true);
1185
+ return fallback;
1186
+ }
1187
+ getAllProjectFoldersFromFiles(workspacePath) {
1188
+ const folders = [];
1189
+ const sessionBase = path.join(getStorageDir(), 'session');
1190
+ try {
1191
+ if (!fs.existsSync(sessionBase))
1192
+ return [];
1193
+ const projectIds = fs.readdirSync(sessionBase).filter((name) => {
1194
+ try {
1195
+ return fs.statSync(path.join(sessionBase, name)).isDirectory();
1196
+ }
1197
+ catch {
1198
+ return false;
1199
+ }
1200
+ });
1201
+ const projectDir = path.join(getStorageDir(), 'project');
1202
+ const projectNames = new Map();
1203
+ if (fs.existsSync(projectDir)) {
1204
+ try {
1205
+ for (const file of fs.readdirSync(projectDir).filter((f) => f.endsWith('.json'))) {
1206
+ const proj = readJsonSafe(path.join(projectDir, file));
1207
+ if (proj) {
1208
+ projectNames.set(proj.id, proj.path || proj.name || proj.id);
1209
+ }
1210
+ }
1211
+ }
1212
+ catch {
1213
+ // Skip
1214
+ }
1215
+ }
1216
+ let currentProjectId = null;
1217
+ if (workspacePath) {
1218
+ currentProjectId = resolveProjectIdFromFiles(workspacePath);
1219
+ }
1220
+ for (const projectId of projectIds) {
1221
+ const projSessionDir = path.join(sessionBase, projectId);
1222
+ let sessionCount = 0;
1223
+ let lastModified = new Date(0);
1224
+ try {
1225
+ const sessions = fs.readdirSync(projSessionDir).filter((f) => f.endsWith('.json'));
1226
+ for (const session of sessions) {
1227
+ try {
1228
+ const fstats = fs.statSync(path.join(projSessionDir, session));
1229
+ if (fstats.size > 0) {
1230
+ sessionCount++;
1231
+ if (fstats.mtime > lastModified) {
1232
+ lastModified = fstats.mtime;
1233
+ }
1234
+ }
1235
+ }
1236
+ catch {
1237
+ // Skip
1238
+ }
1239
+ }
1240
+ }
1241
+ catch {
1242
+ continue;
1243
+ }
1244
+ folders.push({
1245
+ dir: projSessionDir,
1246
+ name: projectNames.get(projectId) || projectId,
1247
+ encodedName: projectId,
1248
+ sessionCount,
1249
+ lastModified,
1250
+ });
1251
+ }
1252
+ folders.sort((a, b) => {
1253
+ if (currentProjectId) {
1254
+ const aIsCurrent = a.encodedName === currentProjectId;
1255
+ const bIsCurrent = b.encodedName === currentProjectId;
1256
+ if (aIsCurrent && !bIsCurrent)
1257
+ return -1;
1258
+ if (!aIsCurrent && bIsCurrent)
1259
+ return 1;
1260
+ }
1261
+ return b.lastModified.getTime() - a.lastModified.getTime();
1262
+ });
1263
+ }
1264
+ catch {
1265
+ // Skip
1266
+ }
1267
+ return folders;
1268
+ }
1269
+ // --- File identification ---
1270
+ isSessionFile(filename) {
1271
+ return filename.endsWith('.json');
1272
+ }
1273
+ getSessionId(sessionPath) {
1274
+ return path.basename(sessionPath, '.json');
1275
+ }
1276
+ encodeWorkspacePath(workspacePath) {
1277
+ const db = this.ensureDb();
1278
+ return resolveProjectId(workspacePath, db, this.getRuntimeStatus()) || workspacePath;
1279
+ }
1280
+ /** Session title from the metadata cache or the database; null without either. */
1281
+ extractSessionLabelFromMetadata(sessionPath) {
1282
+ // Check metadata cache first
1283
+ const cached = this.sessionMetaCache.get(sessionPath);
1284
+ if (cached?.title) {
1285
+ return truncateTitle(cached.title);
1286
+ }
1287
+ // DB lookup
1288
+ const db = this.ensureDb();
1289
+ if (db) {
1290
+ const session = db.getSession(this.getSessionId(sessionPath));
1291
+ if (session?.title) {
1292
+ this.recordDatabaseStatus('extractSessionLabel', true);
1293
+ return truncateTitle(session.title);
1294
+ }
1295
+ }
1296
+ return null;
1297
+ }
1298
+ extractSessionLabel(sessionPath) {
1299
+ const fromMetadata = this.extractSessionLabelFromMetadata(sessionPath);
1300
+ if (fromMetadata)
1301
+ return fromMetadata;
1302
+ // File fallback
1303
+ const sessionId = this.getSessionId(sessionPath);
1304
+ const label = this.extractSessionLabelFromFiles(sessionPath, sessionId);
1305
+ this.recordDatabaseStatus('extractSessionLabel', label !== null || !isDbSessionPath(sessionPath));
1306
+ return label;
1307
+ }
1308
+ async extractSessionLabelsAsync(sessionPaths) {
1309
+ const results = new Map();
1310
+ const unresolved = [];
1311
+ for (const sessionPath of sessionPaths) {
1312
+ const cached = this.sessionMetaCache.get(sessionPath);
1313
+ if (cached)
1314
+ results.set(sessionPath, cached.title ? truncateTitle(cached.title) : null);
1315
+ else
1316
+ unresolved.push(sessionPath);
1317
+ }
1318
+ if (unresolved.length > 0 && (!this.dbInitialized || this.dbStatus.available)) {
1319
+ const db = await this.ensureDbAsync();
1320
+ const byId = new Map(unresolved.map((item) => [this.getSessionId(item), item]));
1321
+ const sessions = await db.getSessionsByIdsAsync([...byId.keys()]);
1322
+ this.dbStatus = db.getRuntimeStatus();
1323
+ this.adoptDbIfAvailable(db);
1324
+ for (const session of sessions) {
1325
+ const sessionPath = byId.get(session.id);
1326
+ if (!sessionPath)
1327
+ continue;
1328
+ this.sessionMetaCache.set(sessionPath, {
1329
+ title: session.title,
1330
+ timeUpdated: session.time_updated,
1331
+ });
1332
+ results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
1333
+ }
1334
+ }
1335
+ for (const sessionPath of unresolved) {
1336
+ if (results.has(sessionPath))
1337
+ continue;
1338
+ if (isDbSessionPath(sessionPath)) {
1339
+ results.set(sessionPath, null);
1340
+ continue;
1341
+ }
1342
+ try {
1343
+ const session = JSON.parse(await fs.promises.readFile(sessionPath, 'utf8'));
1344
+ results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
1345
+ }
1346
+ catch {
1347
+ results.set(sessionPath, null);
1348
+ }
1349
+ await new Promise((resolve) => setImmediate(resolve));
1350
+ }
1351
+ this.recordDatabaseStatus('extractSessionLabelsAsync', true);
1352
+ return results;
1353
+ }
1354
+ extractSessionLabelFromFiles(sessionPath, sessionId) {
1355
+ if (isDbSessionPath(sessionPath))
1356
+ return null;
1357
+ const session = readJsonSafe(sessionPath);
1358
+ if (session?.title) {
1359
+ return truncateTitle(session.title);
1360
+ }
1361
+ const messageDir = path.join(getStorageDir(), 'message', sessionId);
1362
+ if (!fs.existsSync(messageDir))
1363
+ return null;
1364
+ try {
1365
+ const files = fs
1366
+ .readdirSync(messageDir)
1367
+ .filter((f) => f.endsWith('.json'))
1368
+ .slice(0, 5);
1369
+ for (const file of files) {
1370
+ const msg = readJsonSafe(path.join(messageDir, file));
1371
+ if (msg?.role === 'user') {
1372
+ const partDir = path.join(getStorageDir(), 'part', msg.id);
1373
+ if (fs.existsSync(partDir)) {
1374
+ const partFiles = fs.readdirSync(partDir).filter((f) => f.endsWith('.json'));
1375
+ for (const pf of partFiles) {
1376
+ const part = readJsonSafe(path.join(partDir, pf));
1377
+ if (part?.type === 'text' && part.text.trim().length > 0) {
1378
+ let text = part.text.trim().replace(/\s+/g, ' ');
1379
+ if (text.length > 60) {
1380
+ text = text.substring(0, 57) + '...';
1381
+ }
1382
+ return text;
1383
+ }
1384
+ }
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ catch {
1390
+ // Skip
1391
+ }
1392
+ return null;
1393
+ }
1394
+ // --- Data reading ---
1395
+ createReader(sessionPath) {
1396
+ const db = this.ensureDb();
1397
+ const sessionId = this.getSessionId(sessionPath);
1398
+ // Use DB reader if available and session exists in DB
1399
+ if (db) {
1400
+ const session = db.getSession(sessionId);
1401
+ if (session) {
1402
+ return new OpenCodeDbReader(sessionId, db);
1403
+ }
1404
+ }
1405
+ // File fallback
1406
+ return new OpenCodeFileReader(sessionId);
1407
+ }
1408
+ // --- Subagent support ---
1409
+ scanSubagents(_sessionDir, sessionId) {
1410
+ const db = this.ensureDb();
1411
+ if (!db)
1412
+ return [];
1413
+ try {
1414
+ // Get subtask parts from the parent session for metadata
1415
+ const parts = db.getPartsForSession(sessionId);
1416
+ const subtaskParts = [];
1417
+ for (const partRow of parts) {
1418
+ try {
1419
+ const data = JSON.parse(partRow.data);
1420
+ if (data.type !== 'subtask')
1421
+ continue;
1422
+ subtaskParts.push({
1423
+ id: partRow.id,
1424
+ agent: data.agent || undefined,
1425
+ description: data.description || undefined,
1426
+ timeCreated: partRow.time_created,
1427
+ });
1428
+ }
1429
+ catch {
1430
+ // Skip malformed part data
1431
+ }
1432
+ }
1433
+ // Query child sessions for real metrics
1434
+ const childSessions = db.getChildSessions(sessionId);
1435
+ // Build a lookup from child session index to subtask part metadata.
1436
+ // Child sessions are ordered by time_created; subtask parts likewise.
1437
+ // Match them positionally when counts align, otherwise fall back to
1438
+ // child session data only.
1439
+ const results = [];
1440
+ if (childSessions.length > 0) {
1441
+ for (let i = 0; i < childSessions.length; i++) {
1442
+ const child = childSessions[i];
1443
+ const subtask = i < subtaskParts.length ? subtaskParts[i] : undefined;
1444
+ // Aggregate tokens from child session messages
1445
+ const childMessages = db.getMessagesForSession(child.id);
1446
+ const tokenTotals = {
1447
+ agentId: '',
1448
+ toolCalls: [],
1449
+ inputTokens: 0,
1450
+ outputTokens: 0,
1451
+ };
1452
+ for (const msgRow of childMessages) {
1453
+ try {
1454
+ const msgData = JSON.parse(msgRow.data);
1455
+ const tokens = msgData.tokens;
1456
+ if (tokens) {
1457
+ // Same semantics as the main-session parser (openCodeParser).
1458
+ const cache = tokens.cache;
1459
+ const providerID = msgData.providerID;
1460
+ addUsageToSubagent(tokenTotals, normalizeProviderUsage({
1461
+ semantics: providerID === 'openai'
1462
+ ? 'openai'
1463
+ : providerID === 'anthropic'
1464
+ ? 'anthropic'
1465
+ : 'sidekick',
1466
+ provider: providerID ?? 'opencode',
1467
+ inputTokens: tokens.input,
1468
+ outputTokens: tokens.output,
1469
+ cacheReadTokens: cache?.read,
1470
+ cacheWriteTokens: cache?.write,
1471
+ reasoningTokens: tokens.reasoning,
1472
+ reasoningIncludedInOutput: providerID === 'openai',
1473
+ }));
1474
+ }
1475
+ }
1476
+ catch {
1477
+ // Skip malformed messages
1478
+ }
1479
+ }
1480
+ const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens, } = tokenTotals;
1481
+ // Extract tool calls from child session parts
1482
+ const childParts = db.getPartsForSession(child.id);
1483
+ const toolCalls = [];
1484
+ for (const childPart of childParts) {
1485
+ try {
1486
+ const partData = JSON.parse(childPart.data);
1487
+ if (partData.type !== 'tool' && partData.type !== 'tool-invocation')
1488
+ continue;
1489
+ const state = partData.state;
1490
+ let duration;
1491
+ const timeInfo = state?.time;
1492
+ if (timeInfo?.start && timeInfo?.end) {
1493
+ const startMs = typeof timeInfo.start === 'number'
1494
+ ? timeInfo.start
1495
+ : new Date(timeInfo.start).getTime();
1496
+ const endMs = typeof timeInfo.end === 'number'
1497
+ ? timeInfo.end
1498
+ : new Date(timeInfo.end).getTime();
1499
+ if (endMs > startMs)
1500
+ duration = endMs - startMs;
1501
+ }
1502
+ toolCalls.push({
1503
+ name: normalizeToolName(partData.tool || ''),
1504
+ input: normalizeToolInput(state?.input || {}),
1505
+ timestamp: new Date(childPart.time_created),
1506
+ duration,
1507
+ isError: state?.status === 'error',
1508
+ });
1509
+ }
1510
+ catch {
1511
+ // Skip malformed parts
1512
+ }
1513
+ }
1514
+ // Calculate duration from first/last message timestamps
1515
+ let startTime;
1516
+ let endTime;
1517
+ let durationMs;
1518
+ if (childMessages.length > 0) {
1519
+ startTime = new Date(childMessages[0].time_created);
1520
+ endTime = new Date(childMessages[childMessages.length - 1].time_created);
1521
+ durationMs = endTime.getTime() - startTime.getTime();
1522
+ }
1523
+ else {
1524
+ startTime = new Date(child.time_created);
1525
+ }
1526
+ const rawAgentType = subtask?.agent || child.title || undefined;
1527
+ const description = subtask?.description || child.title || undefined;
1528
+ results.push({
1529
+ agentId: subtask?.id || child.id,
1530
+ agentType: normalizeAgentType(rawAgentType, description),
1531
+ description,
1532
+ toolCalls,
1533
+ inputTokens,
1534
+ outputTokens,
1535
+ cacheReadTokens,
1536
+ cacheWriteTokens,
1537
+ reasoningTokens,
1538
+ totalTokens,
1539
+ startTime,
1540
+ endTime,
1541
+ durationMs,
1542
+ });
1543
+ }
1544
+ }
1545
+ else {
1546
+ // Fallback: no child sessions found, return stubs from subtask parts
1547
+ for (const sp of subtaskParts) {
1548
+ results.push({
1549
+ agentId: sp.id,
1550
+ agentType: normalizeAgentType(sp.agent, sp.description),
1551
+ description: sp.description,
1552
+ toolCalls: [],
1553
+ inputTokens: 0,
1554
+ outputTokens: 0,
1555
+ startTime: new Date(sp.timeCreated),
1556
+ });
1557
+ }
1558
+ }
1559
+ return results;
1560
+ }
1561
+ catch {
1562
+ return [];
1563
+ }
1564
+ }
1565
+ // --- Cross-session search ---
1566
+ searchInSession(sessionPath, query, maxResults) {
1567
+ const db = this.ensureDb();
1568
+ const sessionId = this.getSessionId(sessionPath);
1569
+ const queryLower = query.toLowerCase();
1570
+ // DB primary
1571
+ if (db) {
1572
+ const dbSession = db.getSession(sessionId);
1573
+ if (dbSession) {
1574
+ return this.searchInSessionFromDb(db, sessionId, sessionPath, dbSession.project_id, queryLower, query, maxResults);
1575
+ }
1576
+ }
1577
+ // File fallback
1578
+ return this.searchInSessionFromFiles(sessionPath, sessionId, queryLower, query, maxResults);
1579
+ }
1580
+ searchInSessionFromDb(db, sessionId, sessionPath, projectId, queryLower, query, maxResults) {
1581
+ const results = [];
1582
+ try {
1583
+ const parts = db.getPartsForSession(sessionId);
1584
+ const messages = db.getMessagesForSession(sessionId);
1585
+ const messageMap = new Map(messages.map((m) => [m.id, m]));
1586
+ for (const partRow of parts) {
1587
+ if (results.length >= maxResults)
1588
+ break;
1589
+ const dataStr = partRow.data;
1590
+ const dataLower = dataStr.toLowerCase();
1591
+ const matchIdx = dataLower.indexOf(queryLower);
1592
+ if (matchIdx < 0)
1593
+ continue;
1594
+ // Extract a snippet from the raw data
1595
+ const start = Math.max(0, matchIdx - 40);
1596
+ const end = Math.min(dataStr.length, matchIdx + query.length + 40);
1597
+ const snippet = (start > 0 ? '...' : '') +
1598
+ dataStr.substring(start, end) +
1599
+ (end < dataStr.length ? '...' : '');
1600
+ const msgRow = messageMap.get(partRow.message_id);
1601
+ const msgData = msgRow ? JSON.parse(msgRow.data) : {};
1602
+ results.push({
1603
+ sessionPath,
1604
+ line: snippet.replace(/\n/g, ' '),
1605
+ eventType: msgData.role || 'unknown',
1606
+ timestamp: String(partRow.time_created),
1607
+ projectPath: projectId,
1608
+ });
1609
+ }
1610
+ }
1611
+ catch {
1612
+ // Skip
1613
+ }
1614
+ return results;
1615
+ }
1616
+ searchInSessionFromFiles(sessionPath, sessionId, queryLower, query, maxResults) {
1617
+ const results = [];
1618
+ const messageDir = path.join(getStorageDir(), 'message', sessionId);
1619
+ if (!fs.existsSync(messageDir))
1620
+ return results;
1621
+ try {
1622
+ const session = readJsonSafe(sessionPath);
1623
+ const projectPath = session?.projectID || sessionId;
1624
+ const messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
1625
+ for (const file of messageFiles) {
1626
+ if (results.length >= maxResults)
1627
+ break;
1628
+ const msg = readJsonSafe(path.join(messageDir, file));
1629
+ if (!msg)
1630
+ continue;
1631
+ const partDir = path.join(getStorageDir(), 'part', msg.id);
1632
+ if (!fs.existsSync(partDir))
1633
+ continue;
1634
+ try {
1635
+ const partFiles = fs.readdirSync(partDir).filter((f) => f.endsWith('.json'));
1636
+ for (const pf of partFiles) {
1637
+ if (results.length >= maxResults)
1638
+ break;
1639
+ const part = readJsonSafe(path.join(partDir, pf));
1640
+ if (!part)
1641
+ continue;
1642
+ let text = '';
1643
+ if (part.type === 'text')
1644
+ text = part.text;
1645
+ else if (part.type === 'reasoning')
1646
+ text = part.text;
1647
+ else if (part.type === 'tool-invocation' && part.state.output)
1648
+ text = part.state.output;
1649
+ else if (part.type === 'tool' && part.state.output)
1650
+ text = part.state.output;
1651
+ if (!text)
1652
+ continue;
1653
+ const textLower = text.toLowerCase();
1654
+ const matchIdx = textLower.indexOf(queryLower);
1655
+ if (matchIdx < 0)
1656
+ continue;
1657
+ const start = Math.max(0, matchIdx - 40);
1658
+ const end = Math.min(text.length, matchIdx + query.length + 40);
1659
+ const snippet = (start > 0 ? '...' : '') +
1660
+ text.substring(start, end) +
1661
+ (end < text.length ? '...' : '');
1662
+ results.push({
1663
+ sessionPath,
1664
+ line: snippet.replace(/\n/g, ' '),
1665
+ eventType: msg.role,
1666
+ timestamp: String(msg.time.created),
1667
+ projectPath,
1668
+ });
1669
+ }
1670
+ }
1671
+ catch {
1672
+ // Skip
1673
+ }
1674
+ }
1675
+ }
1676
+ catch {
1677
+ // Skip
1678
+ }
1679
+ return results;
1680
+ }
1681
+ getProjectsBaseDir() {
1682
+ return path.join(getStorageDir(), 'session');
1683
+ }
1684
+ getWatchRoots() {
1685
+ return [getOpenCodeDataDir(), this.getProjectsBaseDir()];
1686
+ }
1687
+ // --- Stats ---
1688
+ readSessionStats(sessionPath) {
1689
+ // One reader pass (database-backed or file-backed) feeds the shared
1690
+ // aggregator, so compaction and truncation counts are real instead of the
1691
+ // hardcoded zeros the direct SQLite read returned, and a session that
1692
+ // cannot be read reports `availability: 'unavailable'` instead of zeros.
1693
+ return readSessionFileStats(this, sessionPath, {
1694
+ resolveLabel: () => this.extractSessionLabelFromMetadata(sessionPath),
1695
+ });
1696
+ }
1697
+ readSessionContextSnapshot(sessionPath, options = {}) {
1698
+ return readSessionContextSnapshot(this, sessionPath, options);
1699
+ }
1700
+ // --- Optional methods ---
1701
+ getSessionMetadata(sessionPath) {
1702
+ // Check cache first
1703
+ const cached = this.sessionMetaCache.get(sessionPath);
1704
+ if (cached) {
1705
+ return { mtime: new Date(cached.timeUpdated) };
1706
+ }
1707
+ // DB lookup
1708
+ const db = this.ensureDb();
1709
+ if (db) {
1710
+ const sessionId = this.getSessionId(sessionPath);
1711
+ const session = db.getSession(sessionId);
1712
+ if (session) {
1713
+ this.sessionMetaCache.set(sessionPath, {
1714
+ title: session.title,
1715
+ timeUpdated: session.time_updated,
1716
+ });
1717
+ return { mtime: new Date(session.time_updated) };
1718
+ }
1719
+ }
1720
+ // Try filesystem for non-DB paths
1721
+ if (!isDbSessionPath(sessionPath)) {
1722
+ try {
1723
+ const stats = fs.statSync(sessionPath);
1724
+ return { mtime: stats.mtime };
1725
+ }
1726
+ catch {
1727
+ // File doesn't exist
1728
+ }
1729
+ }
1730
+ return null;
1731
+ }
1732
+ async getSessionMetadataAsync(sessionPath) {
1733
+ if (!isDbSessionPath(sessionPath)) {
1734
+ try {
1735
+ return { mtime: (await fs.promises.stat(sessionPath)).mtime };
1736
+ }
1737
+ catch {
1738
+ return null;
1739
+ }
1740
+ }
1741
+ const cached = this.sessionMetaCache.get(sessionPath);
1742
+ try {
1743
+ const db = await this.ensureDbAsync();
1744
+ const sessionId = this.getSessionId(sessionPath);
1745
+ const session = (await db.getSessionsByIdsAsync([sessionId]))[0];
1746
+ this.dbStatus = db.getRuntimeStatus();
1747
+ this.adoptDbIfAvailable(db);
1748
+ if (session) {
1749
+ this.sessionMetaCache.set(sessionPath, {
1750
+ title: session.title,
1751
+ timeUpdated: session.time_updated,
1752
+ });
1753
+ this.recordDatabaseStatus('getSessionMetadataAsync', true);
1754
+ return { mtime: new Date(session.time_updated) };
1755
+ }
1756
+ this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
1757
+ }
1758
+ catch {
1759
+ this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
1760
+ }
1761
+ return cached ? { mtime: new Date(cached.timeUpdated) } : null;
1762
+ }
1763
+ /**
1764
+ * Returns the latest assistant message's token snapshot for context window sizing.
1765
+ * Cumulative session totals are derived from readNew() events processed by SessionMonitor.
1766
+ */
1767
+ getCurrentUsageSnapshot(sessionPath) {
1768
+ const db = this.ensureDb();
1769
+ if (!db)
1770
+ return null;
1771
+ const sessionId = this.getSessionId(sessionPath);
1772
+ const snapshot = db.getLatestAssistantContextUsage(sessionId);
1773
+ if (!snapshot)
1774
+ return null;
1775
+ return {
1776
+ inputTokens: Number(snapshot.inputTokens) || 0,
1777
+ outputTokens: Number(snapshot.outputTokens) || 0,
1778
+ cacheWriteTokens: Number(snapshot.cacheWriteTokens) || 0,
1779
+ cacheReadTokens: Number(snapshot.cacheReadTokens) || 0,
1780
+ reasoningTokens: Number(snapshot.reasoningTokens) || 0,
1781
+ model: snapshot.modelId || 'unknown',
1782
+ timestamp: new Date(Number(snapshot.timeCreated) || Date.now()),
1783
+ };
1784
+ }
1785
+ computeContextSize(usage) {
1786
+ // Context = input tokens + cache tokens. Output and reasoning tokens
1787
+ // are the model's response, not part of the context window.
1788
+ return usage.inputTokens + usage.cacheWriteTokens + usage.cacheReadTokens;
1789
+ }
1790
+ getContextAttribution(sessionPath) {
1791
+ const db = this.ensureDb();
1792
+ if (!db)
1793
+ return null;
1794
+ const sessionId = this.getSessionId(sessionPath);
1795
+ try {
1796
+ const messages = db.getMessagesForSession(sessionId);
1797
+ if (messages.length === 0)
1798
+ return null;
1799
+ const attribution = {
1800
+ systemPrompt: 0,
1801
+ userMessages: 0,
1802
+ assistantResponses: 0,
1803
+ toolInputs: 0,
1804
+ toolOutputs: 0,
1805
+ thinking: 0,
1806
+ other: 0,
1807
+ };
1808
+ // Accumulate input tokens by role. OpenCode messages store actual
1809
+ // token counts from the provider, so this is more accurate than
1810
+ // heuristic content estimation.
1811
+ for (const row of messages) {
1812
+ try {
1813
+ const data = JSON.parse(row.data);
1814
+ const role = data.role;
1815
+ const msgTokens = data.tokens;
1816
+ const cache = msgTokens?.cache;
1817
+ // Context contribution = input + cacheWrite + cacheRead
1818
+ const input = msgTokens?.input || 0;
1819
+ const cacheRead = cache?.read || 0;
1820
+ const cacheWrite = cache?.write || 0;
1821
+ const reasoning = msgTokens?.reasoning || 0;
1822
+ const contextTokens = input + cacheRead + cacheWrite;
1823
+ if (contextTokens === 0)
1824
+ continue;
1825
+ if (role === 'user') {
1826
+ // User messages include prompts and tool results;
1827
+ // we can't distinguish without parsing parts, so attribute
1828
+ // to userMessages (tool outputs arrive as separate assistant
1829
+ // messages in OpenCode's model)
1830
+ attribution.userMessages += contextTokens;
1831
+ }
1832
+ else if (role === 'assistant') {
1833
+ // Attribute reasoning separately when available
1834
+ if (reasoning > 0) {
1835
+ attribution.thinking += reasoning;
1836
+ attribution.assistantResponses += contextTokens - reasoning;
1837
+ }
1838
+ else {
1839
+ attribution.assistantResponses += contextTokens;
1840
+ }
1841
+ }
1842
+ else if (role === 'system') {
1843
+ attribution.systemPrompt += contextTokens;
1844
+ }
1845
+ else {
1846
+ attribution.other += contextTokens;
1847
+ }
1848
+ }
1849
+ catch {
1850
+ // Skip malformed message data
1851
+ }
1852
+ }
1853
+ // Refine user attribution: scan parts to separate tool outputs
1854
+ // from user prompts for more accurate breakdown
1855
+ const parts = db.getPartsForSession(sessionId);
1856
+ let toolPartCount = 0;
1857
+ let userTextPartCount = 0;
1858
+ for (const partRow of parts) {
1859
+ try {
1860
+ const partData = JSON.parse(partRow.data);
1861
+ const partType = partData.type;
1862
+ if (partType === 'tool' || partType === 'tool-invocation') {
1863
+ toolPartCount++;
1864
+ }
1865
+ else if (partType === 'text') {
1866
+ userTextPartCount++;
1867
+ }
1868
+ }
1869
+ catch {
1870
+ // Skip
1871
+ }
1872
+ }
1873
+ // If there are tool parts, redistribute user token attribution
1874
+ // between userMessages and toolOutputs proportionally
1875
+ const totalParts = toolPartCount + userTextPartCount;
1876
+ if (totalParts > 0 && toolPartCount > 0 && attribution.userMessages > 0) {
1877
+ const toolProportion = toolPartCount / totalParts;
1878
+ const toolTokens = Math.round(attribution.userMessages * toolProportion);
1879
+ attribution.toolOutputs += toolTokens;
1880
+ attribution.userMessages -= toolTokens;
1881
+ }
1882
+ const total = attribution.systemPrompt +
1883
+ attribution.userMessages +
1884
+ attribution.assistantResponses +
1885
+ attribution.toolInputs +
1886
+ attribution.toolOutputs +
1887
+ attribution.thinking +
1888
+ attribution.other;
1889
+ return total > 0 ? attribution : null;
1890
+ }
1891
+ catch {
1892
+ return null;
1893
+ }
1894
+ }
1895
+ getContextWindowLimit(modelId) {
1896
+ if (this.dynamicContextWindowLimit)
1897
+ return this.dynamicContextWindowLimit;
1898
+ return getModelContextWindowSize(modelId);
1899
+ }
1900
+ /** Set a runtime-reported context window limit (overrides static map). */
1901
+ setDynamicContextWindowLimit(limit) {
1902
+ this.dynamicContextWindowLimit = limit;
1903
+ }
1904
+ // --- Lifecycle ---
1905
+ dispose() {
1906
+ this.db?.close();
1907
+ this.db = null;
1908
+ this.dbInitialized = false;
1909
+ this.dbStatus = { available: false, kind: 'db_missing' };
1910
+ this.sessionMetaCache.clear();
1911
+ this.dynamicContextWindowLimit = null;
1912
+ }
1913
+ recordDatabaseStatus(operation, usable) {
1914
+ const status = this.db?.getRuntimeStatus() ?? this.dbStatus;
1915
+ const diagnostic = diagnosticFromRuntimeStatus(this.id, 'query', status);
1916
+ if (!diagnostic || status.kind === 'db_missing') {
1917
+ // A genuinely absent OpenCode DB is expected for legacy file storage.
1918
+ this.diagnostics.available(operation);
1919
+ return;
1920
+ }
1921
+ this.diagnostics.degraded(operation, status, {
1922
+ kind: diagnostic.kind,
1923
+ severity: diagnostic.severity,
1924
+ phase: diagnostic.phase,
1925
+ message: diagnostic.message,
1926
+ }, usable);
1927
+ }
1928
+ // --- z.ai (GLM Coding Plan) compatibility quota derivation ---
1929
+ /**
1930
+ * Derives a z.ai coding-plan `QuotaState` from OpenCode assistant turns
1931
+ * tagged with `providerID ∈ {zai, zai-coding-plan}`.
1932
+ *
1933
+ * This compatibility estimator predates the authoritative z.ai quota API.
1934
+ * New product code should use `zaiQuotaApi.ts`; this method remains for
1935
+ * consumers that still want a local OpenCode DB-derived estimate. Returns
1936
+ * null when no z.ai routing is detected.
1937
+ *
1938
+ * @param tier Plan tier; `'auto'` infers from observed weekly volume.
1939
+ * When the caller has no opinion, the heuristic falls back to
1940
+ * `'max'` (conservative — small overcount rather than large).
1941
+ */
1942
+ getZaiQuotaState(tier = 'auto') {
1943
+ const db = this.ensureDb();
1944
+ if (!db)
1945
+ return null;
1946
+ const sinceMs = Date.now() - 7 * 86_400_000;
1947
+ let rawRows = [];
1948
+ try {
1949
+ rawRows = db.getAssistantMessagesByProviderId([...ZAI_PROVIDER_IDS], sinceMs);
1950
+ }
1951
+ catch {
1952
+ return null;
1953
+ }
1954
+ if (rawRows.length === 0)
1955
+ return null;
1956
+ const turns = rawRows.map((row) => ({
1957
+ timestampMs: row.timeCreated,
1958
+ model: row.modelId,
1959
+ inputTokens: row.inputTokens || 0,
1960
+ outputTokens: row.outputTokens || 0,
1961
+ cacheReadTokens: row.cacheReadTokens || 0,
1962
+ cacheWriteTokens: row.cacheWriteTokens || 0,
1963
+ reasoningTokens: row.reasoningTokens || 0,
1964
+ }));
1965
+ const accumulated = accumulateZaiUsage(turns, Date.now());
1966
+ // Default to 'max' on auto when usage is non-zero — overestimating the
1967
+ // tier gives a lower (safer) utilization % until calibration runs.
1968
+ const resolvedTier = tier === 'auto' ? (accumulated.weeklyTurns > 0 ? 'max' : 'lite') : tier;
1969
+ // Walk rows again to trap any captured z.ai business errors.
1970
+ let authoritativeFiveHourResetAt;
1971
+ let authoritativeWeeklyResetAt;
1972
+ for (const row of rawRows) {
1973
+ if (row.errorCode == null && !row.errorMessage)
1974
+ continue;
1975
+ const parsed = parseZaiQuotaError({
1976
+ code: row.errorCode ?? undefined,
1977
+ message: row.errorMessage ?? undefined,
1978
+ });
1979
+ if (!parsed?.resetsAt)
1980
+ continue;
1981
+ if (parsed.kind === 'exhausted') {
1982
+ authoritativeFiveHourResetAt = parsed.resetsAt;
1983
+ if (String(parsed.code) === '1310')
1984
+ authoritativeWeeklyResetAt = parsed.resetsAt;
1985
+ }
1986
+ }
1987
+ return inferZaiQuotaState(accumulated, resolvedTier, {
1988
+ authoritativeFiveHourResetAt,
1989
+ authoritativeWeeklyResetAt,
1990
+ });
1991
+ }
1992
+ /**
1993
+ * Cheap boolean: does the local OpenCode install currently route at z.ai?
1994
+ * Used by callers to decide whether to render a z.ai quota surface.
1995
+ */
1996
+ isZaiRoutingActive() {
1997
+ const db = this.ensureDb();
1998
+ if (!db)
1999
+ return false;
2000
+ try {
2001
+ const rows = db.getAssistantMessagesByProviderId([...ZAI_PROVIDER_IDS], Date.now() - 7 * 86_400_000);
2002
+ return rows.length > 0;
2003
+ }
2004
+ catch {
2005
+ return false;
2006
+ }
2007
+ }
2008
+ }