@vantran-se/openchamber-web 2.1.1 → 2.2.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 (442) hide show
  1. package/README.md +32 -24
  2. package/bin/cli.js +1 -3
  3. package/bin/cli.test.js +40 -16
  4. package/bin/lib/DOCUMENTATION.md +6 -5
  5. package/bin/lib/cli-args.js +6 -1
  6. package/bin/lib/cli-args.test.js +34 -0
  7. package/bin/lib/cli-lifecycle.js +0 -1
  8. package/bin/lib/cli-network.js +8 -2
  9. package/bin/lib/cli-process.js +1 -1
  10. package/bin/lib/cli-settings-accessors.test.js +21 -13
  11. package/bin/lib/cli-startup.js +17 -11
  12. package/bin/lib/cli-startup.test.js +92 -2
  13. package/bin/lib/commands-connect-url.js +8 -1
  14. package/bin/lib/commands-schedule.js +8 -2
  15. package/bin/lib/commands-serve.js +15 -21
  16. package/bin/lib/commands-update.js +12 -24
  17. package/bin/lib/commands-update.test.js +14 -46
  18. package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
  19. package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
  20. package/dist/assets/FilesView-BVoD8I2x.js +14 -0
  21. package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
  22. package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
  23. package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
  24. package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
  25. package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
  26. package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
  27. package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
  28. package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
  29. package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
  30. package/dist/assets/PlanView-RCBadcY7.js +5 -0
  31. package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
  32. package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
  33. package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
  34. package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
  35. package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
  36. package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
  37. package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
  38. package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
  39. package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
  40. package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
  41. package/dist/assets/de-YKfk5MmC.js +14 -0
  42. package/dist/assets/es-_gwT0kr-.js +20 -0
  43. package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
  44. package/dist/assets/fr-DqzvXmIm.js +18 -0
  45. package/dist/assets/index-BLaHKdtx.js +1 -0
  46. package/dist/assets/index-BkMXRrVQ.css +1 -0
  47. package/dist/assets/index-DxNa7xYB.js +65 -0
  48. package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
  49. package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
  50. package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
  51. package/dist/assets/ja-B-GFYhQE.js +20 -0
  52. package/dist/assets/ko-mzksfqDC.js +20 -0
  53. package/dist/assets/main-C5Xx8dn_.js +2 -0
  54. package/dist/assets/main-CfBSQM1H.js +990 -0
  55. package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
  56. package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
  57. package/dist/assets/mobile-DeEQd0wZ.js +2 -0
  58. package/dist/assets/nl-CWQr6NSq.js +20 -0
  59. package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
  60. package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
  61. package/dist/assets/pl-UmRcDUX2.js +20 -0
  62. package/dist/assets/pt-BR-BSLybvVF.js +20 -0
  63. package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
  64. package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
  65. package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
  66. package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
  67. package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
  68. package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
  69. package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
  70. package/dist/assets/space-apply-DM_2pRW7.js +1 -0
  71. package/dist/assets/space-close-Bpomt8z0.js +1 -0
  72. package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
  73. package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
  74. package/dist/assets/tr-gaTrX_HK.js +11 -0
  75. package/dist/assets/uk-CnJF6ykl.js +20 -0
  76. package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
  77. package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
  78. package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
  79. package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
  84. package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
  86. package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
  87. package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
  88. package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
  89. package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
  90. package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
  91. package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
  92. package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
  93. package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
  94. package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
  95. package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
  96. package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
  97. package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
  98. package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
  99. package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
  100. package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
  101. package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
  102. package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
  103. package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
  104. package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
  105. package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
  106. package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
  107. package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
  108. package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
  109. package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
  110. package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
  111. package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
  112. package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
  113. package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
  114. package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
  115. package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
  116. package/dist/assets/zh-CN-t2vigSB1.js +20 -0
  117. package/dist/assets/zh-TW-jAgqBokB.js +20 -0
  118. package/dist/index.html +6 -6
  119. package/dist/mini-chat.html +6 -6
  120. package/dist/mobile.html +6 -6
  121. package/package.json +8 -5
  122. package/server/index.js +80 -36
  123. package/server/lib/agent-memory/actions.js +1 -6
  124. package/server/lib/agent-memory/routes.js +1 -2
  125. package/server/lib/agent-memory/runtime.js +1 -8
  126. package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
  127. package/server/lib/agent-tool/runtime.js +3 -6
  128. package/server/lib/agent-tool/runtime.test.js +0 -2
  129. package/server/lib/browser-control/DOCUMENTATION.md +6 -0
  130. package/server/lib/changelog/update-notes.js +1 -1
  131. package/server/lib/context-obligatory/runtime.js +1 -2
  132. package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
  133. package/server/lib/dev-tunnel/client.js +21 -10
  134. package/server/lib/dev-tunnel/tunnel.test.js +34 -0
  135. package/server/lib/dispatch-results/runtime.js +1 -1
  136. package/server/lib/enterprise-mode.d.ts +1 -0
  137. package/server/lib/enterprise-mode.js +13 -2
  138. package/server/lib/enterprise-mode.test.js +21 -0
  139. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  140. package/server/lib/event-stream/index.js +1 -3
  141. package/server/lib/event-stream/translate-v2.js +1 -9
  142. package/server/lib/event-stream/translate-v2.test.js +0 -11
  143. package/server/lib/fs/DOCUMENTATION.md +5 -2
  144. package/server/lib/fs/routes.js +62 -15
  145. package/server/lib/fs/routes.test.js +278 -48
  146. package/server/lib/fs/workspace-file-names.js +104 -0
  147. package/server/lib/fs/workspace-file-names.test.js +62 -0
  148. package/server/lib/git/DOCUMENTATION.md +17 -5
  149. package/server/lib/git/contributor-provenance-storage.js +9 -6
  150. package/server/lib/git/contributor-provenance-storage.test.js +29 -0
  151. package/server/lib/git/credential-broker.js +1 -1
  152. package/server/lib/git/credential-resolver.js +1 -4
  153. package/server/lib/git/credentials.js +1 -0
  154. package/server/lib/git/discovery-endpoint.js +1 -1
  155. package/server/lib/git/identity-provisioning.js +2 -1
  156. package/server/lib/git/identity-storage.js +3 -4
  157. package/server/lib/git/lfs-discovery.js +14 -14
  158. package/server/lib/git/lfs-discovery.test.js +16 -6
  159. package/server/lib/git/network-operation-plan.js +50 -62
  160. package/server/lib/git/network-operation-plan.test.js +103 -1
  161. package/server/lib/git/network-operation-registry.js +4 -6
  162. package/server/lib/git/network-operation-storage.js +4 -6
  163. package/server/lib/git/network-operations.js +12 -4
  164. package/server/lib/git/network-operations.test.js +19 -6
  165. package/server/lib/git/redaction.js +2 -1
  166. package/server/lib/git/repository-credential-runtime.js +5 -3
  167. package/server/lib/git/repository-credential-runtime.test.js +22 -0
  168. package/server/lib/git/routes.js +31 -137
  169. package/server/lib/git/routes.test.js +2 -2
  170. package/server/lib/git/service.js +143 -27
  171. package/server/lib/git/service.test.js +215 -1
  172. package/server/lib/git/ssh-credential-storage.js +1 -4
  173. package/server/lib/git/submodule-discovery.js +1 -1
  174. package/server/lib/git/worktree-bootstrap-storage.js +3 -4
  175. package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
  176. package/server/lib/git/worktree-creation.js +194 -0
  177. package/server/lib/git/worktree-creation.test.js +155 -0
  178. package/server/lib/github/DOCUMENTATION.md +11 -6
  179. package/server/lib/github/auth.js +2 -4
  180. package/server/lib/github/device-flow.js +2 -2
  181. package/server/lib/github/gh-cli-credential.js +1 -0
  182. package/server/lib/github/octokit.js +31 -0
  183. package/server/lib/github/pr-status.js +35 -18
  184. package/server/lib/github/pr-status.test.js +271 -3
  185. package/server/lib/github/reference-search.js +102 -13
  186. package/server/lib/github/reference-search.test.js +48 -7
  187. package/server/lib/github/routes.auth.test.js +18 -0
  188. package/server/lib/github/routes.js +360 -6
  189. package/server/lib/github/routes.mutations.test.js +239 -0
  190. package/server/lib/github/routes.test.js +134 -2
  191. package/server/lib/gitlab/DOCUMENTATION.md +5 -1
  192. package/server/lib/gitlab/auth-storage.test.js +1 -0
  193. package/server/lib/gitlab/mappers.js +30 -1
  194. package/server/lib/gitlab/resources.js +230 -6
  195. package/server/lib/gitlab/resources.test.js +179 -0
  196. package/server/lib/gitlab/routes.js +200 -6
  197. package/server/lib/gitlab/routes.test.js +186 -2
  198. package/server/lib/gitlab/validation.js +1 -5
  199. package/server/lib/guests/DOCUMENTATION.md +1 -1
  200. package/server/lib/guests/oauth.js +2 -2
  201. package/server/lib/guests/service.js +1 -1
  202. package/server/lib/guests/sockets.js +1 -1
  203. package/server/lib/injected-env.d.ts +8 -0
  204. package/server/lib/injected-env.js +50 -0
  205. package/server/lib/injected-env.test.js +56 -0
  206. package/server/lib/linear/DOCUMENTATION.md +5 -6
  207. package/server/lib/linear/issues.js +62 -4
  208. package/server/lib/linear/issues.test.js +68 -0
  209. package/server/lib/linear/oauth.js +5 -5
  210. package/server/lib/linear/parse.js +2 -9
  211. package/server/lib/message-queue/DOCUMENTATION.md +23 -1
  212. package/server/lib/message-queue/runtime.js +85 -12
  213. package/server/lib/message-queue/runtime.test.js +172 -2
  214. package/server/lib/notifications/apns-runtime.js +5 -6
  215. package/server/lib/notifications/apns-runtime.test.js +20 -4
  216. package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
  217. package/server/lib/openchamber-control/actions.js +2 -1
  218. package/server/lib/openchamber-control/service.js +119 -20
  219. package/server/lib/openchamber-control/service.test.js +166 -0
  220. package/server/lib/openchamber-sessions/archive-store.js +1 -6
  221. package/server/lib/openchamber-sessions/routes.js +47 -12
  222. package/server/lib/openchamber-sessions/routes.test.js +65 -2
  223. package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
  224. package/server/lib/opencode/DOCUMENTATION.md +22 -8
  225. package/server/lib/opencode/agents.js +62 -0
  226. package/server/lib/opencode/auth-state-runtime.js +3 -1
  227. package/server/lib/opencode/auth.d.ts +1 -1
  228. package/server/lib/opencode/auth.js +8 -0
  229. package/server/lib/opencode/auth.test.js +31 -0
  230. package/server/lib/opencode/config-entity-routes.js +16 -0
  231. package/server/lib/opencode/config-entity-v2.test.js +16 -1
  232. package/server/lib/opencode/config-paths.test.js +2 -2
  233. package/server/lib/opencode/config-v2.js +2 -4
  234. package/server/lib/opencode/feature-routes-runtime.js +89 -49
  235. package/server/lib/opencode/lifecycle.js +41 -21
  236. package/server/lib/opencode/lifecycle.test.js +50 -2
  237. package/server/lib/opencode/model-discovery.js +1 -1
  238. package/server/lib/opencode/model-discovery.test.js +17 -0
  239. package/server/lib/opencode/models-metadata.js +0 -2
  240. package/server/lib/opencode/openchamber-routes.test.js +3 -3
  241. package/server/lib/opencode/plugins.js +6 -9
  242. package/server/lib/opencode/provider-source-route.test.js +101 -3
  243. package/server/lib/opencode/providers-disabled.test.js +59 -0
  244. package/server/lib/opencode/providers.js +34 -0
  245. package/server/lib/opencode/proxy.js +11 -3
  246. package/server/lib/opencode/routes.js +40 -2
  247. package/server/lib/opencode/settings-helpers.js +27 -1
  248. package/server/lib/opencode/settings-helpers.test.js +19 -7
  249. package/server/lib/opencode/settings-registry.json +27 -16
  250. package/server/lib/opencode/settings-runtime.test.js +18 -0
  251. package/server/lib/opencode/shared-service-runtime.js +23 -104
  252. package/server/lib/opencode/shared.js +77 -46
  253. package/server/lib/opencode/shared.test.js +12 -12
  254. package/server/lib/opencode/shutdown-runtime.test.js +2 -3
  255. package/server/lib/opencode/skill-routes.js +123 -38
  256. package/server/lib/opencode/skill-routes.test.js +201 -0
  257. package/server/lib/opencode/skills.js +81 -34
  258. package/server/lib/opencode/skills.test.js +176 -21
  259. package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
  260. package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
  261. package/server/lib/opencode/watcher.test.js +0 -54
  262. package/server/lib/outbound-proxy.js +52 -0
  263. package/server/lib/outbound-proxy.test.js +55 -0
  264. package/server/lib/package-manager.js +23 -17
  265. package/server/lib/package-manager.test.js +5 -18
  266. package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
  267. package/server/lib/permission-auto-accept/runtime.js +19 -47
  268. package/server/lib/permission-auto-accept/runtime.test.js +2 -2
  269. package/server/lib/project-context/DOCUMENTATION.md +7 -1
  270. package/server/lib/project-context/routes.js +1 -2
  271. package/server/lib/project-context/runtime.js +12 -14
  272. package/server/lib/project-context/runtime.test.js +78 -2
  273. package/server/lib/projects/project-config.js +30 -15
  274. package/server/lib/projects/project-config.test.js +30 -0
  275. package/server/lib/projects/project-setup.js +1 -2
  276. package/server/lib/projects/routes.js +1 -2
  277. package/server/lib/quota/DOCUMENTATION.md +35 -7
  278. package/server/lib/quota/index.js +1 -0
  279. package/server/lib/quota/providers/claude/index.js +1 -1
  280. package/server/lib/quota/providers/cline-pass.js +1 -1
  281. package/server/lib/quota/providers/exe-dev.js +1 -1
  282. package/server/lib/quota/providers/google/index.js +1 -1
  283. package/server/lib/quota/providers/hyper.js +1 -1
  284. package/server/lib/quota/providers/kimi.js +116 -36
  285. package/server/lib/quota/providers/kimi.test.js +128 -3
  286. package/server/lib/quota/providers/opencode-go.js +144 -21
  287. package/server/lib/quota/providers/opencode-go.test.js +229 -6
  288. package/server/lib/quota/providers/openrouter.js +1 -1
  289. package/server/lib/quota/providers/zenmux.js +1 -1
  290. package/server/lib/quota/utils/auth.js +1 -0
  291. package/server/lib/quota/utils/transformers.js +2 -6
  292. package/server/lib/relay/DOCUMENTATION.md +18 -3
  293. package/server/lib/relay/e2ee.js +7 -7
  294. package/server/lib/relay/host-client.js +84 -13
  295. package/server/lib/relay/host-client.test.js +201 -1
  296. package/server/lib/relay/identity.js +6 -29
  297. package/server/lib/relay/identity.test.js +18 -28
  298. package/server/lib/relay/key-store.js +141 -0
  299. package/server/lib/relay/key-store.test.js +138 -0
  300. package/server/lib/relay/service.js +8 -8
  301. package/server/lib/relay/service.test.js +10 -5
  302. package/server/lib/relay/signing-key.js +13 -36
  303. package/server/lib/relay/tunnel-codec.js +10 -10
  304. package/server/lib/route-module-exports.test.js +22 -0
  305. package/server/lib/routing/DOCUMENTATION.md +23 -3
  306. package/server/lib/routing/cache.js +87 -0
  307. package/server/lib/routing/defaults.js +11 -0
  308. package/server/lib/routing/history.js +12 -4
  309. package/server/lib/routing/runtime.js +39 -3
  310. package/server/lib/routing/runtime.test.js +81 -2
  311. package/server/lib/routing/store.js +12 -1
  312. package/server/lib/routing/store.test.js +13 -0
  313. package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
  314. package/server/lib/scheduled-tasks/existing-session.js +119 -0
  315. package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
  316. package/server/lib/scheduled-tasks/loops.js +1 -8
  317. package/server/lib/scheduled-tasks/routes.js +8 -8
  318. package/server/lib/scheduled-tasks/runtime.js +103 -13
  319. package/server/lib/scheduled-tasks/runtime.test.js +48 -0
  320. package/server/lib/scheduled-tasks/service.js +33 -11
  321. package/server/lib/scheduled-tasks/service.test.js +128 -2
  322. package/server/lib/scheduled-tasks/session-defaults.js +27 -0
  323. package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
  324. package/server/lib/session-assist/context.js +1 -0
  325. package/server/lib/session-folders/routes.js +2 -2
  326. package/server/lib/session-goal/objectives.js +1 -1
  327. package/server/lib/session-knowledge/routes.js +2 -2
  328. package/server/lib/session-knowledge/runtime.js +1 -2
  329. package/server/lib/session-work/DOCUMENTATION.md +39 -14
  330. package/server/lib/session-work/questions.js +31 -2
  331. package/server/lib/session-work/runtime.js +22 -13
  332. package/server/lib/session-work/runtime.test.js +43 -26
  333. package/server/lib/session-work/state.js +10 -4
  334. package/server/lib/session-work/state.test.js +8 -1
  335. package/server/lib/shared/DOCUMENTATION.md +89 -0
  336. package/server/lib/shared/guards.js +53 -0
  337. package/server/lib/shared/guards.test.js +112 -0
  338. package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
  339. package/server/lib/skills-catalog/cache.js +3 -2
  340. package/server/lib/skills-catalog/curated-sources.js +5 -1
  341. package/server/lib/skills-catalog/git.js +107 -20
  342. package/server/lib/skills-catalog/git.test.js +78 -0
  343. package/server/lib/skills-catalog/install.js +21 -12
  344. package/server/lib/skills-catalog/scan.js +25 -15
  345. package/server/lib/skills-catalog/source.js +26 -1
  346. package/server/lib/skills-catalog/source.test.js +27 -0
  347. package/server/lib/source-control/DOCUMENTATION.md +2 -1
  348. package/server/lib/source-control/audit-storage.js +7 -6
  349. package/server/lib/source-control/audit-storage.test.js +11 -0
  350. package/server/lib/source-control/binding-service.js +2 -5
  351. package/server/lib/source-control/change-request-worktree.js +90 -0
  352. package/server/lib/source-control/change-request-worktree.test.js +121 -0
  353. package/server/lib/source-control/mutation-executor.js +1 -1
  354. package/server/lib/source-control/mutation-storage.js +11 -5
  355. package/server/lib/source-control/provider-instance.js +1 -2
  356. package/server/lib/source-control/repository-identity.js +1 -1
  357. package/server/lib/source-control/routes.js +14 -2
  358. package/server/lib/spaces/DOCUMENTATION.md +42 -17
  359. package/server/lib/spaces/command-stream.test.js +15 -0
  360. package/server/lib/spaces/dispatcher.js +7 -0
  361. package/server/lib/spaces/dispatcher.test.js +17 -0
  362. package/server/lib/spaces/hardening.js +2 -2
  363. package/server/lib/spaces/hardening.test.js +1 -1
  364. package/server/lib/spaces/host.js +2 -0
  365. package/server/lib/spaces/journey.js +66 -5
  366. package/server/lib/spaces/journey.test.js +95 -4
  367. package/server/lib/spaces/layout.js +1 -1
  368. package/server/lib/spaces/places/docker-disk.js +38 -14
  369. package/server/lib/spaces/places/docker-disk.test.js +80 -1
  370. package/server/lib/spaces/places/docker.js +35 -11
  371. package/server/lib/spaces/places/fake-docker.js +22 -3
  372. package/server/lib/spaces/places/memory-place.js +3 -0
  373. package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
  374. package/server/lib/spaces/routes.js +5 -0
  375. package/server/lib/spaces/routes.test.js +4 -2
  376. package/server/lib/spaces/run-command.js +9 -2
  377. package/server/lib/spaces/tools.js +4 -5
  378. package/server/lib/spaces/tools.test.js +2 -2
  379. package/server/lib/terminal/runtime.js +18 -6
  380. package/server/lib/terminal/runtime.test.js +66 -0
  381. package/server/lib/tts/index.js +3 -0
  382. package/server/lib/tts/service.js +4 -2
  383. package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
  384. package/server/lib/walkthrough/index.js +2 -3
  385. package/server/lib/walkthrough/pr-source.test.js +22 -0
  386. package/server/lib/walkthrough/sources.js +6 -1
  387. package/server/opencode-proxy.test.js +127 -0
  388. package/bin/lib/commands-startup.test.js +0 -15
  389. package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
  390. package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
  391. package/dist/assets/FilesView-DT0uEYgT.js +0 -17
  392. package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
  393. package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
  394. package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
  395. package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
  396. package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
  397. package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
  398. package/dist/assets/PlanView-DsRWnsOI.js +0 -5
  399. package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
  400. package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
  401. package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
  402. package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
  403. package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
  404. package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
  405. package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
  406. package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
  407. package/dist/assets/de-CBla4hjd.js +0 -14
  408. package/dist/assets/es-BmnZtxTD.js +0 -20
  409. package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
  410. package/dist/assets/fr-OVKIBUZO.js +0 -18
  411. package/dist/assets/index-CKCFKmjF.js +0 -1
  412. package/dist/assets/index-Dbx4V4GN.css +0 -1
  413. package/dist/assets/index-vQ1Pg1cr.js +0 -61
  414. package/dist/assets/ja-6v-yr-gE.js +0 -20
  415. package/dist/assets/ko-I_ZLvgQ9.js +0 -20
  416. package/dist/assets/main-CmO4dRtR.js +0 -2
  417. package/dist/assets/main-b9vqhlYM.js +0 -999
  418. package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
  419. package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
  420. package/dist/assets/mobile-CVtShif9.js +0 -2
  421. package/dist/assets/nl-DcvIJf46.js +0 -20
  422. package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
  423. package/dist/assets/pl-JxCUl0sd.js +0 -20
  424. package/dist/assets/pt-BR-L89mjja2.js +0 -20
  425. package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
  426. package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
  427. package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
  428. package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
  429. package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
  430. package/dist/assets/space-apply-1trGVIl8.js +0 -1
  431. package/dist/assets/space-close-y7j-NuBv.js +0 -1
  432. package/dist/assets/tr-ka5J1flt.js +0 -11
  433. package/dist/assets/uk-PyGe6Q_u.js +0 -20
  434. package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
  435. package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
  436. package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
  437. package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
  438. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
  439. package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
  440. package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
  441. package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
  442. package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
