@vantran-se/openchamber-web 1.23.1 → 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 (313) 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-BOpqXpFU.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-nd_E8eVM.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-CVYds3sQ.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
  71. package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
  72. package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
  73. package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DnEhjXcC.js} +1 -1
  74. package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
  75. package/dist/assets/PlanView-LW2z_EAP.js +5 -0
  76. package/dist/assets/PluginPane-B4tKno2h.js +3 -0
  77. package/dist/assets/SettingsView-DEaMulN9.js +29 -0
  78. package/dist/assets/{SettingsWindow-BccxY81J.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-BFVgk0UK.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
  82. package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-0pyr_mHY.js} +3 -3
  83. package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-yNj4nOP5.js} +2 -2
  84. package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
  85. package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
  86. package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DxTBMuu-.js} +1 -1
  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-eo1-D_7z.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-BvKH712-.js → isDiffPlainText-D7FkBMP6.js} +1 -1
  94. package/dist/assets/{isFilePlainText-BdVVPukf.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-B7LjppSS.js → patchFileDiff-Bsba8KFp.js} +3 -3
  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-hceovDei.js → scheduledTasksApi-DzjbLd8P.js} +1 -1
  110. package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
  111. package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
  112. package/dist/assets/tr-Dr-oJ22L.js +7 -0
  113. package/dist/assets/uk-9Nx83UcI.js +16 -0
  114. package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
  115. package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
  116. package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
  117. package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
  118. package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
  119. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
  120. package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
  121. package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
  122. package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
  123. package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
  124. package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
  125. package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
  126. package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
  127. package/dist/assets/{web-components-B_Z64So5.js → web-components-B8IcVQAA.js} +2 -2
  128. package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
  129. package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
  130. package/dist/index.html +6 -4
  131. package/dist/mini-chat.html +6 -4
  132. package/dist/mobile.html +6 -4
  133. package/package.json +12 -9
  134. package/server/built-in-extensions/registry.json +4 -0
  135. package/server/index.d.ts +2 -0
  136. package/server/index.js +44 -10
  137. package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
  138. package/server/lib/agent-tool/runtime.js +62 -15
  139. package/server/lib/agent-tool/runtime.test.js +88 -2
  140. package/server/lib/changelog/update-notes.js +2 -1
  141. package/server/lib/changelog/update-notes.test.js +19 -0
  142. package/server/lib/event-stream/DOCUMENTATION.md +23 -0
  143. package/server/lib/event-stream/delta-coalescer.js +0 -0
  144. package/server/lib/event-stream/delta-coalescer.test.js +292 -0
  145. package/server/lib/event-stream/global-hub.js +53 -23
  146. package/server/lib/event-stream/global-hub.test.js +160 -0
  147. package/server/lib/event-stream/global-ws-bridge.js +1 -0
  148. package/server/lib/event-stream/index.js +4 -0
  149. package/server/lib/fs/DOCUMENTATION.md +10 -1
  150. package/server/lib/fs/git-process.test.js +77 -0
  151. package/server/lib/fs/routes.js +58 -5
  152. package/server/lib/fs/routes.test.js +116 -0
  153. package/server/lib/fs/search.js +2 -1
  154. package/server/lib/git/DOCUMENTATION.md +20 -5
  155. package/server/lib/git/routes.js +16 -3
  156. package/server/lib/git/serial-refresh.js +93 -0
  157. package/server/lib/git/serial-refresh.test.js +112 -0
  158. package/server/lib/git/service.js +429 -70
  159. package/server/lib/git/service.test.js +418 -0
  160. package/server/lib/github/pr-status.js +5 -5
  161. package/server/lib/github/pr-status.test.js +1 -1
  162. package/server/lib/github/routes.js +4 -4
  163. package/server/lib/guests/DOCUMENTATION.md +68 -0
  164. package/server/lib/guests/auth-store.js +184 -0
  165. package/server/lib/guests/auth-store.test.js +112 -0
  166. package/server/lib/guests/background.test.js +83 -0
  167. package/server/lib/guests/builtins.js +34 -0
  168. package/server/lib/guests/builtins.test.js +174 -0
  169. package/server/lib/guests/catalog.js +477 -0
  170. package/server/lib/guests/catalog.test.js +390 -0
  171. package/server/lib/guests/clone.js +297 -0
  172. package/server/lib/guests/extract-zip.js +136 -0
  173. package/server/lib/guests/extract-zip.test.js +109 -0
  174. package/server/lib/guests/files.js +308 -0
  175. package/server/lib/guests/files.test.js +262 -0
  176. package/server/lib/guests/grant-scope.js +81 -0
  177. package/server/lib/guests/grant-scope.test.js +75 -0
  178. package/server/lib/guests/host-session.js +73 -0
  179. package/server/lib/guests/host-session.test.js +30 -0
  180. package/server/lib/guests/html-styles.js +6 -0
  181. package/server/lib/guests/html-styles.test.js +45 -0
  182. package/server/lib/guests/html-tokens.js +73 -0
  183. package/server/lib/guests/html-tokens.test.js +53 -0
  184. package/server/lib/guests/install.js +406 -0
  185. package/server/lib/guests/install.test.js +338 -0
  186. package/server/lib/guests/oauth.js +501 -0
  187. package/server/lib/guests/oauth.test.js +481 -0
  188. package/server/lib/guests/persist.js +336 -0
  189. package/server/lib/guests/persist.test.js +288 -0
  190. package/server/lib/guests/request.js +112 -0
  191. package/server/lib/guests/request.test.js +206 -0
  192. package/server/lib/guests/routes.js +838 -0
  193. package/server/lib/guests/service.js +538 -0
  194. package/server/lib/guests/service.test.js +277 -0
  195. package/server/lib/guests/sockets.js +129 -0
  196. package/server/lib/guests/sockets.test.js +110 -0
  197. package/server/lib/guests/ssh-install.test.js +188 -0
  198. package/server/lib/guests/storage.js +68 -0
  199. package/server/lib/guests/storage.test.js +59 -0
  200. package/server/lib/guests/updates.js +294 -0
  201. package/server/lib/guests/updates.test.js +334 -0
  202. package/server/lib/guests/upload.js +50 -0
  203. package/server/lib/guests/upload.test.js +131 -0
  204. package/server/lib/linear/DOCUMENTATION.md +1 -1
  205. package/server/lib/message-queue/DOCUMENTATION.md +10 -1
  206. package/server/lib/message-queue/runtime.js +21 -1
  207. package/server/lib/message-queue/runtime.test.js +52 -5
  208. package/server/lib/notifications/routes.js +5 -0
  209. package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
  210. package/server/lib/openchamber-sessions/routes.js +62 -29
  211. package/server/lib/openchamber-sessions/routes.test.js +96 -0
  212. package/server/lib/opencode/DOCUMENTATION.md +55 -1
  213. package/server/lib/opencode/bootstrap-runtime.js +1 -0
  214. package/server/lib/opencode/core-routes.js +39 -1
  215. package/server/lib/opencode/feature-routes-runtime.js +15 -0
  216. package/server/lib/opencode/lifecycle-process.test.js +84 -0
  217. package/server/lib/opencode/lifecycle.js +54 -67
  218. package/server/lib/opencode/openchamber-routes.js +72 -14
  219. package/server/lib/opencode/openchamber-routes.test.js +71 -3
  220. package/server/lib/opencode/session-runtime.js +68 -0
  221. package/server/lib/opencode/session-runtime.test.js +43 -0
  222. package/server/lib/opencode/settings-helpers.js +8 -0
  223. package/server/lib/opencode/settings-helpers.test.js +13 -1
  224. package/server/lib/opencode/settings-normalization-runtime.js +2 -0
  225. package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
  226. package/server/lib/opencode/settings-registry.json +15 -0
  227. package/server/lib/opencode/shutdown-runtime.js +3 -0
  228. package/server/lib/opencode/shutdown-runtime.test.js +24 -0
  229. package/server/lib/opencode/theme-archive.js +53 -0
  230. package/server/lib/opencode/theme-catalog.js +171 -0
  231. package/server/lib/opencode/theme-catalog.test.js +125 -0
  232. package/server/lib/opencode/theme-import.test.js +144 -0
  233. package/server/lib/opencode/theme-runtime.js +100 -28
  234. package/server/lib/opencode/theme-runtime.test.js +15 -0
  235. package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
  236. package/server/lib/permission-auto-accept/runtime.js +13 -0
  237. package/server/lib/permission-auto-accept/runtime.test.js +37 -1
  238. package/server/lib/projects/project-config.js +2 -2
  239. package/server/lib/quota/DOCUMENTATION.md +0 -1
  240. package/server/lib/quota/providers/index.js +0 -7
  241. package/server/lib/realtime-proxy-terminal.test.js +150 -0
  242. package/server/lib/realtime-proxy.js +2 -0
  243. package/server/lib/routing/DOCUMENTATION.md +83 -0
  244. package/server/lib/routing/defaults.js +98 -0
  245. package/server/lib/routing/feature-flag.js +11 -0
  246. package/server/lib/routing/history.js +34 -0
  247. package/server/lib/routing/jev.js +98 -0
  248. package/server/lib/routing/routes.http.test.js +100 -0
  249. package/server/lib/routing/routes.js +81 -0
  250. package/server/lib/routing/runtime.js +217 -0
  251. package/server/lib/routing/runtime.test.js +188 -0
  252. package/server/lib/routing/store.js +249 -0
  253. package/server/lib/routing/store.test.js +96 -0
  254. package/server/lib/scheduled-tasks/runtime.js +2 -2
  255. package/server/lib/scheduled-tasks/runtime.test.js +14 -0
  256. package/server/lib/security/request-security.js +9 -5
  257. package/server/lib/security/request-security.test.js +37 -0
  258. package/server/lib/small-model/DOCUMENTATION.md +14 -2
  259. package/server/lib/small-model/call.js +37 -6
  260. package/server/lib/small-model/call.test.js +182 -0
  261. package/server/lib/terminal/DOCUMENTATION.md +23 -2
  262. package/server/lib/terminal/runtime.js +27 -5
  263. package/server/lib/terminal/runtime.test.js +67 -0
  264. package/server/lib/terminal/shutdown.js +106 -0
  265. package/server/lib/terminal/shutdown.test.js +84 -0
  266. package/server/lib/tts/service.test.js +78 -0
  267. package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
  268. package/server/lib/ui-auth/session-cookie.js +51 -0
  269. package/server/lib/ui-auth/session-cookie.test.js +46 -0
  270. package/server/lib/ui-auth/ui-auth.js +56 -17
  271. package/server/lib/ui-auth/ui-auth.test.js +96 -0
  272. package/dist/assets/DiffView-CyLmb-wF.js +0 -4
  273. package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
  274. package/dist/assets/FilesView-WtJB0y_l.js +0 -13
  275. package/dist/assets/GitView-DkNrV0BK.js +0 -6
  276. package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
  277. package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
  278. package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
  279. package/dist/assets/PlanView-CAFziKH5.js +0 -5
  280. package/dist/assets/SettingsView-BRittavZ.js +0 -28
  281. package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
  282. package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
  283. package/dist/assets/de-c7CYLCAW.js +0 -10
  284. package/dist/assets/es-BKAVPQmQ.js +0 -16
  285. package/dist/assets/fr-Bj1glqfV.js +0 -14
  286. package/dist/assets/index-BYFyOu_D.css +0 -1
  287. package/dist/assets/index-BfwRClDe.js +0 -40
  288. package/dist/assets/ja-5jwSIbK0.js +0 -16
  289. package/dist/assets/ko-BaDbg-Eb.js +0 -16
  290. package/dist/assets/main-CGG2_uHq.js +0 -2
  291. package/dist/assets/main-CcrSaL4J.js +0 -930
  292. package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
  293. package/dist/assets/miniChat-DEaAhP4b.js +0 -2
  294. package/dist/assets/mobile-BC3rsvv0.js +0 -2
  295. package/dist/assets/pl-BFKVBDFu.js +0 -16
  296. package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
  297. package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
  298. package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
  299. package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
  300. package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
  301. package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
  302. package/dist/assets/tr-DYXPoEDG.js +0 -7
  303. package/dist/assets/uk-CwAoloR8.js +0 -16
  304. package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
  305. package/dist/assets/useGitComparison-CpxioduU.js +0 -1
  306. package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
  307. package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
  308. package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
  309. package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
  310. package/dist/assets/zh-TW-1HNK7shh.js +0 -16
  311. package/server/lib/quota/providers/crof.js +0 -96
  312. package/server/lib/quota/providers/crof.test.js +0 -98
  313. /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
