@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,350 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OpenCode } from '@opencode/client';
3
+ import { createExistingSessionTasks } from './existing-session.js';
4
+ import { createScheduledTaskService } from './service.js';
5
+ import { registerScheduledTaskRoutes } from './routes.js';
6
+ import { createScheduledTasksRuntime } from './runtime.js';
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ const task = {
14
+ id: 'task-1', name: 'Repeat', enabled: true, targetSessionId: 'ses_target',
15
+ schedule: { kind: 'daily', time: '09:00' }, execution: { prompt: 'Follow up', useDefaults: true }, state: {},
16
+ };
17
+
18
+ const setup = (overrides = {}) => {
19
+ let stored = structuredClone(task);
20
+ const projectConfigRuntime = {
21
+ listScheduledTasks: async () => [stored],
22
+ updateScheduledTaskState: async (_project, _task, patch) => {
23
+ stored = { ...stored, state: { ...stored.state, ...patch } };
24
+ return { task: stored };
25
+ },
26
+ updateScheduledTaskStateIf: async (_project, _task, predicate, patch) => {
27
+ if (!predicate(stored)) return { task: stored, updated: false };
28
+ stored = { ...stored, state: { ...stored.state, ...patch } };
29
+ return { task: stored, updated: true };
30
+ },
31
+ upsertScheduledTask: async (_project, input) => {
32
+ stored = { ...stored, ...input };
33
+ return { task: stored };
34
+ },
35
+ deleteScheduledTask: async () => ({ deleted: true }),
36
+ };
37
+ const messageQueueRuntime = { enqueue: vi.fn(async () => {}), cancelScheduledTask: vi.fn(async () => {}) };
38
+ const runtime = createExistingSessionTasks({
39
+ projectConfigRuntime, messageQueueRuntime,
40
+ createClient: () => ({ session: { get: async ({ sessionID }) => ({ id: sessionID, location: { directory: '/repo' } }) } }),
41
+ listProjects: async () => [{ id: 'project-1', path: '/repo' }],
42
+ ...overrides,
43
+ });
44
+ return { runtime, projectConfigRuntime, messageQueueRuntime, stored: () => stored };
45
+ };
46
+
47
+ describe('existing session scheduling', () => {
48
+ it('the scheduler reuses a target without creating a chat or reading new-session defaults', async () => {
49
+ const { projectConfigRuntime, messageQueueRuntime } = setup();
50
+ const fetchImpl = vi.fn(async () => Response.json({ location: { directory: '/repo' }, data: { id: 'ses_target', location: { directory: '/repo' } } }));
51
+ vi.stubGlobal('fetch', fetchImpl);
52
+ const readSessionDefaults = vi.fn();
53
+ const createChatDirectory = vi.fn();
54
+ const scheduler = createScheduledTasksRuntime({
55
+ projectConfigRuntime, messageQueueRuntime,
56
+ listProjects: async () => [{ id: 'project-1', path: '/repo' }],
57
+ chatsScope: { id: 'project-1', root: '/repo', contains: () => true, createChatDirectory },
58
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`, getOpenCodeAuthHeaders: () => ({}),
59
+ readSessionDefaults,
60
+ });
61
+ await scheduler.syncProject('project-1');
62
+ expect(await scheduler.runNow('project-1', 'task-1')).toMatchObject({ ok: true, sessionID: 'ses_target' });
63
+ expect(createChatDirectory).not.toHaveBeenCalled();
64
+ expect(readSessionDefaults).not.toHaveBeenCalled();
65
+ expect(fetchImpl).toHaveBeenCalledOnce();
66
+ expect(new URL(fetchImpl.mock.calls[0][0]).pathname).toBe('/api/session/ses_target');
67
+ });
68
+ it('admits to the ordinary queue, records the target, then finishes at send', async () => {
69
+ const { runtime, messageQueueRuntime, stored } = setup();
70
+ const result = await runtime.run('project-1', { ...task, execution: { ...task.execution, providerID: 'old', modelID: 'old', agent: 'old' } }, 'manual');
71
+ expect(result).toMatchObject({ ok: true, sessionID: 'ses_target', directory: '/repo' });
72
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledWith('ses_target', '/repo', {
73
+ content: 'Follow up', text: 'Follow up', sendConfig: {}, scheduledTask: { projectId: 'project-1', taskId: 'task-1' },
74
+ });
75
+ expect(stored().state).toMatchObject({ lastStatus: 'queued', lastSessionId: 'ses_target' });
76
+ await runtime.result({ projectId: 'project-1', taskId: 'task-1' }, 'sent');
77
+ expect(stored().state.lastStatus).toBe('sent');
78
+ });
79
+
80
+ it('records a duplicate Run now as skipped with queued busy status', async () => {
81
+ const { runtime, messageQueueRuntime, stored } = setup();
82
+ messageQueueRuntime.enqueue.mockRejectedValue(Object.assign(new Error('task is already queued'), { status: 409 }));
83
+ expect(await runtime.run('project-1', task, 'manual')).toMatchObject({ ok: false, queued: true, statusCode: 409 });
84
+ expect(stored().state.lastStatus).toBe('skipped');
85
+ expect(messageQueueRuntime.cancelScheduledTask).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it.each(['manual', 'scheduled'])('records a %s attempt that overlaps an admission before the final queued write', async (reason) => {
89
+ let releaseTarget;
90
+ const target = new Promise((resolve) => { releaseTarget = resolve; });
91
+ const { runtime, projectConfigRuntime, stored, messageQueueRuntime } = setup({
92
+ createClient: () => ({ session: { get: () => target } }),
93
+ });
94
+ const claim = vi.spyOn(projectConfigRuntime, 'updateScheduledTaskStateIf');
95
+ const first = runtime.run('project-1', task, 'manual');
96
+ expect(await runtime.run('project-1', task, reason, Date.now())).toMatchObject({ ok: false, queued: true });
97
+ expect(stored().state).toMatchObject({ lastStatus: 'skipped', lastError: 'task is already queued' });
98
+ expect(claim).not.toHaveBeenCalled();
99
+ releaseTarget({ id: 'ses_target', location: { directory: '/repo' } });
100
+ expect(await first).toMatchObject({ ok: true });
101
+ expect(stored().state).toMatchObject({ lastStatus: 'queued', lastSessionId: 'ses_target' });
102
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it.each(['manual', 'scheduled'])('returns queued busy when the %s overlap state write rejects', async (reason) => {
106
+ let releaseTarget;
107
+ const target = new Promise((resolve) => { releaseTarget = resolve; });
108
+ const { runtime, projectConfigRuntime, messageQueueRuntime } = setup({
109
+ createClient: () => ({ session: { get: () => target } }),
110
+ });
111
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
112
+ const first = runtime.run('project-1', task, 'manual');
113
+ const write = vi.spyOn(projectConfigRuntime, 'updateScheduledTaskState').mockRejectedValueOnce(new Error('project lock timeout'));
114
+ try {
115
+ await expect(runtime.run('project-1', task, reason, Date.now())).resolves.toMatchObject({
116
+ ok: false, queued: true, error: 'task is already queued',
117
+ });
118
+ expect(write).toHaveBeenCalledWith('project-1', task.id, expect.objectContaining({ lastStatus: 'skipped' }));
119
+ expect(warn).toHaveBeenCalledWith('[scheduled-tasks] admission failure state write failed:', 'project lock timeout');
120
+ } finally {
121
+ releaseTarget({ id: 'ses_target', location: { directory: '/repo' } });
122
+ await first;
123
+ warn.mockRestore();
124
+ }
125
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledOnce();
126
+ expect(messageQueueRuntime.cancelScheduledTask).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('keeps manual busy 409 and re-arms the scheduled timer when overlap state writes reject', async () => {
130
+ vi.useFakeTimers();
131
+ vi.setSystemTime(new Date('2026-10-07T08:00:00Z'));
132
+ const { projectConfigRuntime, messageQueueRuntime } = setup();
133
+ await projectConfigRuntime.upsertScheduledTask('project-1', {
134
+ ...task, schedule: { kind: 'daily', time: '08:01', timezone: 'UTC' },
135
+ });
136
+ let releaseAdmission;
137
+ let enteredAdmission;
138
+ const admission = new Promise((resolve) => { releaseAdmission = resolve; });
139
+ const entered = new Promise((resolve) => { enteredAdmission = resolve; });
140
+ messageQueueRuntime.enqueue.mockImplementationOnce(() => {
141
+ enteredAdmission();
142
+ return admission;
143
+ });
144
+ vi.stubGlobal('fetch', async () => Response.json({ data: { id: 'ses_target', location: { directory: '/repo' } } }));
145
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
146
+ const schedulerWarn = vi.fn();
147
+ const scheduler = createScheduledTasksRuntime({
148
+ projectConfigRuntime, messageQueueRuntime, logger: { warn: schedulerWarn },
149
+ listProjects: async () => [{ id: 'project-1', path: '/repo' }],
150
+ chatsScope: { id: 'project-1', root: '/repo', contains: () => true },
151
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`, getOpenCodeAuthHeaders: () => ({}),
152
+ });
153
+ const service = createScheduledTaskService({
154
+ projectConfigRuntime, scheduledTasksRuntime: scheduler,
155
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
156
+ });
157
+ const handlers = new Map();
158
+ const app = Object.fromEntries(['get', 'put', 'delete', 'patch', 'post'].map((method) => [method, (route, handler) => handlers.set(`${method}:${route}`, handler)]));
159
+ registerScheduledTaskRoutes(app, { scheduledTaskService: service });
160
+ let first;
161
+ try {
162
+ await scheduler.start();
163
+ first = scheduler.runNow('project-1', task.id);
164
+ await entered;
165
+ vi.spyOn(projectConfigRuntime, 'updateScheduledTaskState')
166
+ .mockRejectedValueOnce(new Error('disk write failed'))
167
+ .mockRejectedValueOnce(new Error('project lock timeout'));
168
+ const response = { statusCode: 200, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; } };
169
+ await handlers.get('post:/api/projects/:projectId/scheduled-tasks/:taskId/run')({ params: { projectId: 'project-1', taskId: task.id } }, response);
170
+ expect(response.statusCode).toBe(409);
171
+ expect(response.body).toMatchObject({ busy: 'queued', error: 'task is already queued' });
172
+ await vi.advanceTimersByTimeAsync(65_000);
173
+ expect(warn).toHaveBeenCalledWith('[scheduled-tasks] admission failure state write failed:', 'project lock timeout');
174
+ expect(schedulerWarn).not.toHaveBeenCalled();
175
+ expect(vi.getTimerCount()).toBe(1);
176
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledOnce();
177
+ releaseAdmission();
178
+ await expect(first).resolves.toMatchObject({ ok: true });
179
+ await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
180
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledTimes(2);
181
+ expect(schedulerWarn).not.toHaveBeenCalled();
182
+ expect(vi.getTimerCount()).toBe(1);
183
+ } finally {
184
+ releaseAdmission();
185
+ await first;
186
+ scheduler.stop();
187
+ warn.mockRestore();
188
+ }
189
+ });
190
+
191
+ it('ignores task goal and auto-accept options when reusing a target', async () => {
192
+ const { projectConfigRuntime, messageQueueRuntime } = setup();
193
+ const execution = { ...task.execution, goalEnabled: true, goalTokenBudget: 5000, permissionAutoAccept: true };
194
+ await projectConfigRuntime.upsertScheduledTask('project-1', { ...task, execution });
195
+ const fetchImpl = vi.fn(async () => Response.json({ data: { id: 'ses_target', location: { directory: '/repo' } } }));
196
+ vi.stubGlobal('fetch', fetchImpl);
197
+ const setSessionAutoAccept = vi.fn();
198
+ const persistSessionGoal = vi.fn();
199
+ const scheduler = createScheduledTasksRuntime({
200
+ projectConfigRuntime, messageQueueRuntime, setSessionAutoAccept, persistSessionGoal,
201
+ listProjects: async () => [{ id: 'project-1', path: '/repo' }],
202
+ chatsScope: { id: 'project-1', root: '/repo', contains: () => true },
203
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`, getOpenCodeAuthHeaders: () => ({}),
204
+ });
205
+ await scheduler.syncProject('project-1');
206
+ await scheduler.runNow('project-1', task.id);
207
+ const item = messageQueueRuntime.enqueue.mock.calls[0][2];
208
+ expect(item.scheduledTask).toEqual({ projectId: 'project-1', taskId: 'task-1' });
209
+ await scheduler.beforeScheduledTaskSend('ses_target', '/repo', item);
210
+ expect(setSessionAutoAccept).not.toHaveBeenCalled();
211
+ expect(persistSessionGoal).not.toHaveBeenCalled();
212
+ expect(fetchImpl.mock.calls.every(([, init]) => !init?.method || init.method === 'GET')).toBe(true);
213
+ expect(item.context).toBeUndefined();
214
+ });
215
+
216
+ it('Run now on a deleted target responds 404 with its message', async () => {
217
+ const { runtime, projectConfigRuntime } = setup({ createClient: () => OpenCode.make({ baseUrl: 'http://opencode.test', fetch: async () => Response.json({ name: 'NotFoundError', message: 'Not found' }, { status: 404 }) }) });
218
+ const scheduledTasksRuntime = { runNow: (project, id) => runtime.run(project, { ...task, id }, 'manual') };
219
+ const service = createScheduledTaskService({
220
+ projectConfigRuntime, scheduledTasksRuntime,
221
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
222
+ });
223
+ const handlers = new Map();
224
+ const app = Object.fromEntries(['get', 'put', 'delete', 'patch', 'post'].map((method) => [method, (route, handler) => handlers.set(`${method}:${route}`, handler)]));
225
+ registerScheduledTaskRoutes(app, { scheduledTaskService: service });
226
+ const response = { statusCode: 200, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; } };
227
+ await handlers.get('post:/api/projects/:projectId/scheduled-tasks/:taskId/run')({ params: { projectId: 'project-1', taskId: task.id } }, response);
228
+ expect(response.statusCode).toBe(404);
229
+ expect(response.body.error).toBe('Scheduled target not found');
230
+ });
231
+
232
+ it('refuses archived and foreign targets and admits linked worktrees', async () => {
233
+ await expect(setup({ isSessionArchived: () => true }).runtime.validateTarget('project-1', 'ses_target')).rejects.toMatchObject({ statusCode: 409, message: 'Scheduled target is archived' });
234
+ const foreign = () => ({ session: { get: async () => ({ id: 'ses_target', location: { directory: '/worktree' } }) } });
235
+ await expect(setup({ createClient: foreign }).runtime.validateTarget('project-1', 'ses_target')).rejects.toMatchObject({ statusCode: 409 });
236
+ await expect(setup({ createClient: foreign, resolvePrimaryWorktreeRoot: async () => ({ root: '/repo' }) }).runtime.validateTarget('project-1', 'ses_target')).resolves.toMatchObject({ directory: '/worktree' });
237
+ });
238
+
239
+ it.each(['removed', 'retargeted', 'moved'])('refuses a %s target/task before send with 409', async (change) => {
240
+ let directory = '/repo';
241
+ const { runtime, projectConfigRuntime, messageQueueRuntime } = setup({
242
+ createClient: () => ({ session: { get: async () => ({ id: 'ses_target', location: { directory } }) } }),
243
+ resolvePrimaryWorktreeRoot: async () => ({ root: '/repo' }),
244
+ });
245
+ await runtime.run('project-1', task, 'manual');
246
+ if (change === 'removed') projectConfigRuntime.listScheduledTasks = async () => [];
247
+ if (change === 'retargeted') await projectConfigRuntime.upsertScheduledTask('project-1', { ...task, targetSessionId: 'ses_another' });
248
+ if (change === 'moved') directory = '/worktree';
249
+ await expect(runtime.beforeSend('ses_target', '/repo', messageQueueRuntime.enqueue.mock.calls[0][2])).rejects.toMatchObject({
250
+ statusCode: 409,
251
+ message: change === 'moved' ? 'Scheduled target moved' : 'Scheduled task target changed or was removed',
252
+ });
253
+ });
254
+
255
+ it('cancels waiting prompts on disable, retarget and delete', async () => {
256
+ for (const input of [{ ...task, enabled: false }, { ...task, targetSessionId: 'ses_another' }]) {
257
+ const { runtime, projectConfigRuntime, messageQueueRuntime } = setup();
258
+ const service = createScheduledTaskService({
259
+ projectConfigRuntime, scheduledTasksRuntime: { validateTarget: runtime.validateTarget, cancelWaitingPrompt: runtime.cancel, syncProject: async () => [] },
260
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
261
+ });
262
+ await service.upsert('project-1', input);
263
+ expect(messageQueueRuntime.cancelScheduledTask).toHaveBeenCalledWith('project-1', task.id);
264
+ await service.remove('project-1', task.id);
265
+ expect(messageQueueRuntime.cancelScheduledTask).toHaveBeenCalledTimes(2);
266
+ }
267
+ });
268
+
269
+ it.each([404, 409])('allows pausing and editing an unchanged stale target with refusal %s', async (statusCode) => {
270
+ const { projectConfigRuntime, stored } = setup();
271
+ const validateTarget = vi.fn(async () => { throw Object.assign(new Error('stale target'), { statusCode }); });
272
+ const service = createScheduledTaskService({
273
+ projectConfigRuntime,
274
+ scheduledTasksRuntime: { validateTarget, cancelWaitingPrompt: async () => {}, syncProject: () => projectConfigRuntime.listScheduledTasks() },
275
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
276
+ });
277
+ expect(await service.setEnabled('project-1', task.id, false)).toMatchObject({ enabled: false });
278
+ expect((await service.upsert('project-1', { ...stored(), name: 'Paused edit' })).task.name).toBe('Paused edit');
279
+ expect(validateTarget).not.toHaveBeenCalled();
280
+ await expect(service.setEnabled('project-1', task.id, true)).rejects.toMatchObject({ statusCode });
281
+ await expect(service.upsert('project-1', { ...stored(), targetSessionId: 'ses_another' })).rejects.toMatchObject({ statusCode });
282
+ expect(validateTarget).toHaveBeenCalledTimes(2);
283
+ });
284
+
285
+ it('keeps a waiting prompt when disabling or retargeting fails to save', async () => {
286
+ for (const input of [{ ...task, enabled: false }, { ...task, targetSessionId: 'ses_another' }]) {
287
+ const { runtime, projectConfigRuntime, messageQueueRuntime } = setup();
288
+ projectConfigRuntime.upsertScheduledTask = async () => { throw new Error('disk write failed'); };
289
+ const service = createScheduledTaskService({
290
+ projectConfigRuntime, scheduledTasksRuntime: { validateTarget: runtime.validateTarget, cancelWaitingPrompt: runtime.cancel, syncProject: async () => [] },
291
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
292
+ });
293
+ await expect(service.upsert('project-1', input)).rejects.toMatchObject({ statusCode: 500 });
294
+ expect(messageQueueRuntime.cancelScheduledTask).not.toHaveBeenCalled();
295
+ }
296
+ });
297
+
298
+ it('keeps a manually queued prompt when saving an already-paused task, but cancels on retarget', async () => {
299
+ const { runtime, projectConfigRuntime, messageQueueRuntime, stored } = setup();
300
+ await projectConfigRuntime.upsertScheduledTask('project-1', { ...task, enabled: false });
301
+ await runtime.run('project-1', stored(), 'manual');
302
+ const service = createScheduledTaskService({
303
+ projectConfigRuntime, scheduledTasksRuntime: { validateTarget: runtime.validateTarget, cancelWaitingPrompt: runtime.cancel, syncProject: () => projectConfigRuntime.listScheduledTasks() },
304
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'project-1', path: '/repo' }] }), sanitizeProjects: (projects) => projects,
305
+ });
306
+ await service.upsert('project-1', { ...stored(), name: 'Paused edit' });
307
+ expect(stored().enabled).toBe(false);
308
+ expect(stored().state.lastStatus).toBe('queued');
309
+ expect(messageQueueRuntime.cancelScheduledTask).not.toHaveBeenCalled();
310
+ await service.upsert('project-1', { ...stored(), targetSessionId: 'ses_another' });
311
+ expect(messageQueueRuntime.cancelScheduledTask).toHaveBeenCalledWith('project-1', task.id);
312
+ });
313
+
314
+ it.each([
315
+ { missing: false, claimed: true, consumeFails: false },
316
+ { missing: true, claimed: true, consumeFails: false },
317
+ { missing: false, claimed: false, consumeFails: false },
318
+ { missing: false, claimed: true, consumeFails: true },
319
+ ])('consumes only a claimed once attempt, missing=$missing claimed=$claimed consumeFails=$consumeFails', async ({ missing, claimed, consumeFails }) => {
320
+ vi.useFakeTimers();
321
+ vi.setSystemTime(new Date('2026-10-07T08:00:00Z'));
322
+ const { projectConfigRuntime, messageQueueRuntime, stored } = setup();
323
+ if (!claimed) projectConfigRuntime.updateScheduledTaskStateIf = async () => ({ task: stored(), updated: false });
324
+ await projectConfigRuntime.upsertScheduledTask('project-1', { ...task, schedule: { kind: 'once', date: '2026-10-07', time: '08:01', timezone: 'UTC' } });
325
+ if (consumeFails) projectConfigRuntime.upsertScheduledTask = async () => { throw new Error('disk write failed'); };
326
+ const warn = vi.fn();
327
+ vi.stubGlobal('fetch', async () => missing
328
+ ? Response.json({ name: 'NotFoundError', message: 'Not found' }, { status: 404 })
329
+ : Response.json({ data: { id: 'ses_target', location: { directory: '/repo' } } }));
330
+ const scheduler = createScheduledTasksRuntime({
331
+ projectConfigRuntime, messageQueueRuntime, logger: { warn },
332
+ listProjects: async () => [{ id: 'project-1', path: '/repo' }],
333
+ chatsScope: { id: 'project-1', root: '/repo', contains: () => true },
334
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`, getOpenCodeAuthHeaders: () => ({}),
335
+ });
336
+ try {
337
+ await scheduler.start();
338
+ await vi.advanceTimersByTimeAsync(65_000);
339
+ expect(stored().enabled).toBe(!claimed || consumeFails);
340
+ expect(scheduler.getStatus().enabledScheduledTasksCount).toBe(claimed && !consumeFails ? 0 : 1);
341
+ expect(stored().state.lastStatus).toBe(claimed ? (missing ? 'failed' : 'queued') : undefined);
342
+ expect(messageQueueRuntime.enqueue).toHaveBeenCalledTimes(claimed && !missing ? 1 : 0);
343
+ expect(messageQueueRuntime.cancelScheduledTask).not.toHaveBeenCalled();
344
+ if (consumeFails) expect(warn).toHaveBeenCalledWith('[ScheduledTasks] failed to consume one-time task', { projectID: 'project-1', taskID: task.id, error: 'disk write failed' });
345
+ else expect(warn).not.toHaveBeenCalled();
346
+ } finally {
347
+ scheduler.stop();
348
+ }
349
+ });
350
+ });
@@ -37,6 +37,7 @@
37
37
  * the markdown file; it continues to live in the project config/state store.