@@ -0,0 +1,87 @@
1
+ /**
2
+ * "Try to preserve cache usage". A provider keeps a prompt cache per model, so
3
+ * switching models mid-session makes the new model read the whole context
4
+ * uncached, and the old model's cache goes cold while the session is away from
5
+ * it. While the model that wrote the last answer likely still holds a warm
6
+ * cache, Auto stays on it instead of moving down to a lower category; moving up
7
+ * is never held back.
8
+ *
9
+ * "Lower" is the built-in category order: trivial < research < implement <
10
+ * hard. Categories the user added have no place in that order, so a send routed
11
+ * to one of them switches as it always did.
12
+ */
13
+ import { z } from 'zod';
14
+ import { BUILTIN_CATEGORIES } from './defaults.js';
15
+
16
+ /** How long a provider keeps a prompt cache after its last use. */
17
+ export const PROMPT_CACHE_TTL_MS = 5 * 60 * 1000;
18
+
19
+ /** OpenCode's warming `duration` when the config says only `true` (core `plugin/warming.ts`). */
20
+ const DEFAULT_WARMING_DURATION_MS = 30 * 60 * 1000;
21
+
22
+ const RANK = new Map(BUILTIN_CATEGORIES.map((category, index) => [category.id, index]));
23
+
24
+ const rankOf = (category) => (category.builtin ? RANK.get(category.id) ?? null : null);
25
+
26
+ const sameModel = (a, b) => a.providerID === b.providerID && a.modelID === b.modelID;
27
+
28
+ /**
29
+ * The category Auto keeps instead of `chosen`, or null to switch as Jev said.
30
+ * `current` is the model that wrote the session's last answer. Several
31
+ * categories can share that model; the highest of them is where the session
32
+ * is, so a choice below it is a move down. Only a category that runs the same
33
+ * agent as `chosen` is kept: another agent sends another system prompt, which
34
+ * misses the cache anyway, and would run the request with tools it did not ask
35
+ * for (a planning agent for an edit).
36
+ */
37
+ export const cacheHoldCategory = ({ categories, fallback, composerAgent, chosen, current }) => {
38
+ const chosenRank = chosen ? rankOf(chosen) : null;
39
+ if (chosenRank === null || !current) return null;
40
+ const modelOf = (category) => category.model ?? fallback.model;
41
+ const agentOf = (category) => category.agent || composerAgent || null;
42
+ if (sameModel(modelOf(chosen), current)) return null;
43
+ let held = null;
44
+ for (const category of categories) {
45
+ const rank = rankOf(category);
46
+ if (rank === null || !sameModel(modelOf(category), current) || agentOf(category) !== agentOf(chosen)) continue;
47
+ if (!held || rank > rankOf(held)) held = category;
48
+ }
49
+ return held && rankOf(held) > chosenRank ? held : null;
50
+ };
51
+
52
+ const DURATION = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i;
53
+ const UNIT_MS = {
54
+ ms: 1, millis: 1, millisecond: 1, milliseconds: 1,
55
+ s: 1000, second: 1000, seconds: 1000,
56
+ m: 60_000, minute: 60_000, minutes: 60_000,
57
+ h: 3_600_000, hour: 3_600_000, hours: 3_600_000,
58
+ };
59
+
60
+ /** OpenCode answers a duration as `"1800000 millis"`; a hand-written `"30 minutes"` reads too. */
61
+ const durationMs = (text) => {
62
+ const match = DURATION.exec(text.trim());
63
+ const unit = match ? UNIT_MS[match[2].toLowerCase()] : undefined;
64
+ const ms = unit ? Number(match[1]) * unit : NaN;
65
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
66
+ };
67
+
68
+ const warmingSchema = z.union([z.boolean(), z.object({ duration: z.string().optional() })]);
69
+ const configEntriesSchema = z.array(z.object({ info: z.object({ warming: warmingSchema.optional() }).nullish() }));
70
+
71
+ /**
72
+ * How long after its last answer a session's cache stays warm, from the config
73
+ * entries OpenCode answers for the directory (lowest priority first, the last
74
+ * one that sets `warming` wins, as OpenCode reads it). With warming on,
75
+ * OpenCode pings the model until `duration` after the last turn, and the last
76
+ * ping keeps the cache one more lifetime. Entries this cannot read count as
77
+ * warming off: holding a model on a cache that went cold is the costlier
78
+ * mistake.
79
+ */
80
+ export const warmWindowMs = (configEntries) => {
81
+ const parsed = configEntriesSchema.safeParse(configEntries);
82
+ if (!parsed.success) return PROMPT_CACHE_TTL_MS;
83
+ const warming = parsed.data.findLast((entry) => entry.info?.warming !== undefined)?.info.warming;
84
+ if (!warming) return PROMPT_CACHE_TTL_MS;
85
+ const duration = warming === true || !warming.duration ? null : durationMs(warming.duration);
86
+ return (duration ?? DEFAULT_WARMING_DURATION_MS) + PROMPT_CACHE_TTL_MS;
87
+ };
@@ -79,11 +79,20 @@ export const JEV_TIMEOUT_MS = 4000;
79
79
  * request refers to, and that finished work carries no weight, put them at
