coding-tool-x 3.8.4 → 3.8.6

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 (434) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +86 -15
  3. package/dist/web/assets/AgentsDrawer-Db9wAyaZ.js +1 -0
  4. package/dist/web/assets/AgentsDrawer-Pw2k-ZAa.css +1 -0
  5. package/dist/web/assets/Analytics-C4gMzv7g.js +1 -0
  6. package/dist/web/assets/Analytics-CyHhR126.css +1 -0
  7. package/dist/web/assets/AssetPathField-Bd_nMFu3.js +1 -0
  8. package/dist/web/assets/ChatHistoryDrawer-B2qBQs9L.css +1 -0
  9. package/dist/web/assets/ChatHistoryDrawer-CwzJHyNP.js +1 -0
  10. package/dist/web/assets/CollapseItem-CTCfSiue.js +1 -0
  11. package/dist/web/assets/CommandsDrawer-BZJxlPvV.css +1 -0
  12. package/dist/web/assets/CommandsDrawer-vDghB1U9.js +1 -0
  13. package/dist/web/assets/ConfigExportDrawer-C-XDvlHc.js +1 -0
  14. package/dist/web/assets/ConfigExportDrawer-dwruRKWr.css +1 -0
  15. package/dist/web/assets/ConfigTemplates-CYY8XRED.js +1 -0
  16. package/dist/web/assets/ConfigTemplatesDrawer-BfDi8yO3.css +1 -0
  17. package/dist/web/assets/ConfigTemplatesDrawer-CbsJp76X.js +1 -0
  18. package/dist/web/assets/ConfigTemplatesPanel-CWyPtk67.css +1 -0
  19. package/dist/web/assets/ConfigTemplatesPanel-liMSTh7s.js +1 -0
  20. package/dist/web/assets/Divider-D28g1gwj.js +1 -0
  21. package/dist/web/assets/DrawerContent-ChXgZ1Xm.js +1 -0
  22. package/dist/web/assets/FavoritesDrawer-CG9eI9Ay.css +1 -0
  23. package/dist/web/assets/FavoritesDrawer-D6QSiYaL.js +1 -0
  24. package/dist/web/assets/GatewayConvertDrawer-CuNlf7AC.css +1 -0
  25. package/dist/web/assets/GatewayConvertDrawer-RvET6iNG.js +1 -0
  26. package/dist/web/assets/Home-CSuFuJ9Y.css +1 -0
  27. package/dist/web/assets/Home-P8eseZcf.js +1 -0
  28. package/dist/web/assets/MarkdownEditor-CYmLGYju.js +1 -0
  29. package/dist/web/assets/MarkdownEditor-qfVgSre_.css +1 -0
  30. package/dist/web/assets/McpDrawer-C0dnsZhQ.css +1 -0
  31. package/dist/web/assets/McpDrawer-Ch3bkKIq.js +1 -0
  32. package/dist/web/assets/McpFormDrawer-BRbDcawn.css +1 -0
  33. package/dist/web/assets/McpFormDrawer-BcTlgSpN.js +1 -0
  34. package/dist/web/assets/PluginManager-Dh2o0xkZ.js +1 -0
  35. package/dist/web/assets/PluginsDrawer-DM3QjprE.css +1 -0
  36. package/dist/web/assets/PluginsDrawer-deKQ5u3I.js +1 -0
  37. package/dist/web/assets/PluginsPanel-BofsgQgj.js +1 -0
  38. package/dist/web/assets/PluginsPanel-D6ZqITcR.css +1 -0
  39. package/dist/web/assets/ProjectList-677RLZ8O.css +1 -0
  40. package/dist/web/assets/ProjectList-DhSWJHTA.js +1 -0
  41. package/dist/web/assets/PromptsDrawer-BIgiYCEQ.css +1 -0
  42. package/dist/web/assets/PromptsDrawer-Cfb2FeeU.js +1 -0
  43. package/dist/web/assets/RecentSessionsDrawer-3eXamJhr.js +1 -0
  44. package/dist/web/assets/RecentSessionsDrawer-CKFsOYDM.css +1 -0
  45. package/dist/web/assets/SessionCard-CW0eHURI.js +1 -0
  46. package/dist/web/assets/SessionCard-aM2NHu6i.css +1 -0
  47. package/dist/web/assets/SessionList-BpYRz1cX.css +1 -0
  48. package/dist/web/assets/SessionList-DQfyDGWU.js +1 -0
  49. package/dist/web/assets/SettingsDrawer-Dw5NUgHC.css +1 -0
  50. package/dist/web/assets/SettingsDrawer-u_vwUuPS.js +1 -0
  51. package/dist/web/assets/SkillManager-C0GCT2Kb.css +1 -0
  52. package/dist/web/assets/SkillManager-DHMprJLA.js +1 -0
  53. package/dist/web/assets/SkillsDrawer-BCNme-aJ.css +1 -0
  54. package/dist/web/assets/SkillsDrawer-CkF3BpyC.js +1 -0
  55. package/dist/web/assets/SkillsPanel-B3Nm5j32.css +1 -0
  56. package/dist/web/assets/SkillsPanel-DWE4pFGx.js +1 -0
  57. package/dist/web/assets/SpeedTestDrawer-BgfG1Ked.js +1 -0
  58. package/dist/web/assets/SpeedTestDrawer-Dc0WToJ_.css +1 -0
  59. package/dist/web/assets/Upload-CNwaGOws.js +1 -0
  60. package/dist/web/assets/WorkspaceDrawer-Br5CeCzL.js +1 -0
  61. package/dist/web/assets/WorkspaceDrawer-CGnMu9p1.css +1 -0
  62. package/dist/web/assets/WorkspaceManager-B2O8-qIw.js +1 -0
  63. package/dist/web/assets/WorkspacePanel-5oDKit3c.js +1 -0
  64. package/dist/web/assets/WorkspacePanel-D0-zHTHP.css +1 -0
  65. package/dist/web/assets/_plugin-vue_export-helper-BeLZJZij.js +1 -0
  66. package/dist/web/assets/client-UDwXBP9i.js +1 -0
  67. package/dist/web/assets/clipboard-B46yyoz_.js +1 -0
  68. package/dist/web/assets/headers-cf-QapUq.js +1 -0
  69. package/dist/web/assets/icons-B2Vg-IOC.js +1 -0
  70. package/dist/web/assets/index-BMAUPq5K.js +2 -0
  71. package/dist/web/assets/index-Bi1MQHYX.css +1 -0
  72. package/dist/web/assets/light-Cp0t7v32.js +1 -0
  73. package/dist/web/assets/markdown-u082bbtj.js +1 -0
  74. package/dist/web/assets/platforms-CBkB45JP.js +1 -0
  75. package/dist/web/assets/platforms-FTpjxAT6.js +1 -0
  76. package/dist/web/assets/project-config-B2baPzYL.js +1 -0
  77. package/dist/web/assets/sessions-Cmy31slD.js +1 -0
  78. package/dist/web/assets/sessions-yC_oVVh8.js +1 -0
  79. package/dist/web/assets/skill-repo-input-B_fkGlUK.js +1 -0
  80. package/dist/web/assets/use-lock-html-scroll-DXIqmUW4.js +1 -0
  81. package/dist/web/assets/use-message-DoLM8C1C.js +1 -0
  82. package/dist/web/assets/useResponsiveDrawer-jbmqZuJs.js +1 -0
  83. package/dist/web/assets/vendors-D0c_0KLP.js +1 -0
  84. package/dist/web/assets/vue-vendor-BqZbbXHA.js +1 -0
  85. package/dist/web/assets/workspaces-mjyPVGS8.js +1 -0
  86. package/dist/web/assets/xml-CgY3RMlX.js +1 -0
  87. package/dist/web/index.html +12 -8
  88. package/docs/native-log-memory-2026-09-16.md +43 -0
  89. package/docs/superpowers/plans/2026-08-26-configuration-driven-cli-platform.md +1578 -0
  90. package/docs/superpowers/plans/2026-08-30-project-scoped-config-control.md +1260 -0
  91. package/docs/superpowers/plans/2026-09-01-omp-skill-refresh-boundary.md +213 -0
  92. package/docs/superpowers/plans/2026-09-01-realtime-logs-panel.md +125 -0
  93. package/docs/superpowers/plans/2026-09-02-config-driven-platform-boundary.md +86 -0
  94. package/docs/superpowers/plans/2026-09-02-platform-implementation-drivers.md +508 -0
  95. package/docs/superpowers/plans/2026-09-03-cli-platform-correctness-boundary.md +760 -0
  96. package/docs/superpowers/plans/2026-09-03-dependency-security-remediation.md +133 -0
  97. package/docs/superpowers/plans/2026-09-04-model-catalog-pricing-sync.md +1078 -0
  98. package/docs/superpowers/plans/2026-09-05-codex-project-scan-500.md +128 -0
  99. package/docs/superpowers/plans/2026-09-06-project-skill-path-fix.md +97 -0
  100. package/docs/superpowers/specs/2026-08-26-configuration-driven-cli-platform-design.md +408 -0
  101. package/docs/superpowers/specs/2026-08-30-project-scoped-config-control-design.md +467 -0
  102. package/docs/superpowers/specs/2026-09-01-claude-session-content-repair-design.md +111 -0
  103. package/docs/superpowers/specs/2026-09-01-omp-skill-refresh-boundary-design.md +83 -0
  104. package/docs/superpowers/specs/2026-09-01-realtime-logs-panel-design.md +27 -0
  105. package/docs/superpowers/specs/2026-09-02-config-driven-platform-boundary-design.md +64 -0
  106. package/docs/superpowers/specs/2026-09-02-platform-implementation-driver-boundary-design.md +123 -0
  107. package/docs/superpowers/specs/2026-09-03-cli-platform-correctness-boundary-design.md +249 -0
  108. package/docs/superpowers/specs/2026-09-03-dependency-security-remediation-design.md +87 -0
  109. package/docs/superpowers/specs/2026-09-04-model-catalog-pricing-sync-design.md +301 -0
  110. package/docs/superpowers/specs/2026-09-05-codex-project-scan-500-design.md +24 -0
  111. package/package.json +8 -4
  112. package/src/commands/channels.js +88 -191
  113. package/src/commands/cli-type.js +19 -11
  114. package/src/commands/daemon.js +104 -32
  115. package/src/commands/doctor.js +16 -9
  116. package/src/commands/export-config.js +23 -5
  117. package/src/commands/list.js +130 -52
  118. package/src/commands/logs.js +44 -27
  119. package/src/commands/platform-command-registry.js +78 -0
  120. package/src/commands/plugin.js +11 -5
  121. package/src/commands/port-config.js +34 -52
  122. package/src/commands/proxy-control.js +207 -145
  123. package/src/commands/proxy.js +2 -2
  124. package/src/commands/resume.js +87 -91
  125. package/src/commands/search.js +137 -67
  126. package/src/commands/stats.js +30 -22
  127. package/src/commands/switch.js +1 -1
  128. package/src/commands/toggle-proxy.js +94 -111
  129. package/src/commands/ui.js +67 -6
  130. package/src/commands/workspace.js +90 -8
  131. package/src/config/default.js +6 -53
  132. package/src/config/loader.js +61 -24
  133. package/src/config/model-metadata.js +49 -59
  134. package/src/config/model-metadata.json +4095 -99
  135. package/src/config/paths.js +181 -8
  136. package/src/index.js +134 -71
  137. package/src/platforms/access.js +196 -0
  138. package/src/{server/services → platforms}/agents-service.js +354 -197
  139. package/src/platforms/channel-auth-registry.js +165 -0
  140. package/src/platforms/channel-auth-service.js +107 -0
  141. package/src/{server/services → platforms}/commands-service.js +195 -115
  142. package/src/platforms/driver-registry.js +59 -0
  143. package/src/platforms/drivers/claude/api-operations.js +62 -0
  144. package/src/{server/services/channels.js → platforms/drivers/claude/channels-implementation.js} +131 -33
  145. package/src/platforms/drivers/claude/channels.js +39 -0
  146. package/src/{server/services/config-sync-service.js → platforms/drivers/claude/config-sync.js} +29 -5
  147. package/src/{server/services → platforms/drivers/claude}/health-check.js +4 -4
  148. package/src/platforms/drivers/claude/health.js +22 -0
  149. package/src/platforms/drivers/claude/mcp.js +9 -0
  150. package/src/{server/services/settings-manager.js → platforms/drivers/claude/native-config-implementation.js} +13 -4
  151. package/src/platforms/drivers/claude/native-config.js +46 -0
  152. package/src/platforms/drivers/claude/native-logs.js +72 -0
  153. package/src/{server/services/claude-openai-gateway.js → platforms/drivers/claude/openai-gateway.js} +8 -65
  154. package/src/platforms/drivers/claude/project-config.js +21 -0
  155. package/src/platforms/drivers/claude/projects.js +62 -0
  156. package/src/platforms/drivers/claude/prompts.js +9 -0
  157. package/src/{server/proxy-server.js → platforms/drivers/claude/proxy-implementation.js} +41 -278
  158. package/src/platforms/drivers/claude/proxy.js +16 -0
  159. package/src/platforms/drivers/claude/resource-sync.js +9 -0
  160. package/src/platforms/drivers/claude/session-history-adapter.js +313 -0
  161. package/src/{server/services/sessions.js → platforms/drivers/claude/sessions-implementation.js} +145 -35
  162. package/src/platforms/drivers/claude/sessions.js +76 -0
  163. package/src/{server/services/claude-statistics-service.js → platforms/drivers/claude/statistics-implementation.js} +2 -2
  164. package/src/platforms/drivers/claude/statistics.js +16 -0
  165. package/src/{server/services/claude-wire.js → platforms/drivers/claude/wire.js} +1 -1
  166. package/src/platforms/drivers/codex/api-operations.js +9 -0
  167. package/src/{server/services/codex-channels.js → platforms/drivers/codex/channels-implementation.js} +149 -14
  168. package/src/platforms/drivers/codex/channels.js +45 -0
  169. package/src/{server/services/codex-config.js → platforms/drivers/codex/config.js} +12 -2
  170. package/src/{server/services/codex-env-manager.js → platforms/drivers/codex/env-manager.js} +1 -1
  171. package/src/platforms/drivers/codex/mcp.js +9 -0
  172. package/src/{server/services/codex-settings-manager.js → platforms/drivers/codex/native-config-implementation.js} +15 -6
  173. package/src/platforms/drivers/codex/native-config.js +47 -0
  174. package/src/platforms/drivers/codex/native-logs.js +166 -0
  175. package/src/platforms/drivers/codex/project-config.js +21 -0
  176. package/src/platforms/drivers/codex/projects.js +26 -0
  177. package/src/platforms/drivers/codex/prompts.js +9 -0
  178. package/src/{server/codex-proxy-server.js → platforms/drivers/codex/proxy-implementation.js} +30 -233
  179. package/src/platforms/drivers/codex/proxy.js +16 -0
  180. package/src/platforms/drivers/codex/resource-sync.js +9 -0
  181. package/src/{server/services/session-history-adapters/codex.js → platforms/drivers/codex/session-history-adapter.js} +37 -23
  182. package/src/{server/services/codex-sessions.js → platforms/drivers/codex/sessions-implementation.js} +81 -75
  183. package/src/platforms/drivers/codex/sessions.js +36 -0
  184. package/src/{server/services/codex-statistics-service.js → platforms/drivers/codex/statistics-implementation.js} +2 -2
  185. package/src/platforms/drivers/codex/statistics.js +16 -0
  186. package/src/platforms/drivers/gemini/api-operations.js +9 -0
  187. package/src/{server/services/gemini-channels.js → platforms/drivers/gemini/channels-implementation.js} +202 -37
  188. package/src/platforms/drivers/gemini/channels.js +39 -0
  189. package/src/{server/services/gemini-config.js → platforms/drivers/gemini/config.js} +12 -2
  190. package/src/platforms/drivers/gemini/mcp.js +9 -0
  191. package/src/{server/services/gemini-settings-manager.js → platforms/drivers/gemini/native-config-implementation.js} +14 -5
  192. package/src/platforms/drivers/gemini/native-config.js +39 -0
  193. package/src/platforms/drivers/gemini/native-logs.js +180 -0
  194. package/src/platforms/drivers/gemini/project-config.js +21 -0
  195. package/src/platforms/drivers/gemini/projects.js +26 -0
  196. package/src/platforms/drivers/gemini/prompts.js +9 -0
  197. package/src/{server/gemini-proxy-server.js → platforms/drivers/gemini/proxy-implementation.js} +36 -267
  198. package/src/platforms/drivers/gemini/proxy.js +16 -0
  199. package/src/platforms/drivers/gemini/resource-sync.js +9 -0
  200. package/src/{server/services/session-history-adapters/gemini.js → platforms/drivers/gemini/session-history-adapter.js} +70 -18
  201. package/src/{server/services/gemini-sessions.js → platforms/drivers/gemini/sessions-implementation.js} +53 -37
  202. package/src/platforms/drivers/gemini/sessions.js +47 -0
  203. package/src/{server/services/gemini-statistics-service.js → platforms/drivers/gemini/statistics-implementation.js} +2 -2
  204. package/src/platforms/drivers/gemini/statistics.js +16 -0
  205. package/src/{server/services/gemini-wire.js → platforms/drivers/gemini/wire.js} +1 -1
  206. package/src/platforms/drivers/generic-filesystem.js +166 -0
  207. package/src/platforms/drivers/generic-jsonl.js +141 -0
  208. package/src/platforms/drivers/generic-mcp.js +44 -0
  209. package/src/platforms/drivers/generic-openai-compatible.js +45 -0
  210. package/src/platforms/drivers/generic-prompt.js +41 -0
  211. package/src/platforms/drivers/legacy.js +677 -0
  212. package/src/platforms/drivers/native-config-snapshot.js +125 -0
  213. package/src/platforms/drivers/native-log-utils.js +1292 -0
  214. package/src/platforms/drivers/notification-hooks.js +578 -0
  215. package/src/{server/api/omp-config.js → platforms/drivers/omp/api-operations.js} +52 -64
  216. package/src/{server/services/omp-auth-provider-worker.js → platforms/drivers/omp/auth-provider-worker.js} +1 -1
  217. package/src/{server/services/omp-auth-providers.js → platforms/drivers/omp/auth-providers.js} +1 -1
  218. package/src/{server/services/omp-channels.js → platforms/drivers/omp/channels-implementation.js} +166 -39
  219. package/src/platforms/drivers/omp/channels.js +58 -0
  220. package/src/{server/services/omp-config.js → platforms/drivers/omp/config.js} +53 -13
  221. package/src/{server/services/omp-gateway-routing.js → platforms/drivers/omp/gateway-routing.js} +14 -7
  222. package/src/{server/services/omp-gateway.js → platforms/drivers/omp/gateway.js} +4 -262
  223. package/src/platforms/drivers/omp/mcp.js +9 -0
  224. package/src/{server/services/omp-settings-manager.js → platforms/drivers/omp/native-config-implementation.js} +75 -10
  225. package/src/platforms/drivers/omp/native-config.js +46 -0
  226. package/src/platforms/drivers/omp/native-logs.js +52 -0
  227. package/src/{server/services/omp-native-plugin-adapter.js → platforms/drivers/omp/native-plugin-adapter.js} +91 -4
  228. package/src/platforms/drivers/omp/project-config.js +29 -0
  229. package/src/platforms/drivers/omp/projects.js +20 -0
  230. package/src/platforms/drivers/omp/prompts.js +9 -0
  231. package/src/{server/omp-proxy-server.js → platforms/drivers/omp/proxy-implementation.js} +6 -15
  232. package/src/platforms/drivers/omp/proxy.js +16 -0
  233. package/src/platforms/drivers/omp/resource-sync.js +9 -0
  234. package/src/{server/services/session-history-adapters/omp.js → platforms/drivers/omp/session-history-adapter.js} +42 -18
  235. package/src/{server/services/omp-sessions.js → platforms/drivers/omp/sessions-implementation.js} +130 -94
  236. package/src/platforms/drivers/omp/sessions.js +36 -0
  237. package/src/{server/services/omp-skill-discovery.js → platforms/drivers/omp/skill-discovery.js} +159 -32
  238. package/src/{server/services/omp-skill-settings-service.js → platforms/drivers/omp/skill-settings.js} +1 -1
  239. package/src/{server/services/omp-statistics-service.js → platforms/drivers/omp/statistics-implementation.js} +3 -3
  240. package/src/platforms/drivers/omp/statistics.js +16 -0
  241. package/src/platforms/drivers/opencode/api-operations.js +9 -0
  242. package/src/{server/services/opencode-channels.js → platforms/drivers/opencode/channels-implementation.js} +28 -9
  243. package/src/platforms/drivers/opencode/channels.js +40 -0
  244. package/src/platforms/drivers/opencode/conversion.js +41 -0
  245. package/src/{server/services/opencode-gateway-converter.js → platforms/drivers/opencode/gateway-converter.js} +1 -1
  246. package/src/platforms/drivers/opencode/mcp.js +9 -0
  247. package/src/{server/services/opencode-settings-manager.js → platforms/drivers/opencode/native-config-implementation.js} +17 -6
  248. package/src/platforms/drivers/opencode/native-config.js +26 -0
  249. package/src/platforms/drivers/opencode/native-logs.js +133 -0
  250. package/src/platforms/drivers/opencode/project-config.js +98 -0
  251. package/src/platforms/drivers/opencode/projects.js +20 -0
  252. package/src/platforms/drivers/opencode/prompts.js +9 -0
  253. package/src/{server/opencode-proxy-server.js → platforms/drivers/opencode/proxy-implementation.js} +27 -362
  254. package/src/platforms/drivers/opencode/proxy.js +16 -0
  255. package/src/platforms/drivers/opencode/resource-sync.js +9 -0
  256. package/src/{server/services/opencode-sessions.js → platforms/drivers/opencode/sessions-implementation.js} +211 -64
  257. package/src/platforms/drivers/opencode/sessions.js +48 -0
  258. package/src/{server/services/opencode-statistics-service.js → platforms/drivers/opencode/statistics-implementation.js} +2 -2
  259. package/src/platforms/drivers/opencode/statistics.js +16 -0
  260. package/src/platforms/drivers/secure-file-driver.js +272 -0
  261. package/src/platforms/drivers/unsupported.js +7 -0
  262. package/src/platforms/manifest-schema.js +398 -0
  263. package/src/platforms/manifests/claude.json +489 -0
  264. package/src/platforms/manifests/codex.json +448 -0
  265. package/src/platforms/manifests/gemini.json +447 -0
  266. package/src/platforms/manifests/omp.json +496 -0
  267. package/src/platforms/manifests/opencode.json +422 -0
  268. package/src/{server/services → platforms}/native-oauth-adapters.js +98 -418
  269. package/src/{server/services → platforms}/notification-hooks.js +190 -1023
  270. package/src/platforms/oauth-credentials-service.js +635 -0
  271. package/src/platforms/path-resolver.js +179 -0
  272. package/src/platforms/platform-path-context.js +305 -0
  273. package/src/platforms/registry.js +248 -0
  274. package/src/platforms/remote-notification-providers.js +678 -0
  275. package/src/platforms/runtime.js +259 -0
  276. package/src/{server/services/session-history-adapters/index.js → platforms/session-history-adapters.js} +8 -1
  277. package/src/plugins/plugin-loader.js +2 -2
  278. package/src/plugins/plugin-manager.js +2 -2
  279. package/src/plugins/registry.js +27 -10
  280. package/src/reset-config.js +21 -5
  281. package/src/server/api/agents.js +46 -72
  282. package/src/server/api/commands.js +16 -6
  283. package/src/server/api/config-registry.js +206 -59
  284. package/src/server/api/config-sync.js +3 -2
  285. package/src/server/api/config-templates.js +20 -4
  286. package/src/server/api/config.js +111 -102
  287. package/src/server/api/convert.js +54 -178
  288. package/src/server/api/dashboard.js +168 -102
  289. package/src/server/api/hooks.js +1 -1
  290. package/src/server/api/mcp.js +224 -84
  291. package/src/server/api/platform-api-config.js +102 -0
  292. package/src/server/api/platform-route-factory.js +215 -0
  293. package/src/server/api/platform-routes.js +26 -0
  294. package/src/server/api/platforms.js +110 -0
  295. package/src/server/api/plugins.js +57 -57
  296. package/src/server/api/project-config.js +168 -0
  297. package/src/server/api/prompts.js +58 -45
  298. package/src/server/api/settings.js +55 -8
  299. package/src/server/api/skills.js +262 -133
  300. package/src/server/api/ui-config.js +2 -2
  301. package/src/server/api/validation-errors.js +18 -4
  302. package/src/server/api/workspaces.js +47 -12
  303. package/src/server/index.js +164 -213
  304. package/src/server/platform-context.js +81 -0
  305. package/src/server/services/channel-balance.js +133 -24
  306. package/src/server/services/channel-health.js +21 -6
  307. package/src/server/services/channel-scheduler.js +29 -39
  308. package/src/server/services/channel-sync-utils.js +23 -13
  309. package/src/server/services/config-export-service.js +350 -319
  310. package/src/server/services/config-registry-service.js +280 -54
  311. package/src/server/services/config-sync-manager.js +172 -944
  312. package/src/server/services/config-templates-service.js +419 -353
  313. package/src/server/services/control-manifest-store.js +206 -0
  314. package/src/server/services/dashboard-snapshot-worker.js +656 -100
  315. package/src/server/services/effective-control-service.js +557 -0
  316. package/src/server/services/local-resource-index.js +314 -0
  317. package/src/server/services/mcp-service.js +607 -977
  318. package/src/server/services/model-detector.js +15 -10
  319. package/src/server/services/native-log-channel-resolver.js +214 -0
  320. package/src/server/services/native-log-observer.js +291 -0
  321. package/src/server/services/network-access.js +12 -2
  322. package/src/server/services/platform-catalog.js +82 -0
  323. package/src/server/services/platform-resolution.js +32 -29
  324. package/src/server/services/plugins-service.js +376 -122
  325. package/src/server/services/project-config-service.js +550 -0
  326. package/src/server/services/project-path-validation.js +31 -0
  327. package/src/server/services/prompts-service.js +267 -233
  328. package/src/server/services/proxy-runtime.js +7 -4
  329. package/src/server/services/remote-credential-cache.js +43 -0
  330. package/src/server/services/repo-scanner-base.js +182 -75
  331. package/src/server/services/request-logger.js +7 -4
  332. package/src/server/services/session-history-index.js +876 -214
  333. package/src/server/services/session-history-worker.js +228 -33
  334. package/src/server/services/session-snapshot-worker.js +124 -34
  335. package/src/server/services/session-snapshots.js +4 -2
  336. package/src/server/services/skill-artifact-store.js +355 -0
  337. package/src/server/services/skill-format-adapters.js +65 -0
  338. package/src/server/services/skill-projection-service.js +323 -0
  339. package/src/server/services/skill-refresh-task-service.js +275 -0
  340. package/src/server/services/skill-service.js +2020 -677
  341. package/src/server/services/snapshot-cache.js +30 -8
  342. package/src/server/services/speed-test.js +8 -8
  343. package/src/server/services/sqlite-connection.js +19 -3
  344. package/src/server/services/statistics-service.js +240 -57
  345. package/src/server/services/ui-config.js +107 -76
  346. package/src/server/services/usage-log-utils.js +98 -0
  347. package/src/server/services/web-build.js +1 -1
  348. package/src/server/services/workspace-service.js +59 -29
  349. package/src/server/utils/pricing.js +10 -19
  350. package/src/server/websocket-server.js +153 -28
  351. package/src/{server/services/base → shared}/base-channel-service.js +156 -28
  352. package/src/shared/capability-driver.js +104 -0
  353. package/src/shared/channel-auth-policy.js +27 -0
  354. package/src/shared/channel-driver.js +161 -0
  355. package/src/shared/driver-factories/api.js +180 -0
  356. package/src/shared/driver-factories/mcp.js +244 -0
  357. package/src/shared/driver-factories/projects.js +121 -0
  358. package/src/shared/driver-factories/prompts.js +58 -0
  359. package/src/shared/driver-factories/resource-sync.js +375 -0
  360. package/src/shared/driver-result.js +78 -0
  361. package/src/shared/mcp-format.js +189 -0
  362. package/src/shared/platforms.js +104 -167
  363. package/src/shared/project-config.js +512 -0
  364. package/src/shared/proxy-driver.js +57 -0
  365. package/src/{server/services/base → shared}/response-usage-parser.js +8 -4
  366. package/src/ui/menu.js +91 -67
  367. package/src/utils/port-helper.js +35 -19
  368. package/src/utils/session.js +1 -1
  369. package/dist/web/assets/Analytics-COEUXmd4.js +0 -25
  370. package/dist/web/assets/Analytics-RNn1BUbG.css +0 -1
  371. package/dist/web/assets/ConfigTemplates-DLLkS53H.js +0 -1
  372. package/dist/web/assets/Home-DCEyLzwX.js +0 -1
  373. package/dist/web/assets/Home-DYVy4cGZ.css +0 -1
  374. package/dist/web/assets/PluginManager-Oe1ZHoMO.js +0 -1
  375. package/dist/web/assets/ProjectList-CWjuAFof.js +0 -1
  376. package/dist/web/assets/ProjectList-VeMNXfgO.css +0 -1
  377. package/dist/web/assets/SessionList-C4chCPxK.css +0 -1
  378. package/dist/web/assets/SessionList-v3ehGg9U.js +0 -1
  379. package/dist/web/assets/SkillManager-BsX8Qpj0.css +0 -1
  380. package/dist/web/assets/SkillManager-DUF_qP5S.js +0 -1
  381. package/dist/web/assets/WorkspaceManager-CjAWBosO.js +0 -1
  382. package/dist/web/assets/icons-nGdSKQn2.js +0 -1
  383. package/dist/web/assets/index-D5hyHPj7.js +0 -2
  384. package/dist/web/assets/index-bJRG3RXH.css +0 -1
  385. package/dist/web/assets/markdown-BNJfEgCA.js +0 -1
  386. package/dist/web/assets/markdown-BfC0goYb.css +0 -10
  387. package/dist/web/assets/naive-ui-BALVpMgd.js +0 -1
  388. package/dist/web/assets/vendors-DQA4Hw9v.js +0 -7
  389. package/dist/web/assets/vue-vendor-B5RqjEjQ.js +0 -45
  390. package/src/config/model-pricing.js +0 -35
  391. package/src/server/api/channels.js +0 -439
  392. package/src/server/api/claude-hooks.js +0 -56
  393. package/src/server/api/claude-statistics.js +0 -57
  394. package/src/server/api/codex-channels.js +0 -491
  395. package/src/server/api/codex-projects.js +0 -117
  396. package/src/server/api/codex-proxy.js +0 -293
  397. package/src/server/api/codex-sessions.js +0 -652
  398. package/src/server/api/codex-statistics.js +0 -57
  399. package/src/server/api/gemini-channels.js +0 -392
  400. package/src/server/api/gemini-projects.js +0 -103
  401. package/src/server/api/gemini-proxy.js +0 -231
  402. package/src/server/api/gemini-sessions.js +0 -550
  403. package/src/server/api/gemini-statistics.js +0 -57
  404. package/src/server/api/health-check.js +0 -31
  405. package/src/server/api/oauth-credentials.js +0 -198
  406. package/src/server/api/omp-channels.js +0 -575
  407. package/src/server/api/omp-projects.js +0 -88
  408. package/src/server/api/omp-proxy.js +0 -125
  409. package/src/server/api/omp-sessions.js +0 -386
  410. package/src/server/api/omp-statistics.js +0 -40
  411. package/src/server/api/opencode-channels.js +0 -471
  412. package/src/server/api/opencode-projects.js +0 -110
  413. package/src/server/api/opencode-proxy.js +0 -354
  414. package/src/server/api/opencode-sessions.js +0 -466
  415. package/src/server/api/opencode-statistics.js +0 -57
  416. package/src/server/api/projects.js +0 -120
  417. package/src/server/api/proxy.js +0 -330
  418. package/src/server/api/sessions.js +0 -810
  419. package/src/server/services/oauth-credentials-service.js +0 -945
  420. package/src/server/services/omp-session-log-observer.js +0 -138
  421. package/src/server/services/opencode-gateway-adapters.js +0 -43
  422. package/src/server/services/project-snapshots.js +0 -59
  423. package/src/server/services/proxy-log-helper.js +0 -298
  424. package/src/server/services/session-cache.js +0 -127
  425. package/src/server/services/session-converter.js +0 -582
  426. package/src/server/services/session-history-adapters/claude.js +0 -235
  427. /package/src/{server/services/claude-token-recovery.js → platforms/drivers/claude/token-recovery.js} +0 -0
  428. /package/src/{server/services/codex-parser.js → platforms/drivers/codex/parser.js} +0 -0
  429. /package/src/{server/services/codex-speed-test-template.json → platforms/drivers/codex/speed-test-template.json} +0 -0
  430. /package/src/{server/services/codex-wire.js → platforms/drivers/codex/wire.js} +0 -0
  431. /package/src/{server/services/omp-auth-gateway-client.js → platforms/drivers/omp/auth-gateway-client.js} +0 -0
  432. /package/src/{server/services/opencode-normalization.js → platforms/drivers/opencode/normalization.js} +0 -0
  433. /package/src/{server/services → shared}/config-artifact-paths.js +0 -0
  434. /package/src/{server/services/base → shared}/proxy-utils.js +0 -0
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { openDatabase, closeDatabase } = require('./sqlite-connection');
4
+
4
5
  const { PATHS } = require('../../config/paths');
