@vantran-se/openchamber-web 1.23.0 → 1.24.3

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 (362) hide show
  1. package/bin/cli.test.js +29 -6
  2. package/bin/lib/cli-http.js +5 -1
  3. package/bin/lib/commands-status.js +5 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-DFg2wnpo.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
  5. package/dist/assets/DiffView-COVQhWGd.js +4 -0
  6. package/dist/assets/FilesView-B1V5JxFX.js +13 -0
  7. package/dist/assets/GitView-CDOJy5Ay.js +6 -0
  8. package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
  9. package/dist/assets/{JsonTreeView-BKdsjg5B.js → JsonTreeView-D0C9nU7K.js} +1 -1
  10. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  12. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  30. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  33. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  39. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  42. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  54. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  57. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  60. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  62. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  65. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  68. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  69. package/dist/assets/LinearIssuesView-D7FKg1XJ.js +1 -0
  70. package/dist/assets/{MarkdownImageGallery-_5gEVr0X.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
  71. package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
  72. package/dist/assets/{OnboardingScreen-DdP4_nuc.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
  73. package/dist/assets/{PatchDiff-am3SAss2.js → PatchDiff-DnEhjXcC.js} +1 -1
  74. package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
  75. package/dist/assets/{PlanView-BoJxdRZe.js → PlanView-LW2z_EAP.js} +4 -4
  76. package/dist/assets/PluginPane-B4tKno2h.js +3 -0
  77. package/dist/assets/SettingsView-DEaMulN9.js +29 -0
  78. package/dist/assets/{SettingsWindow-DVTdDSol.js → SettingsWindow-FLDJXOgQ.js} +1 -1
  79. package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
  80. package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
  81. package/dist/assets/{ToolPartDiffPreview-C7WLk5i9.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
  82. package/dist/assets/{VirtualizedFileDiff-DcdgkH-x.js → VirtualizedFileDiff-0pyr_mHY.js} +4 -4
  83. package/dist/assets/WalkthroughView-yNj4nOP5.js +4 -0
  84. package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
  85. package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
  86. package/dist/assets/{computeVirtualFileMetrics-C8byMnRh.js → computeVirtualFileMetrics-DxTBMuu-.js} +2 -2
  87. package/dist/assets/de-DgxgFyGG.js +10 -0
  88. package/dist/assets/es-BFsZj5F_.js +16 -0
  89. package/dist/assets/fr-jdZ5KKQi.js +14 -0
  90. package/dist/assets/{index-BB7ryz8y.js → index-CKHjPKbq.js} +1 -1
  91. package/dist/assets/index-Cbci4i1W.js +40 -0
  92. package/dist/assets/index-DqaMWNrC.css +1 -0
  93. package/dist/assets/{isDiffPlainText-45ujMPGb.js → isDiffPlainText-D7FkBMP6.js} +1 -1
  94. package/dist/assets/{isFilePlainText-Cc4KFSYi.js → isFilePlainText-CL2vsrIZ.js} +1 -1
  95. package/dist/assets/ja-C5kKrhO4.js +16 -0
  96. package/dist/assets/ko-DXDTT_0O.js +16 -0
  97. package/dist/assets/main-CZALtVek.js +896 -0
  98. package/dist/assets/main-CgXMBNvG.js +2 -0
  99. package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
  100. package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
  101. package/dist/assets/miniChat-CwTmDixi.js +2 -0
  102. package/dist/assets/mobile-CLf2vCtO.js +2 -0
  103. package/dist/assets/patchFileDiff-Bsba8KFp.js +13 -0
  104. package/dist/assets/pl-D-7x4Y8Q.js +16 -0
  105. package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
  106. package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
  107. package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
  108. package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
  109. package/dist/assets/scheduledTasksApi-DzjbLd8P.js +1 -0
  110. package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
  111. package/dist/assets/surface-B6n7wJtt.js +1 -0
  112. package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
  113. package/dist/assets/tr-Dr-oJ22L.js +7 -0
  114. package/dist/assets/uk-9Nx83UcI.js +16 -0
  115. package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
  116. package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
  117. package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
  118. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-CsXYnR5c.js} +1 -1
  119. package/dist/assets/{vendor-codemirror-language-data-DzlSIu_W.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
  120. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
  121. package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
  122. package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
  123. package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
  124. package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
  125. package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
  126. package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
  127. package/dist/assets/vendor-zod-CgAdXWZO.js +39 -0
  128. package/dist/assets/{web-components-CdGfR2XJ.js → web-components-B8IcVQAA.js} +2 -2
  129. package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
  130. package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
  131. package/dist/index.html +6 -4
  132. package/dist/mini-chat.html +6 -4
  133. package/dist/mobile.html +6 -4
  134. package/package.json +12 -9
  135. package/server/built-in-extensions/registry.json +4 -0
  136. package/server/index.d.ts +2 -0
  137. package/server/index.js +47 -23
  138. package/server/lib/agent-memory/runtime.js +7 -2
  139. package/server/lib/agent-memory/runtime.test.js +22 -0
  140. package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
  141. package/server/lib/agent-tool/runtime.js +62 -15
  142. package/server/lib/agent-tool/runtime.test.js +88 -2
  143. package/server/lib/changelog/update-notes.js +2 -1
  144. package/server/lib/changelog/update-notes.test.js +19 -0
  145. package/server/lib/event-stream/DOCUMENTATION.md +26 -0
  146. package/server/lib/event-stream/delta-coalescer.js +0 -0
  147. package/server/lib/event-stream/delta-coalescer.test.js +292 -0
  148. package/server/lib/event-stream/global-hub.js +68 -13
  149. package/server/lib/event-stream/global-hub.test.js +182 -0
  150. package/server/lib/event-stream/global-ws-bridge.js +16 -18
  151. package/server/lib/event-stream/index.js +4 -0
  152. package/server/lib/event-stream/protocol.js +21 -7
  153. package/server/lib/event-stream/runtime.js +9 -6
  154. package/server/lib/event-stream/runtime.test.js +60 -0
  155. package/server/lib/fs/DOCUMENTATION.md +10 -1
  156. package/server/lib/fs/git-process.test.js +77 -0
  157. package/server/lib/fs/routes.js +58 -5
  158. package/server/lib/fs/routes.test.js +116 -0
  159. package/server/lib/fs/search.js +2 -1
  160. package/server/lib/git/DOCUMENTATION.md +30 -6
  161. package/server/lib/git/routes.js +37 -11
  162. package/server/lib/git/routes.test.js +78 -0
  163. package/server/lib/git/serial-refresh.js +93 -0
  164. package/server/lib/git/serial-refresh.test.js +112 -0
  165. package/server/lib/git/service.js +588 -74
  166. package/server/lib/git/service.test.js +492 -0
  167. package/server/lib/github/DOCUMENTATION.md +5 -0
  168. package/server/lib/github/pr-status.js +5 -5
  169. package/server/lib/github/pr-status.test.js +1 -1
  170. package/server/lib/github/routes.js +6 -6
  171. package/server/lib/guests/DOCUMENTATION.md +68 -0
  172. package/server/lib/guests/auth-store.js +184 -0
  173. package/server/lib/guests/auth-store.test.js +112 -0
  174. package/server/lib/guests/background.test.js +83 -0
  175. package/server/lib/guests/builtins.js +34 -0
  176. package/server/lib/guests/builtins.test.js +174 -0
  177. package/server/lib/guests/catalog.js +477 -0
  178. package/server/lib/guests/catalog.test.js +390 -0
  179. package/server/lib/guests/clone.js +297 -0
  180. package/server/lib/guests/extract-zip.js +136 -0
  181. package/server/lib/guests/extract-zip.test.js +109 -0
  182. package/server/lib/guests/files.js +308 -0
  183. package/server/lib/guests/files.test.js +262 -0
  184. package/server/lib/guests/grant-scope.js +81 -0
  185. package/server/lib/guests/grant-scope.test.js +75 -0
  186. package/server/lib/guests/host-session.js +73 -0
  187. package/server/lib/guests/host-session.test.js +30 -0
  188. package/server/lib/guests/html-styles.js +6 -0
  189. package/server/lib/guests/html-styles.test.js +45 -0
  190. package/server/lib/guests/html-tokens.js +73 -0
  191. package/server/lib/guests/html-tokens.test.js +53 -0
  192. package/server/lib/guests/install.js +406 -0
  193. package/server/lib/guests/install.test.js +338 -0
  194. package/server/lib/guests/oauth.js +501 -0
  195. package/server/lib/guests/oauth.test.js +481 -0
  196. package/server/lib/guests/persist.js +336 -0
  197. package/server/lib/guests/persist.test.js +288 -0
  198. package/server/lib/guests/request.js +112 -0
  199. package/server/lib/guests/request.test.js +206 -0
  200. package/server/lib/guests/routes.js +838 -0
  201. package/server/lib/guests/service.js +538 -0
  202. package/server/lib/guests/service.test.js +277 -0
  203. package/server/lib/guests/sockets.js +129 -0
  204. package/server/lib/guests/sockets.test.js +110 -0
  205. package/server/lib/guests/ssh-install.test.js +188 -0
  206. package/server/lib/guests/storage.js +68 -0
  207. package/server/lib/guests/storage.test.js +59 -0
  208. package/server/lib/guests/updates.js +294 -0
  209. package/server/lib/guests/updates.test.js +334 -0
  210. package/server/lib/guests/upload.js +50 -0
  211. package/server/lib/guests/upload.test.js +131 -0
  212. package/server/lib/linear/DOCUMENTATION.md +1 -1
  213. package/server/lib/message-queue/DOCUMENTATION.md +17 -1
  214. package/server/lib/message-queue/runtime.js +34 -1
  215. package/server/lib/message-queue/runtime.test.js +83 -6
  216. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  217. package/server/lib/notifications/emitter-runtime.js +4 -3
  218. package/server/lib/notifications/routes.js +5 -0
  219. package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
  220. package/server/lib/openchamber-sessions/routes.js +62 -29
  221. package/server/lib/openchamber-sessions/routes.test.js +96 -0
  222. package/server/lib/opencode/DOCUMENTATION.md +73 -5
  223. package/server/lib/opencode/bootstrap-runtime.js +1 -0
  224. package/server/lib/opencode/core-routes.js +39 -1
  225. package/server/lib/opencode/env-runtime.js +5 -1
  226. package/server/lib/opencode/env-runtime.test.js +72 -0
  227. package/server/lib/opencode/feature-routes-runtime.js +30 -1
  228. package/server/lib/opencode/lifecycle-process.test.js +84 -0
  229. package/server/lib/opencode/lifecycle.js +58 -70
  230. package/server/lib/opencode/lifecycle.test.js +29 -0
  231. package/server/lib/opencode/managed-plugin-config.js +1 -1
  232. package/server/lib/opencode/openchamber-routes.js +72 -14
  233. package/server/lib/opencode/openchamber-routes.test.js +71 -3
  234. package/server/lib/opencode/proxy.js +33 -0
  235. package/server/lib/opencode/session-runtime.js +68 -0
  236. package/server/lib/opencode/session-runtime.test.js +43 -0
  237. package/server/lib/opencode/settings-helpers.js +11 -0
  238. package/server/lib/opencode/settings-helpers.test.js +24 -2
  239. package/server/lib/opencode/settings-normalization-runtime.js +2 -0
  240. package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
  241. package/server/lib/opencode/settings-registry.json +18 -0
  242. package/server/lib/opencode/settings-runtime.js +36 -9
  243. package/server/lib/opencode/settings-runtime.test.js +158 -2
  244. package/server/lib/opencode/shutdown-runtime.js +3 -0
  245. package/server/lib/opencode/shutdown-runtime.test.js +24 -0
  246. package/server/lib/opencode/theme-archive.js +53 -0
  247. package/server/lib/opencode/theme-catalog.js +171 -0
  248. package/server/lib/opencode/theme-catalog.test.js +125 -0
  249. package/server/lib/opencode/theme-import.test.js +144 -0
  250. package/server/lib/opencode/theme-runtime.js +100 -28
  251. package/server/lib/opencode/theme-runtime.test.js +15 -0
  252. package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
  253. package/server/lib/permission-auto-accept/runtime.js +13 -0
  254. package/server/lib/permission-auto-accept/runtime.test.js +37 -1
  255. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  256. package/server/lib/project-context/runtime.js +8 -2
  257. package/server/lib/project-context/runtime.test.js +54 -0
  258. package/server/lib/projects/DOCUMENTATION.md +2 -1
  259. package/server/lib/projects/project-config.js +40 -11
  260. package/server/lib/projects/project-config.test.js +97 -0
  261. package/server/lib/projects/project-id.js +26 -0
  262. package/server/lib/quota/DOCUMENTATION.md +0 -1
  263. package/server/lib/quota/providers/index.js +0 -7
  264. package/server/lib/realtime-proxy-terminal.test.js +150 -0
  265. package/server/lib/realtime-proxy.js +2 -0
  266. package/server/lib/relay/DOCUMENTATION.md +20 -0
  267. package/server/lib/relay/host-client.js +7 -0
  268. package/server/lib/relay/host-client.test.js +14 -0
  269. package/server/lib/routing/DOCUMENTATION.md +83 -0
  270. package/server/lib/routing/defaults.js +98 -0
  271. package/server/lib/routing/feature-flag.js +11 -0
  272. package/server/lib/routing/history.js +34 -0
  273. package/server/lib/routing/jev.js +98 -0
  274. package/server/lib/routing/routes.http.test.js +100 -0
  275. package/server/lib/routing/routes.js +81 -0
  276. package/server/lib/routing/runtime.js +217 -0
  277. package/server/lib/routing/runtime.test.js +188 -0
  278. package/server/lib/routing/store.js +249 -0
  279. package/server/lib/routing/store.test.js +96 -0
  280. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  281. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  282. package/server/lib/scheduled-tasks/runtime.js +14 -4
  283. package/server/lib/scheduled-tasks/runtime.test.js +67 -0
  284. package/server/lib/security/request-security.js +9 -5
  285. package/server/lib/security/request-security.test.js +37 -0
  286. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  287. package/server/lib/session-assist/context.js +127 -0
  288. package/server/lib/session-assist/context.test.js +132 -0
  289. package/server/lib/session-assist/prompt.js +54 -0
  290. package/server/lib/session-assist/runtime.js +111 -257
  291. package/server/lib/session-assist/runtime.test.js +203 -0
  292. package/server/lib/small-model/DOCUMENTATION.md +14 -2
  293. package/server/lib/small-model/call.js +37 -6
  294. package/server/lib/small-model/call.test.js +182 -0
  295. package/server/lib/terminal/DOCUMENTATION.md +23 -2
  296. package/server/lib/terminal/runtime.js +27 -5
  297. package/server/lib/terminal/runtime.test.js +67 -0
  298. package/server/lib/terminal/shutdown.js +106 -0
  299. package/server/lib/terminal/shutdown.test.js +84 -0
  300. package/server/lib/tts/service.test.js +78 -0
  301. package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
  302. package/server/lib/ui-auth/session-cookie.js +51 -0
  303. package/server/lib/ui-auth/session-cookie.test.js +46 -0
  304. package/server/lib/ui-auth/ui-auth.js +56 -17
  305. package/server/lib/ui-auth/ui-auth.test.js +96 -0
  306. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  307. package/server/lib/walkthrough/pr-source.test.js +21 -0
  308. package/server/lib/walkthrough/pull-request.js +7 -4
  309. package/server/lib/walkthrough/pull-request.test.js +16 -0
  310. package/server/lib/walkthrough/routes.js +19 -0
  311. package/server/lib/walkthrough/routes.test.js +26 -0
  312. package/server/lib/walkthrough/sources.js +13 -2
  313. package/server/opencode-worktree-readiness.test.js +130 -0
  314. package/dist/assets/DiffView-3Dn4QHbu.js +0 -4
  315. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +0 -1
  316. package/dist/assets/FilesView-BdTOpew9.js +0 -17
  317. package/dist/assets/GitView-Dqf9vHJh.js +0 -6
  318. package/dist/assets/LinearIssuesView-CmbeL7I6.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +0 -12
  320. package/dist/assets/PierreDiffViewer-DCsydlKs.js +0 -161
  321. package/dist/assets/SettingsView-CcM8pF5O.js +0 -28
  322. package/dist/assets/ToolOutputDialog-BgfTbDZ4.js +0 -15
  323. package/dist/assets/WalkthroughView-BEVUDxuw.js +0 -4
  324. package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
  325. package/dist/assets/de-C8jV7J9y.js +0 -10
  326. package/dist/assets/es-BI6J1M5R.js +0 -16
  327. package/dist/assets/fr-D6oDFt7E.js +0 -14
  328. package/dist/assets/index-BqhiCmYS.js +0 -39
  329. package/dist/assets/index-FqSOBz0J.css +0 -1
  330. package/dist/assets/ja-C_4i5EQu.js +0 -16
  331. package/dist/assets/ko-BFbOTpbq.js +0 -16
  332. package/dist/assets/main-C9ETLKFc.js +0 -2
  333. package/dist/assets/main-CJmb2dF8.js +0 -930
  334. package/dist/assets/markdownCore-DVuZpnli.js +0 -5
  335. package/dist/assets/miniChat-B4Ytoq2n.js +0 -2
  336. package/dist/assets/mobile-BUT6-lo1.js +0 -2
  337. package/dist/assets/patchFileDiff-8j1apICU.js +0 -13
  338. package/dist/assets/pl-COFvT56l.js +0 -16
  339. package/dist/assets/pt-BR-DtVOO2Lj.js +0 -16
  340. package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
  341. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +0 -2
  342. package/dist/assets/renderMobileApp-BNgHraq6.js +0 -25
  343. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +0 -17
  344. package/dist/assets/scheduledTasksApi-GC4epIYA.js +0 -1
  345. package/dist/assets/shikiHighlight-BJISeZsQ.js +0 -1
  346. package/dist/assets/surface-BvRzS58U.js +0 -1
  347. package/dist/assets/tr-B8Q0Go2l.js +0 -7
  348. package/dist/assets/uk-Dk4a-Fn-.js +0 -16
  349. package/dist/assets/useAppFontEffects-DpFNefUA.js +0 -7284
  350. package/dist/assets/useGitComparison-Dfxaqd0s.js +0 -1
  351. package/dist/assets/useInlineCommentController-CscsE4t-.js +0 -2
  352. package/dist/assets/useRootScrollLock-BaCvuRlT.js +0 -1
  353. package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
  354. package/dist/assets/vendor-zod-BPAANqvH.js +0 -39
  355. package/dist/assets/zh-CN-B_BXxG1Q.js +0 -16
  356. package/dist/assets/zh-TW-DLrl1SPZ.js +0 -16
  357. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  358. package/server/lib/mcp-reconnect/runtime.js +0 -122
  359. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
  360. package/server/lib/quota/providers/crof.js +0 -96
  361. package/server/lib/quota/providers/crof.test.js +0 -98
  362. /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
@@ -196,6 +196,23 @@ const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
196
196
  return true;
197
197
  };
198
198
 
199
+ // A chats root may not exist until the first draft. Resolve its existing
200
+ // ancestor so both that first mkdir and later OpenCode sessions use disk casing.
201
+ const canonicalDirectoryPath = async (directory, fsPromises, path) => {
202
+ let ancestor = path.resolve(directory);
203
+ const missing = [];
204
+ for (;;) {
205
+ try {
206
+ return path.join(await fsPromises.realpath(ancestor), ...missing);
207
+ } catch (error) {
208
+ const parent = path.dirname(ancestor);
209
+ if (error.code !== 'ENOENT' || parent === ancestor) throw error;
210
+ missing.unshift(path.basename(ancestor));
211
+ ancestor = parent;
212
+ }
213
+ }
214
+ };
215
+
199
216
  const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
200
217
  const normalized = normalizeDirectoryPath(targetPath);
201
218
  if (!normalized || typeof normalized !== 'string') {
@@ -253,7 +270,7 @@ const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, pa
253
270
  return { ok: false, error: 'Path is outside of active workspace' };
254
271
  };
255
272
 
256
- const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
273
+ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, managedRoots }) => {
257
274
  const resolvedProject = await resolveProjectDirectory(req);
258
275
  if (!resolvedProject.directory) {
259
276
  return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
@@ -292,13 +309,30 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
292
309
  }
293
310
  }