80
80
  * 97% and ~75% trivial while "ok, do it" after a plan stayed implement and a
81
81
  * regression report stayed hard (jev-router lab, test/follow-up-instructions.js).
82
+ *
83
+ * The fourth line covers a bare approval, which the third would otherwise
84
+ * read as trivial: "go" after a plan carries no work of its own, so it takes
85
+ * the category of the work it approves (the #4374 author measured this on
86
+ * their own prompts; not yet replayed in the jev-router lab).
87
+ * It does not let finished work raise a follow-up; a follow-up that only
88
+ * looks cheap because it reuses a warm context is the router's concern, not
89
+ * the classifier's.
82
90
  */
83
91
  export const ROUTING_INSTRUCTIONS = [
84
92
  'Pick the task category that best describes this coding request, judging the reasoning it demands rather than the length of the reply it asks for.',
85
93
  'A request that wants a one-line answer to a hard debugging or design question is still hard; a long but mechanical edit is still trivial.',
86
94
  'The thing to classify is `request`, the latest user message. Read `history` (earlier conversation, oldest first) only to understand what `request` refers to, then judge the reasoning `request` itself demands. Do not let the difficulty of work already done in `history` raise or lower the category.',
95
+ 'A bare approval such as "go", "yes", "approve", or "do it" authorises the work just described in history: classify it as the category that work demands, not as trivial.',
87
96
  ];