6
+ const platformRuntime = require('../../platforms/runtime');
5
7
  const fs = require('fs');
6
8
  const path = require('path');
7
9
 
@@ -12,11 +14,180 @@ const path = require('path');
12
14
  /** @type {number} Maximum time a source inventory is considered fresh */
13
15
  const INDEX_INVENTORY_TTL_MS = 30000;
14
16
 
15
- /** @type {number} Max wait for stale-ok on a cold source */
16
- const COLD_STALE_WAIT_MS = 2500;
17
17
 
18
- /** @type {number} Hard timeout for worker processes */
19
- const WORKER_TIMEOUT_MS = 180000;
18
+ /** @type {number} Maximum time a cold stale-ok read waits for inventory */
19
+ const INDEX_COLD_WAIT_MS = 1500;
20
+ /** @type {number} Maximum age for a lock whose owner cannot be inspected */
21
+ const INVENTORY_LOCK_STALE_MS = 30 * 60 * 1000;
22
+ /** @type {number} Maximum time a competing inventory waits for the database writer */
23
+ const INVENTORY_LOCK_WAIT_MS = 30000;
24
+ /** @type {number} Number of files parsed before results are released */
25
+ const INVENTORY_PARSE_BATCH_SIZE = 8;
26
+ /** @type {number} Maximum number of files parsed concurrently */
27
+ const INVENTORY_PARSE_CONCURRENCY = 2;
28
+ const BUILTIN_SESSION_SOURCES = new Set(['claude', 'codex', 'gemini', 'omp']);
29
+ // Keep synchronized with the parserVersion exposed by the built-in session drivers.
30
+ const BUILTIN_SESSION_PARSER_VERSIONS = Object.freeze({ claude: 2, codex: 2, gemini: 2, omp: 2 });
31
+ function _parserVersionForDriver(driver) {
32
+ const version = Number(driver?.parserVersion);
33
+ return Number.isInteger(version) && version >= 1 ? version : 1;
34
+ }
35
+ function _isUsableRuntime(runtime) {
36
+ return !!runtime && typeof runtime === 'object' && typeof runtime.getDriver === 'function';
37
+ }
38
+
39
+ function _inventoryLockPath(dbPath) {
40
+ return `${path.resolve(String(dbPath))}.inventory.lock`;
41
+ }
42
+
43
+ function _isProcessAlive(pid) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch (error) {
49
+ return error && error.code === 'EPERM';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Acquire a durable database-wide inventory lock. The in-memory single-flight
55
+ * map only protects requests inside one Node process; production indexing is
56
+ * deliberately performed in child processes, so it cannot prevent two
57
+ * workers from writing the same database after a parent restart.
58
+ */
59
+ function _tryAcquireInventoryLock(dbPath, source) {
60
+ const lockPath = _inventoryLockPath(dbPath);
61
+
62
+ for (let attempt = 0; attempt < 2; attempt++) {
63
+ let fd;
64
+ try {
65
+ fd = fs.openSync(lockPath, 'wx');
66
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, source, startedAt: Date.now() }), 'utf8');
67
+ return { fd, lockPath };
68
+ } catch (error) {
69
+ if (fd !== undefined) {
70
+ try { fs.closeSync(fd); } catch (_err) {}
71
+ }
72
+ if (error?.code !== 'EEXIST') throw error;
73
+ }
74
+
75
+ let owner = null;
76
+ let ageMs = 0;
77
+ try {
78
+ owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
79
+ ageMs = Math.max(0, Date.now() - fs.statSync(lockPath).mtimeMs);
80
+ } catch (error) {
81
+ if (error?.code === 'ENOENT') continue;
82
+ }
83
+
84
+ const ownerPid = Number(owner?.pid);
85
+ if (_isProcessAlive(ownerPid)) {
86
+ return {
87
+ busy: true,
88
+ sameSource: owner?.source && String(owner.source) === String(source)
89
+ };
90
+ }
91
+
92
+ if ((!Number.isInteger(ownerPid) || ownerPid <= 0) && ageMs < INVENTORY_LOCK_STALE_MS) {
93
+ return null;
94
+ }
95
+
96
+ try {
97
+ fs.unlinkSync(lockPath);
98
+ } catch (error) {
99
+ if (error?.code !== 'ENOENT') return null;
100
+ }
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ async function _acquireInventoryLock(dbPath, source) {
107
+ const deadline = Date.now() + INVENTORY_LOCK_WAIT_MS;
108
+ while (true) {
109
+ const lock = _tryAcquireInventoryLock(dbPath, source);
110
+ if (lock?.busy) {
111
+ if (lock.sameSource) return null;
112
+ } else if (lock) {
113
+ return lock;
114
+ }
115
+ if (Date.now() >= deadline) return null;
116
+ await new Promise(resolve => setTimeout(resolve, 50));
117
+ }
118
+ }
119
+
120
+ function _releaseInventoryLock(lock) {
121
+ if (!lock) return;
122
+ try { fs.closeSync(lock.fd); } catch (_err) {}
123
+ try { fs.unlinkSync(lock.lockPath); } catch (_err) {}
124
+ }
125
+
126
+ /**
127
+ * Remove inventory locks left by workers that are no longer alive.
128
+ *
129
+ * A normal worker releases its lock in `_runInventory`'s finally block. This
130
+ * fallback is needed for `ctx stop` when a worker is terminated before that
131
+ * block can run. Locks owned by a live PID are intentionally retained.
132
+ *
133
+ * @param {string} [dbPath]
134
+ * @returns {{ removed: string[], retained: string[] }}
135
+ */
136
+ function cleanupStaleInventoryLocks(dbPath = PATHS?.sessionHistoryIndex) {
137
+ const targetPath = dbPath ? path.resolve(String(dbPath)) : '';
138
+ if (!targetPath) return { removed: [], retained: [] };
139
+
140
+ const directory = path.dirname(targetPath);
141
+ const prefix = `${path.basename(targetPath)}.`;
142
+ let entries;
143
+ try {
144
+ entries = fs.readdirSync(directory);
145
+ } catch (_) {
146
+ return { removed: [], retained: [] };
147
+ }
148
+
149
+ const removed = [];
150
+ const retained = [];
151
+ for (const entry of entries) {
152
+ if (!entry.startsWith(prefix) || !entry.endsWith('.inventory.lock')) continue;
153
+ const lockPath = path.join(directory, entry);
154
+ let owner = null;
155
+ let ageMs = 0;
156
+ try {
157
+ ageMs = Math.max(0, Date.now() - fs.statSync(lockPath).mtimeMs);
158
+ } catch (_) {
159
+ retained.push(lockPath);
160
+ continue;
161
+ }
162
+ try {
163
+ owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
164
+ } catch (_) {
165
+ // Keep malformed young locks; an old one is handled by the stale rule.
166
+ }
167
+
168
+ const ownerPid = Number(owner?.pid);
169
+ if (_isProcessAlive(ownerPid)) {
170
+ retained.push(lockPath);
171
+ continue;
172
+ }
173
+
174
+ // A valid but dead owner can be cleaned immediately. Unreadable lock
175
+ // files remain protected by the normal acquisition path's stale check.
176
+ if ((Number.isInteger(ownerPid) && ownerPid > 0) || ageMs >= INVENTORY_LOCK_STALE_MS) {
177
+ try {
178
+ fs.unlinkSync(lockPath);
179
+ removed.push(lockPath);
180
+ } catch (_) {
181
+ retained.push(lockPath);
182
+ }
183
+ } else {
184
+ retained.push(lockPath);
185
+ }
186
+ }
187
+
188
+ return { removed, retained };
189
+ }
190
+
20
191
 