38
38
  */
39
39
 
40
+ import { asNonEmptyString } from '../shared/guards.js';
40
41
  import { createHash } from 'crypto';
41
42
  import fs from 'fs';
42
43
  import os from 'os';
@@ -47,14 +48,6 @@ import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
47
48
  const LOOP_DIR_NAME = 'loops';
48
49
  const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME);
49
50
 
50
- const asNonEmptyString = (value) => {
51
- if (typeof value !== 'string') {
52
- return null;
53
- }
54
- const trimmed = value.trim();
55
- return trimmed.length > 0 ? trimmed : null;
56
- };
57
-
58
51
  /**
59
52
  * Split a `provider/model` string into its two parts. Splits on the first `/`
60
53
  * so model ids containing a slash (e.g. `openai/gpt-5`) still resolve.
@@ -1,11 +1,5 @@
1
+ import { asNonEmptyString } from '../shared/guards.js';
1
2
  import { randomUUID } from 'node:crypto';
2
- const asNonEmptyString = (value) => {
3
- if (typeof value !== 'string') {
4
- return null;
5
- }
6
- const trimmed = value.trim();
7
- return trimmed.length > 0 ? trimmed : null;
8
- };
9
3
 
10
4
  const parseProjectID = (req) => asNonEmptyString(req?.params?.projectId);
11
5
  const parseTaskID = (req) => asNonEmptyString(req?.params?.taskId);
@@ -124,7 +118,13 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
124
118
  try {
125
119
  return res.json({ ok: true, ...await scheduledTaskService.run(projectID, taskID) });
126
120
  } catch (error) {
127
- if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message, ...(error.task ? { task: error.task } : {}) });
121
+ if (error?.statusCode) {
122
+ return res.status(error.statusCode).json({
123
+ error: error.message,
124
+ ...(error.task ? { task: error.task } : {}),
125
+ ...(error.busy ? { busy: error.busy } : {}),
126
+ });
127
+ }
128
128
  console.error('[ScheduledTasks] failed to run task:', error);
129
129
  return res.status(500).json({ error: 'Failed to run scheduled task' });
130
130
  }
@@ -5,6 +5,7 @@ import { expandSnippets } from '../opencode/snippets.js';
5
5
  import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
6
6
  import { discoverLoops } from './loops.js';
7
7
  import { assertOpenCodeApiResponse } from '../opencode/prompt-response.js';
8
+ import { createExistingSessionTasks } from './existing-session.js';
8
9
 
9
10
  const DEFAULT_GLOBAL_CONCURRENCY = 4;
10
11
  const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -262,6 +263,12 @@ export const createScheduledTasksRuntime = (deps) => {
262
263
  // Chats scope (see chats-scope.js): scheduled like a project, but each run
263
264
  // opens a new chat directory and loop files are not discovered for it.
264
265
  chatsScope = null,
266
+ // The session defaults a task with `useDefaults` runs on:
267
+ // `(projectID) => { providerID, modelID, variant, agent }`, any of them null.
268
+ readSessionDefaults = null,
269
+ messageQueueRuntime,
270
+ resolvePrimaryWorktreeRoot,
271
+ isSessionArchived,
265
272
  logger = console,
266
273
  maxGlobalConcurrency = DEFAULT_GLOBAL_CONCURRENCY,
267
274
  maxProjectConcurrency = DEFAULT_PROJECT_CONCURRENCY,
@@ -284,6 +291,37 @@ export const createScheduledTasksRuntime = (deps) => {
284
291
  },
285
292
  });
286
293
 
294
+ const existingSessionTasks = messageQueueRuntime ? createExistingSessionTasks({
295
+ projectConfigRuntime, messageQueueRuntime, createClient: createScopedClient,
296
+ listProjects, chatsScope, resolvePrimaryWorktreeRoot, isSessionArchived, emitTaskRunEvent,
297
+ }) : null;
298
+
299
+ // A pinned task runs on what it stores; one that follows the session
300
+ // defaults reads them now, so changing a default reaches it on its next run.
301
+ const resolveRunSelection = async (projectID, task) => {
302
+ const execution = task.execution;
303
+ if (!execution.useDefaults) {
304
+ return {
305
+ providerID: execution.providerID ?? null,
306
+ modelID: execution.modelID ?? null,
307
+ variant: execution.variant ?? null,
308
+ agent: execution.agent ?? null,
309
+ };
310
+ }
311
+ try {
312
+ const defaults = typeof readSessionDefaults === 'function' ? await readSessionDefaults(projectID) : null;
313
+ return {
314
+ providerID: defaults?.providerID ?? null,
315
+ modelID: defaults?.modelID ?? null,
316
+ variant: defaults?.variant ?? null,
317
+ agent: defaults?.agent ?? null,
318
+ };
319
+ } catch (error) {
320
+ logger.warn?.('[scheduled-tasks] session defaults unavailable, using OpenCode defaults:', error?.message ?? error);
321
+ return { providerID: null, modelID: null, variant: null, agent: null };
322
+ }
323
+ };
324
+
287
325
  let started = false;
288
326
  const tasksByProject = new Map();
289
327
  const projectPathByID = new Map();
@@ -574,7 +612,7 @@ export const createScheduledTasksRuntime = (deps) => {
574
612
  await recordKnowledge(sessionID, projectPath, knowledge);
575
613
  };
576
614
 
577
- const runTaskWithWatchdog = async (projectID, task, reason) => {
615
+ const runTaskWithWatchdog = async (projectID, task, reason, onSessionCreated) => {
578
616
  const startedAt = Date.now();
579
617
  const title = formatScheduledSessionTitle(task, startedAt);
580
618
  const projectPath = projectPathByID.get(projectID);
@@ -595,6 +633,7 @@ export const createScheduledTasksRuntime = (deps) => {
595
633
  const baseUrl = openCodeOrigin();
596
634
  const authHeaders = getOpenCodeAuthHeaders();
597
635
  const client = createScopedClient(directory);
636
+ const selection = await resolveRunSelection(projectID, task);
598
637
 
599
638
  // Agent, model and variant belong to the session in v2: a scheduled run
600
639
  // fixes them here instead of repeating them on every prompt.
@@ -603,13 +642,15 @@ export const createScheduledTasksRuntime = (deps) => {
603
642
  const session = await client.session.create({
604
643
  title,
605
644
  location: { directory },
606
- metadata: { openchamber: { adopted: true } },
607
- model: {
608
- providerID: task.execution.providerID,
609
- id: task.execution.modelID,
610
- ...(task.execution.variant ? { variant: task.execution.variant } : {}),
611
- },
612
- ...(task.execution.agent ? { agent: task.execution.agent } : {}),
645
+ // No model or agent at all: OpenCode picks its own default.
646
+ ...(selection.providerID && selection.modelID ? {
647
+ model: {
648
+ providerID: selection.providerID,
649
+ id: selection.modelID,
650
+ ...(selection.variant ? { variant: selection.variant } : {}),
651
+ },
652
+ } : {}),
653
+ ...(selection.agent ? { agent: selection.agent } : {}),
613
654
  });
614
655
  sessionID = session?.id;
615
656
  if (!sessionID) {
@@ -622,6 +663,9 @@ export const createScheduledTasksRuntime = (deps) => {
622
663
  throw error;
623
664
  }
624
665
 
666
+ // Before the event, so a UI refreshing on it already sees the session.
667
+ await onSessionCreated?.(sessionID);
668
+
625
669
  try {
626
670
  emitTaskRunEvent?.({
627
671
  projectID,
@@ -658,8 +702,8 @@ export const createScheduledTasksRuntime = (deps) => {
658
702
  directory,
659
703
  objective: commandObjective ?? expandSnippets(task.execution.prompt, directory),
660
704
  tokenBudget: task.execution.goalTokenBudget,
661
- providerID: task.execution.providerID,
662
- modelID: task.execution.modelID,
705
+ providerID: selection.providerID ?? undefined,
706
+ modelID: selection.modelID ?? undefined,
663
707
  onWarning: (message, error) => console.warn(`[scheduled-tasks] ${message}:`, error?.message || error),
664
708
  });
665
709
  }
@@ -733,6 +777,27 @@ export const createScheduledTasksRuntime = (deps) => {
733
777
  return { ok: false, skipped: true };
734
778
  }
735
779
 
780
+ if (task.targetSessionId) {
781
+ if (!existingSessionTasks) return { ok: false, error: 'Existing-session scheduling is unavailable', statusCode: 503 };
782
+ const nextRunAt = computeNextRunAt(task, Math.max(Date.now(), (scheduledFor ?? 0) + 1));
783
+ const result = await existingSessionTasks.run(projectID, task, reason, scheduledFor, nextRunAt);
784
+ if (reason === 'scheduled') {
785
+ if (task.schedule.kind === 'once' && result.task && !result.skipped) {
786
+ try {
787
+ const consumed = await projectConfigRuntime.upsertScheduledTask(projectID, { ...result.task, enabled: false });
788
+ updateInMemoryTask(projectID, consumed.task);
789
+ } catch (consumeError) {
790
+ logger.warn?.('[ScheduledTasks] failed to consume one-time task', {
791
+ projectID,
792
+ taskID,
793
+ error: safeErrorMessage(consumeError),
794
+ });
795
+ }
796
+ } else scheduleFutureRun(projectID, taskID, nextRunAt);
797
+ }
798
+ return result;
799
+ }
800
+
736
801
  const taskKey = buildTaskKey(projectID, taskID);
737
802
  if (runningTaskKeys.has(taskKey)) {
738
803
  return { ok: false, running: true };
@@ -762,6 +827,8 @@ export const createScheduledTasksRuntime = (deps) => {
762
827
  lastRunAt: runStartedAt,
763
828
  lastStatus: 'running',
764
829
  lastError: undefined,
830
+ // Set again once this run creates its session.
831
+ lastSessionId: undefined,
765
832
  updatedAt: runStartedAt,
766
833
  // Always set nextRunAt so a past once-slot is cleared when there is
767
834
  // no following occurrence (omitting the key would leave the past value).
@@ -882,6 +949,7 @@ export const createScheduledTasksRuntime = (deps) => {
882
949
  lastRunAt: runStartedAt,
883
950
  lastStatus: 'running',
884
951
  lastError: undefined,
952
+ lastSessionId: undefined,
885
953
  updatedAt: runStartedAt,
886
954
  });
887
955
  if (startResult.task) {
@@ -903,9 +971,27 @@ export const createScheduledTasksRuntime = (deps) => {
903
971
  let sessionDirectory;
904
972
  let durationMs = 0;
905
973
  let errorMessage;
974
+ // Known as soon as the run creates its session, so a run that fails or
975
+ // times out afterwards still points at the session it left behind.
976
+ let createdSessionID;
977
+ const recordCreatedSession = async (id) => {
978
+ createdSessionID = id;
979
+ try {
980
+ const recorded = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, { lastSessionId: id });
981
+ if (recorded.task) {
982
+ updateInMemoryTask(projectID, recorded.task);
983
+ }
984
+ } catch (error) {
985
+ logger.warn?.('[ScheduledTasks] failed to record the run session', {
986
+ projectID,
987
+ taskID,
988
+ error: safeErrorMessage(error),
989
+ });
990
+ }
991
+ };
906
992
 
907
993
  try {
908
- const runPromise = runTaskWithWatchdog(projectID, task, reason);
994
+ const runPromise = runTaskWithWatchdog(projectID, task, reason, recordCreatedSession);
909
995
  let timeoutID;
910
996
  const timeoutPromise = new Promise((_, reject) => {
911
997
  timeoutID = setTimeout(() => {
@@ -967,7 +1053,7 @@ export const createScheduledTasksRuntime = (deps) => {
967
1053
  lastStatus: status,
968
1054
  lastDurationMs: durationMs,
969
1055
  lastError: status === 'error' ? errorMessage : undefined,
970
- lastSessionId: status === 'success' ? sessionID : undefined,
1056
+ lastSessionId: sessionID ?? createdSessionID,
971
1057
  nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
972
1058
  updatedAt: finishedAt,
973
1059
  };
@@ -1004,7 +1090,7 @@ export const createScheduledTasksRuntime = (deps) => {
1004
1090
  lastStatus: status,
1005
1091
  lastDurationMs: durationMs,
1006
1092
  lastError: status === 'error' ? errorMessage : undefined,
1007
- lastSessionId: status === 'success' ? sessionID : undefined,
1093
+ lastSessionId: sessionID ?? createdSessionID,
1008
1094
  nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
1009
1095
  updatedAt: finishedAt,
1010
1096
  },
@@ -1175,5 +1261,9 @@ export const createScheduledTasksRuntime = (deps) => {
1175
1261
  syncProject,
1176
1262
  runNow,
1177
1263
  getStatus,
1264
+ validateTarget: (projectID, sessionID) => existingSessionTasks.validateTarget(projectID, sessionID),
1265
+ beforeScheduledTaskSend: (sessionID, directory, item) => existingSessionTasks.beforeSend(sessionID, directory, item),
1266
+ onScheduledTaskResult: (provenance, status, error) => existingSessionTasks.result(provenance, status, error),
1267
+ cancelWaitingPrompt: (projectID, taskID) => existingSessionTasks?.cancel(projectID, taskID),
1178
1268
  };
1179
1269
  };