88
97
 
89
98
  /**
@@ -139,4 +148,6 @@ export const BUILTIN_CATEGORIES = [
139
148
  ];
140
149
 
141
150
  export const DEFAULT_MIN_CONFIDENCE = 0.6;
151
+ /** "Try to preserve cache usage" (`cache.js`) is on unless the user turned it off. */
152
+ export const DEFAULT_PRESERVE_CACHE = true;
142
153
  export const DEFAULT_SAFETY_THRESHOLD = 0.6;
@@ -23,12 +23,20 @@ export const turnsToHistory = (turns, limits = HISTORY_LIMITS) => {
23
23
  return history;
24
24
  };
25
25
 
26
+ /** The model that wrote a settled answer and when it finished, or null when the record lacks either. */
27
+ const answeredBy = (message) => (message?.providerID && message.modelID && message.completed
28
+ ? { providerID: message.providerID, modelID: message.modelID, completed: message.completed }
29
+ : null);
30
+
26
31
  /**
27
- * Empty when the session has no settled answer yet (a new session, or one
28
- * interrupted mid-turn) — routing then judges the request on its own. A read
29
- * failure is thrown so the caller can decide; it is not an empty history.
32
+ * `history` is empty and `lastAnswer` null when the session has no settled
33
+ * answer yet (a new session, or one interrupted mid-turn): routing then judges
34
+ * the request on its own and has no cache to preserve. A read failure is
35
+ * thrown so the caller can decide; it is not an empty history.
30
36
  */