@@ -36,6 +36,14 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
36
36
  - Returns `{ searchFilesystemFiles(rootPath, options) }`.
37
37
  - Supports fuzzy matching, hidden-file handling, and optional `git check-ignore` filtering.
38
38
 
39
+ Both search and directory listing discard `git check-ignore` stderr at spawn.
40
+ They consume stdout for ignore matches. Never create an unread stderr pipe:
41
+ Git diagnostics can fill it and block the child indefinitely, so repeated
42
+ searches accumulate live Git processes. `git-process.test.js` exercises both
43
+ paths with real OS pipes, twelve concurrent checks, and 2 MiB of diagnostics
44
+ per child. Successful completion must preserve ignore filtering and reap all
45
+ twelve children.
46
+
39
47
  ## Composition contract with `index.js`
40
48
  - `index.js` provides composition-time dependencies only (platform primitives + callbacks such as `resolveProjectDirectory`, `normalizeDirectoryPath`, and `buildAugmentedPath`).
41
49
  - `index.js` no longer owns FS route handlers or FS exec job state.
@@ -43,7 +51,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
43
51
  ## Notes for contributors
44
52
  - Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
45
53
  - Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the OpenChamber config root and the managed chats root (`managedChatsRoot` dependency; `OPENCHAMBER_CHATS_DIR` upstream, default `<config root>/chats`). Chat worktrees may legitimately live outside every project workspace.
