@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
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * "In work": Jev moves a session into work when real work starts in it, and
3
- * says when a turn looks like the end of that work. The user closes; this
4
- * runtime never does. See DOCUMENTATION.md.
3
+ * says when a turn looks like the end of that work, or handed over changes
4
+ * worth a look. The user closes; this runtime never does. See
5
+ * DOCUMENTATION.md.
5
6
  *
6
7
  * Two moments ask Jev:
7
8
  * - a user message was sent (`message.updated`, role user): only while the
@@ -26,9 +27,10 @@ import {
26
27
  buildTurnEndRequest,
27
28
  decideAssist,
28
29
  decideOpen,
30
+ decideReviewReady,
29
31
  decideWrapUp,
30
32
  } from './questions.js';
31
- import { clearSuggestionPatch, openByJevPatch, readWork, suggestDonePatch } from './state.js';
33
+ import { clearSuggestionPatch, offerReviewPatch, openByJevPatch, readWork, suggestDonePatch } from './state.js';
32
34
 
33
35
  const OPENCHAMBER_SETTINGS_FILE = path.join(
34
36
  process.env.OPENCHAMBER_DATA_DIR
@@ -37,17 +39,20 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
37
39
  'settings.json',
38
40
  );
39
41
 
40
- /** Both default on; read at every use so a change applies without a restart. */
42
+ /** In work and auto-open default on, the review offer off; read at every use so a change applies without a restart. */
41
43
  const readSessionWorkSettings = () => {
42
44
  const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
43
45
  return {
44
46
  enabled: settings.sessionWorkEnabled !== false,
45
47
  autoOpen: settings.sessionWorkAutoOpen !== false,
48
+ reviewOffer: settings.sessionReviewOfferEnabled === true,
46
49
  };
47
50
  };
48
51
 
49
52
  const READ_TIMEOUT_MS = 5_000;
50
53
  const REQUEST_CHARS = 1_500;
54
+ /** Head and tail of the turn's answer: what was changed sits early, the outcome late. */
55
+ const ANSWER_EDGE_CHARS = 500;
51
56
  const SEEN_MESSAGES_LIMIT = 500;
52
57
 
53
58
  const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