21
192
  // ---------------------------------------------------------------------------
22
193
  // Schema
@@ -49,6 +220,7 @@ CREATE TABLE IF NOT EXISTS session_file (
49
220
  message_count INTEGER NOT NULL DEFAULT 0,
50
221
  usage_json TEXT,
51
222
  extra_json TEXT,
223
+ parser_version INTEGER NOT NULL DEFAULT 0,
52
224
  PRIMARY KEY (source, file_path),
53
225
  UNIQUE (source, session_id)
54
226
  );
@@ -80,6 +252,9 @@ CREATE TABLE IF NOT EXISTS session_message (
80
252
 
81
253
  CREATE INDEX IF NOT EXISTS idx_session_message_sid_ord
82
254
  ON session_message(source, session_id, ordinal);
255
+
256
+ CREATE INDEX IF NOT EXISTS idx_session_message_user_number
257
+ ON session_message(source, session_id, user_message_number, ordinal);
83
258
  `;
84
259
 
85
260
  const FTS_SETUP_SQL = `
@@ -144,27 +319,197 @@ function _ftsQuote(s) {
144
319
  return '"' + String(s).replace(/"/g, '""') + '"';
145
320
  }
146
321
 
147
- // ---------------------------------------------------------------------------
148
- // Core: createSessionHistoryIndex
322
+ function _normalizeTypedSessionsResult(result, defaultOperation = 'resolve-driver') {
323
+ if (!result || typeof result !== 'object') {
324
+ return null;
325
+ }
326
+ if (result.status !== 'failed' && result.status !== 'unsupported') {
327
+ return null;
328
+ }
329
+ if (typeof result.platform !== 'string' || typeof result.capability !== 'string') {
330
+ return null;
331
+ }
332
+ return {
333
+ ...result,
334
+ operation: typeof result.operation === 'string' ? result.operation : defaultOperation
335
+ };
336
+ }
337
+
338
+ function _isTypedSessionsResult(result) {
339
+ return !!_normalizeTypedSessionsResult(result);
340
+ }
341
+
342
+ function _typedSessionsFailure(source, error, operation = 'resolve-driver') {
343
+ const result = {
344
+ status: 'failed',
345
+ platform: source,
346
+ capability: 'sessions',
347
+ operation,
348
+ error: error && error.message ? error.message : String(error)
349
+ };
350
+ if (error) {
351
+ Object.defineProperty(result, 'cause', { value: error, enumerable: false });
352
+ }
353
+ return result;
354
+ }
355
+
356
+ function _typedSessionsUnsupported(source, error, operation = 'resolve-driver') {
357
+ const result = {
358
+ status: 'unsupported',
359
+ platform: source,
360
+ capability: 'sessions',
361
+ operation,
362
+ error: error && error.message ? error.message : String(error)
363
+ };
364
+ if (error) {
365
+ Object.defineProperty(result, 'cause', { value: error, enumerable: false });
366
+ }
367
+ return result;
368
+ }
369
+
370
+ function _getRuntimeSessionsDriver(runtime, source, config = {}) {
371
+ if (!runtime || typeof runtime.getDriver !== 'function') {
372
+ return null;
373
+ }
374
+
375
+ try {
376
+ const driver = runtime.getDriver(source, 'sessions', { config });
377
+ const typedResult = _normalizeTypedSessionsResult(driver);
378
+ if (typedResult) {
379
+ return typedResult;
380
+ }
381
+ if (!driver || typeof driver !== 'object') {
382
+ return null;
383
+ }
384
+ if (typeof driver.inventory !== 'function' || typeof driver.parse !== 'function') {
385
+ return null;
386
+ }
387
+ return driver;
388
+ } catch (error) {
389
+ return _typedSessionsFailure(source, error);
390
+ }
391
+ }
392
+
393
+ function _safeParseJson(value, fallback) {
394
+ if (!value) return fallback;
395
+ try {
396
+ return JSON.parse(value);
397
+ } catch (_) {
398
+ return fallback;
399
+ }
400
+ }
401
+
402
+ function _safeParseObject(value) {
403
+ const parsed = _safeParseJson(value, {});
404
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
405
+ return {};
406
+ }
407
+ return parsed;
408
+ }
409
+
410
+
411
+ function _normalizeRuntimeParseResult(result, descriptor = {}, { preservePayloadUpdatedAt = false } = {}) {
412
+ if (!result || typeof result !== 'object') {
413
+ return result;
414
+ }
415
+
416
+ if (result.session && Array.isArray(result.messages)) {
417
+ const sessionExtra = _safeParseObject(result.session.extraJson);
418
+ const extraJson = JSON.stringify({
419
+ ...sessionExtra,
420
+ projectHint: result.session.projectHint || descriptor.projectHint || null,
421
+ mtimeMs: descriptor.mtimeMs ?? sessionExtra.mtimeMs ?? null
422
+ });
423
+ return {
424
+ ...result,
425
+ session: {
426
+ ...result.session,
427
+ projectHint: result.session.projectHint || descriptor.projectHint || '',
428
+ projectName: result.session.projectName || result.session.projectHint || descriptor.projectHint || '',
429
+ projectDisplayName: result.session.projectDisplayName || result.session.projectName || result.session.projectHint || descriptor.projectHint || '',
430
+ projectFullPath: result.session.projectFullPath || result.session.projectPath || '',
431
+ updatedAt: preservePayloadUpdatedAt ? (result.session.updatedAt ?? descriptor.mtimeMs ?? null) : descriptor.mtimeMs ?? null,
432
+ extraJson
433
+ }
434
+ };
435
+ }
436
+
437
+ if (result.sessionId) {
438
+ const projectHint = descriptor.projectHint || result.projectHint || result.projectName || '';
439
+ const projectName = result.projectName || projectHint || '';
440
+ const projectDisplayName = result.projectDisplayName || result.projectName || projectHint || '';
441
+ const resultExtra = _safeParseObject(result.extraJson);
442
+ const descriptorMtime = descriptor.mtimeMs ?? null;
443
+
444
+ return {
445
+ session: {
446
+ sessionId: result.sessionId,
447
+ projectHint,
448
+ projectName,
449
+ projectDisplayName,
450
+ projectFullPath: result.projectFullPath || result.projectPath || '',
451
+ firstMessage: result.firstMessage || null,
452
+ gitBranch: result.gitBranch || null,
453
+ provider: result.provider || null,
454
+ model: result.model || null,
455
+ startedAt: result.startedAt || null,
456
+ updatedAt: descriptorMtime ?? result.updatedAt ?? null,
457
+ usageJson: result.usageJson || null,
458
+ extraJson: JSON.stringify({
459
+ ...resultExtra,
460
+ projectHint: projectHint || null,
461
+ mtimeMs: descriptor.mtimeMs ?? resultExtra.mtimeMs ?? null
462
+ })
463
+ },
464
+ messages: Array.isArray(result.messages) ? result.messages : []
465
+ };
466
+ }
467
+
468
+ return result;
469
+ }
470
+
471
+ function _adaptRuntimeSessionsDriver(driver) {
472
+ if (!driver) return null;
473
+ const unwrap = result => result && result.status === 'ok' ? result.data : result;
474
+ return {
475
+ parserVersion: driver.parserVersion,
476
+ inventory: async (...args) => unwrap(await driver.inventory(...args)),
477
+ parse: async (descriptor, ...args) => _normalizeRuntimeParseResult(
478
+ await unwrap(await driver.parse(descriptor, ...args)),
479
+ descriptor,
480
+ { preservePayloadUpdatedAt: driver.preservePayloadUpdatedAt === true }
481
+ )
482
+ };
483
+ }
149
484
  // ---------------------------------------------------------------------------
150
485
 
151
486
  /**
152
487
  * @param {object} [opts]
153
488
  * @param {string} [opts.dbPath]
154
489
  * @param {object} [opts.adapterRegistry] - source → { inventory, parse }
490
+ * @param {object} [opts.runtime] - runtime with getDriver(source, capability)
155
491
  * @param {Function} [opts.workerRunner] - (source, indexDbPath) => Promise<void>
156
492
  * @param {boolean|null} [opts.ftsEnabledOverride] - override FTS detection for tests
157
493
  * @returns {object} index API
158
494
  */
159
495
  function createSessionHistoryIndex(opts = {}) {
160
496
  const dbPath = opts.dbPath || PATHS?.sessionHistoryIndex || path.join(PATHS?.base || process.cwd(), 'session-history.sqlite');
161
- const adapters = opts.adapterRegistry || require('./session-history-adapters');
497
+ const explicitProjectsDir = opts.projectsDir;
498
+ const indexConfig = opts.config || (opts.projectsDir ? { projectsDir: opts.projectsDir } : {});
499
+ const explicitAdapters = opts.adapterRegistry || null;
500
+ const adapters = explicitAdapters || {};
501
+ const runtimeProvided = !explicitAdapters && process.env.NODE_ENV === 'test' && _isUsableRuntime(opts.runtime);
502
+ const runtime = explicitAdapters ? null : (runtimeProvided ? opts.runtime : platformRuntime.getPlatformRuntime());
162
503
  const workerRunner = opts.workerRunner || _defaultWorkerRunner;
163
504
  const ftsEnabled = opts.ftsEnabledOverride !== undefined
164
505
  ? opts.ftsEnabledOverride
165
506
  : null;
166
507
  const shouldUseWorker = process.env.NODE_ENV !== 'test' && process.env.CC_TOOL_SESSION_HISTORY_CHILD !== '1';
167
508
  let _db = null;
509
+ /** @type {Map<string, {size: number, mtimeMs: number, checkedAt: number, filePath: string}>} */
510
+ const fileVersions = new Map();
511
+ const fileChecks = new Map();
512
+ const sourceFreshness = new Map();
168
513
  let _ftsAvailable = null;
169
514
  /** @type {Map<string, Promise<void>>} */
170
515
  const _inflight = new Map();
@@ -183,10 +528,68 @@ function createSessionHistoryIndex(opts = {}) {
183
528
  }
184
529
  return _db;
185
530
  }
531
+ function _parserVersionForSource(source, adapter = null) {
532
+ if (adapter) return _parserVersionForDriver(adapter);
533
+ if (explicitAdapters) return _parserVersionForDriver(adapters[source]);
534
+ const runtimeDriver = _getRuntimeSessionsDriver(runtime, source, indexConfig);
535
+ return _parserVersionForDriver(runtimeDriver);
536
+ }
537
+
538
+ /**
539
+ * Check only the persisted inventory timestamp. Inventory owns file checks.
540
+ *
541
+ * @param {string} source
542
+ * @returns {boolean}
543
+ */
544
+ function _isSourceFresh(source) {
545
+ const row = _getDb().prepare(
546
+ 'SELECT last_inventory_ms FROM source_state WHERE source = ?'
547
+ ).get(source);
548
+ return Boolean(
549
+ row?.last_inventory_ms
550
+ && Date.now() - Number(row.last_inventory_ms) < INDEX_INVENTORY_TTL_MS
551
+ && !_hasKnownParserMigrationPending(source)
552
+ && (!explicitAdapters || !_hasParserMigrationPending(source))
553
+ );
554
+ }
555
+
556
+ function _hasKnownParserMigrationPending(source) {
557
+ if (explicitAdapters || runtimeProvided) return false;
558
+ const parserVersion = BUILTIN_SESSION_PARSER_VERSIONS[source];
559
+ if (!parserVersion) return false;
560
+ const row = _getDb().prepare(
561
+ 'SELECT 1 AS pending FROM session_file WHERE source = ? AND parser_version <> ? LIMIT 1'
562
+ ).get(source, parserVersion);
563
+ return Boolean(row);
564
+ }
565
+
566
+ function _hasParserMigrationPending(source) {
567
+ const row = _getDb().prepare(
568
+ 'SELECT 1 AS pending FROM session_file WHERE source = ? AND parser_version <> ? LIMIT 1'
569
+ ).get(source, _parserVersionForSource(source));
570
+ return Boolean(row);
571
+ }
572
+
573
+ function _hasIndexedData(source) {
574
+ const row = _getDb().prepare(
575
+ 'SELECT 1 AS indexed FROM session_file WHERE source = ? LIMIT 1'
576
+ ).get(source);
577
+ return Boolean(row);
578
+ }
579
+
580
+ function _hasUsableIndexedData(source) {
581
+ return _hasIndexedData(source)
582
+ && !_hasKnownParserMigrationPending(source)
583
+ && (!explicitAdapters || !_hasParserMigrationPending(source));
584
+ }
186
585
 
187
586
  function _initSchema(db) {
188
587
  db.exec('PRAGMA journal_mode = WAL');
189
588
  db.exec(SCHEMA_SQL);
589
+ const columns = db.prepare('PRAGMA table_info(session_file)').all();
590
+ if (!columns.some((column) => column.name === 'parser_version')) {
591
+ db.exec('ALTER TABLE session_file ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0');
592
+ }
190
593
  }
191
594
 
192
595
  function _initFts(db) {
@@ -199,28 +602,41 @@ function createSessionHistoryIndex(opts = {}) {
199
602
 
200
603
  /**
201
604
  * @param {string} source
202
- * @param {{ force?: boolean, consistency?: string }} [options]
605
+ * @param {{ force?: boolean, consistency?: string, allowStaleData?: boolean }} [options]
203
606
  * @returns {Promise<void>}
204
607
  */
205
608
  async function ensureSourceIndexed(source, options = {}) {
206
609
  const consistency = options.consistency || 'stale-ok';
207
610
  const force = options.force === true;
208
-
611
+ const allowStaleData = options.allowStaleData === true;
209
612
  const key = `ensure:${source}`;
613
+
614
+ if (!force && _isSourceFresh(source)) {
615
+ return;
616
+ }
617
+
210
618
  if (_inflight.has(key)) {
211
619
  if (consistency === 'complete') {
212
620
  return _inflight.get(key);
213
621
  }
214
- const inflight = _inflight.get(key);
215
- const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), COLD_STALE_WAIT_MS));
216
- const result = await Promise.race([inflight, timeout]);
217
- if (result === 'timeout') return;
218
- return result;
622
+ // The project directory can safely use the previous rows while a
623
+ // parser migration or inventory refresh is running. Session reads
624
+ // retain their existing behavior and wait for migration completion.
625
+ if (allowStaleData ? _hasIndexedData(source) : _hasUsableIndexedData(source)) {
626
+ return;
627
+ }
628
+ return _inflight.get(key);
219
629
  }
220
630
 
221
- const promise = shouldUseWorker
222
- ? workerRunner(source, dbPath, { force })
223
- : _runInventory(source, { force });
631
+ const useWorker = shouldUseWorker && !runtimeProvided && !explicitAdapters;
632
+ const workerOptions = { force };
633
+ const workerProjectsDir = source === 'claude'
634
+ ? (options.config?.projectsDir || explicitProjectsDir)
635
+ : explicitProjectsDir;
636
+ if (workerProjectsDir) workerOptions.projectsDir = workerProjectsDir;
637
+ const promise = useWorker
638
+ ? workerRunner(source, dbPath, workerOptions)
639
+ : _runInventory(source, { force, config: options.config || indexConfig });
224
640
  _inflight.set(key, promise);
225
641
  promise.finally(() => {
226
642
  if (_inflight.get(key) === promise) {
@@ -228,166 +644,305 @@ function createSessionHistoryIndex(opts = {}) {
228
644
  }
229
645
  }).catch(() => {});
230
646
 
647
+ if (consistency === 'stale-ok' && (allowStaleData ? _hasIndexedData(source) : _hasUsableIndexedData(source))) {
648
+ return;
649
+ }
650
+
231
651
  if (consistency === 'stale-ok') {
232
- const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), COLD_STALE_WAIT_MS));
233
- const result = await Promise.race([promise, timeout]);
234
- if (result === 'timeout') {
235
- return;
236
- }
652
+ await Promise.race([
653
+ promise,
654
+ new Promise(resolve => setTimeout(resolve, INDEX_COLD_WAIT_MS))
655
+ ]);
656
+ return;
237
657
  }