294
311
 
295
- return resolveWorkspacePathFromWorktrees({
312
+ // Keep legacy lexical paths valid, but also accept the canonical roots
313
+ // returned by /fs/home. Never infer filesystem identity from letter case.
314
+ let resolutionError;
315
+ for (const root of managedRoots) {
316
+ try {
317
+ const canonicalRoot = await canonicalDirectoryPath(root, fsPromises, path);
318
+ const resolvedPath = path.resolve(normalizeDirectoryPath(targetPath));
319
+ if (isPathWithinRoot(resolvedPath, canonicalRoot, path, os)) {
320
+ return { ok: true, base: canonicalRoot, resolved: resolvedPath };
321
+ }
322
+ } catch (error) {
323
+ resolutionError ??= error;
324
+ }
325
+ }
326
+ const worktree = await resolveWorkspacePathFromWorktrees({
296
327
  targetPath,
297
328
  baseDirectory: resolvedProject.directory,
298
329
  path,
299
330
  os,
300
331
  normalizeDirectoryPath,
301
332
  });
333
+ if (worktree.ok) return worktree;
334
+ if (resolutionError) throw resolutionError;
335
+ return worktree;
302
336
  };
303
337
 
304
338
  // Nested repository discovery bounds: only shallow walks are useful for the
@@ -437,6 +471,7 @@ const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProje
437
471
  resolveProjectDirectory,
438
472
  path,
439
473
  os,
474
+ fsPromises,
440
475
  normalizeDirectoryPath,
441
476
  managedRoots,
442
477
  });