31
37
  export const loadRoutingHistory = async ({ readPage, signal }) => {
32
38
  const context = await loadAssistContext({ readPage, signal });
33
- return context ? turnsToHistory(context.turns) : [];
39
+ return context
40
+ ? { history: turnsToHistory(context.turns), lastAnswer: answeredBy(context.last) }
41
+ : { history: [], lastAnswer: null };
34
42
  };
@@ -26,6 +26,7 @@ import {
26
26
  resolveClassifier,
27
27
  } from './classifier.js';
28
28
  import { loadRoutingHistory } from './history.js';
29
+ import { PROMPT_CACHE_TTL_MS, cacheHoldCategory, warmWindowMs } from './cache.js';
29
30
  import { readOpenCodeCredentials } from '../opencode/auth.js';
30
31
  import { ENTERPRISE_MODE_ERROR, isEnterpriseMode } from '../enterprise-mode.js';
31
32
 
@@ -122,6 +123,8 @@ export function createRoutingRuntime({
122
123
  enterpriseMode = isEnterpriseMode,
123
124
  readPinnedEndpoint = readPinnedCustomEndpoint,
124
125
  listCatalogModels = null,
126
+ readSessionHistory = null,
127
+ readConfigEntries = null,
125
128
  now = Date.now,
126
129
  }) {
127
130
  const permissionDecisions = new Map();
@@ -220,7 +223,7 @@ export function createRoutingRuntime({
220
223
  return OpenCode.make({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers });
221
224
  };
222
225
 
223
- const readHistory = async ({ sessionId, directory }) => {
226
+ const readHistory = readSessionHistory ?? (async ({ sessionId, directory }) => {
224
227
  const client = openCodeClient(directory);
225
228
  const signal = AbortSignal.timeout(HISTORY_TIMEOUT_MS);
226
229
  return loadRoutingHistory({
@@ -231,6 +234,31 @@ export function createRoutingRuntime({
231
234
  { signal },
232
235
  ),
233
236
  });
237
+ });
238
+
239
+ // v2 answers the effective config as its documents, lowest priority first.
240
+ const readConfig = readConfigEntries ?? ((directory) => openCodeClient(directory).config.get());
241
+
242
+ /**
243
+ * "Try to preserve cache usage": the category to stay on instead of a lower
244
+ * one while the model that wrote the last answer likely still holds a warm
245
+ * prompt cache, or null to switch as Jev said (`cache.js`). OpenCode's
246
+ * warming config is read only once the plain cache lifetime has passed.
247
+ */
248
+ const cacheHold = async ({ config, chosen, composerAgent, lastAnswer, directory }) => {
249
+ if (!config.preserveCache || !lastAnswer) return null;
250
+ const held = cacheHoldCategory({
251
+ categories: enabledCategories(config), fallback: config.fallback, composerAgent, chosen, current: lastAnswer,
252
+ });
253
+ if (!held) return null;
254
+ const idle = now() - lastAnswer.completed;
255
+ if (idle < PROMPT_CACHE_TTL_MS) return held;
256
+ try {
257
+ return idle < warmWindowMs(await readConfig(directory)) ? held : null;
258
+ } catch (error) {
259
+ console.warn('[routing] OpenCode config unavailable, treating the prompt cache as cold:', errorMessage(error));
260
+ return null;
261
+ }
234
262
  };
235
263
 
236
264
  // A category without a model of its own means "the fallback pair"; a variant
@@ -300,8 +328,9 @@ export function createRoutingRuntime({
300
328
  let selection;
301
329
  if (state.autoReady) {
302
330
  let history = [];
331
+ let lastAnswer = null;
303
332
  try {
304
- history = await readHistory({ sessionId, directory });
333
+ ({ history, lastAnswer } = await readHistory({ sessionId, directory }));
305
334
  } catch (error) {
306
335
  console.warn('[routing] history unavailable, routing on the request alone:', errorMessage(error));
307
336
  }
@@ -315,7 +344,14 @@ export function createRoutingRuntime({
315
344
  decision.confidence = result.confidence;
316
345
  decision.reason = result.reason;
317
346
  decision.ms = ms;
318
- selection = chooseSelection(config, result.category, agent);
347
+ const held = await cacheHold({ config, chosen: result.category, composerAgent: agent, lastAnswer, directory });
348
+ if (held) {
349
+ // The send runs as the category the session is already on;
350
+ // `cacheHold.requested` keeps what Jev asked for.
351
+ decision.category = held.id;
352
+ decision.cacheHold = { requested: result.category.id };
353
+ }
354
+ selection = chooseSelection(config, held ?? result.category, agent);
319
355
  } catch (error) {
320
356
  decision.reason = 'error';
321
357
  decision.error = errorMessage(error);
@@ -19,7 +19,10 @@ const readyConfig = () => {
19
19
  return config;
20
20
  };
21
21
 
22
- const makeRuntime = ({ config = readyConfig(), token = 'key', classifierSource = null, customEndpoint = null, providerKeys = {}, zenPromotionActive = true, enterprise = false, pinned = null, catalog = [], answers, askError } = {}) => {
22
+ const makeRuntime = ({
23
+ config = readyConfig(), token = 'key', classifierSource = null, customEndpoint = null, providerKeys = {}, zenPromotionActive = true,
24
+ enterprise = false, pinned = null, catalog = [], answers, askError, lastAnswer = null, configEntries = [], nowMs = 1_000_000_000,
25
+ } = {}) => {
23
26
  const events = [];
24
27
  const store = {
25
28
  readConfig: vi.fn(async () => config),
@@ -34,6 +37,10 @@ const makeRuntime = ({ config = readyConfig(), token = 'key', classifierSource =
34
37
  clearCustomEndpoint: vi.fn(async () => undefined),
35
38
  };
36
39
  const jev = { ask: vi.fn(async () => { if (askError) throw askError; return { answers, ms: 12 }; }) };
40
+ const readConfigEntries = vi.fn(async () => {
41
+ if (configEntries instanceof Error) throw configEntries;
42
+ return configEntries;
43
+ });
37
44
  const runtime = createRoutingRuntime({
38
45
  dataDir: '/unused',
39
46
  buildOpenCodeUrl: () => 'http://127.0.0.1:1/',
@@ -49,8 +56,11 @@ const makeRuntime = ({ config = readyConfig(), token = 'key', classifierSource =
49
56
  if (catalog instanceof Error) throw catalog;
50
57
  return catalog;
51
58
  },
59
+ readSessionHistory: async () => ({ history: [], lastAnswer }),
60
+ readConfigEntries,
61
+ now: () => nowMs,
52
62
  });
53
- return { runtime, store, jev, events };
63
+ return { runtime, store, jev, events, readConfigEntries };
54
64
  };
55
65
 
56
66
  describe('requestTextOf', () => {
@@ -173,6 +183,75 @@ describe('resolveAutoSelection', () => {
173
183
  });
174
184
  });
175
185
 
186
+ describe('preserving the prompt cache', () => {
187
+ const NOW = 1_000_000_000;
188
+ const MINUTE = 60_000;
189
+ const HARD = { providerID: 'openai', modelID: 'gpt-6-astra' };
190
+ // Hard on its own model and the composer's agent, so only the model differs.
191
+ const cacheConfig = () => {
192
+ const config = readyConfig();
193
+ config.categories = config.categories.map((c) => (c.id === 'hard' ? { ...c, agent: null } : c));
194
+ return config;
195
+ };
196
+ const answeredBy = (model, minutesAgo) => ({ ...model, completed: NOW - minutesAgo * MINUTE });
197
+ const route = async ({ choice, config = cacheConfig(), lastAnswer, configEntries, agent = 'build' }) => {
198
+ const made = makeRuntime({ config, lastAnswer, configEntries, nowMs: NOW, answers: { category: { choice, confidence: 0.95 } } });
199
+ const resolved = await made.runtime.resolveAutoSelection({ sessionId: 's1', model: AUTO, agent, requestText: 'push it' });
200
+ return { ...made, resolved };
201
+ };
202
+
203
+ it('stays on the warm model instead of moving down, and records what Jev asked for', async () => {
204
+ const { resolved, readConfigEntries } = await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 2) });
205
+ expect(resolved.model).toEqual({ providerID: 'openai', id: 'gpt-6-astra', variant: 'high' });
206
+ expect(resolved.agent).toBe('build');
207
+ expect(resolved.decision).toMatchObject({ category: 'hard', reason: 'routed', cacheHold: { requested: 'trivial' } });
208
+ expect(readConfigEntries).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('moves down once the cache has expired, and moves up at any time', async () => {
212
+ const cold = await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 6) });
213
+ expect(cold.resolved.model).toMatchObject({ providerID: 'anthropic', id: 'claude-sonnet-5' });
214
+ expect(cold.resolved.decision.cacheHold).toBeUndefined();
215
+
216
+ const up = await route({ choice: 'hard', lastAnswer: answeredBy(FALLBACK.model, 1) });
217
+ expect(up.resolved.model).toMatchObject({ providerID: 'openai', id: 'gpt-6-astra' });
218
+ });
219
+
220
+ it("counts OpenCode's session warming: its duration plus one cache lifetime", async () => {
221
+ const warming = [{ info: { model: 'anthropic/claude-sonnet-5' } }, { info: { warming: true } }];
222
+ expect((await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 20), configEntries: warming })).resolved.decision.category).toBe('hard');
223
+ expect((await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 36), configEntries: warming })).resolved.decision.category).toBe('trivial');
224
+
225
+ const hour = [{ info: { warming: { duration: '3600000 millis' } } }];
226
+ expect((await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 50), configEntries: hour })).resolved.decision.category).toBe('hard');
227
+
228
+ const turnedOff = [{ info: { warming: true } }, { info: { warming: false } }];
229
+ expect((await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 20), configEntries: turnedOff })).resolved.decision.category).toBe('trivial');
230
+ });
231
+
232
+ it('treats the cache as cold when the OpenCode config cannot be read', async () => {
233
+ const { resolved } = await route({ choice: 'trivial', lastAnswer: answeredBy(HARD, 20), configEntries: new Error('OpenCode down') });
234
+ expect(resolved.decision.category).toBe('trivial');
235
+ });
236
+
237
+ it('switches as before with the setting off, for a category of the user, or when the agent would change', async () => {
238
+ const off = cacheConfig();
239
+ off.preserveCache = false;
240
+ expect((await route({ choice: 'trivial', config: off, lastAnswer: answeredBy(HARD, 1) })).resolved.decision.category).toBe('trivial');
241
+
242
+ const own = cacheConfig();
243
+ own.categories.push({ id: 'docs', builtin: false, enabled: true, name: 'Docs', description: 'Docs', model: null, variant: null, agent: null });
244
+ expect((await route({ choice: 'docs', config: own, lastAnswer: answeredBy(HARD, 1) })).resolved.decision.category).toBe('docs');
245
+
246
+ // readyConfig's hard category runs the plan agent.
247
+ expect((await route({ choice: 'trivial', config: readyConfig(), lastAnswer: answeredBy(HARD, 1) })).resolved.decision.category).toBe('trivial');
248
+ });
249
+
250
+ it('switches when the session has no settled answer to have warmed a cache', async () => {
251
+ expect((await route({ choice: 'trivial', lastAnswer: null })).resolved.decision.category).toBe('trivial');
252
+ });
253
+ });
254
+
176
255
  describe('jev endpoint', () => {
177
256
  const capture = async (source, keys = {}) => {
178
257
  let call = null;
@@ -20,6 +20,7 @@ import { z } from 'zod';
20
20
  import {
21
21
  BUILTIN_CATEGORIES,
22
22
  DEFAULT_MIN_CONFIDENCE,
23
+ DEFAULT_PRESERVE_CACHE,
23
24
  DEFAULT_SAFETY_THRESHOLD,
24
25
  THINKING_LEVELS,
25
26
  isAutoModel,
@@ -66,6 +67,7 @@ const fileSchema = z.object({
66
67
  enabled: z.boolean().optional(),
67
68
  fallback: z.object({ model: modelSchema, variant: variantSchema.optional() }).strict().nullable().optional(),
68
69
  minConfidence: z.number().min(0).max(1).optional(),
70
+ preserveCache: z.boolean().optional(),
69
71
  safetyNet: z.object({ enabled: z.boolean(), threshold: z.number().min(0).max(1) }).strict().optional(),
70
72
  categories: z.record(z.string().regex(CATEGORY_ID), z.discriminatedUnion('builtin', [builtinOverrideSchema, userCategorySchema])).optional(),
71
73
  }).strict();
@@ -86,6 +88,8 @@ const effectiveConfigSchema = z.object({
86
88
  enabled: z.boolean(),
87
89
  fallback: z.object({ model: modelSchema, variant: variantSchema }).strict().nullable(),
88
90
  minConfidence: z.number().min(0).max(1),
91
+ // Clients from before the setting send a config without it.
92
+ preserveCache: z.boolean().default(DEFAULT_PRESERVE_CACHE),
89
93
  safetyNet: z.object({ enabled: z.boolean(), threshold: z.number().min(0).max(1) }).strict(),
90
94
  categories: z.array(effectiveCategorySchema).max(32),
91
95
  }).strict();
@@ -178,6 +182,7 @@ export const resolveEffectiveConfig = (stored) => {
178
182
  enabled: file.enabled ?? false,
179
183
  fallback: file.fallback ? { model: file.fallback.model, variant: file.fallback.variant ?? null } : null,
180
184
  minConfidence: file.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
185
+ preserveCache: file.preserveCache ?? DEFAULT_PRESERVE_CACHE,
181
186
  safetyNet: file.safetyNet ?? { enabled: false, threshold: DEFAULT_SAFETY_THRESHOLD },
182
187
  categories,
183
188
  };
@@ -214,7 +219,13 @@ export const toStoredConfig = (config) => {
214
219
  for (const builtin of BUILTIN_CATEGORIES) {
215
220
  if (!present.has(builtin.id)) categories[builtin.id] = { builtin: true, deleted: true };
216
221
  }
217
- const stored = { version: FILE_VERSION, enabled: config.enabled, minConfidence: config.minConfidence, safetyNet: config.safetyNet };
222
+ const stored = {
223
+ version: FILE_VERSION,
224
+ enabled: config.enabled,
225
+ minConfidence: config.minConfidence,
226
+ preserveCache: config.preserveCache,
227
+ safetyNet: config.safetyNet,
228
+ };
218
229
  if (config.fallback) {
219
230
  stored.fallback = { model: config.fallback.model };
220
231
  if (config.fallback.variant) stored.fallback.variant = config.fallback.variant;
@@ -11,6 +11,7 @@ const sampleConfig = (patch = {}) => ({
11
11
  enabled: true,
12
12
  fallback: { model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' }, variant: 'medium' },
13
13
  minConfidence: 0.6,
14
+ preserveCache: true,
14
15
  safetyNet: { enabled: true, threshold: 0.6 },
15
16
  categories: resolveEffectiveConfig(null).categories,
16
17
  ...patch,
@@ -44,6 +45,18 @@ describe('routing store', () => {
44
45
  expect(await store.readConfig()).toEqual(config);
45
46
  });
46
47
 
48
+ it('keeps the cache setting on until it is turned off, also for clients that do not send it', async () => {
49
+ expect(resolveEffectiveConfig(null).preserveCache).toBe(true);
50
+ expect(resolveEffectiveConfig({ version: 1, enabled: true }).preserveCache).toBe(true);
51
+
52
+ const store = createRoutingStore({ dataDir: await tempDir() });
53
+ await store.writeConfig(sampleConfig({ preserveCache: false }));
54
+ expect((await store.readConfig()).preserveCache).toBe(false);
55
+
56
+ const { preserveCache: _omitted, ...older } = sampleConfig();
57
+ expect(parseEffectiveConfig(older).preserveCache).toBe(true);
58
+ });
59
+
47
60
  it('records a removed built-in as deleted and keeps the others', async () => {
48
61
  const config = sampleConfig();
49
62
  config.categories = config.categories.filter((c) => c.id !== 'research');
@@ -100,6 +100,55 @@ Manual `runNow` does not claim a schedule occurrence. It also runs paused
100
100
  (`enabled: false`) tasks — that is the point of the button — while scheduled
101
101
  dispatches still skip disabled tasks, and completion never re-arms a paused task.
102
102
 
103
+ ## Existing-session tasks
104
+
105
+ `targetSessionId` sends to an existing session instead of creating one.
106
+ `existing-session.js` validates changed or enabled targets on save, and all
107
+ targets on admission and send. A paused task can keep a stale target. The
108
+ target must be active and belong to the task project or its linked worktree.
109
+ Another registered project owns its exact checkout. Chats targets must remain
110
+ inside the chats scope. Missing targets return 404; archived or foreign targets
111
+ return 409, including through Run now.
112
+
113
+ The prompt enters the ordinary server message queue with `scheduledTask`
114
+ provenance. One project and task can have at most one waiting prompt. Run now
115
+ on an already queued prompt records `skipped` and returns 409 with
116
+ `busy: queued`. Admission sets `lastSessionId` to the target. Disable, delete
117
+ and retarget cancel waiting prompts, but cannot recall a send already in flight.
118
+ Saving an already-paused task with the same target keeps a prompt admitted by
119
+ Run now. Cancellation on disable applies only to an enabled-to-disabled change.
120
+ Scheduled once tasks are disabled after an admission attempt, including a
121
+ failed attempt. An admitted prompt stays queued. Run now can still admit a
122
+ paused task.
123
+
124
+ An overlapping admission attempt records `skipped` before the active admission
125
+ records `queued`. A later duplicate enqueue records `skipped` while the earlier
126
+ prompt remains waiting. The card shows the last attempt's status in that case.
127
+
128
+ Reuse status ends at queue send: `queued` becomes `sent`, `failed` or
129
+ `cancelled`. The existing task state fields hold the result. There is no
130
+ per-run history or assistant-completion tracking. `execution.useDefaults`
131
+ leaves the target model and agent unchanged. A pinned selection switches the
132
+ requested model and agent before sending. Tasks without a target keep the
133
+ new-session execution path below.
134
+
135
+ Reuse preserves the target's goal and permission auto-accept policy. Task
136
+ creation options for these policies are hidden in Existing mode and ignored
137
+ by the reuse runtime. Transient queue send failures use the ordinary retry
138
+ policy. Only definitive before-send target or task 404 or 409 refusals are
139
+ terminal, including removed or retargeted tasks and moved targets. Model or
140
+ prompt send errors use the ordinary retry path.
141
+
142
+ ## New-session selection
143
+
144
+ A task either pins them (`execution.providerID`, `modelID`, `variant`, `agent`)
145
+ or sets `execution.useDefaults: true`. Such a task reads the session defaults
146
+ when each run starts (`session-defaults.js`: the project's `defaultModel` /
147
+ `defaultVariant` / `defaultAgent`, then the global ones), so changing a default
148
+ reaches it on its next run without editing it. With nothing set, the session is
149
+ created without a model or agent and OpenCode applies its own. A pinned value
150
+ stored next to `useDefaults` is kept only for when the task is pinned again.
151
+
103
152
  ## Files
104
153
 
105
154
  - `packages/web/server/lib/scheduled-tasks/runtime.js`
@@ -193,7 +242,16 @@ project write lock on every `syncProject` when the project path is known:
193
242
  name, schedule and execution. Enabling a loop in the UI (the loop-file
194
243
  endpoint) records that approval; disabling withdraws it. A pull that changes
195
244
  what a loop runs or when changes its fingerprint, and the loop waits for a
196
- new approval. User-scope loops (`~/.agents/loops`) need none.
245
+ new approval. User-scope loops (`~/.agents/loops`) need none. A task the
246
+ gate holds back is returned with `loopApproval`: `required` (never enabled
247
+ here) or `outdated` (enabled, then the file changed); the UI explains the
248
+ pause from it. The field is never stored.
249
+ - **Agent tool and CLI.** `setEnabled` (behind `schedule.toggle` and
250
+ `openchamber schedule enable|disable`) changes a loop task the way the
251
+ Scheduled tasks checkbox does, through the loop file. It refuses (409) to
252
+ enable a paused project-scope loop: the approval is the user's decision in
253
+ Scheduled tasks, never an agent's or a script's. Disabling works for every
254
+ loop.
197
255
  - **Loop-file mutations.** The loop file remains authoritative. The scheduled-
198
256
  tasks UI opens it in the built-in file editor, updates its `enabled`
199
257
  frontmatter through the loop-file endpoint, and deletes the file through the
@@ -0,0 +1,119 @@
1
+ import path from 'node:path';
2
+ import { z } from 'zod';
3
+ import { OpenChamberControlError } from '../openchamber-control/error.js';
4
+ import { expandSnippets } from '../opencode/snippets.js';
5
+
6
+ export const createExistingSessionTasks = ({ projectConfigRuntime, messageQueueRuntime, createClient, listProjects, chatsScope, resolvePrimaryWorktreeRoot, isSessionArchived, emitTaskRunEvent }) => {
7
+ const admissions = new Set();
8
+ const validateTarget = async (projectId, targetSessionId) => {
9
+ if (!z.string().regex(/^[A-Za-z0-9_-]{4,128}$/).safeParse(targetSessionId).success) {
10
+ throw new OpenChamberControlError('targetSessionId is invalid', 400);
11
+ }
12
+ if (await isSessionArchived?.(targetSessionId)) throw new OpenChamberControlError('Scheduled target is archived', 409);
13
+ let session;
14
+ try {
15
+ session = await createClient('').session.get({ sessionID: targetSessionId });
16
+ } catch (error) {
17
+ if (error?.name === 'NotFoundError' || error?.status === 404 || error?.statusCode === 404) throw new OpenChamberControlError('Scheduled target not found', 404);
18
+ throw error;
19
+ }
20
+ if (!session?.id) throw new OpenChamberControlError('Scheduled target not found', 404);
21
+ if (session.id !== targetSessionId) throw new OpenChamberControlError('Scheduled target identity mismatch', 409);
22
+ if (session.time?.archived) throw new OpenChamberControlError('Scheduled target is archived', 409);
23
+ const projects = await listProjects();
24
+ const project = projects.find((entry) => entry.id === projectId);
25
+ const directory = session.location?.directory;
26
+ if (!directory) throw new OpenChamberControlError('Scheduled target has no directory', 409);
27
+ if (projects.some((entry) => entry.id !== projectId && path.resolve(entry.path) === path.resolve(directory))) {
28
+ throw new OpenChamberControlError('Scheduled target belongs to another project', 409);
29
+ }
30
+ let belongs = false;
31
+ if (chatsScope && projectId === chatsScope.id) belongs = chatsScope.contains(directory);
32
+ else if (project && directory) {
33
+ belongs = path.resolve(directory) === path.resolve(project.path);
34
+ if (!belongs && resolvePrimaryWorktreeRoot) {
35
+ const owner = await resolvePrimaryWorktreeRoot(directory);
36
+ belongs = Boolean(owner?.root) && path.resolve(owner.root) === path.resolve(project.path);
37
+ }
38
+ }
39
+ if (!belongs) throw new OpenChamberControlError('Scheduled target is outside the task project', 409);
40
+ return { session, directory };
41
+ };
42
+
43
+ const record = async (projectId, taskId, status, error, lastRunAt) => {
44
+ const patch = {
45
+ lastStatus: status, lastError: error || undefined, updatedAt: Date.now(),
46
+ };
47
+ if (lastRunAt !== undefined) patch.lastRunAt = lastRunAt;
48
+ const result = await projectConfigRuntime.updateScheduledTaskState(projectId, taskId, patch);
49
+ emitTaskRunEvent?.({ projectID: projectId, taskID: taskId, status, ranAt: Date.now(), sessionID: result.task?.state?.lastSessionId });
50
+ return result.task;
51
+ };
52
+
53
+ const run = async (projectId, task, reason, scheduledFor, nextRunAt) => {
54
+ const triggeredAt = Date.now();
55
+ const key = `${projectId}:${task.id}`;
56
+ if (admissions.has(key)) {
57
+ try {
58
+ await record(projectId, task.id, 'skipped', 'task is already queued');
59
+ } catch (persistError) {
60
+ console.warn('[scheduled-tasks] admission failure state write failed:', persistError?.message);
61
+ }
62
+ return { ok: false, queued: true, error: 'task is already queued' };
63
+ }
64
+ admissions.add(key);
65
+ let admitted = false;
66
+ try {
67
+ if (reason === 'scheduled') {
68
+ const claim = await projectConfigRuntime.updateScheduledTaskStateIf(projectId, task.id,
69
+ (candidate) => candidate.enabled && (!Number.isFinite(candidate.state?.lastScheduledFor) || Math.abs(candidate.state.lastScheduledFor - scheduledFor) > 5_000),
70
+ { lastScheduledFor: scheduledFor, nextRunAt: nextRunAt ?? undefined });
71
+ if (!claim.updated) return { ok: false, skipped: true };
72
+ }
73
+ const { directory } = await validateTarget(projectId, task.targetSessionId);
74
+ const sendConfig = task.execution.useDefaults ? {} : {
75
+ providerID: task.execution.providerID, modelID: task.execution.modelID,
76
+ agent: task.execution.agent, variant: task.execution.variant,
77
+ };
78
+ const scheduledTask = { projectId, taskId: task.id };
79
+ await messageQueueRuntime.enqueue(task.targetSessionId, directory, {
80
+ content: task.execution.prompt,
81
+ text: expandSnippets(task.execution.prompt, directory),
82
+ sendConfig, scheduledTask,
83
+ });
84
+ admitted = true;
85
+ const result = await projectConfigRuntime.updateScheduledTaskState(projectId, task.id, {
86
+ lastRunAt: triggeredAt, lastStatus: 'queued', lastError: undefined,
87
+ lastSessionId: task.targetSessionId, updatedAt: Date.now(),
88
+ });
89
+ emitTaskRunEvent?.({ projectID: projectId, taskID: task.id, status: 'queued', ranAt: Date.now(), sessionID: task.targetSessionId });
90
+ return { ok: true, task: result.task, sessionID: task.targetSessionId, directory };
91
+ } catch (error) {
92
+ if (admitted) await messageQueueRuntime.cancelScheduledTask(projectId, task.id);
93
+ const queued = error?.status === 409 && error.message === 'task is already queued';
94
+ let updated = task;
95
+ try {
96
+ updated = await record(projectId, task.id, queued ? 'skipped' : 'failed', error.message, triggeredAt);
97
+ } catch (persistError) {
98
+ console.warn('[scheduled-tasks] admission failure state write failed:', persistError?.message);
99
+ }
100
+ return { ok: false, queued, error: error.message, statusCode: error.statusCode ?? error.status, task: updated };
101
+ } finally {
102
+ admissions.delete(key);
103
+ }
104
+ };
105
+
106
+ const beforeSend = async (sessionId, directory, item) => {
107
+ const { projectId, taskId } = item.scheduledTask;
108
+ const task = (await projectConfigRuntime.listScheduledTasks(projectId)).find((entry) => entry.id === taskId);
109
+ if (!task || task.targetSessionId !== sessionId) throw new OpenChamberControlError('Scheduled task target changed or was removed', 409);
110
+ const target = await validateTarget(projectId, sessionId);
111
+ if (target.directory !== directory) throw new OpenChamberControlError('Scheduled target moved', 409);
112
+ };
113
+
114
+ return {
115
+ validateTarget, run, beforeSend,
116
+ result: ({ projectId, taskId }, status, error) => record(projectId, taskId, status, error),
117
+ cancel: (projectId, taskId) => messageQueueRuntime.cancelScheduledTask(projectId, taskId),
118
+ };
119
+ };