238
658
 
239
659
  await promise;
240
660
  }
661
+ function _isTypedFailureResult(result) {
662
+ return _isTypedSessionsResult(result);
663
+ }
664
+ function _typedFailureToError(result) {
665
+ const fallbackMessage = result.status === 'unsupported'
666
+ ? `unsupported ${result.capability}`
667
+ : 'inventory failed';
668
+ const cause = result.cause || (result.error instanceof Error ? result.error : new Error(String(result.error || fallbackMessage)));
669
+ const error = new Error(`Runtime ${result.capability} ${result.operation} ${result.status} on ${result.platform}: ${cause.message}`);
670
+ error.status = result.status;
671
+ error.platform = result.platform;
672
+ error.capability = result.capability;
673
+ error.operation = result.operation;
674
+ error.context = result;
675
+ error.cause = cause;
676
+ error.failure = result;
677
+ return error;
678
+ }
241
679
 
242
- async function _runInventory(source, { force = false } = {}) {
243
- const db = _getDb();
244
- const adapter = adapters[source];
245
- if (!adapter || !adapter.inventory) {
680
+ function _recordSourceState(db, source, lastInventoryMs, lastError) {
681
+ db.prepare(
682
+ 'INSERT INTO source_state(source, last_inventory_ms, last_error) VALUES(?, ?, ?) ON CONFLICT(source) DO UPDATE SET last_inventory_ms = excluded.last_inventory_ms, last_error = excluded.last_error'
683
+ ).run(source, lastInventoryMs, lastError);
684
+ }
685
+
686
+ async function _runInventory(source, { force = false, config = indexConfig } = {}) {
687
+ // SQLite's writer lock is database-wide, so source-specific lock files do
688
+ // not prevent two different sources from entering BEGIN IMMEDIATE at the
689
+ // same time. Acquire one lock for the whole index before opening/schema
690
+ // initializing the writer connection.
691
+ const inventoryLock = await _acquireInventoryLock(dbPath, source);
692
+ if (!inventoryLock) {
693
+ // Another process is already doing the expensive work. Existing rows
694
+ // remain readable, and the next scheduled inventory will observe the
695
+ // new source_state timestamp once that worker finishes.
246
696
  return;
247
697
  }
698
+ let db = null;
699
+ let errorMsg = null;
700
+ let stateToRecord = null;
248
701
 
249
- if (!force) {
250
- const row = db.prepare('SELECT last_inventory_ms FROM source_state WHERE source = ?').get(source);
251
- if (row && row.last_inventory_ms && (Date.now() - row.last_inventory_ms) < INDEX_INVENTORY_TTL_MS) {
702
+ try {
703
+ db = _getDb();
704
+ if (!force && _isSourceFresh(source)) {
252
705
  return;
253
706
  }
254
- }
707
+ let adapter = null;
255
708
 
256
- const descriptors = await adapter.inventory();
257
- if (!Array.isArray(descriptors)) return;
709
+ if (explicitAdapters) {
710
+ adapter = adapters[source];
711
+ } else {
712
+ const runtimeDriver = _getRuntimeSessionsDriver(runtime, source, config);
713
+ if (_isTypedFailureResult(runtimeDriver)) {
714
+ throw _typedFailureToError(runtimeDriver);
715
+ }
258
716
 
259
- const seen = new Map();
260
- for (const d of descriptors) {
261
- const prev = seen.get(d.sessionId);
262
- if (!prev || d.mtimeMs > prev.mtimeMs || (d.mtimeMs === prev.mtimeMs && d.filePath < prev.filePath)) {
263
- seen.set(d.sessionId, d);
717
+ if (runtimeDriver) {
718
+ adapter = _adaptRuntimeSessionsDriver(runtimeDriver);
719
+ } else if (BUILTIN_SESSION_SOURCES.has(source) && adapters[source]) {
720
+ adapter = adapters[source];
721
+ } else {
722
+ throw _typedFailureToError(_typedSessionsUnsupported(source, new Error(`unsupported sessions source: ${source}`)));
723
+ }
264
724
  }
265
- }
266
-
267
- const indexedFiles = new Map();
268
- const indexedRows = db.prepare('SELECT file_path, size, mtime_ms FROM session_file WHERE source = ?').all(source);
269
- for (const r of indexedRows) {
270
- indexedFiles.set(r.file_path, r);
271
- }
272
-
273
- const toParse = [];
274
- const activePaths = new Set();
275
- for (const d of seen.values()) {
276
- activePaths.add(d.filePath);
277
- const idx = indexedFiles.get(d.filePath);
278
- if (!idx || idx.size !== d.size || idx.mtime_ms !== d.mtimeMs) {
279
- toParse.push(d);
725
+ const parserVersion = _parserVersionForSource(source, adapter);
726
+ const inventoryResult = await adapter.inventory({
727
+ projectsDir: source === 'claude' ? config.projectsDir : explicitProjectsDir
728
+ });
729
+ if (_isTypedFailureResult(inventoryResult)) {
730
+ throw _typedFailureToError(inventoryResult);
280
731
  }
281
- }
282
732
 
283
- for (const filePath of indexedFiles.keys()) {
284
- if (!activePaths.has(filePath)) {
285
- db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?').run(source, filePath);
733
+ const indexedFiles = new Map();
734
+ for (const row of db.prepare('SELECT file_path, size, mtime_ms, session_id, parser_version FROM session_file WHERE source = ?').all(source)) {
735
+ indexedFiles.set(row.file_path, {
736
+ size: row.size,
737
+ mtime_ms: row.mtime_ms,
738
+ sessionId: row.session_id,
739
+ parserVersion: row.parser_version
740
+ });
286
741
  }
287
- }
288
742
 
289
- let errorMsg = null;
290
- for (const d of toParse) {
291
- try {
292
- const preStat = { size: d.size, mtimeMs: d.mtimeMs };
293
- const { session, messages } = await adapter.parse(d);
294
-
295
- try {
296
- const postStat = fs.statSync(d.filePath);
297
- if (postStat.size !== preStat.size || postStat.mtimeMs !== preStat.mtimeMs) {
298
- const retry = await adapter.parse(d);
299
- const retryStat = fs.statSync(d.filePath);
300
- if (retryStat.size !== postStat.size || retryStat.mtimeMs !== postStat.mtimeMs) {
301
- const existing = db.prepare('SELECT 1 FROM session_file WHERE source = ? AND file_path = ?').get(source, d.filePath);
302
- if (existing) continue;
303
- continue;
304
- }
305
- _upsertSession(db, source, d, retry.session, retry.messages);
306
- continue;
307
- }
308
- } catch (_statErr) {
309
- continue;
743
+ const winnersBySessionId = new Map();
744
+ for (const descriptor of inventoryResult) {
745
+ const current = winnersBySessionId.get(descriptor.sessionId);
746
+ if (!current
747
+ || descriptor.mtimeMs > current.mtimeMs
748
+ || (descriptor.mtimeMs === current.mtimeMs && descriptor.filePath < current.filePath)) {
749
+ winnersBySessionId.set(descriptor.sessionId, descriptor);
310
750
  }
751
+ }
311
752
 
312
- _upsertSession(db, source, d, session, messages);
313
- } catch (err) {
314
- const existing = db.prepare('SELECT 1 FROM session_file WHERE source = ? AND file_path = ?').get(source, d.filePath);
315
- if (!existing) {
316
- errorMsg = (errorMsg ? errorMsg + '; ' : '') + `${d.filePath}: ${err.message}`;
753
+ const toParse = [];
754
+ const activePaths = new Set();
755
+ for (const d of winnersBySessionId.values()) {
756
+ activePaths.add(d.filePath);
757
+ const idx = indexedFiles.get(d.filePath);
758
+ if (!idx
759
+ || idx.size !== d.size
760
+ || idx.mtime_ms !== d.mtimeMs
761
+ || idx.parserVersion !== parserVersion) {
762
+ toParse.push(d);
317
763
  }
318
764
  }
319
- }
320
-
321
- db.prepare(
322
- 'INSERT INTO source_state(source, last_inventory_ms, last_error) VALUES(?, ?, ?) ON CONFLICT(source) DO UPDATE SET last_inventory_ms = excluded.last_inventory_ms, last_error = excluded.last_error'
323
- ).run(source, Date.now(), errorMsg);
324
- }
765
+ const parseDescriptor = async (d) => {
766
+ const preFingerprint = { size: d.size, mtimeMs: d.mtimeMs };
767
+ const parseResult = await adapter.parse({ ...d, projectsDir: config.projectsDir });
768
+ if (_isTypedFailureResult(parseResult)) throw _typedFailureToError(parseResult);
769
+ if (!parseResult || typeof parseResult !== 'object' || !parseResult.session || typeof parseResult.session !== 'object' || typeof parseResult.session.sessionId !== 'string' || !parseResult.session.sessionId.trim() || !Array.isArray(parseResult.messages)) {
770
+ throw new Error('invalid parsed session result');
771
+ }
325
772
 
326
- function _upsertSession(db, source, descriptor, session, messages) {
327
- try {
328
- db.exec('BEGIN IMMEDIATE');
329
- db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, session.sessionId);
773
+ let postStat;
774
+ try {
775
+ postStat = await fs.promises.stat(d.filePath);
776
+ } catch (_) {
777
+ return null;
778
+ }
779
+ if (postStat.size === preFingerprint.size && postStat.mtimeMs === preFingerprint.mtimeMs) {
780
+ return { descriptor: d, parseResult };
781
+ }
782
+ const retryDescriptor = { ...d, size: postStat.size, mtimeMs: postStat.mtimeMs };
783
+ const retryResult = await adapter.parse(retryDescriptor);
784
+ if (_isTypedFailureResult(retryResult)) {
785
+ throw _typedFailureToError(retryResult);
786
+ }
787
+ if (!retryResult || typeof retryResult !== 'object' || !retryResult.session || typeof retryResult.session !== 'object' || typeof retryResult.session.sessionId !== 'string' || !retryResult.session.sessionId.trim() || !Array.isArray(retryResult.messages)) {
788
+ throw new Error('invalid parsed session result');
789
+ }
790
+ let retryStat;
791
+ try {
792
+ retryStat = await fs.promises.stat(d.filePath);
793
+ } catch (_) {
794
+ return null;
795
+ }
796
+ if (retryStat.size !== postStat.size || retryStat.mtimeMs !== postStat.mtimeMs) {
797
+ return null;
798
+ }
799
+ return {
800
+ descriptor: { ...retryDescriptor, size: retryStat.size, mtimeMs: retryStat.mtimeMs },
801
+ parseResult: retryResult
802
+ };
803
+ };
330
804
 
331
- db.prepare(`
805
+ const deletePath = db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?');
806
+ const deleteSession = db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?');
807
+ const insertFile = db.prepare(`
332
808
  INSERT INTO session_file(
333
809
  source, file_path, size, mtime_ms, session_id,
334
810
  project_name, project_display_name, project_full_path,
335
811
  first_message, git_branch, provider, model,
336
- started_at, updated_at, message_count, usage_json, extra_json
337
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
- `).run(
339
- source,
340
- descriptor.filePath,
341
- descriptor.size,
342
- descriptor.mtimeMs,
343
- session.sessionId,
344
- session.projectName || '',
345
- session.projectDisplayName || null,
346
- session.projectFullPath || null,
347
- session.firstMessage || null,
348
- session.gitBranch || null,
349
- session.provider || null,
350
- session.model || null,
351
- session.startedAt || null,
352
- session.updatedAt || null,
353
- messages.length,
354
- session.usageJson || null,
355
- session.extraJson || null
356
- );
812
+ started_at, updated_at, message_count, usage_json, extra_json,
813
+ parser_version
814
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
815
+ `);
816
+ const insertMessage = db.prepare(`
817
+ INSERT INTO session_message(
818
+ source, session_id, ordinal, message_id, role, type, subtype,
819
+ content, timestamp, model, provider, user_message_number, extra_json
820
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
821
+ `);
357
822
 
358
- if (messages.length > 0) {
359
- const stmt = db.prepare(`
360
- INSERT INTO session_message(
361
- source, session_id, ordinal, message_id, role, type, subtype,
362
- content, timestamp, model, provider, user_message_number, extra_json
363
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
364
- `);
823
+ db.exec('BEGIN IMMEDIATE');
824
+ try {
825
+ for (const filePath of indexedFiles.keys()) {
826
+ if (!activePaths.has(filePath)) deletePath.run(source, filePath);
827
+ }
828
+ db.exec('COMMIT');
829
+ } catch (error) {
830
+ try { db.exec('ROLLBACK'); } catch (_) {}
831
+ throw error;
832
+ }
365
833
 
366
- let ordinal = 0;
367
- for (const msg of messages) {
368
- stmt.run(
369
- source,
370
- session.sessionId,
371
- ordinal,
372
- msg.messageId || null,
373
- msg.role || null,
374
- msg.type || null,
375
- msg.subtype || null,
376
- msg.content || null,
377
- msg.timestamp || null,
378
- msg.model || null,
379
- msg.provider || null,
380
- msg.userMessageNumber != null ? msg.userMessageNumber : null,
381
- msg.extraJson || null
382
- );
383
- ordinal++;
834
+ const recordParseError = (descriptor, error) => {
835
+ const detail = `${descriptor.filePath}: ${error.message}`;
836
+ errorMsg = errorMsg ? `${errorMsg}; ${detail}` : detail;
837
+ };
838
+
839
+ for (let batchStart = 0; batchStart < toParse.length; batchStart += INVENTORY_PARSE_BATCH_SIZE) {
840
+ const batch = toParse.slice(batchStart, batchStart + INVENTORY_PARSE_BATCH_SIZE);
841
+ const parsed = new Array(batch.length);
842
+ let next = 0;
843
+ const parseWorker = async () => {
844
+ while (true) {
845
+ const index = next++;
846
+ if (index >= batch.length) return;
847
+ const descriptor = batch[index];
848
+ try {
849
+ parsed[index] = { descriptor, value: await parseDescriptor(descriptor) };
850
+ } catch (error) {
851
+ parsed[index] = { descriptor, error };
852
+ }
853
+ }
854
+ };
855
+ await Promise.all(Array.from({ length: Math.min(INVENTORY_PARSE_CONCURRENCY, batch.length) }, parseWorker));
856
+
857
+ for (const item of parsed) {
858
+ if (item?.error) recordParseError(item.descriptor, item.error);
384
859
  }
860
+
861
+ db.exec('BEGIN IMMEDIATE');
862
+ try {
863
+ for (const item of parsed) {
864
+ if (item?.value) {
865
+ const { descriptor, parseResult } = item.value;
866
+ _insertSession({ deletePath, deleteSession, insertFile, insertMessage }, source, descriptor, parseResult.session, parseResult.messages, parserVersion);
867
+ }
868
+ }
869
+ db.exec('COMMIT');
870
+ } catch (error) {
871
+ try { db.exec('ROLLBACK'); } catch (_) {}
872
+ throw error;
873
+ }
874
+ // Drop the batch references before parsing the next group. This is
875
+ // the key difference from the old whole-inventory array: large
876
+ // session payloads no longer accumulate until the final COMMIT.
385
877
  }
386
878
 
387
- db.exec('COMMIT');
879
+ stateToRecord = {
880
+ lastInventoryMs: Date.now(),
881
+ lastError: errorMsg
882
+ };
388
883
  } catch (err) {
389
- try { db.exec('ROLLBACK'); } catch (_) {}
884
+ errorMsg = err && err.message ? err.message : String(err);
885
+ stateToRecord = {
886
+ lastInventoryMs: null,
887
+ lastError: errorMsg
888
+ };
390
889
  throw err;
890
+ } finally {
891
+ if (stateToRecord) {
892
+ try {
893
+ if (db) {
894
+ _recordSourceState(db, source, stateToRecord.lastInventoryMs, stateToRecord.lastError);
895
+ }
896
+ } catch (_err) {}
897
+ }
898
+ _releaseInventoryLock(inventoryLock);
899
+ }
900
+ }
901
+
902
+
903
+ function _insertSession(statements, source, descriptor, session, messages, parserVersion) {
904
+ const { deletePath, deleteSession, insertFile, insertMessage } = statements;
905
+ deletePath.run(source, descriptor.filePath);
906
+ deleteSession.run(source, session.sessionId);
907
+ insertFile.run(
908
+ source,
909
+ descriptor.filePath,
910
+ descriptor.size,
911
+ descriptor.mtimeMs,
912
+ session.sessionId,
913
+ session.projectName || '',
914
+ session.projectDisplayName || null,
915
+ session.projectFullPath || null,
916
+ session.firstMessage || null,
917
+ session.gitBranch || null,
918
+ session.provider || null,
919
+ session.model || null,
920
+ session.startedAt || null,
921
+ session.updatedAt || null,
922
+ messages.length,
923
+ session.usageJson || null,
924
+ session.extraJson || null,
925
+ parserVersion
926
+ );
927
+
928
+ let ordinal = 0;
929
+ for (const msg of messages) {
930
+ insertMessage.run(
931
+ source,
932
+ session.sessionId,
933
+ ordinal,
934
+ msg.messageId || null,
935
+ msg.role || null,
936
+ msg.type || null,
937
+ msg.subtype || null,
938
+ msg.content || null,
939
+ msg.timestamp || null,
940
+ msg.model || null,
941
+ msg.provider || null,
942
+ msg.userMessageNumber != null ? msg.userMessageNumber : null,
943
+ msg.extraJson || null
944
+ );
945
+ ordinal++;
391
946
  }
392
947
  }
393
948
 
@@ -401,7 +956,12 @@ function createSessionHistoryIndex(opts = {}) {
401
956
  * @returns {Promise<Array<{name, displayName, fullPath, path, sessionCount, lastUsed, latestSession, source}>>}
402
957
  */
403
958
  async function listProjects(source, options = {}) {
404
- await ensureSourceIndexed(source, { consistency: options.consistency || 'stale-ok' });
959
+ await ensureSourceIndexed(source, {
960
+ force: options.force === true,
961
+ consistency: options.consistency || 'stale-ok',
962
+ allowStaleData: true,
963
+ config: options.config
964
+ });
405
965
  const db = _getDb();
406
966
 
407
967
  const rows = db.prepare(`
@@ -436,7 +996,11 @@ function createSessionHistoryIndex(opts = {}) {
436
996
  * @returns {Promise<Array>}
437
997
  */
438
998
  async function listSessions(source, projectName, options = {}) {
439
- await ensureSourceIndexed(source, { consistency: 'stale-ok' });
999
+ await ensureSourceIndexed(source, {
1000
+ force: options.force === true,
1001
+ consistency: options.consistency || 'stale-ok',
1002
+ config: options.config
1003
+ });
440
1004
 
441
1005
  const db = _getDb();
442
1006
  const rows = db.prepare(`
@@ -445,25 +1009,30 @@ function createSessionHistoryIndex(opts = {}) {
445
1009
  ORDER BY updated_at DESC
446
1010
  `).all(source, projectName);
447
1011
 
448
- return rows.map(r => ({
449
- sessionId: r.session_id,
450
- filePath: r.file_path,
451
- size: r.size,
452
- mtime: r.mtime_ms,
453
- firstMessage: r.first_message,
454
- gitBranch: r.git_branch,
455
- provider: r.provider,
456
- model: r.model,
457
- messageCount: r.message_count,
458
- tokens: r.usage_json ? JSON.parse(r.usage_json) : null,
459
- extra: r.extra_json ? JSON.parse(r.extra_json) : {},
460
- source: r.source,
461
- projectName: r.project_name,
462
- projectDisplayName: r.project_display_name,
463
- projectFullPath: r.project_full_path,
464
- startedAt: r.started_at,
465
- updatedAt: r.updated_at
466
- }));
1012
+ return rows.map(r => {
1013
+ const extra = _safeParseObject(r.extra_json);
1014
+
1015
+ return {
1016
+ sessionId: r.session_id,
1017
+ filePath: r.file_path,
1018
+ size: r.size,
1019
+ mtime: r.mtime_ms,
1020
+ firstMessage: r.first_message,
1021
+ gitBranch: r.git_branch,
1022
+ provider: r.provider,
1023
+ model: r.model,
1024
+ messageCount: r.message_count,
1025
+ tokens: _safeParseJson(r.usage_json, null),
1026
+ extra,
1027
+ source: r.source,
1028
+ projectName: r.project_name,
1029
+ projectHint: extra.projectHint || r.project_name,
1030
+ projectDisplayName: r.project_display_name,
1031
+ projectFullPath: r.project_full_path,
1032
+ startedAt: r.started_at,
1033
+ updatedAt: r.updated_at
1034
+ };
1035
+ });
467
1036
  }
468
1037
 
469
1038
  /**
@@ -473,23 +1042,13 @@ function createSessionHistoryIndex(opts = {}) {
473
1042
  * @returns {Promise<{sessionId, lastModified, size, filePath}|null>}
474
1043
  */
475
1044
  async function getSessionStatus(source, sessionId, options = {}) {
476
- await ensureSourceIndexed(source, { consistency: 'stale-ok' });
1045
+ await _ensureSessionCurrent(source, sessionId);
477
1046
  const db = _getDb();
478
1047
  const row = db.prepare(
479
1048
  'SELECT session_id, mtime_ms, size, file_path FROM session_file WHERE source = ? AND session_id = ?'
480
1049
  ).get(source, sessionId);
481
-
482
1050
  if (!row) return null;
483
1051
 
484
- // Verify file still exists
485
- try {
486
- await fs.promises.stat(row.file_path);
487
- } catch (_) {
488
- // File gone - invalidate and return null
489
- db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, sessionId);
490
- return null;
491
- }
492
-
493
1052
  return {
494
1053
  sessionId: row.session_id,
495
1054
  lastModified: row.mtime_ms,
@@ -575,7 +1134,7 @@ function createSessionHistoryIndex(opts = {}) {
575
1134
  model: r.model,
576
1135
  provider: r.provider,
577
1136
  userMessageNumber: r.user_message_number,
578
- extra: r.extra_json ? JSON.parse(r.extra_json) : {}
1137
+ extra: _safeParseObject(r.extra_json)
579
1138
  })),
580
1139
  metadata: _buildMessageMetadata(sf),
581
1140
  pagination: {
@@ -594,7 +1153,11 @@ function createSessionHistoryIndex(opts = {}) {
594
1153
  * @returns {Promise<Array>}
595
1154
  */
596
1155
  async function getRecentSessions(source, limit = 5, options = {}) {
597
- await ensureSourceIndexed(source, { consistency: 'stale-ok' });
1156
+ await ensureSourceIndexed(source, {
1157
+ force: options.force === true,
1158
+ consistency: options.consistency || 'stale-ok',
1159
+ config: options.config
1160
+ });
598
1161
  const db = _getDb();
599
1162
 
600
1163
  const rows = db.prepare(`
@@ -629,7 +1192,12 @@ function createSessionHistoryIndex(opts = {}) {
629
1192
  * @returns {Promise<Array>}
630
1193
  */
631
1194
  async function searchSessions(source, keyword, options = {}) {
632
- await ensureSourceIndexed(source, { consistency: options.consistency || 'complete' });
1195
+ if (!String(keyword || '').trim()) return [];
1196
+ await ensureSourceIndexed(source, {
1197
+ force: options.force === true,
1198
+ consistency: options.consistency || 'complete',
1199
+ config: options.config
1200
+ });
633
1201
  const db = _getDb();
634
1202
  const contextLength = options.contextLength || 35;
635
1203
  const projectName = options.projectName || null;
@@ -641,13 +1209,15 @@ function createSessionHistoryIndex(opts = {}) {
641
1209
  const needle = String(keyword);
642
1210
  if ([...needle].length >= 3) {
643
1211
  const ftsRows = db.prepare(`
644
- SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp
1212
+ SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp,
1213
+ sf.project_name, sf.project_display_name, sf.project_full_path,
1214
+ sf.file_path, sf.first_message, sf.updated_at
645
1215
  FROM session_message_fts fts
646
1216
  JOIN session_message sm ON sm.rowid = fts.rowid
647
1217
  JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
648
1218
  WHERE sm.source = ? AND session_message_fts MATCH ?
649
1219
  ${projectName ? 'AND sf.project_name = ?' : ''}
650
- ORDER BY sf.updated_at DESC
1220
+ ORDER BY sf.updated_at DESC, sm.ordinal ASC, sm.rowid ASC
651
1221
  LIMIT 500
652
1222
  `);
653
1223
  const params = [source, _ftsQuote(needle)];
@@ -680,20 +1250,26 @@ function createSessionHistoryIndex(opts = {}) {
680
1250
  idx += lowerKeyword.length;
681
1251
  }
682
1252
  if (count === 0) continue;
683
-
684
- if (!matchMap.has(c.session_id)) {
685
- const sf = db.prepare('SELECT * FROM session_file WHERE source = ? AND session_id = ?').get(source, c.session_id);
686
- if (!sf) continue;
687
- matchMap.set(c.session_id, {
688
- session: sf,
1253
+ let entry = matchMap.get(c.session_id);
1254
+ if (!entry) {
1255
+ entry = {
1256
+ session: {
1257
+ source: c.source,
1258
+ session_id: c.session_id,
1259
+ project_name: c.project_name,
1260
+ project_display_name: c.project_display_name,
1261
+ project_full_path: c.project_full_path,
1262
+ file_path: c.file_path,
1263
+ first_message: c.first_message,
1264
+ updated_at: c.updated_at
1265
+ },
689
1266
  matchCount: 0,
690
1267
  messages: []
691
- });
1268
+ };
1269
+ matchMap.set(c.session_id, entry);
692
1270
  }
693
1271
 
694
- const entry = matchMap.get(c.session_id);
695
- // Claude: count every occurrence; Codex/Gemini/OMP: at most 1 per message
696
- entry.matchCount += (source === 'claude') ? count : 1;
1272
+ entry.matchCount += source === 'claude' ? count : 1;
697
1273
  entry.messages.push({
698
1274
  ordinal: c.ordinal,
699
1275
  content,
@@ -724,6 +1300,7 @@ function createSessionHistoryIndex(opts = {}) {
724
1300
 
725
1301
  // Sort: matchCount DESC, then updated_at DESC
726
1302
  results.sort((a, b) => {
1303
+
727
1304
  const cm = b.matchCount - a.matchCount;
728
1305
  if (cm !== 0) return cm;
729
1306
  const aRow = matchMap.get(a.sessionId);
@@ -737,20 +1314,50 @@ function createSessionHistoryIndex(opts = {}) {
737
1314
  }
738
1315
 
739
1316
  function _scanMessagesRelational(db, source, keyword, projectName = null) {
740
- const lowerKeyword = String(keyword).toLocaleLowerCase();
741
- // Use instr for case-insensitive substring matching
742
- let sql = `
743
- SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp
1317
+ const useSqlMatch = /^[\x00-\x7F]*$/.test(String(keyword));
1318
+ const select = `
1319
+ SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp,
1320
+ sf.project_name, sf.project_display_name, sf.project_full_path,
1321
+ sf.file_path, sf.first_message, sf.updated_at
744
1322
  FROM session_message sm
745
1323
  JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
746
- WHERE sm.source = ? AND instr(lower(sm.content), lower(?)) > 0
1324
+ WHERE sm.source = ?
747
1325
  ${projectName ? 'AND sf.project_name = ?' : ''}
748
- ORDER BY sf.updated_at DESC
749
- LIMIT 500
1326
+ ORDER BY sf.updated_at DESC, sm.ordinal ASC, sm.rowid ASC
750
1327
  `;
751
- const params = [source, keyword];
752
- if (projectName) params.push(projectName);
753
- return db.prepare(sql).all(...params);
1328
+
1329
+ if (useSqlMatch) {
1330
+ const sql = `${select.replace('WHERE sm.source = ?', 'WHERE sm.source = ? AND instr(lower(sm.content), lower(?)) > 0')}\nLIMIT 500`;
1331
+ const params = [source, keyword];
1332
+ if (projectName) params.push(projectName);
1333
+ return db.prepare(sql).all(...params);
1334
+ }
1335
+
1336
+ // SQLite's lower() is ASCII-oriented. Page the broad joined query and
1337
+ // apply the same locale-aware JavaScript matcher used by searchSessions,
1338
+ // collecting at most the 500 matching candidates (not 500 raw rows).
1339
+ const candidates = [];
1340
+ const pageSize = 500;
1341
+ let offset = 0;
1342
+ while (candidates.length < pageSize) {
1343
+ const params = [source];
1344
+ if (projectName) params.push(projectName);
1345
+ params.push(pageSize, offset);
1346
+ const batch = db.prepare(`${select}\nLIMIT ? OFFSET ?`).all(...params);
1347
+ if (batch.length === 0) break;
1348
+ for (const row of batch) {
1349
+ if (_containsLocaleMatch(row.content, keyword)) candidates.push(row);
1350
+ if (candidates.length >= pageSize) break;
1351
+ }
1352
+ if (batch.length < pageSize) break;
1353
+ offset += batch.length;
1354
+ }
1355
+ return candidates;
1356
+ }
1357
+
1358
+ function _containsLocaleMatch(content, keyword) {
1359
+ const lowerKeyword = String(keyword).toLocaleLowerCase();
1360
+ return String(content || '').toLocaleLowerCase().indexOf(lowerKeyword) !== -1;
754
1361
  }
755
1362
 
756
1363
  function _extractContext(content, position, contextLength, keywordLength) {
@@ -778,8 +1385,8 @@ function createSessionHistoryIndex(opts = {}) {
778
1385
  provider: sf.provider,
779
1386
  model: sf.model,
780
1387
  messageCount: sf.message_count,
781
- usage: sf.usage_json ? JSON.parse(sf.usage_json) : null,
782
- extra: sf.extra_json ? JSON.parse(sf.extra_json) : {}
1388
+ usage: _safeParseObject(sf.usage_json),
1389
+ extra: _safeParseObject(sf.extra_json)
783
1390
  };
784
1391
  }
785
1392
 
@@ -794,17 +1401,41 @@ function createSessionHistoryIndex(opts = {}) {
794
1401
  ).get(source, sessionId);
795
1402
  if (!row) return;
796
1403
 
797
- let currentStat;
798
- try {
799
- currentStat = await fs.promises.stat(row.file_path);
800
- } catch (_) {
801
- // File gone, session stays indexed as-is
1404
+ const fileKey = `${source}:${row.file_path}`;
1405
+ const now = Date.now();
1406
+ const cached = fileVersions.get(fileKey);
1407
+ if (cached && now - cached.checkedAt < INDEX_INVENTORY_TTL_MS) return;
1408
+
1409
+ const activeCheck = fileChecks.get(fileKey);
1410
+ if (activeCheck) {
1411
+ await activeCheck;
802
1412
  return;
803
1413
  }
804
1414
 
805
- if (currentStat.size !== row.size || currentStat.mtimeMs !== row.mtime_ms) {
806
- // File changed; force re-index
807
- await ensureSourceIndexed(source, { force: true, consistency: 'complete' });
1415
+ const check = (async () => {
1416
+ let currentStat;
1417
+ try {
1418
+ currentStat = await fs.promises.stat(row.file_path);
1419
+ } catch (_) {
1420
+ fileVersions.set(fileKey, { size: -1, mtimeMs: -1, checkedAt: Date.now(), missing: true });
1421
+ db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?').run(source, row.file_path);
1422
+ return;
1423
+ }
1424
+
1425
+ fileVersions.set(fileKey, {
1426
+ size: currentStat.size,
1427
+ mtimeMs: currentStat.mtimeMs,
1428
+ checkedAt: Date.now()
1429
+ });
1430
+ if (currentStat.size !== row.size || currentStat.mtimeMs !== row.mtime_ms) {
1431
+ await ensureSourceIndexed(source, { force: true, consistency: 'complete' });
1432
+ }
1433
+ })();
1434
+ fileChecks.set(fileKey, check);
1435
+ try {
1436
+ await check;
1437
+ } finally {
1438
+ if (fileChecks.get(fileKey) === check) fileChecks.delete(fileKey);
808
1439
  }
809
1440
  }
810
1441
 
@@ -819,16 +1450,38 @@ function createSessionHistoryIndex(opts = {}) {
819
1450
  if (options.deleted && options.sessionId) {
820
1451
  db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, options.sessionId);
821
1452
  }
1453
+ if (options.projectName) {
1454
+ db.prepare('DELETE FROM session_file WHERE source = ? AND project_name = ?').run(source, options.projectName);
1455
+ }
822
1456
 
823
1457
  // Mark state as stale
824
1458
  db.prepare(
825
1459
  'UPDATE source_state SET last_inventory_ms = NULL WHERE source = ?'
826
1460
  ).run(source);
1461
+ for (const key of fileVersions.keys()) {
1462
+ if (key.startsWith(`${source}:`)) fileVersions.delete(key);
1463
+ }
1464
+ }
1465
+ function getSourceIndexMeta(source) {
1466
+ const row = _getDb().prepare(
1467
+ 'SELECT last_inventory_ms, last_error FROM source_state WHERE source = ?'
1468
+ ).get(source);
1469
+ const generatedAt = row?.last_inventory_ms ? Number(row.last_inventory_ms) : null;
1470
+ const stale = !generatedAt || Date.now() - generatedAt >= INDEX_INVENTORY_TTL_MS;
1471
+ const refreshing = _inflight.has(`ensure:${source}`);
1472
+ return {
1473
+ generatedAt,
1474
+ stale,
1475
+ refreshing,
1476
+ fallback: Boolean(row?.last_error && !_hasUsableIndexedData(source)),
1477
+ error: row?.last_error || null
1478
+ };
827
1479
  }
828
-
829
1480
  function closeSessionHistoryIndex() {
830
1481
  closeDatabase(dbPath);
831
1482
  _db = null;
1483
+ fileVersions.clear();
1484
+ fileChecks.clear();
832
1485
  }
833
1486
 
834
1487
  // Build the API object
@@ -842,6 +1495,7 @@ function createSessionHistoryIndex(opts = {}) {
842
1495
  getRecentSessions,
843
1496
  searchSessions,
844
1497
  invalidateSource,
1498
+ getSourceIndexMeta,
845
1499
  closeSessionHistoryIndex,
846
1500
  };
847
1501
 
@@ -858,9 +1512,11 @@ function createSessionHistoryIndex(opts = {}) {
858
1512
 
859
1513
  let _defaultIndex = null;
860
1514
 
861
- function _getDefaultIndex() {
1515
+ function _getDefaultIndex(options = {}) {
862
1516
  if (!_defaultIndex) {
863
- _defaultIndex = createSessionHistoryIndex();
1517
+ _defaultIndex = Object.keys(options).length > 0
1518
+ ? createSessionHistoryIndex({ config: options })
1519
+ : createSessionHistoryIndex();
864
1520
  }
865
1521
  return _defaultIndex;
866
1522
  }
@@ -870,7 +1526,7 @@ async function ensureSourceIndexed(source, options) {
870
1526
  }
871
1527
 
872
1528
  async function listProjects(source, options) {
873
- return _getDefaultIndex().listProjects(source, options);
1529
+ return _getDefaultIndex(options).listProjects(source, options);
874
1530
  }
875
1531
 
876
1532
  async function listSessions(source, projectName, options) {
@@ -897,6 +1553,10 @@ async function searchSessions(source, keyword, options) {
897
1553
  return _getDefaultIndex().searchSessions(source, keyword, options);
898
1554
  }
899
1555
 
1556
+ function getSourceIndexMeta(source) {
1557
+ return _getDefaultIndex().getSourceIndexMeta(source);
1558
+ }
1559
+
900
1560
  function invalidateSource(source, options) {
901
1561
  _getDefaultIndex().invalidateSource(source, options);
902
1562
  }
@@ -926,5 +1586,7 @@ module.exports = {
926
1586
  getRecentSessions,
927
1587
  searchSessions,
928
1588
  invalidateSource,
929
- closeSessionHistoryIndex
1589
+ getSourceIndexMeta,
1590
+ closeSessionHistoryIndex,
1591
+ cleanupStaleInventoryLocks,
930
1592
  };