@@ -704,13 +739,22 @@ export const registerFsRoutes = (app, dependencies) => {
704
739
  job.updatedAt = Date.now();
705
740
  };
706
741
 
707
- app.get('/api/fs/home', (_req, res) => {
742
+ app.get('/api/fs/home', async (_req, res) => {
708
743
  try {
709
744
  const home = os.homedir();
710
745
  if (!home || typeof home !== 'string' || home.length === 0) {
711
746
  return res.status(500).json({ error: 'Failed to resolve home directory' });
712
747
  }
713
- return res.json({ home, chatsRoot });
748
+ const [canonicalChatsRoot, canonicalLegacyChatsRoot] = await Promise.all([
749
+ canonicalDirectoryPath(chatsRoot, fsPromises, path),
750
+ canonicalDirectoryPath(path.join(home, '.config', 'openchamber', 'chats'), fsPromises, path).catch((error) => {
751
+ // A relocated root must remain usable if the old root is inaccessible.
752
+ // Omit only this optional alias; clients retain exact legacy matching.
753
+ console.warn('Failed to resolve legacy chats root:', error);
754
+ return undefined;
755
+ }),
756
+ ]);
757
+ return res.json({ home, chatsRoot, canonicalChatsRoot, canonicalLegacyChatsRoot });
714
758
  } catch (error) {
715
759
  console.error('Failed to resolve home directory:', error);
716
760
  return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
@@ -730,6 +774,7 @@ export const registerFsRoutes = (app, dependencies) => {
730
774
  return res.status(403).json({ error: 'Outside workspace directory creation requires a grant' });
731
775
  } else {
732
776
  const resolved = await resolveWorkspacePathFromContext({
777
+ fsPromises,
733
778
  req,
734
779
  targetPath: dirPath,
735
780
  resolveProjectDirectory,
@@ -1153,6 +1198,7 @@ export const registerFsRoutes = (app, dependencies) => {
1153
1198
 
1154
1199
  try {
1155
1200
  const resolved = await resolveWorkspacePathFromContext({
1201
+ fsPromises,
1156
1202
  req,
1157
1203
  targetPath: filePath,
1158
1204
  resolveProjectDirectory,
@@ -1223,6 +1269,7 @@ export const registerFsRoutes = (app, dependencies) => {
1223
1269
 
1224
1270
  try {
1225
1271
  const resolved = await resolveWorkspacePathFromContext({
1272
+ fsPromises,
1226
1273
  req,
1227
1274
  targetPath: filePath,
1228
1275
  resolveProjectDirectory,
@@ -1330,6 +1377,7 @@ export const registerFsRoutes = (app, dependencies) => {
1330
1377
 
1331
1378
  try {
1332
1379
  const resolved = await resolveWorkspacePathFromContext({
1380
+ fsPromises,
1333
1381
  req,
1334
1382
  targetPath,
1335
1383
  resolveProjectDirectory,
@@ -1368,6 +1416,7 @@ export const registerFsRoutes = (app, dependencies) => {
1368
1416
 
1369
1417
  try {
1370
1418
  const resolvedOld = await resolveWorkspacePathFromContext({
1419
+ fsPromises,
1371
1420
  req,
1372
1421
  targetPath: oldPath,
1373
1422
  resolveProjectDirectory,
@@ -1381,6 +1430,7 @@ export const registerFsRoutes = (app, dependencies) => {
1381
1430
  }
1382
1431
 
1383
1432
  const resolvedNew = await resolveWorkspacePathFromContext({
1433
+ fsPromises,
1384
1434
  req,
1385
1435
  targetPath: newPath,
1386
1436
  resolveProjectDirectory,
@@ -1487,6 +1537,7 @@ export const registerFsRoutes = (app, dependencies) => {
1487
1537
  }
1488
1538
  const resolvedCwdCandidate = path.resolve(normalizeDirectoryPath(cwd));
1489
1539
  const resolvedForWorkspace = await resolveWorkspacePathFromContext({
1540
+ fsPromises,
1490
1541
  req,
1491
1542
  targetPath: resolvedCwdCandidate,
1492
1543
  resolveProjectDirectory,
@@ -1619,7 +1670,8 @@ export const registerFsRoutes = (app, dependencies) => {
1619
1670
  const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
1620
1671
  cwd: resolvedPath,
1621
1672
  windowsHide: true,
1622
- stdio: ['ignore', 'pipe', 'pipe'],
1673
+ // Diagnostics are unused here. An unread pipe can block Git forever.
1674
+ stdio: ['ignore', 'pipe', 'ignore'],
1623
1675
  });
1624
1676
 
1625
1677
  let stdout = '';
@@ -1725,6 +1777,7 @@ export const registerFsRoutes = (app, dependencies) => {
1725
1777
 
1726
1778
  try {
1727
1779
  const resolved = await resolveWorkspacePathFromContext({
1780
+ fsPromises,
1728
1781
  req,
1729
1782
  targetPath: rawPath,
1730
1783
  resolveProjectDirectory,
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import path from 'path';
3
3
  import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
4
+ import * as nativeFs from 'node:fs/promises';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { execFileSync } from 'node:child_process';
6
7
  import { pathToFileURL } from 'node:url';
@@ -1586,6 +1587,40 @@ describe('fs managed chats root', () => {
1586
1587
  expect(res.body.chatsRoot).toBe('/srv/openchamber-chats');
1587
1588
  });
1588
1589
 
1590
+ it('fails home lookup on permission errors and retries the next request', async () => {
1591
+ const failure = Object.assign(new Error('permission denied'), { code: 'EACCES' });
1592
+ let inaccessible = true;
1593
+ const { home } = registerWithChatsRoot({ fsPromises: {
1594
+ realpath: async directory => { if (inaccessible) throw failure; return directory; },
1595
+ } });
1596
+ const failed = createMockResponse();
1597
+ await home(undefined, failed);
1598
+ expect(failed.statusCode).toBe(500);
1599
+ expect(failed.body).toEqual({ error: 'permission denied' });
1600
+ inaccessible = false;
1601
+ const recovered = createMockResponse();
1602
+ await home(undefined, recovered);
1603
+ expect(recovered.statusCode).toBe(200);
1604
+ expect(recovered.body.chatsRoot).toBe('/home/user/.config/openchamber/chats');
1605
+ });
1606
+
1607
+ it('keeps an accessible relocated root available when legacy alias lookup fails', async () => {
1608
+ const { home } = registerWithChatsRoot({
1609
+ managedChatsRoot: '/srv/chat-alias',
1610
+ fsPromises: {
1611
+ realpath: async directory => {
1612
+ if (directory === '/srv/chat-alias') return '/storage/chats';
1613
+ throw Object.assign(new Error('legacy root is inaccessible'), { code: 'EACCES' });
1614
+ },
1615
+ },
1616
+ });
1617
+ const response = createMockResponse();
1618
+ await home(undefined, response);
1619
+ expect(response.statusCode).toBe(200);
1620
+ expect(response.body.canonicalChatsRoot).toBe('/storage/chats');
1621
+ expect(response.body.canonicalLegacyChatsRoot).toBeUndefined();
1622
+ });
1623
+
1589
1624
  it('allows mkdir inside the relocated chats root outside the active workspace', async () => {
1590
1625
  const mkdirCalls = [];
1591
1626
  const { mkdir } = registerWithChatsRoot({
@@ -1605,6 +1640,22 @@ describe('fs managed chats root', () => {
1605
1640
  expect(mkdirCalls).toEqual(['/srv/openchamber-chats/2026-08-25/session-a']);
1606
1641
  });
1607
1642
 
1643
+ it('accepts a canonical relocated root even when the config root cannot be resolved', async () => {
1644
+ const { mkdir } = registerWithChatsRoot({
1645
+ managedChatsRoot: '/srv/chat-alias',
1646
+ fsPromises: {
1647
+ realpath: async directory => {
1648
+ if (directory === '/srv/chat-alias') return '/storage/chats';
1649
+ throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
1650
+ },
1651
+ },
1652
+ });
1653
+ const response = createMockResponse();
1654
+ await mkdir({ body: { path: '/storage/chats/day/session-a' } }, response);
1655
+ expect(response.statusCode).toBe(200);
1656
+ expect(response.body.path).toBe('/storage/chats/day/session-a');
1657
+ });
1658
+
1608
1659
  it('still rejects mkdir outside the workspace and all managed roots', async () => {
1609
1660
  const { mkdir } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
1610
1661
 
@@ -1615,3 +1666,68 @@ describe('fs managed chats root', () => {
1615
1666
  expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
1616
1667
  });
1617
1668
  });
1669
+
1670
+ describe('canonical managed roots with real filesystem aliases', () => {
1671
+ const setup = async (context, { relocated = false, caseAlias = false } = {}) => {
1672
+ const root = await nativeFs.mkdtemp(path.join(tmpdir(), 'oc-canonical-chats-'));
1673
+ context.onTestFinished(() => nativeFs.rm(root, { recursive: true, force: true }));
1674
+ const home = path.join(root, 'Home');
1675
+ await nativeFs.mkdir(home);
1676
+ const reportedHome = path.join(root, caseAlias ? 'home' : 'home-alias');
1677
+ if (!caseAlias) await nativeFs.symlink(home, reportedHome, 'junction');
1678
+ const config = path.join(reportedHome, '.config/openchamber');
1679
+ const rawChats = relocated ? path.join(reportedHome, 'scratch/chats') : path.join(config, 'chats');
1680
+ const canonicalHome = await nativeFs.realpath(home);
1681
+ if (caseAlias && await nativeFs.realpath(reportedHome).catch(() => null) !== canonicalHome) {
1682
+ context.skip('This volume distinguishes Home from home');
1683
+ }
1684
+ const canonicalChats = path.join(canonicalHome, relocated ? 'scratch/chats' : '.config/openchamber/chats');
1685
+ const { app, getRoute } = createRouteRegistry();
1686
+ registerFsRoutes(app, {
1687
+ os: { homedir: () => reportedHome }, path, fsPromises: nativeFs,
1688
+ normalizeDirectoryPath: value => value,
1689
+ resolveProjectDirectory: async () => ({ directory: path.join(root, 'unrelated-project') }),
1690
+ openchamberUserConfigRoot: config, managedChatsRoot: rawChats,
1691
+ });
1692
+ return { getRoute, canonicalHome, canonicalChats, rawChats, reportedHome };
1693
+ };
1694
+
1695
+ for (const relocated of [false, true]) {
1696
+ it(`returns canonical paths before the ${relocated ? 'relocated' : 'default'} root exists and permits its lifecycle`, async context => {
1697
+ const { getRoute, canonicalHome, canonicalChats, rawChats, reportedHome } = await setup(context, { relocated });
1698
+ const home = createMockResponse();
1699
+ await getRoute('GET', '/api/fs/home')({}, home);
1700
+ expect(home.body).toEqual({
1701
+ home: reportedHome, chatsRoot: rawChats,
1702
+ canonicalChatsRoot: canonicalChats,
1703
+ canonicalLegacyChatsRoot: path.join(canonicalHome, '.config/openchamber/chats'),
1704
+ });
1705
+ await expect(nativeFs.stat(canonicalChats)).rejects.toMatchObject({ code: 'ENOENT' });
1706
+
1707
+ const directory = path.join(home.body.canonicalChatsRoot, 'day/session-a');
1708
+ const created = createMockResponse();
1709
+ await getRoute('POST', '/api/fs/mkdir')({ body: { path: directory } }, created);
1710
+ expect(created.statusCode).toBe(200);
1711
+ expect(await nativeFs.realpath(directory)).toBe(directory);
1712
+ const deleted = createMockResponse();
1713
+ await getRoute('POST', '/api/fs/delete')({ body: { path: directory } }, deleted);
1714
+ expect(deleted.statusCode).toBe(200);
1715
+ await expect(nativeFs.stat(directory)).rejects.toMatchObject({ code: 'ENOENT' });
1716
+
1717
+ const legacy = createMockResponse();
1718
+ await getRoute('POST', '/api/fs/mkdir')({ body: { path: path.join(rawChats, 'legacy-session') } }, legacy);
1719
+ expect(legacy.statusCode).toBe(200);
1720
+ expect((await nativeFs.stat(path.join(canonicalChats, 'legacy-session'))).isDirectory()).toBe(true);
1721
+ });
1722
+ }
1723
+
1724
+ it('reports on-disk home casing on a case-insensitive macOS volume', { skip: process.platform !== 'darwin' }, async context => {
1725
+ const { getRoute, canonicalChats, rawChats, reportedHome } = await setup(context, { caseAlias: true });
1726
+ const response = createMockResponse();
1727
+ await getRoute('GET', '/api/fs/home')({}, response);
1728
+ expect(response.body).toEqual({
1729
+ home: reportedHome, chatsRoot: rawChats,
1730
+ canonicalChatsRoot: canonicalChats, canonicalLegacyChatsRoot: canonicalChats,
1731
+ });
1732
+ });
1733
+ });
@@ -127,7 +127,8 @@ export const createFsSearchRuntime = ({ fsPromises, path, spawn, resolveGitBinar
127
127
  const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
128
128
  cwd: dir,
129
129
  windowsHide: true,
130
- stdio: ['ignore', 'pipe', 'pipe'],
130
+ // Diagnostics are unused here. An unread pipe can block Git forever.
131
+ stdio: ['ignore', 'pipe', 'ignore'],
131
132
  });
132
133
 
133
134
  let stdout = '';
@@ -16,7 +16,7 @@ This module provides Git repository operations for the web server runtime, inclu
16
16
  The following functions are exported and used by the web server:
17
17
 
18
18
  ### Repository Operations
19
- - `isGitRepository(directory)`: Check if a directory is a Git repository.
19
+ - `isGitRepository(directory)`: Check if a directory is a Git repository. A repository whose root is the home directory or a filesystem root (`C:\`, `/`) answers `false` (`unsupportedRepositoryRootReason`): such a repository covers the whole disk, every status read would walk it, and it is nearly always an accidental `git init`. All Git surfaces then show the non-repository state for that directory.
20
20
  - `getGlobalIdentity()`: Get global Git user.name, user.email, and core.sshCommand.
21
21
  - `getCurrentIdentity(directory)`: Get local Git identity (fallback to global if not set locally).
22
22
  - `hasLocalIdentity(directory)`: Check if local Git identity is configured.
@@ -24,13 +24,16 @@ The following functions are exported and used by the web server:
24
24
  - `getRemoteUrl(directory, remoteName)`: Get URL for a specific remote.
25
25
 
26
26
  ### Status and Diff Operations
27
- - `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
27
+ - `getStatus(directory, { mode })`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state. `mode: 'light'` skips the diff stats. One read runs per directory at a time and at most four run across directories (`serial-refresh.js`): a call made while a read is running waits for one follow-up read that starts after the call, so no caller gets a snapshot older than its request, and every caller that arrives during one read shares that single follow-up at the widest mode any of them asked for. Clients refresh after every completed agent tool call and from several surfaces at once; on a large repository (a status read is a dozen Git processes walking the working tree) this bound is what keeps identical `git status` processes from piling up side by side. A slot is held only while the read is alive: every process the read spawns is killed after two minutes without output (one minute for the untracked-directory listing, thirty seconds for the repository probe), so a Git process that hangs, which happens on Windows, fails that read instead of holding a slot until someone kills it by hand. On Windows the listing is ended with `taskkill /T`, because the spawned `git.exe` is Git for Windows' launcher and killing it alone leaves the real `git` child walking the tree as an orphan. Untracked files are listed with `-unormal` and each new directory is then expanded to its files with a bounded `ls-files` listing (`UNTRACKED_DIRECTORY_EXPANSION_LIMIT`, 1000): up to that many files the result equals `-uall`; beyond it the directory stays one `dir/` entry, because `-uall` would walk a forgotten build or dependency directory in full on every read. A nested repository stays a `dir/` entry as before.
28
+ - `getTrackingBranch(directory)`: Upstream of the checked-out branch as `remote/branch` (the same value as `status.tracking`, including an upstream whose remote ref is gone), or `null` when HEAD is detached or unborn or no upstream is configured. Reads refs and config only. Callers that need just the tracking name (GitHub PR status polling, PR creation) use this instead of `getStatus`.
28
29
  - `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree with full Git blob identities. Untracked symbolic links are represented as link entries without following their targets.
30
+ - `getPathDiff(directory, { path, staged, contextLines })`: `getDiff` for one path, returning `{ diff, submodule }`. `submodule` is `null` for ordinary paths. For a gitlink it is `{ headCommit, indexCommit, worktreeCommit, hasTrackedChanges, hasUntrackedFiles, hasConflict }`, because a submodule that only gained untracked files shows as modified in status while its patch is empty. `worktreeCommit` is `null` when the submodule is not checked out. An unmerged gitlink has no single index commit, so it reports `hasConflict: true` with `indexCommit: null`. Exposed as `GET /api/git/diff`.
31
+ - Paths come from an earlier status listing and can stop resolving. Per-path operations reject with `error.code`: `path_not_found` when the path is absent from the working tree, index, and HEAD (for example, a file removed after the listing), `nested_repository` when the path is a directory holding its own `.git` that is not a submodule (status lists it as `dir/`), and `untracked_directory` when the path is a directory status kept as one `dir/` entry because it holds more untracked files than the expansion bound. `GET /api/git/diff` and `GET /api/git/file-diff` answer these with 404, 422 and 422 and a `{ error, code }` body instead of 500. Entry existence is read from `ls-files --stage` and `ls-tree` modes, not `cat-file -e`: a gitlink's commit is not in the parent's object store, and simple-git reports that silent exit 1 as success.
29
32
  - `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
30
33
  - `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
31
34
  - Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
32
35
  - Working-tree comparisons use the real index read-only. When untracked paths exist, a temporary copy of the index receives intent-to-add entries so Git computes additions, deletions, recreations, and renames together. Current contents come from the working tree, symlinks remain links, ignored files stay excluded, and temporary files are removed on success or failure.
33
- - `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
36
+ - `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text). For a submodule, both sides are Git's `Subproject commit <sha>` text (HEAD against the worktree checkout, or against the index when `staged`) and the result carries the same `submodule` state as `getPathDiff`; other paths return no `submodule`, which the route sends as `null`.
34
37
  - `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
35
38
  - `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
36
39
  - `collectDiffs(directory, files)`: Collect diff output for multiple files.
@@ -50,12 +53,21 @@ The following functions are exported and used by the web server:
50
53
  - `getRemotes(directory)`: Get list of configured remotes.
51
54
 
52
55
  ### Worktree Operations
53
- - `getWorktrees(directory)`: List all git worktrees for a repository.
56
+ - `getWorktrees(directory)`: List all git worktrees for a repository. A directory outside any repository (or one that does not exist) is an authoritative empty list; any other git failure throws so callers keep their last known topology instead of clearing it. `GET /api/git/worktrees` answers such a failure with 500.
57
+ - `observeWorktreeTopology(directory)`: Compare the repository's registered linked-worktree set with the last one seen for it and notify `subscribeWorktreeTopologyChanges` listeners when it changed. The set is fingerprinted from the `worktrees` directory under the common Git directory (mtime plus entry names), so the check is a stat and a readdir; the common directory is resolved with `git rev-parse --git-common-dir` once per requested directory and cached. The first observation only records a baseline. Never throws.
58
+ - `subscribeWorktreeTopologyChanges(listener)`: Listener receives `{ directories, at }`, where `directories` are every directory of that repository the server has observed, so clients can map them onto registered projects. Returns an unsubscribe function.
54
59
  - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
55
60
  - `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
56
61
  - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
57
62
  - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
58
63
 
64
+ ### Worktree topology change tracking
65
+ There is no filesystem watcher and no polling. The server notices worktree changes in two ways, and both scale with what users are doing rather than with the number of registered projects:
66
+ - Its own `createWorktree` and `removeWorktree` publish a change right after `git worktree add` / `git worktree remove` succeed (creation notifies before background population and setup scripts run).
67
+ - `GET /api/git/status` for a repository and `GET /api/git/worktrees` with a non-empty listing call `observeWorktreeTopology` beside the response. Clients request status while they work in a repository, and a completed agent tool call already triggers a status refresh, so a worktree added by an agent or from a terminal is noticed on the next such request; nothing runs while the app is idle.
68
+
69
+ `feature-routes-runtime.js` forwards each change to connected control-event clients as `openchamber:worktree-changed` with `{ directories, at }`. A repository nobody sends status or listing requests for is not observed until the next ordinary listing. `git worktree move` rewrites files inside an entry without touching the `worktrees` directory and is not detected. Tracking state is bounded: 500 directory-to-repository entries, 200 repositories, 100 directories per repository, least recently used dropped first.
70
+
59
71
  ### Worktree creation from a GitHub pull request
60
72
  The UI provisions `pr-<owner>` via `ensureRemoteName`/`ensureRemoteUrl`
61
73
  (HTTPS clone URL preferred over SSH) and checks out
@@ -72,6 +84,17 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
72
84
  - `removeRemote(directory, options)`: Remove a configured remote (except `origin`).
73
85
  - `deleteRemoteBranch(directory, options)`: Delete a remote branch.
74
86
 
87
+ `push` leaves an unspecified destination to Git, including `branch.<name>.pushRemote`
88
+ and `remote.pushDefault`. Its missing-upstream fallback uses that same destination;
89
+ an explicit remote overrides configuration. `pushed` contains only refs changed by
90
+ the operation, derived from Git's porcelain flags, including first publication,
91
+ fast-forward and forced updates. Each entry's `remote` is the destination remote
92
+ name, not a ref. A successful no-op returns an empty array; rejected pushes throw.
93
+ Commit & Push and Sync share the same fetch/pull/push flow in web, Electron and
94
+ mobile, and never infer publication from an upstream `ahead` count. Fetch follows
95
+ the selected upstream while push routing remains independent. VS Code does not
96
+ mount these Git panels and keeps its separate extension-host Git implementation.
97
+
75
98
  ### Log Operations
76
99
  - `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
77
100
  - `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit relative to its first parent, or the empty tree for a root commit. NUL-delimited paths preserve whitespace; renamed files return their destination in `path` and source in `previousPath`.
@@ -99,7 +122,7 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
99
122
 
100
123
  The following functions are internal helpers used by exported functions:
101
124
  - `buildSshCommand(sshKeyPath)`: Build SSH command string for git config.
102
- - `buildGitEnv()`: Build Git environment with SSH_AUTH_SOCK resolution.
125
+ - `buildGitEnv()`: Build Git environment with SSH_AUTH_SOCK resolution and `GIT_TERMINAL_PROMPT=0` (unless the server was started with it set): the server has no terminal a user could answer, so a Git command that would ask for a username or password fails instead of waiting forever on a console nobody sees. Credential helpers, including GUI ones, still run before Git would prompt.
103
126
  - `createGit(directory)`: Create simple-git instance with environment.
104
127
  - `normalizeDirectoryPath(value)`: Normalize directory paths (supports ~ expansion).
105
128
  - `cleanBranchName(branch)`: Remove refs/heads/ or refs/ prefixes.
@@ -121,7 +144,7 @@ The following functions are internal helpers used by exported functions:
121
144
  - `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
122
145
  - `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
123
146
  - `isClean`: Boolean indicating if working tree is clean.
124
- - `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
147
+ - `diffStats`: Scope-aware per-file line stats, `{ staged, working }`. `staged` is HEAD → index (`git diff --cached --numstat`), `working` is index → working tree (`git diff --numstat`). A partially staged file appears in both maps with its own scope's counts; the two are never summed together. Untracked and working-tree-added files are counted into `working`; files added to the index are counted into `staged`.
125
148
  - `mergeInProgress`: Object with `{ head, message }` if merge in progress.
126
149
  - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
127
150
 
@@ -157,6 +180,7 @@ The following functions are internal helpers used by exported functions:
157
180
  surface and VS Code does not mount these controls.
158
181
  - Untracked patches from `getDiff` and `getUntrackedDiffs` use `git diff --no-index` with separate stdout, stderr, and process exit status. Exit codes 0 and 1 return stdout only, so line-ending warnings never become patch text or request failures. Other exits and process failures reject the single-file request; the batch keeps an empty entry for the failed path and preserves the other results.
159
182
  - `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
183
+ - `status.diffStats` follows the same scopes (`staged`, `working`), so a staged row shows HEAD → index counts and an unstaged row shows index → working-tree counts. A file with edits in both scopes reports each part in its own row instead of one combined total.
160
184
  - A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
161
185
  - The shared Git panel exposes explicit staging actions. Unstaged rows use `stageFile`, staged rows use `unstageFile`, and commits operate on the current staged index.
162
186
  - `stageFiles` remains supported for callers that need to stage a selected unstaged subset as part of commit. In that mode the server temporarily unstages unrelated index entries, stages `stageFiles`, commits from the index, then restores temporarily unstaged entries.
@@ -1,12 +1,25 @@
1
- export function registerGitRoutes(app) {
1
+ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
2
2
  let gitLibraries = null;
3
3
  const getGitLibraries = async () => {
4
4
  if (!gitLibraries) {
5
5
  gitLibraries = await import('./index.js');
6
+ if (emitWorktreeChanged) {
7
+ gitLibraries.subscribeWorktreeTopologyChanges(emitWorktreeChanged);
8
+ }
6
9
  }
7
10
  return gitLibraries;
8
11
  };
9
12
 
13
+ // A path from an earlier status listing that no longer resolves is a stale
14
+ // row or a nested repository, not a server fault.
15
+ const GIT_PATH_ERROR_STATUS = new Map([['path_not_found', 404], ['nested_repository', 422], ['untracked_directory', 422]]);
16
+ const sendGitPathError = (res, error) => {
17
+ const status = GIT_PATH_ERROR_STATUS.get(error?.code);
18
+ if (!status) return false;
19
+ res.status(status).json({ error: error.message, code: error.code });
20
+ return true;
21
+ };
22
+
10
23
  const resolveDirectoryQuery = (value, preserveWhitespace = false) => {
11
24
  const raw = Array.isArray(value) ? value[0] : value;
12
25
  if (typeof raw !== 'string') {
@@ -220,7 +233,7 @@ export function registerGitRoutes(app) {
220
233
  });
221
234
 
222
235
  app.get('/api/git/status', async (req, res) => {
223
- const { getStatus, isGitRepository } = await getGitLibraries();
236
+ const { getStatus, isGitRepository, observeWorktreeTopology } = await getGitLibraries();
224
237
 
225
238
  try {
226
239
  const directory = resolveDirectoryQuery(req.query.directory);
@@ -233,6 +246,11 @@ export function registerGitRoutes(app) {
233
246
  return res.json(nonRepoStatusPayload());
234
247
  }
235
248
 
249
+ // Clients ask for status while they work in a repository, so this is
250
+ // where an externally added or removed worktree gets noticed. It runs
251
+ // beside the status call and never delays or fails the response.
252
+ void observeWorktreeTopology(directory);
253
+
236
254
  const mode = req.query.mode === 'light' ? 'light' : undefined;
237
255
  const status = await getStatus(directory, { mode });
238
256
  res.json(status);
@@ -337,7 +355,7 @@ export function registerGitRoutes(app) {
337
355
  });
338
356
 
339
357
  app.get('/api/git/diff', async (req, res) => {
340
- const { getDiff } = await getGitLibraries();
358
+ const { getPathDiff } = await getGitLibraries();
341
359
  try {
342
360
  const directory = req.query.directory;
343
361
  if (!directory) {
@@ -352,14 +370,15 @@ export function registerGitRoutes(app) {
352
370
  const staged = req.query.staged === 'true';
353
371
  const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
354
372
 
355
- const diff = await getDiff(directory, {
373
+ const { diff, submodule } = await getPathDiff(directory, {
356
374
  path,
357
375
  staged,
358
376
  contextLines: Number.isFinite(context) ? context : 3,
359
377
  });
360
378
 
361
- res.json({ diff });
379
+ res.json({ diff, submodule });
362
380
  } catch (error) {
381
+ if (sendGitPathError(res, error)) return;
363
382
  console.error('Failed to get git diff:', error);
364
383
  res.status(500).json({ error: error.message || 'Failed to get git diff' });
365
384
  }
@@ -390,8 +409,10 @@ export function registerGitRoutes(app) {
390
409
  modified: result.modified,
391
410
  path: result.path,
392
411
  isBinary: Boolean(result.isBinary),
412
+ submodule: result.submodule ?? null,
393
413
  });
394
414
  } catch (error) {
415
+ if (sendGitPathError(res, error)) return;
395
416
  console.error('Failed to get git file diff:', error);
396
417
  res.status(500).json({ error: error.message || 'Failed to get git file diff' });
397
418
  }
@@ -1078,7 +1099,7 @@ export function registerGitRoutes(app) {
1078
1099
  });
1079
1100
 
1080
1101
  app.get('/api/git/worktrees', async (req, res) => {
1081
- const { getWorktrees } = await getGitLibraries();
1102
+ const { getWorktrees, observeWorktreeTopology } = await getGitLibraries();
1082
1103
  try {
1083
1104
  const directory = req.query.directory;
1084
1105
  if (!directory) {
@@ -1086,13 +1107,18 @@ export function registerGitRoutes(app) {
1086
1107
  }
1087
1108
 
1088
1109
  const worktrees = await getWorktrees(directory);
1110
+ // A repository always lists at least its primary worktree; an empty
1111
+ // list means "not a repository" and has no topology to track.
1112
+ if (worktrees.length > 0) {
1113
+ void observeWorktreeTopology(directory);
1114
+ }
1089
1115
  res.json(worktrees);
1090
1116
  } catch (error) {
1091
- // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
1092
- // when the directory isn't a git repo or uses shell shorthand like "~/".
1093
- console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
1094
- res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable');
1095
- res.json([]);
1117
+ // A directory outside any repository still answers `[]` from getWorktrees.
1118
+ // Anything else is a real failure the client must not mistake for "no
1119
+ // worktrees", or it would drop the ones it already knows.
1120
+ console.error('Failed to get worktrees:', error);
1121
+ res.status(500).json({ error: error.message || 'Failed to get worktrees' });
1096
1122
  }
1097
1123
  });
1098
1124
 
@@ -5,6 +5,9 @@ const gitLibraries = {
5
5
  unstageFiles: vi.fn(),
6
6
  isGitRepository: vi.fn(),
7
7
  getStatus: vi.fn(),
8
+ getWorktrees: vi.fn(),
9
+ observeWorktreeTopology: vi.fn(),
10
+ subscribeWorktreeTopologyChanges: vi.fn(),
8
11
  };
9
12
 
10
13
  vi.mock('./index.js', () => ({
@@ -12,6 +15,9 @@ vi.mock('./index.js', () => ({
12
15
  unstageFiles: gitLibraries.unstageFiles,
13
16
  isGitRepository: gitLibraries.isGitRepository,
14
17
  getStatus: gitLibraries.getStatus,
18
+ getWorktrees: gitLibraries.getWorktrees,
19
+ observeWorktreeTopology: gitLibraries.observeWorktreeTopology,
20
+ subscribeWorktreeTopologyChanges: gitLibraries.subscribeWorktreeTopologyChanges,
15
21
  }));
16
22
 
17
23
  const { registerGitRoutes } = await import('./routes.js');
@@ -141,6 +147,78 @@ describe('git routes index mutations', () => {
141
147
  });
142
148
  });
143
149
 
150
+ describe('git worktree topology routes', () => {
151
+ beforeEach(() => {
152
+ gitLibraries.isGitRepository.mockReset();
153
+ gitLibraries.getStatus.mockReset();
154
+ gitLibraries.getWorktrees.mockReset();
155
+ gitLibraries.observeWorktreeTopology.mockReset();
156
+ gitLibraries.subscribeWorktreeTopologyChanges.mockReset();
157
+ gitLibraries.observeWorktreeTopology.mockResolvedValue(undefined);
158
+ });
159
+
160
+ it('observes the repository topology while serving status, never for non-repositories', async () => {
161
+ gitLibraries.isGitRepository.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
162
+ gitLibraries.getStatus.mockResolvedValue({ current: 'main' });
163
+ const { app, getRoute } = createRouteRegistry();
164
+ registerGitRoutes(app);
165
+ const route = getRoute('GET', '/api/git/status');
166
+
167
+ const repoResponse = createMockResponse();
168
+ await route({ query: { directory: '/repo' } }, repoResponse);
169
+ expect(repoResponse.body).toEqual({ current: 'main' });
170
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledWith('/repo');
171
+
172
+ await route({ query: { directory: '/plain-folder' } }, createMockResponse());
173
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it('observes topology after a repository listing and reports listing failures', async () => {
177
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
178
+ gitLibraries.getWorktrees
179
+ .mockResolvedValueOnce([{ path: '/repo', branch: 'main' }])
180
+ .mockResolvedValueOnce([])
181
+ .mockRejectedValueOnce(new Error('git failed'));
182
+ const { app, getRoute } = createRouteRegistry();
183
+ registerGitRoutes(app);
184
+ const route = getRoute('GET', '/api/git/worktrees');
185
+
186
+ const listed = createMockResponse();
187
+ await route({ query: { directory: '/repo' } }, listed);
188
+ expect(listed.body).toEqual([{ path: '/repo', branch: 'main' }]);
189
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledWith('/repo');
190
+
191
+ await route({ query: { directory: '/plain-folder' } }, createMockResponse());
192
+ expect(gitLibraries.observeWorktreeTopology).toHaveBeenCalledTimes(1);
193
+
194
+ const failed = createMockResponse();
195
+ await route({ query: { directory: '/repo' } }, failed);
196
+ expect(failed.statusCode).toBe(500);
197
+ expect(failed.body).toEqual({ error: 'git failed' });
198
+ errorSpy.mockRestore();
199
+ });
200
+
201
+ it('forwards topology changes to the control event emitter once', async () => {
202
+ let listener = null;
203
+ gitLibraries.subscribeWorktreeTopologyChanges.mockImplementation((next) => {
204
+ listener = next;
205
+ return () => undefined;
206
+ });
207
+ gitLibraries.getWorktrees.mockResolvedValue([]);
208
+ const emitWorktreeChanged = vi.fn();
209
+ const { app, getRoute } = createRouteRegistry();
210
+ registerGitRoutes(app, { emitWorktreeChanged });
211
+ const route = getRoute('GET', '/api/git/worktrees');
212
+
213
+ await route({ query: { directory: '/repo' } }, createMockResponse());
214
+ await route({ query: { directory: '/repo' } }, createMockResponse());
215
+ expect(gitLibraries.subscribeWorktreeTopologyChanges).toHaveBeenCalledTimes(1);
216
+
217
+ listener({ directories: ['/repo'], at: 123 });
218
+ expect(emitWorktreeChanged).toHaveBeenCalledWith({ directories: ['/repo'], at: 123 });
219
+ });
220
+ });
221
+
144
222
  describe('git routes status discovery', () => {
145
223
  beforeEach(() => {
146
224
  gitLibraries.isGitRepository.mockReset();