46
- - `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment).
54
+ - `GET /api/fs/home` preserves `{ home, chatsRoot }` and adds `canonicalChatsRoot` and `canonicalLegacyChatsRoot`, resolved by the server's filesystem. If a root does not exist yet, it resolves the nearest existing ancestor and appends the missing segments, without creating directories. Errors resolving the configured root fail the request. A failed legacy lookup warns and omits only that optional alias, retaining exact legacy matching without blocking an accessible relocated root. Clients use confirmed aliases for exact membership while keeping the original roots as folder/scope identities. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment).
55
+ - Workspace authorization accepts both the configured managed paths and their filesystem-confirmed canonical roots. Canonical lookup runs only when lexical workspace/managed-root checks fail. One failed managed-root lookup cannot reject an independently authorized root or worktree. Path comparisons remain case-sensitive on POSIX: distinct Linux directories named `chats` and `Chats` never become aliases through string folding. Shared UI keeps exact root membership for classification and directory cleanup.
47
56
  - Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
48
57
  - `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown.
49
58
  - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { createFsSearchRuntime } from './search.js';
8
+ import { registerFsRoutes } from './routes.js';
9
+
10
+ // Use real OS pipes. A mocked data event cannot reproduce a full stderr pipe.
11
+ const noisyGit = `
12
+ const fs = require('node:fs');
13
+ const chunk = Buffer.alloc(65536, 'x');
14
+ for (let i = 0; i < 32; i++) fs.writeSync(2, chunk);
15
+ fs.writeSync(1, 'ignored.txt\\n');
16
+ `;
17
+
18
+ describe('filesystem Git process ownership', () => {
19
+ for (const operation of ['search', 'list']) {
20
+ it(`${operation} completes repeated checks with more diagnostics than an OS pipe can hold`, async () => {
21
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-git-pipes-'));
22
+ const children = [];
23
+ const exits = [];
24
+ const launch = (binary, args, options) => {
25
+ expect(binary).toBe('git');
26
+ expect(args).toContain('check-ignore');
27
+ expect(args).toContain('ignored.txt');
28
+ const child = spawn(process.execPath, ['-e', noisyGit], options);
29
+ children.push(child);
30
+ exits.push(new Promise((resolve) => child.once('close', (code) => resolve(code))));
31
+ return child;
32
+ };
33
+ let deadline;
34
+ try {
35
+ await fs.writeFile(path.join(root, 'ignored.txt'), 'ignored');
36
+ await fs.writeFile(path.join(root, 'visible.txt'), 'visible');
37
+ const dependencies = { fsPromises: fs, path, spawn: launch, resolveGitBinaryForSpawn: () => 'git' };
38
+ const search = createFsSearchRuntime(dependencies);
39
+ const routes = new Map();
40
+ registerFsRoutes({
41
+ get(route, handler) { routes.set(route, handler); }, post() {}, put() {}, delete() {},
42
+ }, {
43
+ ...dependencies, os, crypto, normalizeDirectoryPath: (value) => value,
44
+ resolveProjectDirectory: async () => ({ directory: root }),
45
+ buildAugmentedPath: () => process.env.PATH,
46
+ openchamberUserConfigRoot: root,
47
+ gitCheckIgnoreTimeoutMs: 10_000,
48
+ });
49
+ const work = Array.from({ length: 12 }, async () => {
50
+ if (operation === 'search') {
51
+ const files = await search.searchFilesystemFiles(root, { query: '', limit: 10, respectGitignore: true });
52
+ expect(files.map((file) => file.name)).toEqual(['visible.txt']);
53
+ } else {
54
+ const response = { status() { return this; }, json(value) { this.body = value; } };
55
+ await routes.get('/api/fs/list')({ query: { path: root, respectGitignore: 'true' } }, response);
56
+ expect(response.body.entries.map((entry) => entry.name)).toEqual(['visible.txt']);
57
+ }
58
+ });
59
+ await Promise.race([
60
+ Promise.all(work),
61
+ new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error('Git blocked on unread diagnostics')), 5000); }),
62
+ ]);
63
+ expect(children).toHaveLength(12);
64
+ expect(await Promise.all(exits)).toEqual(Array(12).fill(0));
65
+ for (const child of children) expect(child.exitCode).toBe(0);
66
+ } finally {
67
+ clearTimeout(deadline);
68
+ for (const child of children) {
69
+ child.stderr?.resume();
70
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
71
+ }
72
+ await Promise.all(exits);
73
+ await fs.rm(root, { recursive: true, force: true });
74
+ }
75
+ }, 15_000);
76
+ }
77
+ });
@@ -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.
@@ -81,6 +84,17 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
81
84
  - `removeRemote(directory, options)`: Remove a configured remote (except `origin`).