@@ -75,7 +80,7 @@ const reviewSessionSchema = z.object({ openchamber: z.object({ kind: z.literal('
75
80
  export function createSessionWorkRuntime({
76
81
  buildOpenCodeUrl,
77
82
  getOpenCodeAuthHeaders,
78
- /** `{ enabled, autoOpen }` as currently saved. */
83
+ /** `{ enabled, autoOpen, reviewOffer }` as currently saved. */
79
84
  getSettings = readSessionWorkSettings,
80
85
  /** The classification provider's endpoint, or null when there is no Jev. */
81
86
  classifierEndpoint,
@@ -98,12 +103,12 @@ export function createSessionWorkRuntime({
98
103
  };
99
104
  let stopped = false;
100
105
  const seenMessages = new Set();
101
- // Sessions where this process wrote a done hint, so a new turn can retire it
106
+ // Sessions where this process wrote a hint, so a new turn can retire it
102
107
  // without reading every session's metadata at every turn start.
103
108
  const suggested = new Map();
104
109
  // A turn counter per session, moved by every turn start and every new user
105
110
  // message. A turn-end check captures it before asking Jev and writes the
106
- // done hint only if no newer turn began in the meantime: a late answer about
111
+ // hint only if no newer turn began in the meantime: a late answer about
107
112
  // the previous turn must not land on the next one.
108
113
  // Values come from one process-wide counter, so they never repeat: a session
109
114
  // evicted from the bounded map reads 0, which no pending check captured
@@ -139,7 +144,6 @@ export function createSessionWorkRuntime({
139
144
  const session = await client.session.get({ sessionID: sessionId }, { signal });
140
145
  if (session?.id === sessionId) lineage?.remember(sessionId, session.parentID ?? null);
141
146
  if (session?.id !== sessionId || session.parentID) return null;
142
- if (session.metadata?.openchamber?.adopted !== true) return null;
143
147
  if (isChatDirectory(session.location?.directory ?? session.directory)) return null;
144
148
  if (reviewSessionSchema.safeParse(session.metadata).success) return null;
145
149
  if (await isSessionArchived(sessionId)) return null;
@@ -181,7 +185,7 @@ export function createSessionWorkRuntime({
181
185
  const directory = suggested.get(sessionId);
182
186
  suggested.delete(sessionId);
183
187
  Promise.resolve(updateMetadata(sessionId, clearSuggestionPatch, { directory }))
184
- .catch((error) => console.warn('[session-work] could not retire a done hint:', errorMessage(error)));
188
+ .catch((error) => console.warn('[session-work] could not retire a hint:', errorMessage(error)));
185
189
  };
186
190
 
187
191
  const processPayload = (payload, directoryHint = '') => {
@@ -217,7 +221,7 @@ export function createSessionWorkRuntime({
217
221
  const evaluateTurnEnd = async ({ sessionId, directory, assist }) => {
218
222
  if (stopped || lineage?.isChild(sessionId) === true) return null;
219
223
  const settings = getSettings();
220
- if (!settings.enabled && !assist.recap && !assist.suggestion) return null;
224
+ if (!settings.enabled && !settings.reviewOffer && !assist.recap && !assist.suggestion) return null;
221
225
  // Never capture 0: an evicted session also reads 0.
222
226
  if (!turnGenerations.has(sessionId)) advanceTurn(sessionId);
223
227
  const generation = turnGeneration(sessionId);
@@ -232,6 +236,7 @@ export function createSessionWorkRuntime({
232
236
  const ask = {
233
237
  open: settings.enabled && settings.autoOpen && work?.state !== 'open',
234
238
  wrapUp: settings.enabled && work?.state === 'open',
239
+ reviewReady: settings.reviewOffer,
235
240
  recap: assist.recap,
236
241
  nextStep: assist.suggestion,
237
242
  };
@@ -241,7 +246,7 @@ export function createSessionWorkRuntime({
241
246
  const request = buildTurnEndRequest({
242
247
  history: turnsToHistory(context.turns.slice(0, -1)),
243
248
  request: excerptHead(turn.user.text, REQUEST_CHARS),
244
- answer: excerptHeadTail(turn.assistant.text, 300, 300),
249
+ answer: excerptHeadTail(turn.assistant.text, ANSWER_EDGE_CHARS, ANSWER_EDGE_CHARS),
245
250
  ask,
246
251
  });
247
252
  if (!request) return null;
@@ -251,12 +256,16 @@ export function createSessionWorkRuntime({
251
256
  const requestAt = turn.user.created ?? now();
252
257
  await updateMetadata(sessionId, (metadata) => openByJevPatch(metadata, { requestAt, now: now() }), { directory });
253
258
  }
254
- if (ask.wrapUp && decideWrapUp(answers)) {
259
+ // Looks done wins over a review offer from the same turn.
260
+ const suggest = ask.wrapUp && decideWrapUp(answers)
261
+ ? suggestDonePatch
262
+ : ask.reviewReady && decideReviewReady(answers) ? offerReviewPatch : null;
263
+ if (suggest) {
255
264
  // Decided at write time: a turn that started while Jev was answering
256
265
  // makes this answer about an older turn.
257
266
  const { changed } = await updateMetadata(
258
267
  sessionId,
259
- (metadata) => (turnGeneration(sessionId) === generation ? suggestDonePatch(metadata, { now: now() }) : null),
268
+ (metadata) => (turnGeneration(sessionId) === generation ? suggest(metadata, { now: now() }) : null),
260
269
  { directory },
261
270
  );
262
271
  if (changed) markSuggested(sessionId, directory);
@@ -18,12 +18,11 @@ const answeredTurn = [
18
18
  ...settledPair,
19
19
  ];
20
20
 
21
- const stubOpenCode = ({ session, records = answeredTurn } = {}) => {
22
- const sessionRecord = session ?? { metadata: { openchamber: { adopted: true } } };
21
+ const stubOpenCode = ({ session = {}, records = answeredTurn } = {}) => {
23
22
  const fetchMock = vi.fn(async (input) => {
24
23
  const url = new URL(String(input));
25
24
  let body;
26
- if (url.pathname === '/api/session/ses_1') body = { data: { id: 'ses_1', ...sessionRecord } };
25
+ if (url.pathname === '/api/session/ses_1') body = { data: { id: 'ses_1', ...session } };
27
26
  else if (url.pathname === '/api/session/ses_1/message') body = { data: records, cursor: {} };
28
27
  else throw new Error(`unexpected ${url.pathname}`);
29
28
  return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
@@ -32,7 +31,7 @@ const stubOpenCode = ({ session, records = answeredTurn } = {}) => {
32
31
  return fetchMock;
33
32
  };
34
33
 
35
- const makeRuntime = ({ metadata = { openchamber: { adopted: true } }, answers = {}, askError = null, settings = {}, endpoint = ENDPOINT, lineage = null } = {}) => {
34
+ const makeRuntime = ({ metadata = {}, answers = {}, askError = null, settings = {}, endpoint = ENDPOINT, lineage = null } = {}) => {
36
35
  const records = new Map([['ses_1', metadata]]);
37
36
  const updateMetadata = vi.fn(async (id, decide) => {
38
37
  const current = records.get(id) ?? {};
@@ -47,7 +46,7 @@ const makeRuntime = ({ metadata = { openchamber: { adopted: true } }, answers =
47
46
  const runtime = createSessionWorkRuntime({
48
47
  buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
49
48
  getOpenCodeAuthHeaders: () => ({}),
50
- getSettings: () => ({ enabled: true, autoOpen: true, ...settings }),
49
+ getSettings: () => ({ enabled: true, autoOpen: true, reviewOffer: true, ...settings }),
51
50
  classifierEndpoint: async () => endpoint,
52
51
  jev,
53
52
  readMetadata: async (id) => records.get(id) ?? {},
@@ -89,24 +88,13 @@ describe('session work decisions', () => {
89
88
  });
90
89
 
91
90
  describe('session work runtime: a message was sent', () => {
92
- it('does not send a TUI-only session to Jev', async () => {
93
- stubOpenCode({ session: {}, records: [{ id: 'msg_new', type: 'user', text: 'Keep this local' }, ...settledPair] });
94
- const { runtime, jev } = makeRuntime({ metadata: {} });
95
-
96
- runtime.processPayload(sent('msg_new', 'Keep this local'));
97
- await new Promise((resolve) => setTimeout(resolve, 20));
98
-
99
- expect(jev.ask).not.toHaveBeenCalled();
100
- });
101
-
102
91
  it('asks Jev on the new message with the settled turns as history, and opens the session', async () => {
103
92
  stubOpenCode({ records: [{ id: 'msg_new', type: 'user', text: 'Now fix the header' }, ...settledPair] });
104
93
  const { runtime, jev, records } = makeRuntime({ answers: { change: { noul: 0.95 }, toward_change: { noul: 0.9 } } });
105
94
 
106
95
  runtime.processPayload(sent());
107
- await settle(() => records.get('ses_1').openchamber?.work?.state === 'open');
96
+ await settle(() => records.get('ses_1').openchamber);
108
97
 
109
- expect(jev.ask).toHaveBeenCalledOnce();
110
98
  const [request] = jev.ask.mock.calls[0];
111
99
  expect(request.state.request).toBe('Now fix the header');
112
100
  expect(request.state.history.map((entry) => entry.role)).toEqual(['user', 'assistant']);
@@ -116,7 +104,7 @@ describe('session work runtime: a message was sent', () => {
116
104
 
117
105
  it('does not ask again for a session already in work, or for the same message twice', async () => {
118
106
  stubOpenCode();
119
- const open = makeRuntime({ metadata: { openchamber: { adopted: true, work: { state: 'open' } } } });
107
+ const open = makeRuntime({ metadata: { openchamber: { work: { state: 'open' } } } });
120
108
  open.runtime.processPayload(sent());
121
109
  await new Promise((resolve) => setTimeout(resolve, 30));
122
110
  expect(open.jev.ask).not.toHaveBeenCalled();
@@ -177,22 +165,22 @@ describe('session work runtime: a turn ended', () => {
177
165
  const gate = await runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: true, suggestion: true } });
178
166
 
179
167
  expect(jev.ask).toHaveBeenCalledTimes(1);
180
- expect(Object.keys(jev.ask.mock.calls[0][0].questions).sort()).toEqual(['change', 'next_step', 'recap', 'toward_change']);
168
+ expect(Object.keys(jev.ask.mock.calls[0][0].questions).sort()).toEqual(['change', 'next_step', 'recap', 'review_ready', 'toward_change']);
181
169
  expect(jev.ask.mock.calls[0][0].state).toMatchObject({ request: 'Implement it', answer: 'Done, try it.' });
182
170
  expect(gate).toEqual({ recap: true, suggestion: false });
183
171
  expect(records.get('ses_1')).toMatchObject({ openchamber: { work: { state: 'open' } } });
184
172
  });
185
173
 
186
- it('asks only whether the work looks done in a session already in work, and a new turn retires the hint', async () => {
174
+ it('asks only about the end of the work and a review in a session already in work, and a new turn retires the hint', async () => {
187
175
  stubOpenCode();
188
176
  const { runtime, jev, records, updateMetadata } = makeRuntime({
189
- metadata: { openchamber: { adopted: true, work: { state: 'open', openedAt: 1 } } },
177
+ metadata: { openchamber: { work: { state: 'open', openedAt: 1 } } },
190
178
  answers: { wrap_up: { noul: 0.93 } },
191
179
  });
192
180
 
193
181
  const gate = await runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
194
182
 
195
- expect(Object.keys(jev.ask.mock.calls[0][0].questions)).toEqual(['wrap_up']);
183
+ expect(Object.keys(jev.ask.mock.calls[0][0].questions)).toEqual(['wrap_up', 'review_ready']);
196
184
  expect(gate).toBeNull();
197
185
  expect(records.get('ses_1').openchamber.work.suggestDoneAt).toBeTypeOf('number');
198
186
 
@@ -205,10 +193,39 @@ describe('session work runtime: a turn ended', () => {
205
193
  expect(updateMetadata).toHaveBeenCalledTimes(2);
206
194
  });
207
195
 
196
+ it('offers a review after a turn that changed the project, and looks done wins over it', async () => {
197
+ stubOpenCode();
198
+ const open = { openchamber: { work: { state: 'open', openedAt: 1 } } };
199
+ const review = makeRuntime({ metadata: open, answers: { wrap_up: { noul: 0.2 }, review_ready: { noul: 0.75 } } });
200
+ await review.runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
201
+ expect(review.records.get('ses_1').openchamber.reviewOffer.at).toBeTypeOf('number');
202
+ expect(review.records.get('ses_1').openchamber.work).not.toHaveProperty('suggestDoneAt');
203
+
204
+ const both = makeRuntime({ metadata: open, answers: { wrap_up: { noul: 0.9 }, review_ready: { noul: 0.9 } } });
205
+ await both.runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
206
+ expect(both.records.get('ses_1').openchamber.work.suggestDoneAt).toBeTypeOf('number');
207
+ expect(both.records.get('ses_1').openchamber).not.toHaveProperty('reviewOffer');
208
+ });
209
+
210
+ it('offers a review without In work, asks nothing about it when it is off, and a new turn retires it', async () => {
211
+ stubOpenCode();
212
+ const solo = makeRuntime({ settings: { enabled: false }, answers: { review_ready: { noul: 0.8 } } });
213
+ await solo.runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
214
+ expect(Object.keys(solo.jev.ask.mock.calls[0][0].questions)).toEqual(['review_ready']);
215
+ expect(solo.records.get('ses_1')).toEqual({ openchamber: { reviewOffer: { at: expect.any(Number) } } });
216
+ solo.runtime.processPayload({ type: 'session.status', properties: { sessionID: 'ses_1', status: { type: 'busy' } } });
217
+ await settle(() => !solo.records.get('ses_1').openchamber?.reviewOffer);
218
+ expect(solo.records.get('ses_1').openchamber).not.toHaveProperty('reviewOffer');
219
+
220
+ const off = makeRuntime({ settings: { reviewOffer: false }, answers: { change: { noul: 0.1 } } });
221
+ await off.runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
222
+ expect(Object.keys(off.jev.ask.mock.calls[0][0].questions)).not.toContain('review_ready');
223
+ });
224
+
208
225
  it('drops a late done answer once the next turn started', async () => {
209
226
  stubOpenCode();
210
227
  const { runtime, jev, records } = makeRuntime({
211
- metadata: { openchamber: { adopted: true, work: { state: 'open', openedAt: 1 } } },
228
+ metadata: { openchamber: { work: { state: 'open', openedAt: 1 } } },
212
229
  });
213
230
  let answer;
214
231
  jev.ask.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
@@ -239,7 +256,7 @@ describe('session work runtime: a turn ended', () => {
239
256
  it('never closes: a closing turn in a session in work only hints', async () => {
240
257
  stubOpenCode();
241
258
  const { runtime, records } = makeRuntime({
242
- metadata: { openchamber: { adopted: true, work: { state: 'open', openedAt: 1 } } },
259
+ metadata: { openchamber: { work: { state: 'open', openedAt: 1 } } },
243
260
  answers: { wrap_up: { noul: 0.99 }, change: { noul: 0 } },
244
261
  });
245
262
  await runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } });
@@ -257,9 +274,9 @@ describe('session work runtime: a turn ended', () => {
257
274
  expect(updateMetadata).not.toHaveBeenCalled();
258
275
  });
259
276
 
260
- it('makes no call when the feature and both assist fields are off', async () => {
277
+ it('makes no call when In work, the review offer and both assist fields are off', async () => {
261
278
  const fetchMock = stubOpenCode();
262
- const { runtime, jev } = makeRuntime({ settings: { enabled: false } });
279
+ const { runtime, jev } = makeRuntime({ settings: { enabled: false, reviewOffer: false } });
263
280
  expect(await runtime.evaluateTurnEnd({ sessionId: 'ses_1', directory: '/repo', assist: { recap: false, suggestion: false } })).toBeNull();
264
281
  expect(jev.ask).not.toHaveBeenCalled();
265
282
  expect(fetchMock).not.toHaveBeenCalled();
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `metadata.openchamber.work`: whether a session is in work and what Jev
3
- * currently suggests about it. Every function returns a JSON merge patch for
3
+ * currently suggests about it; `metadata.openchamber.reviewOffer`: Jev's offer
4
+ * to look over the last turn's changes, which does not depend on work. Every function returns a JSON merge patch for
4
5
  * the session metadata store, or null when nothing changes, so the store can
5
6
  * decide against the record as it is at write time.
6
7
  *
@@ -19,6 +20,7 @@ const workSchema = z.object({
19
20
  });
20
21
 
21
22
  const metadataSchema = z.object({ openchamber: z.object({ work: workSchema }) });
23
+ const reviewOfferSchema = z.object({ openchamber: z.object({ reviewOffer: z.object({ at: z.number() }) }) });
22
24
 
23
25
  /** The session's work record, or null when it has none (or a malformed one). */
24
26
  export const readWork = (metadata) => metadataSchema.safeParse(metadata).data?.openchamber.work ?? null;
@@ -40,9 +42,13 @@ export const suggestDonePatch = (metadata, { now }) => {
40
42
  return patchOf({ suggestDoneAt: now });
41
43
  };
42
44
 
45
+ /** The turn that just ended handed over changes worth a look; any session. */
46
+ export const offerReviewPatch = (_metadata, { now }) => ({ openchamber: { reviewOffer: { at: now } } });
47
+
43
48
  /** A new turn started: whatever Jev concluded about the last one no longer holds. */
44
49
  export const clearSuggestionPatch = (metadata) => {
45
- const work = readWork(metadata);
46
- if (!work || work.suggestDoneAt === undefined) return null;
47
- return patchOf({ suggestDoneAt: null });
50
+ const patch = {};
51
+ if (readWork(metadata)?.suggestDoneAt !== undefined) patch.work = { suggestDoneAt: null };
52
+ if (reviewOfferSchema.safeParse(metadata).success) patch.reviewOffer = null;
53
+ return Object.keys(patch).length > 0 ? { openchamber: patch } : null;
48
54
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { clearSuggestionPatch, openByJevPatch, readWork, suggestDonePatch } from './state.js';
2
+ import { clearSuggestionPatch, openByJevPatch, readWork, offerReviewPatch, suggestDonePatch } from './state.js';
3
3
 
4
4
  const withWork = (work) => ({ openchamber: { goal: { id: 'g' }, work } });
5
5
 
@@ -31,4 +31,11 @@ describe('session work state', () => {
31
31
  expect(clearSuggestionPatch(withWork({ state: 'open', suggestDoneAt: 3 }))).toEqual({ openchamber: { work: { suggestDoneAt: null } } });
32
32
  expect(clearSuggestionPatch(withWork({ state: 'open' }))).toBeNull();
33
33
  });
34
+
35
+ it('offers a review on any session, and a new turn retires both hints', () => {
36
+ expect(offerReviewPatch({}, { now: 3 })).toEqual({ openchamber: { reviewOffer: { at: 3 } } });
37
+ expect(clearSuggestionPatch({ openchamber: { reviewOffer: { at: 3 } } })).toEqual({ openchamber: { reviewOffer: null } });
38
+ expect(clearSuggestionPatch({ openchamber: { work: { state: 'open', suggestDoneAt: 3 }, reviewOffer: { at: 3 } } }))
39
+ .toEqual({ openchamber: { work: { suggestDoneAt: null }, reviewOffer: null } });
40
+ });
34
41
  });
@@ -0,0 +1,89 @@
1
+ # Shared Guards Documentation
2
+
3
+ ## Purpose
4
+
5
+ `packages/web/server/lib/shared/guards.js` holds the small value predicates and
6
+ coercions that several feature modules used to define privately. It is neutral:
7
+ it knows nothing about git, OpenCode, projects, or any other domain, and it
8
+ imports nothing. Consolidating the copies means a value can no longer be a
9
+ string on one route and not a string on another because two files spelled the
10
+ check differently.
11
+
12
+ Only the dominant, behavior-defining spelling of each guard belongs here.
13
+ Variants that answer differently for real inputs stay with their owner.
14
+
15
+ ## Public exports
16
+
17
+ - `isString(value)`: `Object.prototype.toString.call(value) === '[object String]'`.
18
+ True for primitive strings and boxed `String` objects. This is wider than
19
+ `typeof value === 'string'`, which is why callers that only accept primitives
20
+ keep their own `typeof` check.
21
+ - `isPlainObject(value)`: true only for a plain `{}`-shaped object. Rejects
22
+ `null`, arrays, boxed primitives, `Date`, `Map`, class instances, functions,
23
+ and null-prototype objects (`Object.create(null)`).
24
+ - `isRecord(value)`: true for any non-null, non-array object. Accepts plain
25
+ objects, class instances, `Date`, `Map`, boxed primitives, and null-prototype
26
+ objects.
27
+ - Modules that call the predicate `isObjectRecord` import `isRecord` under that
28
+ local name; the shared module exposes one canonical export.
29
+ - `asNonEmptyString(value)`: trims a string and returns it, or returns `null`
30
+ when the input is not a string or is empty/whitespace after trimming. The
31
+ `null` answer (not `''`) keeps "absent" distinguishable from "present".
32
+
33
+ ## Behavior matrix
34
+
35
+ | input | `isString` | `isPlainObject` | `isRecord` / `isObjectRecord` | `asNonEmptyString` |
36
+ |---|---|---|---|---|
37
+ | `'s'` | true | false | false | `'s'` |
38
+ | `''` | true | false | false | `null` |
39
+ | `' s '` | true | false | false | `'s'` |
40
+ | `' '` | true | false | false | `null` |
41
+ | `new String('s')` | true | false | true | `null` (not a primitive) |
42
+ | `42` / `NaN` | false | false | false | `null` |
43
+ | `null` / `undefined` | false | false | false | `null` |
44
+ | `{}` | false | true | true | `null` |
45
+ | `[]` | false | false | false | `null` |
46
+ | `new Foo()` (class) | false | false | true | `null` |
47
+ | `new Date()` | false | false | true | `null` |
48
+ | `new Map()` | false | false | true | `null` |
49
+ | `Object.create(null)` | false | false | true | `null` |
50
+
51
+ ## What stays out
52
+
53
+ These look adjacent but are not the same predicate, so each keeps its private
54
+ copy:
55
+
56
+ - `terminal/runtime.js` and `terminal/shells.js` use `String(value) === value`,
57
+ which rejects boxed strings and throws on null-prototype objects.
58
+ - `guests/oauth.js`, `opencode/settings-files.js`, and `opencode/shared.js`
59
+ expose a loose `isPlainObject` that uses `typeof value === 'object'` and so
60
+ accepts values the strict predicate rejects. They keep their own name and
61
+ shape; only modules asking for `isRecord` were migrated.
62
+ - `source-control/mutation-executor.js` accepts null-prototype objects under an
63
+ `isPlainObject` name, and `github/routes.js` /
64
+ `github/repo/fork-detection.js` classify by `[object Object]`; all three are
65
+ divergent and stay put.
66
+ - `quota/utils/transformers.js` exports an array-accepting `asObject`; it is not
67
+ a guard for a record and is out of scope.
68
+ - `projects/project-config.js` owns its config normalization pipeline and keeps
69
+ it local.
70
+
71
+ ## Re-exported public APIs
72
+
73
+ The modules below keep their exported names and signatures and now re-export
74
+ the shared functions, so existing importers are unaffected:
75
+
76
+ - `opencode/config-v2.js`: `isRecord`
77
+ - `linear/parse.js`: `isString`, `isPlainObject`
78
+ - `gitlab/validation.js`: `isString`, `isPlainObject`
79
+ - `quota/utils/transformers.js`: `asNonEmptyString`
80
+
81
+ ## Notes for contributors
82
+
83
+ - Add a predicate here only when two or more feature modules need the exact
84
+ same behavior. If a caller needs a stricter or looser answer, keep the
85
+ variant local and name the difference in a comment.
86
+ - Do not add feature imports to this module. It must stay free of domain
87
+ dependencies so every runtime can safely depend on it.
88
+ - When a guard's answer changes, update the behavior matrix test beside this
89
+ file; the test is the contract.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Neutral value guards shared across the web server.
3
+ *
4
+ * These are intentionally dependency-free and feature-agnostic: they describe
5
+ * what a value *is*, never what it means. Feature modules that need one of
6
+ * these predicates should import it from here rather than defining a private
7
+ * copy, so the same input can never classify differently on two surfaces.
8
+ *
9
+ * Only the dominant spelling of each guard lives here. Variants that look
10
+ * similar but answer differently for real inputs stay with their owner:
11
+ * `terminal/runtime.js` uses `String(value) === value`, which rejects boxed
12
+ * strings and throws on null-prototype objects; `guests/oauth.js`,
13
+ * `opencode/settings-files.js`, and `opencode/shared.js` use a loose
14
+ * `typeof value === 'object'` object check that classifies differently from
15
+ * `isPlainObject`; `quota/utils/transformers.js` exports an array-accepting
16
+ * `asObject`. See DOCUMENTATION.md for the full matrix.
17
+ */
18
+
19
+ /**
20
+ * True for primitive strings and `String` objects (`[object String]`).
21
+ * This is broader than `typeof value === 'string'`; callers that only want
22
+ * primitives must check `typeof` themselves.
23
+ */
24
+ export const isString = (value) => Object.prototype.toString.call(value) === '[object String]';
25
+
26
+ /**
27
+ * True only for a plain `{}`-shaped object: not null, not an array, not a
28
+ * boxed primitive, `Date`, `Map`, class instance, or null-prototype object.
29
+ * Use this when the value is decoded from JSON or written back to JSON and a
30
+ * non-plain object would be corrupted by spreading or key enumeration.
31
+ */
32
+ export const isPlainObject = (value) => typeof value === 'object'
33
+ && value !== null
34
+ && !Array.isArray(value)
35
+ && Object.getPrototypeOf(value) === Object.prototype;
36
+
37
+ /**
38
+ * True for any non-null, non-array object: plain objects, class instances,
39
+ * `Date`, `Map`, boxed primitives, and null-prototype objects. Use this when a
40
+ * value only needs to be traversable as an object, not spread as JSON.
41
+ */
42
+ export const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
43
+
44
+ /**
45
+ * Trim a string, returning `null` when the input is not a string or is empty
46
+ * after trimming. The `null` (not `''`) answer lets callers distinguish
47
+ * "absent" from "present".
48
+ */
49
+ export const asNonEmptyString = (value) => {
50
+ if (typeof value !== 'string') return null;
51
+ const trimmed = value.trim();
52
+ return trimmed ? trimmed : null;
53
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ asNonEmptyString,
5
+ isPlainObject,
6
+ isRecord,
7
+ isString,
8
+ } from './guards.js';
9
+
10
+ class Example {
11
+ constructor() {
12
+ this.value = 1;
13
+ }
14
+ }
15
+
16
+ const nullPrototype = Object.assign(Object.create(null), { value: 1 });
17
+ const boxedString = Object('s');
18
+ const label = (value) => {
19
+ try {
20
+ return String(value);
21
+ } catch {
22
+ return Object.prototype.toString.call(value);
23
+ }
24
+ };
25
+
26
+ describe('isString', () => {
27
+ it('accepts primitive and boxed strings, including the empty string', () => {
28
+ expect(isString('s')).toBe(true);
29
+ expect(isString('')).toBe(true);
30
+ expect(isString(' ')).toBe(true);
31
+ expect(isString(boxedString)).toBe(true);
32
+ });
33
+
34
+ it('rejects every non-string value', () => {
35
+ for (const value of [42, 0, NaN, true, null, undefined, [], {}, nullPrototype, new Example(), new Date(), new Map()]) {
36
+ expect(isString(value), `isString(${label(value)})`).toBe(false);
37
+ }
38
+ });
39
+ });
40
+
41
+ describe('isPlainObject', () => {
42
+ it('accepts only a plain object literal', () => {
43
+ expect(isPlainObject({})).toBe(true);
44
+ expect(isPlainObject({ a: 1 })).toBe(true);
45
+ });
46
+
47
+ it('rejects arrays, primitives, and absent values', () => {
48
+ for (const value of [[], [1], 42, 0, 's', '', true, null, undefined]) {
49
+ expect(isPlainObject(value), `isPlainObject(${label(value)})`).toBe(false);
50
+ }
51
+ });
52
+
53
+ it('rejects values that merely look object-shaped', () => {
54
+ expect(isPlainObject(boxedString)).toBe(false);
55
+ expect(isPlainObject(new Example())).toBe(false);
56
+ expect(isPlainObject(new Date())).toBe(false);
57
+ expect(isPlainObject(new Map())).toBe(false);
58
+ expect(isPlainObject(nullPrototype)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe('isRecord', () => {
63
+ it('accept every non-null, non-array object', () => {
64
+ for (const value of [{}, { a: 1 }, boxedString, new Example(), new Date(), new Map(), nullPrototype]) {
65
+ expect(isRecord(value), `isRecord(${label(value)})`).toBe(true);
66
+ }
67
+ });
68
+
69
+ it('reject arrays, primitives, and absent values', () => {
70
+ for (const value of [[], [1], 42, 0, NaN, 's', '', true, null, undefined]) {
71
+ expect(isRecord(value), `isRecord(${label(value)})`).toBe(false);
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('asNonEmptyString', () => {
77
+ it('trims a string and returns the trimmed value', () => {
78
+ expect(asNonEmptyString('s')).toBe('s');
79
+ expect(asNonEmptyString(' s ')).toBe('s');
80
+ expect(asNonEmptyString('\n\ttab\t\n')).toBe('tab');
81
+ });
82
+
83
+ it('returns null for empty, whitespace-only, and non-string values', () => {
84
+ for (const value of ['', ' ', '\n\t', 42, 0, NaN, true, null, undefined, {}, [], boxedString]) {
85
+ expect(asNonEmptyString(value), `asNonEmptyString(${label(value)})`).toBeNull();
86
+ }
87
+ });
88
+ });
89
+
90
+ describe('re-exported public APIs', () => {
91
+ it('linear/parse.js re-exports the shared predicates', async () => {
92
+ const parse = await import('../linear/parse.js');
93
+ expect(parse.isString).toBe(isString);
94
+ expect(parse.isPlainObject).toBe(isPlainObject);
95
+ });
96
+
97
+ it('gitlab/validation.js re-exports the shared predicates', async () => {
98
+ const validation = await import('../gitlab/validation.js');
99
+ expect(validation.isString).toBe(isString);
100
+ expect(validation.isPlainObject).toBe(isPlainObject);
101
+ });
102
+
103
+ it('opencode/config-v2.js re-exports the shared isRecord', async () => {
104
+ const configV2 = await import('../opencode/config-v2.js');
105
+ expect(configV2.isRecord).toBe(isRecord);
106
+ });
107
+
108
+ it('quota/utils/transformers.js re-exports the shared asNonEmptyString', async () => {
109
+ const transformers = await import('../quota/utils/transformers.js');
110
+ expect(transformers.asNonEmptyString).toBe(asNonEmptyString);
111
+ });
112
+ });
@@ -38,17 +38,17 @@ The following functions are exported and used by the web server:
38
38
  - `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format.
39
39
 
40
40
  ### Git Repository Scanning (`scan.js`)
41
- - `scanSkillsRepository({ source, subpath, defaultSubpath, identity })`: Scan git repository for skills by cloning and analyzing SKILL.md files. Returns array of skill items with metadata.
41
+ - `scanSkillsRepository({ source, subpath, defaultSubpath, identity, credentialResolver })`: Scan a Git repository for skills by cloning it and reading its `SKILL.md` files. Account identities use their connected HTTPS credential, SSH identities use their managed key, anonymous identities use credential-free HTTPS, and no identity (labelled "This machine" in the catalog dialog) uses the server's own Git configuration and credential helpers.
42
42
 
43
43
  ### Git Repository Installation (`install.js`)
44
- - `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency.
44
+ - `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, credentialResolver, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install selected skills from a Git repository. It uses the same identity transports as scanning and supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout.
45
45
 
46
46
  ## Internal Helpers
47
47
 
48
48
  The following functions are internal helpers used by exported functions:
49
49
 
50
50
  ### Git Helpers (`git.js`)
51
- - `runGit(args, options)`: Execute git command with optional SSH identity, timeout, and max buffer. Returns `{ ok, stdout, stderr, message, code, signal }`.
51
+ - `runGit(args, options)`: Execute a Git command with optional managed identity, credential resolver, timeout, and max buffer. HTTPS tokens go through the one-use local credential broker and never enter Git's arguments. Returns `{ ok, stdout, stderr, message, code, signal }`.
52
52
  - `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.).
53
53
  - `assertGitAvailable()`: Check if git is available in PATH.
54
54
 
@@ -62,7 +62,7 @@ The following functions are internal helpers used by exported functions:
62
62
  - `normalizeUserSkillDir(userSkillDir)`: Normalize the user skill directory path (handles the legacy `skill` directory in the XDG config location, or `~/.config/opencode/skill` when XDG is unset, by selecting the plural `skills` directory when appropriate).
63
63
 
64
64
  ### Git Clone Helpers (`install.js`, `scan.js`)
65
- - `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode.
65
+ - `cloneRepo({ cloneUrl, identity, credentialResolver, tempDir, ref })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback, on `ref` when the source names one. Uses non-interactive mode.
66
66
 
67
67
  ### SKILL.md Parsing (`scan.js`)
68
68
  - `parseSkillMd(content)`: Parse YAML frontmatter from SKILL.md content. Returns `{ ok, frontmatter, warnings }`.
@@ -111,11 +111,14 @@ The following functions are internal helpers used by exported functions:
111
111
  - Skill names are derived from directory basenames for git repos.
112
112
  - Invalid names result in non-installable skills with appropriate warnings.
113
113
 
114
- ### Git Cloning Strategy
115
- - Use sparse checkout to minimize clone size: `sparse-checkout init`, `sparse-checkout set`, `checkout HEAD`.
114
+ ### Git cloning strategy
115
+ - Use sparse checkout to minimize checkout size: `sparse-checkout init`, `sparse-checkout set`, `checkout HEAD`.
116
+ - Keep managed credentials scoped to the selected provider endpoint. Do not fall back to System Git after a selected identity fails, and refuse a selected identity that names no credential.
117
+ - Only commands that reach the remote resolve the identity: the clone, the `checkout`, and the `show` fallback in `scan.js`, which fetch blobs lazily from the partial clone. Local commands (`sparse-checkout`, `ls-files`, `ls-tree`) run without it.
118
+ - Managed HTTPS and anonymous runs point `HOME`, `USERPROFILE`, and `XDG_CONFIG_HOME` at the null device, so an ambient `~/.netrc` cannot sign in as another account. SSH runs keep `HOME` for `known_hosts`.
116
119
  - Preferred clone uses `--depth=1 --filter=blob:none` for partial clone with fallback to `--depth=1`.
117
120
  - Always use non-interactive mode (`GIT_TERMINAL_PROMPT=0`) to avoid hangs.
118
- - SSH keys are injected via `core.sshCommand` in git config.
121
+ - Managed SSH keys are injected as `-c core.sshCommand=<wrapper>` through the shared SSH wrapper; the operation's key copy is removed after the run.
119
122
 
120
123
  ### Conflict Resolution
121
124
  - Installation checks for existing skills before downloading/cloning.
@@ -133,7 +136,6 @@ The following functions are internal helpers used by exported functions:
133
136
  ### Security Considerations
134
137
  - Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment.
135
138
  - Symlinks are explicitly rejected to prevent escape from skill directory.
136
- - SSH key paths are trimmed but not escaped in `git.js` (assumes safe input from profiles).
137
139
  - Temporary directories are cleaned up in `finally` blocks.
138
140
 
139
141
  ### Error Handling