82
85
  - `deleteRemoteBranch(directory, options)`: Delete a remote branch.
83
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
+
84
98
  ### Log Operations
85
99
  - `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
86
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`.
@@ -108,7 +122,7 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
108
122
 
109
123
  The following functions are internal helpers used by exported functions:
110
124
  - `buildSshCommand(sshKeyPath)`: Build SSH command string for git config.
111
- - `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.
112
126
  - `createGit(directory)`: Create simple-git instance with environment.
113
127
  - `normalizeDirectoryPath(value)`: Normalize directory paths (supports ~ expansion).
114
128
  - `cleanBranchName(branch)`: Remove refs/heads/ or refs/ prefixes.
@@ -130,7 +144,7 @@ The following functions are internal helpers used by exported functions:
130
144
  - `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
131
145
  - `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
132
146
  - `isClean`: Boolean indicating if working tree is clean.
133
- - `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`.
134
148
  - `mergeInProgress`: Object with `{ head, message }` if merge in progress.
135
149
  - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
136
150
 
@@ -166,6 +180,7 @@ The following functions are internal helpers used by exported functions:
166
180
  surface and VS Code does not mount these controls.
167
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.
168
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.
169
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.
170
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.
171
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.
@@ -10,6 +10,16 @@ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
10
10
  return gitLibraries;
11
11
  };
12
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
+
13
23
  const resolveDirectoryQuery = (value, preserveWhitespace = false) => {
14
24
  const raw = Array.isArray(value) ? value[0] : value;
15
25
  if (typeof raw !== 'string') {
@@ -345,7 +355,7 @@ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
345
355
  });
346
356
 
347
357
  app.get('/api/git/diff', async (req, res) => {
348
- const { getDiff } = await getGitLibraries();
358
+ const { getPathDiff } = await getGitLibraries();
349
359
  try {
350
360
  const directory = req.query.directory;
351
361
  if (!directory) {
@@ -360,14 +370,15 @@ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
360
370
  const staged = req.query.staged === 'true';
361
371
  const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
362
372
 
363
- const diff = await getDiff(directory, {
373
+ const { diff, submodule } = await getPathDiff(directory, {
364
374
  path,
365
375
  staged,
366
376
  contextLines: Number.isFinite(context) ? context : 3,
367
377
  });
368
378
 
369
- res.json({ diff });
379
+ res.json({ diff, submodule });
370
380
  } catch (error) {
381
+ if (sendGitPathError(res, error)) return;
371
382
  console.error('Failed to get git diff:', error);
372
383
  res.status(500).json({ error: error.message || 'Failed to get git diff' });
373
384
  }
@@ -398,8 +409,10 @@ export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
398
409
  modified: result.modified,
399
410
  path: result.path,
400
411
  isBinary: Boolean(result.isBinary),
412
+ submodule: result.submodule ?? null,
401
413
  });
402
414
  } catch (error) {
415
+ if (sendGitPathError(res, error)) return;
403
416
  console.error('Failed to get git file diff:', error);
404
417
  res.status(500).json({ error: error.message || 'Failed to get git file diff' });
405
418
  }
@@ -0,0 +1,93 @@
1
+ // Runs one refresh per key at a time and bounds how many keys refresh at once.
2
+ //
3
+ // A request that arrives while a refresh for its key is running does not join
4
+ // that run: the run may have read the working tree before the change the
5
+ // caller just made. Instead the request waits for one follow-up run that starts
6
+ // after the current one finishes, so every caller receives a result at least as
7
+ // fresh as its own arrival. All requests that arrive during one run share the
8
+ // same follow-up, which caps the work per key at "one running, one pending"
9
+ // no matter how many callers ask.
10
+
11
+ export function createSerialRefresh({ maxConcurrent = Number.POSITIVE_INFINITY } = {}) {
12
+ const runs = new Map();
13
+ let running = 0;
14
+ const waiting = [];
15
+
16
+ const acquire = () => new Promise((resolve) => {
17
+ if (running < maxConcurrent) {
18
+ running += 1;
19
+ resolve();
20
+ return;
21
+ }
22
+ waiting.push(resolve);
23
+ });
24
+
25
+ const release = () => {
26
+ const next = waiting.shift();
27
+ if (next) {
28
+ next();
29
+ return;
30
+ }
31
+ running -= 1;
32
+ };
33
+
34
+ const start = (key, requests, execute, deferred) => {
35
+ const run = { follower: null };
36
+ runs.set(key, run);
37
+ const promise = (async () => {
38
+ await acquire();
39
+ try {
40
+ return await execute(requests);
41
+ } finally {
42
+ release();
43
+ }
44
+ })();
45
+ if (deferred) {
46
+ promise.then(deferred.resolve, deferred.reject);
47
+ }
48
+ promise
49
+ .catch(() => undefined)
50
+ .then(() => {
51
+ if (runs.get(key) !== run) return;
52
+ if (run.follower) {
53
+ const follower = run.follower;
54
+ start(key, follower.requests, follower.execute, follower);
55
+ return;
56
+ }
57
+ runs.delete(key);
58
+ });
59
+ return promise;
60
+ };
61
+
62
+ return {
63
+ /**
64
+ * @template TRequest, TResult
65
+ * @param {string} key
66
+ * @param {TRequest} request
67
+ * @param {(requests: TRequest[]) => Promise<TResult>} execute receives every
68
+ * request the run answers, so it can widen the work to satisfy all of them
69
+ * @returns {Promise<TResult>}
70
+ */
71
+ run(key, request, execute) {
72
+ const current = runs.get(key);
73
+ if (!current) {
74
+ return start(key, [request], execute);
75
+ }
76
+ if (!current.follower) {
77
+ let resolve;
78
+ let reject;
79
+ const promise = new Promise((res, rej) => {
80
+ resolve = res;
81
+ reject = rej;
82
+ });
83
+ current.follower = { requests: [], execute, promise, resolve, reject };
84
+ }
85
+ current.follower.requests.push(request);
86
+ return current.follower.promise;
87
+ },
88
+ /** Keys with a running or pending refresh. Exposed for tests. */
89
+ get activeKeys() {
90
+ return Array.from(runs.keys());
91
+ },
92
+ };
93
+ }