@krelborn/markdown-review 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (410) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/dist/cli.js +3322 -0
  4. package/dist/web/assets/abap-CLvhMVsD.js +1 -0
  5. package/dist/web/assets/abnfDiagram-YUKMZFIV-CK8RfoBl.js +1 -0
  6. package/dist/web/assets/actionscript-3-Cs6JJXT5.js +1 -0
  7. package/dist/web/assets/ada-X3Y2mlq2.js +1 -0
  8. package/dist/web/assets/ahk-DwADkxyd.js +1 -0
  9. package/dist/web/assets/ahk2-_6rzW9BS.js +1 -0
  10. package/dist/web/assets/andromeeda-vGVdxbeo.js +1 -0
  11. package/dist/web/assets/angular-html-DeswjFqG.js +1 -0
  12. package/dist/web/assets/angular-ts-0_RRcACO.js +1 -0
  13. package/dist/web/assets/apache-U0d_L8uA.js +1 -0
  14. package/dist/web/assets/apex-KFGFyKwG.js +1 -0
  15. package/dist/web/assets/apl-CCY4uyn_.js +1 -0
  16. package/dist/web/assets/applescript-CCn79oCD.js +1 -0
  17. package/dist/web/assets/ara-4CJ0cIlV.js +1 -0
  18. package/dist/web/assets/arc-BG4zBWWH.js +1 -0
  19. package/dist/web/assets/architecture-WOLXFQ4H-Dd-YnaSX.js +1 -0
  20. package/dist/web/assets/architectureDiagram-47P4ROYG-DiUIBppB.js +36 -0
  21. package/dist/web/assets/array-BifhSqXX.js +1 -0
  22. package/dist/web/assets/asciidoc-SCjQUq34.js +1 -0
  23. package/dist/web/assets/asm-Cmm7eHzH.js +1 -0
  24. package/dist/web/assets/astro-Cwc-oo5s.js +1 -0
  25. package/dist/web/assets/aurora-x-CDeNXAV0.js +1 -0
  26. package/dist/web/assets/awk-BWXHIvNe.js +1 -0
  27. package/dist/web/assets/ayu-dark-DluEY0Gj.js +1 -0
  28. package/dist/web/assets/ayu-light-C3h-C4tm.js +1 -0
  29. package/dist/web/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  30. package/dist/web/assets/ballerina-B7ZEbQpA.js +1 -0
  31. package/dist/web/assets/bat-qdxAdBWQ.js +1 -0
  32. package/dist/web/assets/beancount-C2IN4Vk8.js +1 -0
  33. package/dist/web/assets/berry-DKpUyyne.js +1 -0
  34. package/dist/web/assets/bibtex-Ci_nEsc7.js +1 -0
  35. package/dist/web/assets/bicep-CUHmPFLl.js +1 -0
  36. package/dist/web/assets/bird2-CYIUgYqB.js +1 -0
  37. package/dist/web/assets/blade-84wcXlDd.js +1 -0
  38. package/dist/web/assets/blockDiagram-E7TT5TSB-D97HQ8Sw.js +140 -0
  39. package/dist/web/assets/bsl-_VjlB9mv.js +1 -0
  40. package/dist/web/assets/c-DTQBoa6Q.js +1 -0
  41. package/dist/web/assets/c3-D0apG41V.js +1 -0
  42. package/dist/web/assets/c4Diagram-CRA5TL53-ClvVKHsZ.js +38 -0
  43. package/dist/web/assets/cadence-CQ2zXKGN.js +1 -0
  44. package/dist/web/assets/cairo-DLTphjLi.js +1 -0
  45. package/dist/web/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
  46. package/dist/web/assets/catppuccin-latte-CtsyrtPq.js +1 -0
  47. package/dist/web/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
  48. package/dist/web/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
  49. package/dist/web/assets/channel-CPGqLD1P.js +1 -0
  50. package/dist/web/assets/chapel-CRMNarUY.js +1 -0
  51. package/dist/web/assets/chunk-2BW5OAIV-DSadrz5B.js +10 -0
  52. package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  53. package/dist/web/assets/chunk-3YJQHVM4-D6ohwMAh.js +2 -0
  54. package/dist/web/assets/chunk-4EA7E6EY-BjuRiZLM.js +1 -0
  55. package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  56. package/dist/web/assets/chunk-7TKQ45FW-CdSL2hss.js +1 -0
  57. package/dist/web/assets/chunk-AW2ZBBNX-BUkHB8w8.js +88 -0
  58. package/dist/web/assets/chunk-DBDB3WZW-BmDVHoAq.js +1 -0
  59. package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  60. package/dist/web/assets/chunk-E2ZNV5FY-CHWL0yef.js +72 -0
  61. package/dist/web/assets/chunk-EU5HNXII-TzfEw3rt.js +213 -0
  62. package/dist/web/assets/chunk-FVRAUYC3-Dea7fNxt.js +2 -0
  63. package/dist/web/assets/chunk-HJ2JQQFS-CsqlPveV.js +1 -0
  64. package/dist/web/assets/chunk-HTAEGDNF-uOHLFUZ2.js +1 -0
  65. package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  66. package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  67. package/dist/web/assets/chunk-KQW6MTUR-DiKsme5W.js +1 -0
  68. package/dist/web/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  69. package/dist/web/assets/chunk-NTY3LDVX-CmKaDw95.js +1 -0
  70. package/dist/web/assets/chunk-VPRB5NB3-B00Cf0rw.js +127 -0
  71. package/dist/web/assets/chunk-XC4XBNZT-BDz8GHcg.js +62 -0
  72. package/dist/web/assets/chunk-XXDRQBXY-VdpKW56f.js +1 -0
  73. package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  74. package/dist/web/assets/clarity-CtfBBspg.js +1 -0
  75. package/dist/web/assets/classDiagram-v2-K4WV4PDN-gEKpA9iT.js +217 -0
  76. package/dist/web/assets/clojure-DqKBuwfJ.js +1 -0
  77. package/dist/web/assets/cmake-Bj61d0ZC.js +1 -0
  78. package/dist/web/assets/cobol-DzWF6gu0.js +1 -0
  79. package/dist/web/assets/codeowners-C8r90Shi.js +1 -0
  80. package/dist/web/assets/codeql-oeQT6MSM.js +1 -0
  81. package/dist/web/assets/coffee-YFNN4OQT.js +1 -0
  82. package/dist/web/assets/common-lisp-Cv5bFMCO.js +1 -0
  83. package/dist/web/assets/coq-CGaSfhwR.js +1 -0
  84. package/dist/web/assets/cose-bilkent-JH36ORCC-Bfuj49HT.js +1 -0
  85. package/dist/web/assets/cpp-B_VbYR-1.js +1 -0
  86. package/dist/web/assets/crystal-29rZkanC.js +1 -0
  87. package/dist/web/assets/csharp-dmMxLVLW.js +1 -0
  88. package/dist/web/assets/css-Cs9-eEZt.js +1 -0
  89. package/dist/web/assets/csv-Dx-8-gkx.js +1 -0
  90. package/dist/web/assets/cue-CE9AQfxI.js +1 -0
  91. package/dist/web/assets/cynefin-EF2NZ3EQ-BdejMMtt.js +1 -0
  92. package/dist/web/assets/cynefinDiagram-3GCD6N5R-B0Cf1Lu4.js +62 -0
  93. package/dist/web/assets/cypher-ClKdZ_lG.js +1 -0
  94. package/dist/web/assets/cytoscape.esm-CHBj4VkZ.js +321 -0
  95. package/dist/web/assets/d-qD-0Kul2.js +1 -0
  96. package/dist/web/assets/dagre-W4DXFKR2-CbG5K5pN.js +4 -0
  97. package/dist/web/assets/dark-plus-Cs2F2srj.js +1 -0
  98. package/dist/web/assets/dart-CHZY9Xhz.js +1 -0
  99. package/dist/web/assets/dax-BkyTk9wS.js +1 -0
  100. package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  101. package/dist/web/assets/desktop-Dlh5hvp9.js +1 -0
  102. package/dist/web/assets/diagram-2UJZ2QOL-DnUMvh6f.js +200 -0
  103. package/dist/web/assets/diagram-OVF4WLC6-DWaGIEvp.js +24 -0
  104. package/dist/web/assets/diagram-PFPMY2P6-D5-q1mxk.js +30 -0
  105. package/dist/web/assets/diagram-UMYRVEAY-iaUg3gAk.js +24 -0
  106. package/dist/web/assets/diagram-YEKJPTXX-DJmReb1h.js +41 -0
  107. package/dist/web/assets/diagram-ZIFT7M5P-Sh7BjF9g.js +3 -0
  108. package/dist/web/assets/diff-woXpYk--.js +1 -0
  109. package/dist/web/assets/dist-B8fEp-qP.js +1 -0
  110. package/dist/web/assets/docker-IyjqRm3v.js +1 -0
  111. package/dist/web/assets/dotenv-CuH5my32.js +1 -0
  112. package/dist/web/assets/dracula-BHWKrbxM.js +1 -0
  113. package/dist/web/assets/dracula-soft-5eyTD99u.js +1 -0
  114. package/dist/web/assets/dream-maker-DW3nJb8Q.js +1 -0
  115. package/dist/web/assets/ebnfDiagram-VR2GEFS7-DI4sWEQu.js +1 -0
  116. package/dist/web/assets/edge-B_ygG8c6.js +1 -0
  117. package/dist/web/assets/elixir-CxJKtXuu.js +1 -0
  118. package/dist/web/assets/elk-IJKZMXRS-DCy8oS2y.js +27 -0
  119. package/dist/web/assets/elm-04O0r0pA.js +1 -0
  120. package/dist/web/assets/emacs-lisp-DR2vtWaj.js +1 -0
  121. package/dist/web/assets/erDiagram-O2IAPWRE-BEt6M0BG.js +99 -0
  122. package/dist/web/assets/erb-9rS1Brsj.js +1 -0
  123. package/dist/web/assets/erlang-Cphh6RMH.js +1 -0
  124. package/dist/web/assets/eventmodeling-K75KTNOO-CuoZYl3d.js +1 -0
  125. package/dist/web/assets/everforest-dark-sB-x3p7T.js +1 -0
  126. package/dist/web/assets/everforest-light-Df2xbC6M.js +1 -0
  127. package/dist/web/assets/fennel-DQxkIbk2.js +1 -0
  128. package/dist/web/assets/fish-BJitypiv.js +1 -0
  129. package/dist/web/assets/flowDiagram-OXPTDLAJ-LJSf5aAK.js +1 -0
  130. package/dist/web/assets/fluent-C03EYrpw.js +1 -0
  131. package/dist/web/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
  132. package/dist/web/assets/fortran-free-form-CYNrtFtB.js +1 -0
  133. package/dist/web/assets/fsharp-D13ZGOAj.js +1 -0
  134. package/dist/web/assets/ganttDiagram-R7TSEDQI-BGP21iH1.js +292 -0
  135. package/dist/web/assets/gdresource-xMC7jr7x.js +1 -0
  136. package/dist/web/assets/gdscript-DYvRD3hg.js +1 -0
  137. package/dist/web/assets/gdshader-CBce3t8t.js +1 -0
  138. package/dist/web/assets/genie-CV2tkWYe.js +1 -0
  139. package/dist/web/assets/gherkin-DExj1W_8.js +1 -0
  140. package/dist/web/assets/git-commit-BSykSTBG.js +1 -0
  141. package/dist/web/assets/git-rebase-uf5u70dP.js +1 -0
  142. package/dist/web/assets/gitGraph-VSP46ZUC-DLEE7ZA1.js +1 -0
  143. package/dist/web/assets/gitGraphDiagram-XJZIOB7I-B364Tsss.js +106 -0
  144. package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
  145. package/dist/web/assets/github-dark-default-DXG-b-1a.js +1 -0
  146. package/dist/web/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  147. package/dist/web/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  148. package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
  149. package/dist/web/assets/github-light-default-BXViO-2h.js +1 -0
  150. package/dist/web/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  151. package/dist/web/assets/gleam-CSRkHgEL.js +1 -0
  152. package/dist/web/assets/glimmer-js-CiBb6A2x.js +1 -0
  153. package/dist/web/assets/glimmer-ts-D2lfhEnU.js +1 -0
  154. package/dist/web/assets/glsl-B0_NrykT.js +1 -0
  155. package/dist/web/assets/gn-CAXYAgV-.js +1 -0
  156. package/dist/web/assets/gnuplot-7GGW24-e.js +1 -0
  157. package/dist/web/assets/go-rLFTqkRN.js +1 -0
  158. package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
  159. package/dist/web/assets/graphql-CagH_fy0.js +1 -0
  160. package/dist/web/assets/groovy-CacY0gHj.js +1 -0
  161. package/dist/web/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  162. package/dist/web/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  163. package/dist/web/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  164. package/dist/web/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  165. package/dist/web/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  166. package/dist/web/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  167. package/dist/web/assets/hack-DZpi5MLs.js +1 -0
  168. package/dist/web/assets/haml-BXC9NiVp.js +1 -0
  169. package/dist/web/assets/handlebars-BKcHNDUs.js +1 -0
  170. package/dist/web/assets/haskell-D8IpX4py.js +1 -0
  171. package/dist/web/assets/haxe-C8oCnwJJ.js +1 -0
  172. package/dist/web/assets/hcl-Dh228itO.js +1 -0
  173. package/dist/web/assets/hjson-CxZEssPk.js +1 -0
  174. package/dist/web/assets/hlsl-Cvrh5tZx.js +1 -0
  175. package/dist/web/assets/horizon-CE9ld1lL.js +1 -0
  176. package/dist/web/assets/horizon-bright-DSNQnXHK.js +1 -0
  177. package/dist/web/assets/houston-CsvMBhTu.js +1 -0
  178. package/dist/web/assets/html-DZ111F_a.js +1 -0
  179. package/dist/web/assets/html-derivative-DwoYvsLD.js +1 -0
  180. package/dist/web/assets/http-6X-ZAFMk.js +1 -0
  181. package/dist/web/assets/hurl-CFv0vwqV.js +1 -0
  182. package/dist/web/assets/hxml-Dcm88srW.js +1 -0
  183. package/dist/web/assets/hy-CZbG8q4J.js +1 -0
  184. package/dist/web/assets/imba-DsUTQ-LC.js +1 -0
  185. package/dist/web/assets/index-DdkL6eTr.css +1 -0
  186. package/dist/web/assets/index-thDNjFvN.js +235 -0
  187. package/dist/web/assets/info-OHQRW6UA-BkLeneU8.js +1 -0
  188. package/dist/web/assets/infoDiagram-5W2HQ5XZ-D5sUoBy9.js +2 -0
  189. package/dist/web/assets/ini-B5eOa1yu.js +1 -0
  190. package/dist/web/assets/init-C-OQMol4.js +1 -0
  191. package/dist/web/assets/ishikawaDiagram-K3B6WC7H-D5Oe30A8.js +70 -0
  192. package/dist/web/assets/java-Dd1Ai6aF.js +1 -0
  193. package/dist/web/assets/javascript-C-JN5i9P.js +1 -0
  194. package/dist/web/assets/jinja-WZ65gNFf.js +1 -0
  195. package/dist/web/assets/jison-Bg9NXAdk.js +1 -0
  196. package/dist/web/assets/journeyDiagram-COXZFDF6-WcVKvz_j.js +139 -0
  197. package/dist/web/assets/json-Hnqo41ms.js +1 -0
  198. package/dist/web/assets/json5-BR5RXkoi.js +1 -0
  199. package/dist/web/assets/jsonc-CYpm1nAK.js +1 -0
  200. package/dist/web/assets/jsonl-CmCQp5Yx.js +1 -0
  201. package/dist/web/assets/jsonnet-CJTPZ8u_.js +1 -0
  202. package/dist/web/assets/jssm-DXw9l8Rf.js +1 -0
  203. package/dist/web/assets/jsx-D9aGArJK.js +1 -0
  204. package/dist/web/assets/julia-Bn16XnNV.js +1 -0
  205. package/dist/web/assets/just-KiXY5riV.js +1 -0
  206. package/dist/web/assets/kanagawa-dragon-Bozd7klA.js +1 -0
  207. package/dist/web/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
  208. package/dist/web/assets/kanagawa-wave-BML5mF2J.js +1 -0
  209. package/dist/web/assets/kanban-definition-P3RFRI5V-q-zm7dfv.js +89 -0
  210. package/dist/web/assets/katex-DJhrJSbw.js +257 -0
  211. package/dist/web/assets/kdl-CsD5j6eV.js +1 -0
  212. package/dist/web/assets/kotlin-DhhofPvG.js +1 -0
  213. package/dist/web/assets/kusto-C7mF5XQf.js +1 -0
  214. package/dist/web/assets/laserwave-C_8bwKvT.js +1 -0
  215. package/dist/web/assets/latex-i_9kRMgr.js +1 -0
  216. package/dist/web/assets/lean-CewbzKMR.js +1 -0
  217. package/dist/web/assets/less-DVTAwKKz.js +1 -0
  218. package/dist/web/assets/light-plus-DVQuIRkW.js +1 -0
  219. package/dist/web/assets/line-DDhwvqef.js +1 -0
  220. package/dist/web/assets/linear-c8ViwBO3.js +1 -0
  221. package/dist/web/assets/liquid-B1LRugeL.js +1 -0
  222. package/dist/web/assets/llvm-DqHOMS-s.js +1 -0
  223. package/dist/web/assets/log-BNLmms1o.js +1 -0
  224. package/dist/web/assets/logo-Cluzi2Zq.js +1 -0
  225. package/dist/web/assets/lua-PLqxkaI5.js +1 -0
  226. package/dist/web/assets/luau-VGWVLoqX.js +1 -0
  227. package/dist/web/assets/make-Dixweg8N.js +1 -0
  228. package/dist/web/assets/markdown-BYOwaDjH.js +1 -0
  229. package/dist/web/assets/marko-KKSzWszp.js +1 -0
  230. package/dist/web/assets/material-theme-Bm3Qr25_.js +1 -0
  231. package/dist/web/assets/material-theme-darker-2IIEA8gg.js +1 -0
  232. package/dist/web/assets/material-theme-lighter-uhdI0v04.js +1 -0
  233. package/dist/web/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  234. package/dist/web/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  235. package/dist/web/assets/matlab-D7qyCx1q.js +1 -0
  236. package/dist/web/assets/mdc-BkVjD0F4.js +1 -0
  237. package/dist/web/assets/mdx-DQZ5AkYe.js +1 -0
  238. package/dist/web/assets/mermaid-BxpfTIzt.js +1 -0
  239. package/dist/web/assets/mermaid-parser.core-vLmarQ1b.js +7 -0
  240. package/dist/web/assets/mermaid.core-CaSYqI02.js +44 -0
  241. package/dist/web/assets/min-dark-BSWPekZh.js +1 -0
  242. package/dist/web/assets/min-light-DDpmG2fV.js +1 -0
  243. package/dist/web/assets/mindmap-definition-LZFPQGKD-DQUlG3eq.js +96 -0
  244. package/dist/web/assets/mipsasm-BMqwQI7S.js +1 -0
  245. package/dist/web/assets/mojo-fg40_ntQ.js +1 -0
  246. package/dist/web/assets/monokai-BscAjiQZ.js +1 -0
  247. package/dist/web/assets/moonbit-D9W784E_.js +1 -0
  248. package/dist/web/assets/move-BYnGhZoM.js +1 -0
  249. package/dist/web/assets/narrat-_X_XdTYD.js +1 -0
  250. package/dist/web/assets/nextflow-CSgcYZcx.js +1 -0
  251. package/dist/web/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
  252. package/dist/web/assets/nginx-BCxqweXv.js +1 -0
  253. package/dist/web/assets/night-owl-DhmEMT88.js +1 -0
  254. package/dist/web/assets/night-owl-light-eJ-hLW7d.js +1 -0
  255. package/dist/web/assets/nim-Bn4AK1z1.js +1 -0
  256. package/dist/web/assets/nix-IvuFDN5E.js +1 -0
  257. package/dist/web/assets/nord-Cb4Vim4T.js +1 -0
  258. package/dist/web/assets/nsis-CjNLYCLg.js +1 -0
  259. package/dist/web/assets/nushell--0fT3PI1.js +1 -0
  260. package/dist/web/assets/objective-c-D1A_Heim.js +1 -0
  261. package/dist/web/assets/objective-cpp-BsSzOQcm.js +1 -0
  262. package/dist/web/assets/ocaml-O90oeIOV.js +1 -0
  263. package/dist/web/assets/odin-B1RWQWA5.js +1 -0
  264. package/dist/web/assets/one-dark-pro-C6YyR8OD.js +1 -0
  265. package/dist/web/assets/one-light-D7Lr4KcI.js +1 -0
  266. package/dist/web/assets/openscad-BUDT5pXO.js +1 -0
  267. package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
  268. package/dist/web/assets/org-BdePQpbO.js +1 -0
  269. package/dist/web/assets/packet-JDAUHWVQ-B036XPve.js +1 -0
  270. package/dist/web/assets/pascal-4ZHwLPI5.js +1 -0
  271. package/dist/web/assets/path-fybaL0A-.js +1 -0
  272. package/dist/web/assets/pegDiagram-BGZESJAR-B5VSphc1.js +1 -0
  273. package/dist/web/assets/perl-DOKWME04.js +1 -0
  274. package/dist/web/assets/php-D1jKZSZS.js +1 -0
  275. package/dist/web/assets/pie-XZMESJXO-B0mUrCvT.js +1 -0
  276. package/dist/web/assets/pieDiagram-CAPJLFHJ-C8xmyQ-z.js +39 -0
  277. package/dist/web/assets/pkl-ot-7Btpt.js +1 -0
  278. package/dist/web/assets/plastic-DQwYfKfQ.js +1 -0
  279. package/dist/web/assets/plsql-DGHpHOYJ.js +1 -0
  280. package/dist/web/assets/po-BiJDBrnU.js +1 -0
  281. package/dist/web/assets/poimandres-DRFjx7u4.js +1 -0
  282. package/dist/web/assets/polar-C7UOKdEL.js +1 -0
  283. package/dist/web/assets/postcss-BXeXVLqQ.js +1 -0
  284. package/dist/web/assets/powerquery-DNMTfnFr.js +1 -0
  285. package/dist/web/assets/powershell-QtSnmXcu.js +1 -0
  286. package/dist/web/assets/prisma-DMgNhnYS.js +1 -0
  287. package/dist/web/assets/prolog-iXnhIJG7.js +1 -0
  288. package/dist/web/assets/proto-DB4EqR-F.js +1 -0
  289. package/dist/web/assets/pug-BJpuboRB.js +1 -0
  290. package/dist/web/assets/puppet-CDv2pdJW.js +1 -0
  291. package/dist/web/assets/purescript-9MfHhQsQ.js +1 -0
  292. package/dist/web/assets/python-gzcpVVnB.js +1 -0
  293. package/dist/web/assets/qml-CEJGh-Kq.js +1 -0
  294. package/dist/web/assets/qmldir-DCQb3MpD.js +1 -0
  295. package/dist/web/assets/qss-Fe1Jh2GI.js +1 -0
  296. package/dist/web/assets/quadrantDiagram-GDMTTRAM-D9H7_cMs.js +7 -0
  297. package/dist/web/assets/r-D99YzVsv.js +1 -0
  298. package/dist/web/assets/racket-DcIDlBhZ.js +1 -0
  299. package/dist/web/assets/radar-ABXABTNO-BsvigTX1.js +1 -0
  300. package/dist/web/assets/railroad-I3PHUGI6-BniuGKI_.js +1 -0
  301. package/dist/web/assets/railroad-abnf-LNEFI6M7-Dq8rfUcB.js +1 -0
  302. package/dist/web/assets/railroad-ebnf-DWYD2UWJ-eqGmJVi-.js +1 -0
  303. package/dist/web/assets/railroad-peg-JR7BVNR7-pKU4bxp7.js +1 -0
  304. package/dist/web/assets/railroadDiagram-SM67HX2B-BtmVYBDt.js +1 -0
  305. package/dist/web/assets/raku-B3gFvitq.js +1 -0
  306. package/dist/web/assets/razor-V3YH92c-.js +1 -0
  307. package/dist/web/assets/rbs-C0MpOrj4.js +1 -0
  308. package/dist/web/assets/red-CYlAq2b0.js +1 -0
  309. package/dist/web/assets/reg-CRGYupPL.js +1 -0
  310. package/dist/web/assets/regexp-zxy_MBYX.js +1 -0
  311. package/dist/web/assets/rel-BtDbiS_P.js +1 -0
  312. package/dist/web/assets/requirementDiagram-X7JNWC4A-BhsEMfWN.js +84 -0
  313. package/dist/web/assets/riscv-Ckw8ddFX.js +1 -0
  314. package/dist/web/assets/ron-VUp2lXgN.js +1 -0
  315. package/dist/web/assets/rose-pine-aMsaMD1D.js +1 -0
  316. package/dist/web/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
  317. package/dist/web/assets/rose-pine-moon-DO-qXg-I.js +1 -0
  318. package/dist/web/assets/rosmsg-CAekHB0j.js +1 -0
  319. package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  320. package/dist/web/assets/rst-mDxKxoTF.js +1 -0
  321. package/dist/web/assets/ruby-2fdsFnyL.js +1 -0
  322. package/dist/web/assets/rust-DpiIf5JO.js +1 -0
  323. package/dist/web/assets/sankeyDiagram-UM26HJRW-Blo8vqk5.js +40 -0
  324. package/dist/web/assets/sas-B9eZjlRt.js +1 -0
  325. package/dist/web/assets/sass-DXrisJhu.js +1 -0
  326. package/dist/web/assets/scala-BiFt_7mu.js +1 -0
  327. package/dist/web/assets/scheme-DQCgrYNe.js +1 -0
  328. package/dist/web/assets/scss-CzLxdZSp.js +1 -0
  329. package/dist/web/assets/sdbl-bTVj8UrX.js +1 -0
  330. package/dist/web/assets/sequenceDiagram-ZO4K6R2Y-DrzXts2L.js +169 -0
  331. package/dist/web/assets/shaderlab-TOUzSsQk.js +1 -0
  332. package/dist/web/assets/shellscript-DjP-Cu--.js +1 -0
  333. package/dist/web/assets/shellsession-CwreK3FS.js +1 -0
  334. package/dist/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  335. package/dist/web/assets/slack-dark-DnToyrRv.js +1 -0
  336. package/dist/web/assets/slack-ochin-B2OO5cIa.js +1 -0
  337. package/dist/web/assets/smalltalk-BHpQb5nM.js +1 -0
  338. package/dist/web/assets/smithy-Dgaz7aur.js +1 -0
  339. package/dist/web/assets/snazzy-light-4G7pJPwS.js +1 -0
  340. package/dist/web/assets/solarized-dark-b97czJOk.js +1 -0
  341. package/dist/web/assets/solarized-light-DFPxpIYQ.js +1 -0
  342. package/dist/web/assets/solidity-Bj7E1mRn.js +1 -0
  343. package/dist/web/assets/soy-C5Ng8TEx.js +1 -0
  344. package/dist/web/assets/sparql-D_iOobhT.js +1 -0
  345. package/dist/web/assets/splunk-BC2Px7Mm.js +1 -0
  346. package/dist/web/assets/sql-BfbMjUZp.js +1 -0
  347. package/dist/web/assets/src-zUDYm5ap.js +1 -0
  348. package/dist/web/assets/ssh-config-BgfXC-Er.js +1 -0
  349. package/dist/web/assets/stata-d4tyAeP_.js +1 -0
  350. package/dist/web/assets/stateDiagram-v2-TBUQTH76-DjZ1vBcn.js +291 -0
  351. package/dist/web/assets/stylus-B6D30XZt.js +1 -0
  352. package/dist/web/assets/surrealql-DTHkj589.js +1 -0
  353. package/dist/web/assets/svelte-BuYW1eGR.js +1 -0
  354. package/dist/web/assets/swift-CyEgAFGc.js +1 -0
  355. package/dist/web/assets/swimlanes-N4OXWK64-B01cawi9.js +1 -0
  356. package/dist/web/assets/swimlanesDiagram-K3J5GTZL-CzwG903S.js +8 -0
  357. package/dist/web/assets/synthwave-84-nFMaYfgc.js +1 -0
  358. package/dist/web/assets/system-verilog-Dh5ihWbf.js +1 -0
  359. package/dist/web/assets/systemd-BxMlprV5.js +1 -0
  360. package/dist/web/assets/talonscript-CohzipZa.js +1 -0
  361. package/dist/web/assets/tasl-DMoTqEGO.js +1 -0
  362. package/dist/web/assets/tcl-CZd0xW_V.js +1 -0
  363. package/dist/web/assets/templ-C2uBh9Ag.js +1 -0
  364. package/dist/web/assets/terraform-DswuEJGm.js +1 -0
  365. package/dist/web/assets/tex-CM41T9FS.js +1 -0
  366. package/dist/web/assets/timeline-definition-YOQAKGHF-DA3lBb9c.js +120 -0
  367. package/dist/web/assets/tokyo-night-oM2G3aXe.js +1 -0
  368. package/dist/web/assets/toml-CcmNWLt0.js +1 -0
  369. package/dist/web/assets/treeView-D4JQ5SDB-BLlFhQyR.js +1 -0
  370. package/dist/web/assets/treemap-SAJKECNS-DHDxrnYC.js +1 -0
  371. package/dist/web/assets/ts-tags-CubWvvW8.js +1 -0
  372. package/dist/web/assets/tsv-sltzmVWM.js +1 -0
  373. package/dist/web/assets/tsx-BOJXRLTs.js +1 -0
  374. package/dist/web/assets/turtle-ByJddavk.js +1 -0
  375. package/dist/web/assets/twig-DRJ5AsA3.js +1 -0
  376. package/dist/web/assets/typescript-BNgJ2FM9.js +1 -0
  377. package/dist/web/assets/typespec-BGhR2SqG.js +1 -0
  378. package/dist/web/assets/typst-DD5tA4KG.js +1 -0
  379. package/dist/web/assets/usecaseDiagram-VIAY4XPW-DMYZGaBl.js +328 -0
  380. package/dist/web/assets/v-DBj4K9d_.js +1 -0
  381. package/dist/web/assets/vala-zf12oZj6.js +1 -0
  382. package/dist/web/assets/vb-DOwzaHZ0.js +1 -0
  383. package/dist/web/assets/vennDiagram-BLWOH2XV-EufOoAyB.js +34 -0
  384. package/dist/web/assets/verilog-B2gwz-sJ.js +1 -0
  385. package/dist/web/assets/vesper-DdrHHSXu.js +1 -0
  386. package/dist/web/assets/vhdl-BroJfC0k.js +1 -0
  387. package/dist/web/assets/viml-DvXPmvsu.js +1 -0
  388. package/dist/web/assets/vitesse-black-fwtXNY1n.js +1 -0
  389. package/dist/web/assets/vitesse-dark-BZCL-v6S.js +1 -0
  390. package/dist/web/assets/vitesse-light-VbXTXTou.js +1 -0
  391. package/dist/web/assets/vue-DodVQd5D.js +1 -0
  392. package/dist/web/assets/vue-html-D8YdbzI8.js +1 -0
  393. package/dist/web/assets/vue-vine-Daazu5-q.js +1 -0
  394. package/dist/web/assets/vyper-CgoNMtux.js +1 -0
  395. package/dist/web/assets/wardley-7MLQ67FV-CXmW0Q5D.js +1 -0
  396. package/dist/web/assets/wardleyDiagram-YMQ3BMBF-B5oI1vuE.js +78 -0
  397. package/dist/web/assets/wasm-ByWQv1Qj.js +1 -0
  398. package/dist/web/assets/wenyan-C8pVoKbM.js +1 -0
  399. package/dist/web/assets/wgsl-BsKzXJz4.js +1 -0
  400. package/dist/web/assets/wikitext-ClFFjSW2.js +1 -0
  401. package/dist/web/assets/wit-DdvCle-K.js +1 -0
  402. package/dist/web/assets/wolfram-DLL8P-h_.js +1 -0
  403. package/dist/web/assets/xml-C00crbAK.js +1 -0
  404. package/dist/web/assets/xsl-DGdUfA8V.js +1 -0
  405. package/dist/web/assets/xychartDiagram-TAQBALBS-kVDrm0rh.js +7 -0
  406. package/dist/web/assets/yaml-SvAgLt0R.js +1 -0
  407. package/dist/web/assets/zenscript-BnlCZFoB.js +1 -0
  408. package/dist/web/assets/zig-CMLA9XwU.js +1 -0
  409. package/dist/web/index.html +13 -0
  410. package/package.json +78 -4
package/dist/cli.js ADDED
@@ -0,0 +1,3322 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+ import { parseArgs, promisify } from "node:util";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ import path from "node:path";
8
+ import { z } from "zod";
9
+ import os from "node:os";
10
+ import openInBrowser from "open";
11
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
12
+ import { createServer } from "node:http";
13
+ import { getRequestListener } from "@hono/node-server";
14
+ import { Hono } from "hono";
15
+ import { stream, streamSSE } from "hono/streaming";
16
+ import { tasklist } from "@mdit/plugin-tasklist";
17
+ import markdownIt from "markdown-it";
18
+ import search from "approx-string-match";
19
+ import { watch } from "chokidar";
20
+ //#region src/cli/createProcessTerminal.ts
21
+ /**
22
+ * Connects a CLI invocation to this process's working directory, environment and standard streams
23
+ */
24
+ function createProcessTerminal() {
25
+ return {
26
+ workingDirectory: process.cwd(),
27
+ env: process.env,
28
+ readStdin: async () => {
29
+ const chunks = [];
30
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
31
+ return Buffer.concat(chunks).toString("utf8");
32
+ },
33
+ stderr: (text) => {
34
+ process.stderr.write(text);
35
+ },
36
+ stdout: (text) => {
37
+ process.stdout.write(text);
38
+ }
39
+ };
40
+ }
41
+ //#endregion
42
+ //#region src/server/runtime/packageVersion.ts
43
+ var packageVersion = {
44
+ name: "@krelborn/markdown-review",
45
+ version: "0.1.0",
46
+ description: "Review agent-written markdown in the browser and hand the comments back to the agent",
47
+ license: "MIT",
48
+ repository: {
49
+ "type": "git",
50
+ "url": "git+https://github.com/Krelborn/markdown-review.git"
51
+ },
52
+ bin: { "markdown-review": "dist/cli.js" },
53
+ files: ["dist"],
54
+ type: "module",
55
+ publishConfig: {
56
+ "@krelborn:registry": "https://registry.npmjs.org/",
57
+ "access": "public"
58
+ },
59
+ scripts: {
60
+ "build": "vite build && vite build --config vite.web.config.mts",
61
+ "typecheck": "tsc -b",
62
+ "lint": "oxlint",
63
+ "format": "oxfmt",
64
+ "format:check": "oxfmt --check",
65
+ "test": "vitest run",
66
+ "test:coverage": "pnpm test --coverage",
67
+ "test:watch": "vitest",
68
+ "verify": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test",
69
+ "prepare": "husky",
70
+ "prepublishOnly": "pnpm build",
71
+ "test:e2e": "playwright test"
72
+ },
73
+ dependencies: {
74
+ "@hono/node-server": "^2.1.4",
75
+ "@mdit/plugin-tasklist": "^1.1.3",
76
+ "approx-string-match": "^2.0.0",
77
+ "chokidar": "^5.0.0",
78
+ "hono": "^4.13.13",
79
+ "markdown-it": "^15.0.2",
80
+ "open": "^11.0.4",
81
+ "zod": "^4.6.5"
82
+ },
83
+ devDependencies: {
84
+ "@babel/core": "^8.0.7",
85
+ "@krelborn/stylesui": "^0.2.1",
86
+ "@playwright/test": "^1.64.0",
87
+ "@rolldown/plugin-babel": "^0.2.4",
88
+ "@testing-library/dom": "^10.4.2",
89
+ "@testing-library/jest-dom": "^7.0.1",
90
+ "@testing-library/react": "^16.3.3",
91
+ "@testing-library/user-event": "^14.6.7",
92
+ "@types/node": "^26.6.4",
93
+ "@types/react": "^19.3.0",
94
+ "@types/react-dom": "^19.3.0",
95
+ "@vitejs/plugin-react": "^6.1.2",
96
+ "@vitest/coverage-v8": "^5.0.3",
97
+ "babel-plugin-react-compiler": "^1.0.0",
98
+ "clsx": "^2.1.1",
99
+ "dompurify": "^3.4.16",
100
+ "fallow": "^3.32.0",
101
+ "husky": "^9.1.7",
102
+ "jsdom": "^30.1.2",
103
+ "lint-staged": "^17.6.0",
104
+ "mermaid": "^12.1.0",
105
+ "oxfmt": "^0.72.0",
106
+ "oxlint": "^1.87.0",
107
+ "react": "^19.3.0",
108
+ "react-dom": "^19.3.0",
109
+ "shiki": "^4.5.0",
110
+ "typescript": "^7.0.2",
111
+ "vite": "^8.3.3",
112
+ "vitest": "^5.0.3"
113
+ },
114
+ engines: { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
115
+ packageManager: "pnpm@10.18.3"
116
+ }.version;
117
+ //#endregion
118
+ //#region src/cli/CliError.ts
119
+ /**
120
+ * A command that cannot finish, with the `next_step` that tells the agent what to do about it
121
+ */
122
+ var CliError = class extends Error {
123
+ exitCode;
124
+ nextStep;
125
+ constructor(message, nextStep, exitCode = 1) {
126
+ super(message);
127
+ this.name = "CliError";
128
+ this.exitCode = exitCode;
129
+ this.nextStep = nextStep;
130
+ }
131
+ };
132
+ //#endregion
133
+ //#region src/server/store/storePaths.ts
134
+ var storeDirectoryName = ".markdown-review";
135
+ function storeDirectory(root) {
136
+ return path.join(root, storeDirectoryName);
137
+ }
138
+ function reviewFilePath(root) {
139
+ return path.join(storeDirectory(root), "review.json");
140
+ }
141
+ function serverFilePath(root) {
142
+ return path.join(storeDirectory(root), "server.json");
143
+ }
144
+ function serverLockPath(root) {
145
+ return path.join(storeDirectory(root), "server.lock");
146
+ }
147
+ function serverLogPath(root) {
148
+ return path.join(storeDirectory(root), "server.log");
149
+ }
150
+ function documentsDirectory(root) {
151
+ return path.join(storeDirectory(root), "documents");
152
+ }
153
+ /**
154
+ * Locates the file holding a doc's threads
155
+ *
156
+ * @param root the repo root
157
+ * @param document a repo-relative POSIX path, e.g. "docs/plan.md"
158
+ * @returns where the doc's threads are stored, e.g. "<root>/.markdown-review/documents/docs/plan.md.json"
159
+ */
160
+ function documentThreadsFilePath(root, document) {
161
+ return path.join(documentsDirectory(root), ...`${document}.json`.split("/"));
162
+ }
163
+ /**
164
+ * Locates a doc's source on disk
165
+ *
166
+ * @param root the repo root
167
+ * @param document a repo-relative POSIX path
168
+ * @returns the doc's path on disk
169
+ */
170
+ function documentSourcePath(root, document) {
171
+ return path.join(root, ...document.split("/"));
172
+ }
173
+ //#endregion
174
+ //#region src/server/runtime/serverFileSchema.ts
175
+ /**
176
+ * `.markdown-review/server.json`: how the CLI finds and authenticates to the root's running server
177
+ *
178
+ * Not strict, so a newer server that records more fields is still recognised by its protocol rather than mistaken for
179
+ * no server
180
+ */
181
+ var serverFileSchema = z.object({
182
+ pid: z.int(),
183
+ port: z.int().min(1).max(65535),
184
+ protocol: z.int(),
185
+ root: z.string(),
186
+ startedAt: z.iso.datetime(),
187
+ token: z.string().regex(/^[0-9a-f]{64}$/),
188
+ version: z.string()
189
+ });
190
+ //#endregion
191
+ //#region src/server/files/isFileNotFound.ts
192
+ /**
193
+ * Tells whether a file system error means the file does not exist
194
+ *
195
+ * @param error the error a file system call rejected with
196
+ * @returns true for ENOENT
197
+ */
198
+ function isFileNotFound(error) {
199
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
200
+ }
201
+ //#endregion
202
+ //#region src/server/files/readTextFileOrNull.ts
203
+ /**
204
+ * Reads a UTF-8 text file
205
+ *
206
+ * @param filePath the file to read
207
+ * @returns the contents, or null when the file does not exist
208
+ * @throws the file system error when the file exists but cannot be read
209
+ */
210
+ async function readTextFileOrNull(filePath) {
211
+ try {
212
+ return await readFile(filePath, "utf8");
213
+ } catch (error) {
214
+ if (isFileNotFound(error)) return null;
215
+ throw error;
216
+ }
217
+ }
218
+ //#endregion
219
+ //#region src/server/store/readStoreFile.ts
220
+ /**
221
+ * Reads and validates a store file
222
+ *
223
+ * @param filePath the file to read
224
+ * @param schema the shape the file must have
225
+ * @returns the file's value, null when the file does not exist, or a description of what is wrong with it
226
+ * @throws the file system error when the file exists but cannot be read
227
+ */
228
+ async function readStoreFile(filePath, schema) {
229
+ const contents = await readTextFileOrNull(filePath);
230
+ if (contents === null) return {
231
+ kind: "valid",
232
+ value: null
233
+ };
234
+ let value;
235
+ try {
236
+ value = JSON.parse(contents);
237
+ } catch (error) {
238
+ return {
239
+ kind: "invalid",
240
+ problem: `${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
241
+ };
242
+ }
243
+ const result = schema.safeParse(value);
244
+ if (!result.success) return {
245
+ kind: "invalid",
246
+ problem: `${filePath} is not a valid store file:\n${z.prettifyError(result.error)}`
247
+ };
248
+ return {
249
+ kind: "valid",
250
+ value: result.data
251
+ };
252
+ }
253
+ //#endregion
254
+ //#region src/shared/review/isRepositoryRelativePath.ts
255
+ /**
256
+ * Tells whether a path names a file inside the repo without leaving it
257
+ *
258
+ * @param value the path to check
259
+ * @returns true for a relative POSIX path with no empty, "." or ".." segments
260
+ */
261
+ function isRepositoryRelativePath(value) {
262
+ if (value === "" || value.startsWith("/") || value.includes("\\")) return false;
263
+ return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
264
+ }
265
+ var documentPathSchema = z.string().refine(isRepositoryRelativePath, "must be a repo-relative POSIX path");
266
+ var reviewAnchorSchema = z.strictObject({ kind: z.literal("review") });
267
+ var documentAnchorSchema = z.strictObject({
268
+ document: documentPathSchema,
269
+ kind: z.literal("document")
270
+ });
271
+ var passageAnchorSchema = z.strictObject({
272
+ anchoredText: z.string().min(1),
273
+ document: documentPathSchema,
274
+ endLine: z.int().positive(),
275
+ endOffset: z.int().nonnegative(),
276
+ kind: z.literal("passage"),
277
+ outdated: z.boolean(),
278
+ prefix: z.string().max(32),
279
+ quote: z.string().min(1),
280
+ startLine: z.int().positive(),
281
+ startOffset: z.int().nonnegative(),
282
+ suffix: z.string().max(32)
283
+ }).refine((anchor) => anchor.startLine <= anchor.endLine && anchor.startOffset <= anchor.endOffset, { message: "must not end before it starts" });
284
+ var anchorSchema = z.discriminatedUnion("kind", [
285
+ reviewAnchorSchema,
286
+ documentAnchorSchema,
287
+ passageAnchorSchema
288
+ ]);
289
+ //#endregion
290
+ //#region src/shared/review/threadSchema.ts
291
+ var messageBodySchema = z.string().refine((body) => body.trim() !== "", "must not be blank");
292
+ var messageSchema = z.strictObject({
293
+ at: z.iso.datetime(),
294
+ author: z.enum(["user", "agent"]),
295
+ body: messageBodySchema
296
+ });
297
+ var draftMessageSchema = z.strictObject({
298
+ at: z.iso.datetime(),
299
+ body: messageBodySchema
300
+ });
301
+ var threadSchema = z.strictObject({
302
+ anchor: anchorSchema,
303
+ createdAt: z.iso.datetime(),
304
+ draft: draftMessageSchema.optional(),
305
+ id: z.int().positive(),
306
+ messages: z.array(messageSchema),
307
+ status: z.enum([
308
+ "draft",
309
+ "open",
310
+ "resolved"
311
+ ]),
312
+ updatedAt: z.iso.datetime()
313
+ }).refine((thread) => thread.status === "draft" === (thread.messages.length === 0), { message: "must have messages unless it is a draft" }).refine((thread) => thread.status !== "draft" || thread.draft !== void 0, { message: "must hold its comment as a draft while it is a draft" });
314
+ //#endregion
315
+ //#region src/shared/api/apiResponseSchemas.ts
316
+ var reviewStateSchema = z.strictObject({
317
+ approved: z.boolean(),
318
+ approvedAt: z.iso.datetime().nullable(),
319
+ requestedAt: z.iso.datetime().nullable()
320
+ });
321
+ /**
322
+ * Not strict, so a newer server that reports more fields is still recognised by its protocol rather than mistaken for
323
+ * no server
324
+ */
325
+ var healthSchema = z.object({
326
+ name: z.literal("markdown-review"),
327
+ pid: z.int(),
328
+ protocol: z.int(),
329
+ root: z.string(),
330
+ version: z.string()
331
+ });
332
+ var threadsSnapshotSchema = z.strictObject({
333
+ problems: z.array(z.string()),
334
+ review: reviewStateSchema,
335
+ threads: z.array(threadSchema)
336
+ });
337
+ var pollResponseSchema = threadsSnapshotSchema.extend({ timedOut: z.boolean() });
338
+ var agentOpenResponseSchema = z.strictObject({
339
+ navigated: z.boolean(),
340
+ review: reviewStateSchema,
341
+ url: z.string()
342
+ });
343
+ var agentThreadResponseSchema = z.strictObject({
344
+ inbox: threadsSnapshotSchema,
345
+ thread: threadSchema
346
+ });
347
+ var shutdownResponseSchema = z.strictObject({ stopping: z.literal(true) });
348
+ z.strictObject({
349
+ documents: z.array(z.strictObject({
350
+ document: documentPathSchema,
351
+ draftCount: z.int().nonnegative(),
352
+ openCount: z.int().nonnegative()
353
+ })),
354
+ problems: z.array(z.string()),
355
+ recent: z.array(documentPathSchema)
356
+ });
357
+ z.strictObject({
358
+ hash: z.string(),
359
+ path: documentPathSchema,
360
+ source: z.string()
361
+ });
362
+ var apiErrorSchema = z.strictObject({ error: z.strictObject({
363
+ message: z.string(),
364
+ reason: z.string()
365
+ }) });
366
+ //#endregion
367
+ //#region src/cli/ServerRequestError.ts
368
+ /**
369
+ * A request the review server refused or answered with something the CLI cannot read
370
+ */
371
+ var ServerRequestError = class extends Error {
372
+ reason;
373
+ status;
374
+ constructor(status, reason, message) {
375
+ super(message);
376
+ this.name = "ServerRequestError";
377
+ this.reason = reason;
378
+ this.status = status;
379
+ }
380
+ };
381
+ //#endregion
382
+ //#region src/cli/ServerClient.ts
383
+ var healthTimeoutMilliseconds = 1e3;
384
+ /**
385
+ * Talks to one root's review server on behalf of the agent
386
+ */
387
+ var ServerClient = class {
388
+ port;
389
+ token;
390
+ constructor(port, token) {
391
+ this.port = port;
392
+ this.token = token;
393
+ }
394
+ /**
395
+ * @throws when nothing answers on the port within a second
396
+ */
397
+ health() {
398
+ return this.request("GET", "/api/health", healthSchema, { signal: AbortSignal.timeout(healthTimeoutMilliseconds) });
399
+ }
400
+ open(document) {
401
+ return this.request("POST", "/api/agent/open", agentOpenResponseSchema, { body: document === null ? {} : { path: document } });
402
+ }
403
+ inbox(document) {
404
+ return this.request("GET", `/api/inbox${documentQuery(document)}`, threadsSnapshotSchema);
405
+ }
406
+ /**
407
+ * Waits for the review to be approved, a thread to need the agent, or the timeout
408
+ */
409
+ poll(document, timeoutSeconds, signal) {
410
+ const query = new URLSearchParams({ timeout: String(timeoutSeconds) });
411
+ if (document !== null) query.set("document", document);
412
+ return this.request("GET", `/api/poll?${query}`, pollResponseSchema, { signal });
413
+ }
414
+ reply(id, body) {
415
+ return this.request("POST", `/api/agent/threads/${id}/reply`, agentThreadResponseSchema, { body: { body } });
416
+ }
417
+ resolve(id, body) {
418
+ return this.request("POST", `/api/agent/threads/${id}/resolve`, agentThreadResponseSchema, { body: { body } });
419
+ }
420
+ async shutdown() {
421
+ await this.request("POST", "/api/shutdown", shutdownResponseSchema);
422
+ }
423
+ async request(method, path, schema, { body, signal } = {}) {
424
+ const response = await fetch(`http://127.0.0.1:${this.port}${path}`, {
425
+ body: body === void 0 ? void 0 : JSON.stringify(body),
426
+ headers: {
427
+ authorization: `Bearer ${this.token}`,
428
+ "content-type": "application/json"
429
+ },
430
+ method,
431
+ signal
432
+ });
433
+ const text = await response.text();
434
+ const value = parseJson(text);
435
+ const refusal = apiErrorSchema.safeParse(value);
436
+ if (!response.ok || refusal.success) {
437
+ const { message, reason } = refusal.data?.error ?? {
438
+ message: text,
439
+ reason: "unknown"
440
+ };
441
+ throw new ServerRequestError(response.status, reason, message);
442
+ }
443
+ const result = schema.safeParse(value);
444
+ if (!result.success) throw new ServerRequestError(response.status, "unreadable-response", `${method} ${path} answered with ${text}`);
445
+ return result.data;
446
+ }
447
+ };
448
+ function documentQuery(document) {
449
+ return document === null ? "" : `?${new URLSearchParams({ document })}`;
450
+ }
451
+ function parseJson(text) {
452
+ try {
453
+ return JSON.parse(text);
454
+ } catch {
455
+ return;
456
+ }
457
+ }
458
+ //#endregion
459
+ //#region src/cli/findServer.ts
460
+ /**
461
+ * Looks for a server already serving the root, using `.markdown-review/server.json`
462
+ *
463
+ * @param root the root's real path
464
+ * @returns "ready" for a healthy server of this protocol, "older" or "newer" for one of another protocol, and
465
+ * "absent" when there is no record, the recorded server does not answer, or it serves another root
466
+ */
467
+ async function findServer(root) {
468
+ const recorded = await readStoreFile(serverFilePath(root), serverFileSchema);
469
+ if (recorded.kind === "invalid" || recorded.value === null) return { kind: "absent" };
470
+ const client = new ServerClient(recorded.value.port, recorded.value.token);
471
+ let health;
472
+ try {
473
+ health = await client.health();
474
+ } catch {
475
+ return { kind: "absent" };
476
+ }
477
+ if (health.root !== root) return { kind: "absent" };
478
+ if (health.protocol < 1) return {
479
+ client,
480
+ kind: "older"
481
+ };
482
+ return health.protocol > 1 ? {
483
+ health,
484
+ kind: "newer"
485
+ } : {
486
+ client,
487
+ kind: "ready"
488
+ };
489
+ }
490
+ //#endregion
491
+ //#region src/cli/withStartLock.ts
492
+ var lockWaitMilliseconds = 15e3;
493
+ var lockRetryMilliseconds = 100;
494
+ /**
495
+ * Runs a task while holding `.markdown-review/server.lock`, so only one command at a time starts the root's server
496
+ *
497
+ * @param root the root's real path; its `.markdown-review/` directory must exist
498
+ * @param task what to do while holding the lock
499
+ * @returns the task's result
500
+ * @throws CliError when another live process holds the lock for longer than 15 seconds
501
+ */
502
+ async function withStartLock(root, task) {
503
+ const lockPath = serverLockPath(root);
504
+ const deadline = Date.now() + lockWaitMilliseconds;
505
+ while (!await tryCreateLock(lockPath)) {
506
+ if (await removeStaleLock(lockPath)) continue;
507
+ if (Date.now() > deadline) throw new CliError(`Another markdown-review command has been starting the server for ${root} for over 15 seconds`, `Wait for it to finish, or delete ${lockPath} if no other markdown-review command is running, then try again.`);
508
+ await setTimeout$1(lockRetryMilliseconds);
509
+ }
510
+ try {
511
+ return await task();
512
+ } finally {
513
+ await rm(lockPath, { force: true });
514
+ }
515
+ }
516
+ async function tryCreateLock(lockPath) {
517
+ try {
518
+ await writeFile(lockPath, String(process.pid), { flag: "wx" });
519
+ return true;
520
+ } catch (error) {
521
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
522
+ throw error;
523
+ }
524
+ }
525
+ /**
526
+ * Removes the lock when its owner has exited without removing it. Commands check and remove it one at a time, under
527
+ * a second lock, so a command that found the lock stale cannot then remove the lock another command has just taken.
528
+ *
529
+ * @returns whether the lock was removed
530
+ */
531
+ async function removeStaleLock(lockPath) {
532
+ const checkPath = `${lockPath}.check`;
533
+ if (!await tryCreateLock(checkPath)) {
534
+ if (await isStaleLock(checkPath)) await rm(checkPath, { force: true });
535
+ return false;
536
+ }
537
+ try {
538
+ if (!await isStaleLock(lockPath)) return false;
539
+ await rm(lockPath, { force: true });
540
+ return true;
541
+ } finally {
542
+ await rm(checkPath, { force: true });
543
+ }
544
+ }
545
+ /**
546
+ * Tells whether the lock's owner has exited without removing it
547
+ */
548
+ async function isStaleLock(lockPath) {
549
+ let owner;
550
+ try {
551
+ owner = Number(await readFile(lockPath, "utf8"));
552
+ } catch {
553
+ return false;
554
+ }
555
+ if (!Number.isInteger(owner) || owner <= 0) return false;
556
+ try {
557
+ process.kill(owner, 0);
558
+ return false;
559
+ } catch (error) {
560
+ return error instanceof Error && "code" in error && error.code === "ESRCH";
561
+ }
562
+ }
563
+ //#endregion
564
+ //#region src/cli/connectToServer.ts
565
+ var startWaitMilliseconds = 1e4;
566
+ var stopWaitMilliseconds = 5e3;
567
+ var checkIntervalMilliseconds = 100;
568
+ /**
569
+ * Connects to the root's review server, starting one when none is running and replacing one of an older protocol
570
+ *
571
+ * @param root the root's real path
572
+ * @param cliPath the built CLI, which the server is started from
573
+ * @returns a client for the running server
574
+ * @throws CliError when a newer markdown-review is serving the root, or the server does not start
575
+ */
576
+ async function connectToServer(root, cliPath) {
577
+ const found = await findServer(root);
578
+ switch (found.kind) {
579
+ case "ready": return found.client;
580
+ case "newer": throw new CliError(`A newer markdown-review (version ${found.health.version}, protocol ${found.health.protocol}) is serving ${root}`, "Run the newer markdown-review, for example `npx @krelborn/markdown-review@latest <command>`, or stop its server with it.");
581
+ case "older":
582
+ await found.client.shutdown();
583
+ await waitUntilStopped(found.client);
584
+ }
585
+ await mkdir(storeDirectory(root), { recursive: true });
586
+ return withStartLock(root, async () => {
587
+ const recheck = await findServer(root);
588
+ if (recheck.kind === "ready") return recheck.client;
589
+ await spawnServer(root, cliPath);
590
+ return waitUntilReady(root);
591
+ });
592
+ }
593
+ /**
594
+ * Waits for a server to stop answering, after asking it to shut down
595
+ *
596
+ * @param client the server's client
597
+ */
598
+ async function waitUntilStopped(client) {
599
+ const deadline = Date.now() + stopWaitMilliseconds;
600
+ while (Date.now() < deadline) {
601
+ try {
602
+ await client.health();
603
+ } catch {
604
+ return;
605
+ }
606
+ await setTimeout$1(checkIntervalMilliseconds);
607
+ }
608
+ }
609
+ async function spawnServer(root, cliPath) {
610
+ const log = await open(serverLogPath(root), "a");
611
+ try {
612
+ spawn(process.execPath, [
613
+ cliPath,
614
+ "serve",
615
+ "--root",
616
+ root
617
+ ], {
618
+ detached: true,
619
+ stdio: [
620
+ "ignore",
621
+ log.fd,
622
+ log.fd
623
+ ]
624
+ }).unref();
625
+ } finally {
626
+ await log.close();
627
+ }
628
+ }
629
+ async function waitUntilReady(root) {
630
+ const deadline = Date.now() + startWaitMilliseconds;
631
+ while (Date.now() < deadline) {
632
+ const found = await findServer(root);
633
+ if (found.kind === "ready") return found.client;
634
+ await setTimeout$1(checkIntervalMilliseconds);
635
+ }
636
+ throw new CliError(`The review server for ${root} did not start within 10 seconds`, `Read ${serverLogPath(root)}, fix what it reports, and run the command again.`);
637
+ }
638
+ //#endregion
639
+ //#region src/cli/findRoot.ts
640
+ var runCommand = promisify(execFile);
641
+ /**
642
+ * Finds the root a directory's reviews belong to
643
+ *
644
+ * @param directory an existing directory
645
+ * @param outsideGitDirectory the root to use when `directory` is not in a git repository; `directory` itself unless
646
+ * given
647
+ * @returns the real path of the git repository (or worktree) root holding the directory, or of `outsideGitDirectory`
648
+ * when it is not in a git repository
649
+ */
650
+ async function findRoot(directory, outsideGitDirectory = directory) {
651
+ try {
652
+ const { stdout } = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: directory });
653
+ return await realpath(stdout.trim());
654
+ } catch {
655
+ return realpath(outsideGitDirectory);
656
+ }
657
+ }
658
+ //#endregion
659
+ //#region src/cli/formatIdList.ts
660
+ /**
661
+ * Lists thread IDs the way the CLI's sentences name them
662
+ *
663
+ * @param ids the thread IDs, in the order to list them
664
+ * @returns e.g. "#14", "#14 and #15", or "#14, #15 and #16"
665
+ */
666
+ function formatIdList(ids) {
667
+ const names = ids.map((id) => `#${id}`);
668
+ const last = names.pop();
669
+ if (last === void 0) return "";
670
+ return names.length === 0 ? last : `${names.join(", ")} and ${last}`;
671
+ }
672
+ //#endregion
673
+ //#region src/cli/nextSteps.ts
674
+ var pollTimeoutAdvice = "Give the shell command a timeout of at least 600000 ms, or in Claude Code run it with run_in_background and `--timeout 7080`.";
675
+ var waitForCommentsStep = `Run \`markdown-review poll\` to wait for the user's comments. ${pollTimeoutAdvice}`;
676
+ /**
677
+ * @param root the root of a doc opened from outside its repository
678
+ */
679
+ function waitForCommentsInRootStep(root) {
680
+ return `This doc belongs to the repository at ${root}, so run markdown-review commands from that directory. ` + waitForCommentsStep;
681
+ }
682
+ var approvedStep = "The user approved the review. Carry on with your task; do not run `markdown-review poll` again for this review.";
683
+ /**
684
+ * @param ids the threads that need the agent, at least one
685
+ */
686
+ function addressThreadsStep(ids) {
687
+ return `Edit the docs, then run \`markdown-review resolve <id> "<what changed>"\` for ${formatIdList(ids)}, or \`markdown-review reply <id> "<question>"\` if you need input. Then run \`markdown-review poll\` to wait for the next round.`;
688
+ }
689
+ /**
690
+ * @param ids the threads the user commented on when approving, at least one
691
+ */
692
+ function approvedWithCommentsStep(ids) {
693
+ const resolveCommand = ids.length === 1 ? `\`markdown-review resolve ${ids[0]} "<what changed>"\`` : "`markdown-review resolve <id> \"<what changed>\"` for each";
694
+ return `The user approved the review with comments. Address ${formatIdList(ids)} and run ${resolveCommand}, then carry on with your task; do not run \`markdown-review poll\` again for this review.`;
695
+ }
696
+ //#endregion
697
+ //#region src/cli/formatAgentAction.ts
698
+ /**
699
+ * Confirms an agent reply or resolve and says what is left
700
+ *
701
+ * @param action what the agent did
702
+ * @param response the thread after the action and the inbox that remains
703
+ * @returns the confirmation, the threads still needing the agent, and a `next_step`
704
+ */
705
+ function formatAgentAction(action, { inbox, thread }) {
706
+ const done = action === "resolved" ? `Resolved #${thread.id}.` : `Replied to #${thread.id}; it waits for the user.`;
707
+ const remaining = inbox.threads.map((remainingThread) => remainingThread.id);
708
+ if (remaining.length > 0) return `${done}\n${remaining.length === 1 ? "1 thread still needs you" : `${remaining.length} threads still need you`}: ${formatIdList(remaining)}.\n\nnext_step: Address ${formatIdList(remaining)}, then run \`markdown-review resolve <id> "<what changed>"\`, or \`markdown-review reply <id> "<question>"\` if you need input.
709
+ `;
710
+ return `${done} No threads need you now.\n\nnext_step: ${inbox.review.approved ? approvedStep : "Run `markdown-review poll` to wait for the user's next round of comments."}\n`;
711
+ }
712
+ //#endregion
713
+ //#region src/cli/readMessageBody.ts
714
+ /**
715
+ * Reads a reply or resolve message from the command's words or from `--file`
716
+ *
717
+ * @param words the words after the thread ID
718
+ * @param file the `--file` option: a path, or "-" for standard input
719
+ * @param terminal where standard input comes from
720
+ * @returns the message, or null when neither was given
721
+ */
722
+ async function readMessageBody(words, file, terminal) {
723
+ if (file === "-") return terminal.readStdin();
724
+ if (file !== void 0) return readFile(path.resolve(terminal.workingDirectory, file), "utf8");
725
+ return words.length === 0 ? null : words.join(" ");
726
+ }
727
+ //#endregion
728
+ //#region src/cli/readThreadIdArgument.ts
729
+ /**
730
+ * @param value the command's first word
731
+ * @param usage the command's usage line, for the error
732
+ * @returns the thread ID
733
+ * @throws CliError when the word is not a thread ID
734
+ */
735
+ function readThreadIdArgument(value, usage) {
736
+ if (value === void 0 || !/^#?[1-9][0-9]*$/.test(value)) throw new CliError(`Expected a thread ID such as 14, not ${value ?? "nothing"}`, `Run \`${usage}\`.`, 2);
737
+ return Number(value.replace("#", ""));
738
+ }
739
+ //#endregion
740
+ //#region src/cli/commands/agentThreadCommands.ts
741
+ async function replyCommand(args, { cliPath, terminal }) {
742
+ const { id, message } = await readArguments(args, terminal, "markdown-review reply <id> \"<text>\"");
743
+ if (message === null) throw new CliError("reply needs the text of the reply", "Run `markdown-review reply <id> \"<text>\"`.", 2);
744
+ const client = await connectToServer(await findRoot(terminal.workingDirectory), cliPath);
745
+ const response = await explainUnknownThread(client, id, () => client.reply(id, message));
746
+ terminal.stdout(formatAgentAction("replied", response));
747
+ return 0;
748
+ }
749
+ async function resolveCommand(args, { cliPath, terminal }) {
750
+ const { id, message } = await readArguments(args, terminal, "markdown-review resolve <id> \"<what changed>\"");
751
+ const client = await connectToServer(await findRoot(terminal.workingDirectory), cliPath);
752
+ const response = await explainUnknownThread(client, id, () => client.resolve(id, message));
753
+ terminal.stdout(formatAgentAction("resolved", response));
754
+ return 0;
755
+ }
756
+ /**
757
+ * Reads the thread ID and the message, before anything connects to the server
758
+ */
759
+ async function readArguments(args, terminal, usage) {
760
+ const { positionals, values } = parseArgs({
761
+ allowPositionals: true,
762
+ args,
763
+ options: { file: { type: "string" } }
764
+ });
765
+ const [idWord, ...words] = positionals;
766
+ return {
767
+ id: readThreadIdArgument(idWord, usage),
768
+ message: await readMessageBody(words, values.file, terminal)
769
+ };
770
+ }
771
+ /**
772
+ * Turns the server's "unknown thread" into an error that lists the threads the agent can act on
773
+ */
774
+ async function explainUnknownThread(client, id, action) {
775
+ try {
776
+ return await action();
777
+ } catch (error) {
778
+ if (!(error instanceof ServerRequestError) || error.reason !== "unknown-thread") throw error;
779
+ const waiting = (await client.inbox(null)).threads.map((thread) => thread.id);
780
+ throw new CliError(`No thread #${id} is waiting for you. ${waiting.length === 0 ? "No threads need you." : `The threads that need you are ${formatIdList(waiting)}.`}`, "Run `markdown-review inbox` to see them.");
781
+ }
782
+ }
783
+ //#endregion
784
+ //#region src/cli/formatInbox.ts
785
+ var longQuoteLength = 200;
786
+ var quoteStartLength = 120;
787
+ /**
788
+ * Describes the agent's inbox as compact text for the agent to read
789
+ *
790
+ * @param snapshot the review state and the threads that need the agent
791
+ * @returns the summary, each thread grouped by doc with its messages, any store problems, and a final `next_step`
792
+ */
793
+ function formatInbox({ problems, review, threads }) {
794
+ const ordered = orderByDocument(threads);
795
+ const ids = ordered.map((thread) => thread.id);
796
+ const sections = [summaryLine(review.approved, ordered)];
797
+ if (ordered.length > 0) sections.push(ordered.map(formatThread).join("\n\n"));
798
+ if (problems.length > 0) sections.push(problems.map((problem) => `warning: ${indentContinuation(problem, " ")}`).join("\n"));
799
+ sections.push(`next_step: ${nextStep(review.approved, ids)}`);
800
+ return `${sections.join("\n\n")}\n`;
801
+ }
802
+ function formatThread(thread) {
803
+ const messages = thread.messages.map(({ author, body }) => ` ${author}: ${indentContinuation(body, " ")}`);
804
+ return [...describeAnchor(thread), ...messages].join("\n");
805
+ }
806
+ /**
807
+ * Says where the thread is, and for a passage, the text it quotes now and what the user originally selected
808
+ */
809
+ function describeAnchor({ anchor, id }) {
810
+ switch (anchor.kind) {
811
+ case "review": return [`#${id} (whole review)`];
812
+ case "document": return [`#${id} ${anchor.document} (whole doc)`];
813
+ case "passage": {
814
+ const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
815
+ const location = `#${id} ${anchor.document}:${range}${anchor.outdated ? " (outdated)" : ""}`;
816
+ if (anchor.outdated) return [location, ` quote: ${formatQuote(anchor.quote)}`];
817
+ const original = anchor.anchoredText === anchor.quote ? [] : [` was: ${formatQuote(anchor.quote)}`];
818
+ return [
819
+ location,
820
+ ` quote: ${formatQuote(anchor.anchoredText)}`,
821
+ ...original
822
+ ];
823
+ }
824
+ }
825
+ }
826
+ function summaryLine(approved, threads) {
827
+ if (threads.length === 0) return approved ? "Review approved. No threads need you." : "No threads need you.";
828
+ const counts = /* @__PURE__ */ new Map();
829
+ for (const thread of threads) {
830
+ const group = thread.anchor.kind === "review" ? "review" : thread.anchor.document;
831
+ counts.set(group, (counts.get(group) ?? 0) + 1);
832
+ }
833
+ const breakdown = [...counts].map(([group, count]) => `${group}: ${count}`).join(", ");
834
+ const needs = threads.length === 1 ? "1 thread needs you" : `${threads.length} threads need you`;
835
+ return approved ? `Review approved, ${needs} (${breakdown})` : `${needs} (${breakdown})`;
836
+ }
837
+ function nextStep(approved, ids) {
838
+ if (ids.length === 0) return approved ? approvedStep : waitForCommentsStep;
839
+ return approved ? approvedWithCommentsStep(ids) : addressThreadsStep(ids);
840
+ }
841
+ function orderByDocument(threads) {
842
+ const groupOf = (thread) => thread.anchor.kind === "review" ? "￿" : thread.anchor.document;
843
+ return [...threads].sort((left, right) => groupOf(left).localeCompare(groupOf(right)) || left.id - right.id);
844
+ }
845
+ function formatQuote(text) {
846
+ const shortened = text.length > longQuoteLength ? `${text.slice(0, quoteStartLength)}…${text.slice(-60)}` : text;
847
+ return JSON.stringify(shortened);
848
+ }
849
+ function indentContinuation(text, indent) {
850
+ return text.split("\n").join(`\n${indent}`);
851
+ }
852
+ //#endregion
853
+ //#region src/cli/toRepositoryPath.ts
854
+ /**
855
+ * Turns a path the agent typed into the repo-relative POSIX path the server uses
856
+ *
857
+ * @param root the root's real path
858
+ * @param absolutePath the path, already resolved against the working directory
859
+ * @throws CliError when the path is outside the root
860
+ */
861
+ function toRepositoryPath(root, absolutePath) {
862
+ const relativePath = path.relative(root, absolutePath);
863
+ if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) throw new CliError(`${absolutePath} is not a file inside the repository at ${root}`, "Pass a path to a markdown file inside the repository.");
864
+ return relativePath.split(path.sep).join("/");
865
+ }
866
+ //#endregion
867
+ //#region src/cli/readDocumentOption.ts
868
+ /**
869
+ * Reads a `--document <path>` option, which may be relative to the working directory
870
+ *
871
+ * @param root the root's real path
872
+ * @param workingDirectory the directory the command runs in
873
+ * @param value the option's value, if it was given
874
+ * @returns the doc's repo-relative POSIX path, or null when the option was not given
875
+ */
876
+ async function readDocumentOption(root, workingDirectory, value) {
877
+ return value === void 0 ? null : toRepositoryPath(root, path.resolve(await realpath(workingDirectory), value));
878
+ }
879
+ //#endregion
880
+ //#region src/cli/commands/inboxCommand.ts
881
+ async function inboxCommand(args, { cliPath, terminal }) {
882
+ const { values } = parseArgs({
883
+ args,
884
+ options: { document: { type: "string" } }
885
+ });
886
+ const root = await findRoot(terminal.workingDirectory);
887
+ const document = await readDocumentOption(root, terminal.workingDirectory, values.document);
888
+ const client = await connectToServer(root, cliPath);
889
+ terminal.stdout(formatInbox(await client.inbox(document)));
890
+ return 0;
891
+ }
892
+ //#endregion
893
+ //#region skills/markdown-review/SKILL.md?raw
894
+ var SKILL_default = "---\nname: markdown-review\ndescription: Hands markdown docs you wrote, such as plans, specs and design notes, to the user to review in their browser, then reads their comments back so you can edit the docs and answer each one. Use when the user asks you to have a doc reviewed, or when a doc you wrote needs their sign-off before you carry on.\n---\n\n# Markdown Review\n\nThe user reviews your markdown in a browser and comments on passages, blocks, whole docs or the review as a whole. You read the comments with the `markdown-review` command, edit the docs, and answer each comment.\n\nIf the `markdown-review` command is not found, install it with `npm install --global @krelborn/markdown-review`.\n\n## The review loop\n\n1. Run `markdown-review open <doc.md>`. It starts a review round and shows the doc in the user's browser.\n2. Run `markdown-review poll`. It waits until the user submits, then prints each comment: its thread ID, file, line range, the quoted text and the user's words.\n - Give the shell command a timeout of at least 600000 ms. `poll` gives up after 540 seconds by default; run it again if it says there are no comments yet.\n - In Claude Code, run it with `run_in_background` and `--timeout 7080`, and a Bash timeout of 7200000 ms. A submit still ends it at once.\n3. Edit the docs. Then answer every thread:\n - `markdown-review resolve <id> \"<what changed>\"` when you have addressed it.\n - `markdown-review reply <id> \"<question>\"` when you need the user's input.\n - For a long message, put it in a file and pass `--file <path>`, or pass `--file -` and write it to standard input.\n4. Run `markdown-review poll` again for the next round.\n\n`markdown-review inbox` prints what `poll` would, without waiting, for picking comments up later.\n\n## Approval\n\nThe review ends when the user approves it, and `poll` and `inbox` then start with \"Review approved\". Address any comments that came with the approval, then carry on with your task. Do not run `poll` again for that review.\n\n## Good to know\n\n- A quote is the text as the browser shows it, not the markdown source. The line range is exact, so use it to find the passage.\n- If `poll` is stopped before the user submits, nothing is lost: run it again.\n- Every command ends with a `next_step` line that says what to do next. `markdown-review --help` lists the commands.\n- Run `markdown-review stop` to stop the review server when you have finished.\n";
895
+ //#endregion
896
+ //#region src/cli/commands/installSkillCommand.ts
897
+ /**
898
+ * Writes the skill that teaches agents the review workflow where Claude Code and OpenCode look for skills
899
+ */
900
+ async function installSkillCommand(args, { terminal }) {
901
+ const { values } = parseArgs({
902
+ args,
903
+ options: { global: { type: "boolean" } }
904
+ });
905
+ const base = values.global === true ? terminal.env.HOME ?? os.homedir() : await findRoot(terminal.workingDirectory);
906
+ const skillPath = path.join(base, ".claude", "skills", "markdown-review", "SKILL.md");
907
+ const installed = await readTextFileOrNull(skillPath);
908
+ await mkdir(path.dirname(skillPath), { recursive: true });
909
+ await writeFile(skillPath, SKILL_default);
910
+ const outcome = installed === null ? "Installed" : installed === "---\nname: markdown-review\ndescription: Hands markdown docs you wrote, such as plans, specs and design notes, to the user to review in their browser, then reads their comments back so you can edit the docs and answer each one. Use when the user asks you to have a doc reviewed, or when a doc you wrote needs their sign-off before you carry on.\n---\n\n# Markdown Review\n\nThe user reviews your markdown in a browser and comments on passages, blocks, whole docs or the review as a whole. You read the comments with the `markdown-review` command, edit the docs, and answer each comment.\n\nIf the `markdown-review` command is not found, install it with `npm install --global @krelborn/markdown-review`.\n\n## The review loop\n\n1. Run `markdown-review open <doc.md>`. It starts a review round and shows the doc in the user's browser.\n2. Run `markdown-review poll`. It waits until the user submits, then prints each comment: its thread ID, file, line range, the quoted text and the user's words.\n - Give the shell command a timeout of at least 600000 ms. `poll` gives up after 540 seconds by default; run it again if it says there are no comments yet.\n - In Claude Code, run it with `run_in_background` and `--timeout 7080`, and a Bash timeout of 7200000 ms. A submit still ends it at once.\n3. Edit the docs. Then answer every thread:\n - `markdown-review resolve <id> \"<what changed>\"` when you have addressed it.\n - `markdown-review reply <id> \"<question>\"` when you need the user's input.\n - For a long message, put it in a file and pass `--file <path>`, or pass `--file -` and write it to standard input.\n4. Run `markdown-review poll` again for the next round.\n\n`markdown-review inbox` prints what `poll` would, without waiting, for picking comments up later.\n\n## Approval\n\nThe review ends when the user approves it, and `poll` and `inbox` then start with \"Review approved\". Address any comments that came with the approval, then carry on with your task. Do not run `poll` again for that review.\n\n## Good to know\n\n- A quote is the text as the browser shows it, not the markdown source. The line range is exact, so use it to find the passage.\n- If `poll` is stopped before the user submits, nothing is lost: run it again.\n- Every command ends with a `next_step` line that says what to do next. `markdown-review --help` lists the commands.\n- Run `markdown-review stop` to stop the review server when you have finished.\n" ? "Reinstalled" : "Updated";
911
+ terminal.stdout(`${outcome} the markdown-review skill at ${skillPath}\n\nnext_step: The skill loads when an agent session starts. To start a review now, run \`markdown-review open <doc.md>\`.
912
+ `);
913
+ return 0;
914
+ }
915
+ //#endregion
916
+ //#region src/cli/commands/openCommand.ts
917
+ async function openCommand(args, { cliPath, terminal }) {
918
+ const { positionals } = parseArgs({
919
+ allowPositionals: true,
920
+ args,
921
+ options: {}
922
+ });
923
+ if (positionals.length > 1) throw new CliError("open takes at most one path", "Run `markdown-review open [path]`.", 2);
924
+ const target = positionals[0];
925
+ const workingRoot = await findRoot(terminal.workingDirectory);
926
+ const { document, root } = target === void 0 ? {
927
+ document: null,
928
+ root: workingRoot
929
+ } : await locate(terminal.workingDirectory, target);
930
+ const { navigated, url } = await (await connectToServer(root, cliPath)).open(document);
931
+ const shown = navigated ? "Shown in the browser tab that was already open." : await openUrl(url, terminal.env);
932
+ const nextStep = root === workingRoot ? waitForCommentsStep : waitForCommentsInRootStep(root);
933
+ terminal.stdout(`Opened ${document ?? "the docs list"} for review: ${url}\n${shown}\n\nnext_step: ${nextStep}\n`);
934
+ return 0;
935
+ }
936
+ async function locate(workingDirectory, target) {
937
+ const absolutePath = path.resolve(workingDirectory, target);
938
+ const found = await stat(absolutePath).catch(() => null);
939
+ if (found === null || !found.isFile()) throw new CliError(`${target} is not a file`, "Check the path and run `markdown-review open <path>` again.");
940
+ const root = await findRoot(path.dirname(absolutePath), workingDirectory);
941
+ return {
942
+ document: toRepositoryPath(root, await realpath(absolutePath)),
943
+ root
944
+ };
945
+ }
946
+ async function openUrl(url, env) {
947
+ if (env.MARKDOWN_REVIEW_NO_BROWSER === "1") return "Not opened in a browser because MARKDOWN_REVIEW_NO_BROWSER is set; give the user the URL.";
948
+ try {
949
+ await openInBrowser(url);
950
+ return "Opened in the user's browser.";
951
+ } catch {
952
+ return "Could not open a browser; give the user the URL.";
953
+ }
954
+ }
955
+ //#endregion
956
+ //#region src/cli/commands/pollCommand.ts
957
+ var defaultTimeoutSeconds$1 = 540;
958
+ var interruptedExitCodes = {
959
+ SIGINT: 130,
960
+ SIGTERM: 143
961
+ };
962
+ async function pollCommand(args, { cliPath, terminal }) {
963
+ const { values } = parseArgs({
964
+ args,
965
+ options: {
966
+ document: { type: "string" },
967
+ timeout: { type: "string" }
968
+ }
969
+ });
970
+ const timeoutSeconds = readTimeout(values.timeout);
971
+ const root = await findRoot(terminal.workingDirectory);
972
+ const document = await readDocumentOption(root, terminal.workingDirectory, values.document);
973
+ const client = await connectToServer(root, cliPath);
974
+ const abort = new AbortController();
975
+ let interruptedBy;
976
+ const onSignal = (signal) => {
977
+ interruptedBy = signal;
978
+ abort.abort();
979
+ };
980
+ process.once("SIGINT", onSignal);
981
+ process.once("SIGTERM", onSignal);
982
+ try {
983
+ terminal.stderr(`Waiting up to ${timeoutSeconds}s for review comments. If this is interrupted, run \`markdown-review poll\` again; nothing is lost.\n`);
984
+ const result = await client.poll(document, timeoutSeconds, abort.signal);
985
+ if (result.timedOut && result.threads.length === 0 && !result.review.approved) terminal.stdout(`No comments yet.\n\nnext_step: Run \`markdown-review poll\` again to keep waiting. ${pollTimeoutAdvice}\n`);
986
+ else terminal.stdout(formatInbox(result));
987
+ return 0;
988
+ } catch (error) {
989
+ if (interruptedBy !== void 0) {
990
+ terminal.stderr("Interrupted while waiting. Nothing was lost; run `markdown-review poll` again to keep waiting.\n");
991
+ return interruptedExitCodes[interruptedBy] ?? 1;
992
+ }
993
+ if (error instanceof ServerRequestError) throw error;
994
+ throw new CliError("The review server stopped while you were waiting", "Run `markdown-review poll` again; it starts the server again and nothing was lost.");
995
+ } finally {
996
+ process.off("SIGINT", onSignal);
997
+ process.off("SIGTERM", onSignal);
998
+ }
999
+ }
1000
+ function readTimeout(value) {
1001
+ if (value === void 0) return defaultTimeoutSeconds$1;
1002
+ if (!/^[0-9]+$/.test(value)) throw new CliError(`--timeout must be a whole number of seconds, not ${value}`, "Run `markdown-review poll --timeout 540`.", 2);
1003
+ return Number(value);
1004
+ }
1005
+ //#endregion
1006
+ //#region src/server/runtime/createConsoleLogger.ts
1007
+ /**
1008
+ * Creates the server process's logger; the CLI points the process's output at `.markdown-review/server.log`
1009
+ *
1010
+ * @returns a logger that writes one timestamped line per entry
1011
+ */
1012
+ function createConsoleLogger() {
1013
+ const write = (level, message, error) => {
1014
+ const detail = error === void 0 ? "" : ` ${error instanceof Error ? error.stack ?? error.message : String(error)}`;
1015
+ process.stdout.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${message}${detail}\n`);
1016
+ };
1017
+ return {
1018
+ error: (message, error) => write("error", message, error),
1019
+ info: (message) => write("info", message),
1020
+ warn: (message, error) => write("warn", message, error)
1021
+ };
1022
+ }
1023
+ //#endregion
1024
+ //#region src/server/events/BrowserTabs.ts
1025
+ /**
1026
+ * The browser tabs connected to the server's event stream, in the order they connected
1027
+ */
1028
+ var BrowserTabs = class {
1029
+ tabs = [];
1030
+ /**
1031
+ * @returns a function that disconnects the tab
1032
+ */
1033
+ connect(tab) {
1034
+ this.tabs.push(tab);
1035
+ return () => {
1036
+ const index = this.tabs.indexOf(tab);
1037
+ if (index !== -1) this.tabs.splice(index, 1);
1038
+ };
1039
+ }
1040
+ /**
1041
+ * Shows a page in the most recently connected tab
1042
+ *
1043
+ * @param url the page to show
1044
+ * @returns false when no tab is connected
1045
+ */
1046
+ navigateLatest(url) {
1047
+ const latest = this.tabs.at(-1);
1048
+ latest?.navigate(url);
1049
+ return latest !== void 0;
1050
+ }
1051
+ };
1052
+ //#endregion
1053
+ //#region src/server/events/ServerEvents.ts
1054
+ /**
1055
+ * Carries changes between the parts of the server: a submit wakes waiting polls, and the rest reach connected tabs
1056
+ */
1057
+ var ServerEvents = class {
1058
+ listeners = /* @__PURE__ */ new Set();
1059
+ publish(event) {
1060
+ for (const listener of this.listeners) listener(event);
1061
+ }
1062
+ /**
1063
+ * @returns a function that stops the listener receiving events
1064
+ */
1065
+ subscribe(listener) {
1066
+ this.listeners.add(listener);
1067
+ return () => {
1068
+ this.listeners.delete(listener);
1069
+ };
1070
+ }
1071
+ };
1072
+ //#endregion
1073
+ //#region src/server/files/writeJsonAtomically.ts
1074
+ /**
1075
+ * Writes a value as formatted JSON so readers see either the old file or the new one, never half of it
1076
+ *
1077
+ * @param filePath the file to write; missing parent directories are created
1078
+ * @param value the value to serialise
1079
+ * @param mode the file's permissions, e.g. 0o600 to keep other users out; the platform default when omitted
1080
+ */
1081
+ async function writeJsonAtomically(filePath, value, mode) {
1082
+ const contents = `${JSON.stringify(value, null, 2)}\n`;
1083
+ await mkdir(path.dirname(filePath), { recursive: true });
1084
+ const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
1085
+ await writeFile(temporaryPath, contents, {
1086
+ encoding: "utf8",
1087
+ mode
1088
+ });
1089
+ await rename(temporaryPath, filePath);
1090
+ }
1091
+ //#endregion
1092
+ //#region src/server/store/StoreError.ts
1093
+ /**
1094
+ * A store operation that cannot be carried out; `reason` says why
1095
+ */
1096
+ var StoreError = class extends Error {
1097
+ reason;
1098
+ constructor(reason, message) {
1099
+ super(message);
1100
+ this.name = "StoreError";
1101
+ this.reason = reason;
1102
+ }
1103
+ };
1104
+ //#endregion
1105
+ //#region src/shared/api/apiRequestSchemas.ts
1106
+ var agentOpenRequestSchema = z.strictObject({ path: documentPathSchema.optional() });
1107
+ var messageRequestSchema = z.strictObject({ body: messageBodySchema });
1108
+ var resolveRequestSchema = z.strictObject({ body: messageBodySchema.nullable() });
1109
+ var submitRequestSchema = z.strictObject({ verdict: z.enum(["request-changes", "approve"]) });
1110
+ //#endregion
1111
+ //#region src/server/files/resolveRepositoryPath.ts
1112
+ /**
1113
+ * Finds a file in the repo from its repo-relative path
1114
+ *
1115
+ * @param root the repo root
1116
+ * @param relativePath a repo-relative POSIX path, e.g. "docs/plan.md"
1117
+ * @returns the file's real path when it is inside the repo; "outside" when it, or a symlink on the way, leads out of
1118
+ * the repo; "private" when it, or a symlink on the way, leads into `.markdown-review/`, which holds the server's
1119
+ * token; "missing" when nothing exists there; "invalid" when the path is not repo-relative
1120
+ */
1121
+ async function resolveRepositoryPath(root, relativePath) {
1122
+ if (!isRepositoryRelativePath(relativePath)) return { kind: "invalid" };
1123
+ const joinedPath = path.join(root, ...relativePath.split("/"));
1124
+ if (isInDirectory(joinedPath, storeDirectory(root))) return { kind: "private" };
1125
+ let absolutePath;
1126
+ try {
1127
+ absolutePath = await realpath(joinedPath);
1128
+ } catch (error) {
1129
+ if (isFileNotFound(error) || isNotDirectory(error)) return { kind: "missing" };
1130
+ throw error;
1131
+ }
1132
+ const realRoot = await realpath(root);
1133
+ if (!absolutePath.startsWith(realRoot + path.sep)) return { kind: "outside" };
1134
+ return isInDirectory(absolutePath, storeDirectory(realRoot)) ? { kind: "private" } : {
1135
+ absolutePath,
1136
+ kind: "inside"
1137
+ };
1138
+ }
1139
+ function isInDirectory(filePath, directory) {
1140
+ return filePath === directory || filePath.startsWith(directory + path.sep);
1141
+ }
1142
+ function isNotDirectory(error) {
1143
+ return error instanceof Error && "code" in error && error.code === "ENOTDIR";
1144
+ }
1145
+ //#endregion
1146
+ //#region src/server/store/parseStoreInput.ts
1147
+ /**
1148
+ * Checks a value before the store acts on it or writes it
1149
+ *
1150
+ * @param schema the shape the value must have
1151
+ * @param value the value
1152
+ * @param description what the value is, for the error message, e.g. "The reply"
1153
+ * @returns the validated value
1154
+ * @throws StoreError "invalid-input" when the value does not have that shape
1155
+ */
1156
+ function parseStoreInput(schema, value, description) {
1157
+ const result = schema.safeParse(value);
1158
+ if (!result.success) throw new StoreError("invalid-input", `${description} is not valid:\n${z.prettifyError(result.error)}`);
1159
+ return result.data;
1160
+ }
1161
+ //#endregion
1162
+ //#region src/server/http/HttpError.ts
1163
+ /**
1164
+ * A request the server refuses, with the HTTP status and a stable `reason` the CLI and the browser can act on
1165
+ */
1166
+ var HttpError = class extends Error {
1167
+ reason;
1168
+ status;
1169
+ constructor(status, reason, message) {
1170
+ super(message);
1171
+ this.name = "HttpError";
1172
+ this.reason = reason;
1173
+ this.status = status;
1174
+ }
1175
+ };
1176
+ //#endregion
1177
+ //#region src/server/http/requestInputs.ts
1178
+ /**
1179
+ * Reads and validates a JSON request body
1180
+ *
1181
+ * @throws StoreError "invalid-input" when the body is not JSON or does not have the schema's shape
1182
+ */
1183
+ async function readJsonBody(context, schema) {
1184
+ let body;
1185
+ try {
1186
+ body = await context.req.json();
1187
+ } catch {
1188
+ body = void 0;
1189
+ }
1190
+ return parseStoreInput(schema, body, "The request body");
1191
+ }
1192
+ /**
1193
+ * Reads the `:id` route parameter
1194
+ *
1195
+ * @throws HttpError 400 when it is not a positive whole number
1196
+ */
1197
+ function readThreadId(context) {
1198
+ const id = context.req.param("id") ?? "";
1199
+ if (!/^[1-9][0-9]*$/.test(id)) throw new HttpError(400, "invalid-input", `"${id}" is not a thread ID`);
1200
+ return Number(id);
1201
+ }
1202
+ /**
1203
+ * Checks that a doc path from a request stays inside the repo
1204
+ *
1205
+ * @param root the repo root
1206
+ * @param document a repo-relative POSIX path
1207
+ * @param allowMissing whether a doc that no longer exists is acceptable, as it is when reading its threads
1208
+ * @throws HttpError 400 for a path that is not repo-relative, 403 for one that leads out of the repo or into the
1209
+ * review store, and 404 for a missing doc unless `allowMissing` is set
1210
+ */
1211
+ async function checkDocumentPath(root, document, allowMissing) {
1212
+ switch ((await resolveRepositoryPath(root, document)).kind) {
1213
+ case "invalid": throw new HttpError(400, "invalid-input", `${document} is not a repo-relative path`);
1214
+ case "outside": throw new HttpError(403, "outside-root", `${document} leads outside the repo`);
1215
+ case "private": throw new HttpError(403, "private-file", `${document} is in the review store`);
1216
+ case "missing":
1217
+ if (!allowMissing) throw new HttpError(404, "missing-document", `${document} does not exist`);
1218
+ return;
1219
+ case "inside": return;
1220
+ }
1221
+ }
1222
+ //#endregion
1223
+ //#region src/server/http/agentRoutes.ts
1224
+ var shutdownDelayMilliseconds = 100;
1225
+ function registerAgentRoutes(app, dependencies) {
1226
+ const { events, port, recentDocuments, root, shutdown, store, tabs, watchDocument } = dependencies;
1227
+ const afterAgentAction = async (thread) => {
1228
+ events.publish({ type: "threads-changed" });
1229
+ return {
1230
+ inbox: await store.readInbox(null),
1231
+ thread
1232
+ };
1233
+ };
1234
+ app.post("/api/shutdown", (context) => {
1235
+ setTimeout(shutdown, shutdownDelayMilliseconds);
1236
+ return context.json({ stopping: true });
1237
+ });
1238
+ app.post("/api/agent/open", async (context) => {
1239
+ const { path } = await readJsonBody(context, agentOpenRequestSchema);
1240
+ if (path !== void 0) {
1241
+ await checkDocumentPath(root, path, false);
1242
+ recentDocuments.add(path);
1243
+ watchDocument(path);
1244
+ }
1245
+ const review = await store.requestReview();
1246
+ events.publish({ type: "threads-changed" });
1247
+ const pagePath = path === void 0 ? "" : `document/${path.split("/").map(encodeURIComponent).join("/")}`;
1248
+ const url = `http://127.0.0.1:${port()}/${pagePath}`;
1249
+ const response = {
1250
+ navigated: tabs.navigateLatest(url),
1251
+ review,
1252
+ url
1253
+ };
1254
+ return context.json(response);
1255
+ });
1256
+ app.get("/api/inbox", async (context) => {
1257
+ const document = context.req.query("document");
1258
+ if (document !== void 0) await checkDocumentPath(root, document, true);
1259
+ return context.json(await store.readInbox(document ?? null));
1260
+ });
1261
+ app.post("/api/agent/threads/:id/reply", async (context) => {
1262
+ const { body } = await readJsonBody(context, messageRequestSchema);
1263
+ return context.json(await afterAgentAction(await store.replyAsAgent(readThreadId(context), body)));
1264
+ });
1265
+ app.post("/api/agent/threads/:id/resolve", async (context) => {
1266
+ const { body } = await readJsonBody(context, resolveRequestSchema);
1267
+ return context.json(await afterAgentAction(await store.resolveAsAgent(readThreadId(context), body)));
1268
+ });
1269
+ }
1270
+ //#endregion
1271
+ //#region src/shared/review/newThreadSchema.ts
1272
+ var newPassageAnchorSchema = z.strictObject({
1273
+ document: documentPathSchema,
1274
+ endOffset: z.int().nonnegative(),
1275
+ kind: z.literal("passage"),
1276
+ prefix: z.string().max(32),
1277
+ quote: z.string().min(1),
1278
+ startOffset: z.int().nonnegative(),
1279
+ suffix: z.string().max(32)
1280
+ }).refine((anchor) => anchor.endOffset - anchor.startOffset === anchor.quote.length, { message: "must span exactly its quote" });
1281
+ var newThreadSchema = z.strictObject({
1282
+ anchor: z.discriminatedUnion("kind", [
1283
+ reviewAnchorSchema,
1284
+ documentAnchorSchema,
1285
+ newPassageAnchorSchema
1286
+ ]),
1287
+ body: messageBodySchema,
1288
+ renderedHash: z.string().optional()
1289
+ });
1290
+ //#endregion
1291
+ //#region src/server/store/hashSource.ts
1292
+ /**
1293
+ * Fingerprints a doc's source so the store can tell when anchors were computed against an older version
1294
+ *
1295
+ * @param source the markdown source
1296
+ * @returns the SHA-256 of the source, as 64 lowercase hex digits
1297
+ */
1298
+ function hashSource(source) {
1299
+ return createHash("sha256").update(source, "utf8").digest("hex");
1300
+ }
1301
+ //#endregion
1302
+ //#region src/server/http/browserRoutes.ts
1303
+ function registerBrowserRoutes(app, dependencies) {
1304
+ const { events, recentDocuments, root, store, watchDocument } = dependencies;
1305
+ const threadsChanged = () => events.publish({ type: "threads-changed" });
1306
+ app.get("/api/documents", async (context) => {
1307
+ const { problems, threads } = await store.readThreads(null);
1308
+ const response = {
1309
+ documents: countByDocument(threads),
1310
+ problems,
1311
+ recent: recentDocuments.list()
1312
+ };
1313
+ return context.json(response);
1314
+ });
1315
+ app.get("/api/document", async (context) => {
1316
+ const document = context.req.query("path") ?? "";
1317
+ await checkDocumentPath(root, document, false);
1318
+ const source = await readFile(path.join(root, ...document.split("/")), "utf8");
1319
+ recentDocuments.add(document);
1320
+ watchDocument(document);
1321
+ const response = {
1322
+ hash: hashSource(source),
1323
+ path: document,
1324
+ source
1325
+ };
1326
+ return context.json(response);
1327
+ });
1328
+ app.get("/api/threads", async (context) => {
1329
+ const document = context.req.query("document");
1330
+ if (document === void 0 && context.req.query("all") !== "1") throw new HttpError(400, "invalid-input", "Pass ?document=<path> or ?all=1");
1331
+ if (document !== void 0) {
1332
+ await checkDocumentPath(root, document, true);
1333
+ watchDocument(document);
1334
+ }
1335
+ return context.json(await store.readThreads(document ?? null));
1336
+ });
1337
+ app.post("/api/threads", async (context) => {
1338
+ const newThread = await readJsonBody(context, newThreadSchema);
1339
+ if (newThread.anchor.kind !== "review") {
1340
+ await checkDocumentPath(root, newThread.anchor.document, false);
1341
+ watchDocument(newThread.anchor.document);
1342
+ }
1343
+ const thread = await store.createDraftThread(newThread);
1344
+ threadsChanged();
1345
+ return context.json({ thread }, 201);
1346
+ });
1347
+ app.put("/api/threads/:id/draft", async (context) => {
1348
+ const { body } = await readJsonBody(context, messageRequestSchema);
1349
+ const thread = await store.writeDraft(readThreadId(context), body);
1350
+ threadsChanged();
1351
+ return context.json({ thread });
1352
+ });
1353
+ app.delete("/api/threads/:id/draft", async (context) => {
1354
+ const thread = await store.deleteDraft(readThreadId(context));
1355
+ threadsChanged();
1356
+ return context.json({ thread });
1357
+ });
1358
+ app.post("/api/threads/:id/resolve", async (context) => {
1359
+ const thread = await store.resolveAsUser(readThreadId(context));
1360
+ threadsChanged();
1361
+ return context.json({ thread });
1362
+ });
1363
+ app.post("/api/submit", async (context) => {
1364
+ const { verdict } = await readJsonBody(context, submitRequestSchema);
1365
+ const result = await store.submit(verdict);
1366
+ events.publish({ type: "submitted" });
1367
+ threadsChanged();
1368
+ return context.json(result);
1369
+ });
1370
+ }
1371
+ function countByDocument(threads) {
1372
+ const counts = /* @__PURE__ */ new Map();
1373
+ for (const thread of threads) {
1374
+ if (thread.anchor.kind === "review") continue;
1375
+ const count = counts.get(thread.anchor.document) ?? {
1376
+ draftCount: 0,
1377
+ openCount: 0
1378
+ };
1379
+ counts.set(thread.anchor.document, {
1380
+ draftCount: count.draftCount + (thread.draft === void 0 ? 0 : 1),
1381
+ openCount: count.openCount + (thread.status === "open" ? 1 : 0)
1382
+ });
1383
+ }
1384
+ return [...counts].filter(([, count]) => count.draftCount + count.openCount > 0).map(([document, count]) => ({
1385
+ document,
1386
+ ...count
1387
+ })).sort((left, right) => left.document.localeCompare(right.document));
1388
+ }
1389
+ //#endregion
1390
+ //#region src/server/http/eventRoutes.ts
1391
+ function registerEventRoutes(app, { activity, events, logger, tabs }) {
1392
+ app.get("/api/events", (context) => streamSSE(context, async (output) => {
1393
+ let writes = Promise.resolve();
1394
+ const send = (event, data) => {
1395
+ writes = writes.then(() => output.writeSSE({
1396
+ data: JSON.stringify(data),
1397
+ event
1398
+ })).catch((error) => logger.warn("Could not write to a browser's event stream", error));
1399
+ };
1400
+ const endBrowser = activity.beginBrowser();
1401
+ const unsubscribe = events.subscribe((event) => {
1402
+ if (event.type !== "submitted") {
1403
+ const { type, ...data } = event;
1404
+ send(type, data);
1405
+ }
1406
+ });
1407
+ const disconnect = tabs.connect({ navigate: (url) => send("navigate", { url }) });
1408
+ send("presence", { agentWaiting: activity.agentWaiting });
1409
+ await new Promise((resolve) => output.onAbort(resolve));
1410
+ unsubscribe();
1411
+ disconnect();
1412
+ endBrowser();
1413
+ }));
1414
+ }
1415
+ //#endregion
1416
+ //#region src/server/http/contentTypeFor.ts
1417
+ var contentTypes = {
1418
+ ".gif": "image/gif",
1419
+ ".html": "text/html; charset=utf-8",
1420
+ ".jpeg": "image/jpeg",
1421
+ ".jpg": "image/jpeg",
1422
+ ".json": "application/json",
1423
+ ".md": "text/markdown; charset=utf-8",
1424
+ ".pdf": "application/pdf",
1425
+ ".png": "image/png",
1426
+ ".svg": "image/svg+xml",
1427
+ ".txt": "text/plain; charset=utf-8",
1428
+ ".webp": "image/webp"
1429
+ };
1430
+ /**
1431
+ * Picks the Content-Type for a repo file from its extension
1432
+ *
1433
+ * @param filePath the file's path
1434
+ * @returns the media type, or application/octet-stream for an extension it does not know
1435
+ */
1436
+ function contentTypeFor(filePath) {
1437
+ return contentTypes[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1438
+ }
1439
+ //#endregion
1440
+ //#region src/server/http/fileRoutes.ts
1441
+ function registerFileRoutes(app, { root }) {
1442
+ app.get("/files/*", async (context) => {
1443
+ const relativePath = decodeURIComponent(context.req.path.slice(7));
1444
+ const resolved = await resolveRepositoryPath(root, relativePath);
1445
+ if (resolved.kind === "invalid" || resolved.kind === "outside") throw new HttpError(403, "outside-root", `${relativePath} is not a file in the repo`);
1446
+ if (resolved.kind === "private") throw new HttpError(403, "private-file", `${relativePath} is in the review store`);
1447
+ if (resolved.kind === "missing" || !(await stat(resolved.absolutePath)).isFile()) throw new HttpError(404, "missing-file", `${relativePath} does not exist`);
1448
+ const contents = await readFile(resolved.absolutePath);
1449
+ return context.body(new Uint8Array(contents), 200, {
1450
+ "Content-Security-Policy": "sandbox",
1451
+ "Content-Type": contentTypeFor(relativePath),
1452
+ "X-Content-Type-Options": "nosniff"
1453
+ });
1454
+ });
1455
+ }
1456
+ //#endregion
1457
+ //#region src/server/http/healthRoutes.ts
1458
+ function registerHealthRoutes(app, { root, version }) {
1459
+ app.get("/api/health", (context) => {
1460
+ const health = {
1461
+ name: "markdown-review",
1462
+ pid: process.pid,
1463
+ protocol: 1,
1464
+ root,
1465
+ version
1466
+ };
1467
+ return context.json(health);
1468
+ });
1469
+ }
1470
+ //#endregion
1471
+ //#region src/server/http/waitForInbox.ts
1472
+ /**
1473
+ * Waits until the review is approved or a thread needs the agent
1474
+ *
1475
+ * @returns the inbox as soon as it has something for the agent, or as it stands when the time runs out or the
1476
+ * request is aborted, with `timedOut` set
1477
+ */
1478
+ async function waitForInbox({ document, events, signal, store, timeoutMilliseconds }) {
1479
+ const deadline = Date.now() + timeoutMilliseconds;
1480
+ for (;;) {
1481
+ const nextSubmit = waitForSubmit(events, deadline - Date.now(), signal);
1482
+ const inbox = await store.readInbox(document);
1483
+ if (inbox.review.approved || inbox.threads.length > 0) {
1484
+ nextSubmit.cancel();
1485
+ return {
1486
+ ...inbox,
1487
+ timedOut: false
1488
+ };
1489
+ }
1490
+ if (await nextSubmit.outcome !== "submitted") return {
1491
+ ...inbox,
1492
+ timedOut: true
1493
+ };
1494
+ }
1495
+ }
1496
+ /**
1497
+ * Starts listening for the next submit straight away, so one that happens while the inbox is being read is not missed
1498
+ */
1499
+ function waitForSubmit(events, timeoutMilliseconds, signal) {
1500
+ let finish = () => {};
1501
+ const outcome = new Promise((resolve) => {
1502
+ finish = resolve;
1503
+ });
1504
+ const timer = setTimeout(() => finish("timed-out"), Math.max(0, timeoutMilliseconds));
1505
+ const onAbort = () => finish("aborted");
1506
+ signal.addEventListener("abort", onAbort);
1507
+ const unsubscribe = events.subscribe((event) => {
1508
+ if (event.type === "submitted") finish("submitted");
1509
+ });
1510
+ const cancel = () => {
1511
+ clearTimeout(timer);
1512
+ signal.removeEventListener("abort", onAbort);
1513
+ unsubscribe();
1514
+ };
1515
+ if (signal.aborted) finish("aborted");
1516
+ outcome.then(cancel);
1517
+ return {
1518
+ cancel,
1519
+ outcome
1520
+ };
1521
+ }
1522
+ //#endregion
1523
+ //#region src/server/http/pollRoutes.ts
1524
+ var defaultTimeoutSeconds = 540;
1525
+ var maximumTimeoutSeconds = 86400;
1526
+ function registerPollRoutes(app, dependencies) {
1527
+ const { activity, events, logger, pollKeepAliveMilliseconds, root, store } = dependencies;
1528
+ app.get("/api/poll", async (context) => {
1529
+ const document = context.req.query("document") ?? null;
1530
+ if (document !== null) await checkDocumentPath(root, document, true);
1531
+ const timeoutMilliseconds = readTimeoutSeconds(context.req.query("timeout")) * 1e3;
1532
+ context.header("Content-Type", "application/json");
1533
+ return stream(context, async (output) => {
1534
+ const abort = new AbortController();
1535
+ output.onAbort(() => abort.abort());
1536
+ const endPoll = activity.beginPoll();
1537
+ const keepAlive = setInterval(() => {
1538
+ output.write(" ").catch(() => abort.abort());
1539
+ }, pollKeepAliveMilliseconds);
1540
+ try {
1541
+ const result = await waitForInbox({
1542
+ document,
1543
+ events,
1544
+ signal: abort.signal,
1545
+ store,
1546
+ timeoutMilliseconds
1547
+ });
1548
+ if (!abort.signal.aborted) await output.write(JSON.stringify(result));
1549
+ } catch (error) {
1550
+ logger.error("A poll failed", error);
1551
+ await output.write(JSON.stringify({ error: {
1552
+ message: "The poll failed; see .markdown-review/server.log",
1553
+ reason: "internal"
1554
+ } }));
1555
+ } finally {
1556
+ clearInterval(keepAlive);
1557
+ endPoll();
1558
+ }
1559
+ });
1560
+ });
1561
+ }
1562
+ function readTimeoutSeconds(value) {
1563
+ if (value === void 0) return defaultTimeoutSeconds;
1564
+ const seconds = Number(value);
1565
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > maximumTimeoutSeconds) throw new HttpError(400, "invalid-input", `The timeout must be a whole number of seconds up to ${maximumTimeoutSeconds}`);
1566
+ return seconds;
1567
+ }
1568
+ //#endregion
1569
+ //#region src/server/http/securityMiddleware.ts
1570
+ /**
1571
+ * Refuses requests whose Host header does not name this server, which blocks DNS rebinding
1572
+ *
1573
+ * @param port the port the server listens on
1574
+ */
1575
+ function requireLocalHost(port) {
1576
+ return async (context, next) => {
1577
+ if (![`127.0.0.1:${port()}`, `localhost:${port()}`].includes(context.req.header("host") ?? "")) throw new HttpError(403, "forbidden", "The Host header does not name this server");
1578
+ await next();
1579
+ };
1580
+ }
1581
+ /**
1582
+ * Refuses requests a browser sends from a page on another site, so such a page can neither read the review nor connect
1583
+ * to the event stream as if it were a review tab
1584
+ *
1585
+ * @param port the port the server listens on
1586
+ */
1587
+ function refuseOtherOrigins(port) {
1588
+ return async (context, next) => {
1589
+ const origin = context.req.header("origin");
1590
+ if (origin !== void 0 && !ownOrigins(port()).includes(origin)) throw new HttpError(403, "forbidden", "Requests must come from this server's own pages");
1591
+ await next();
1592
+ };
1593
+ }
1594
+ /**
1595
+ * Lets only the CLI through: the request must carry the server's token and no Origin header, which browsers add
1596
+ *
1597
+ * @param token the secret from `server.json`
1598
+ */
1599
+ function requireAgentToken(token) {
1600
+ return async (context, next) => {
1601
+ if (context.req.header("origin") !== void 0) throw new HttpError(403, "forbidden", "Agent routes do not accept requests from web pages");
1602
+ if (!isExpectedToken(context.req.header("authorization"), token)) throw new HttpError(401, "unauthorized", "Agent routes need the token from .markdown-review/server.json");
1603
+ await next();
1604
+ };
1605
+ }
1606
+ /**
1607
+ * Lets changes through only from this server's own pages, sent as JSON, which an HTML form on another page cannot do
1608
+ *
1609
+ * @param port the port the server listens on
1610
+ */
1611
+ function requireBrowserOrigin(port) {
1612
+ return async (context, next) => {
1613
+ if (context.req.method === "GET" || context.req.method === "HEAD") {
1614
+ await next();
1615
+ return;
1616
+ }
1617
+ if (!ownOrigins(port()).includes(context.req.header("origin") ?? "")) throw new HttpError(403, "forbidden", "Changes must come from this server's own pages");
1618
+ if (!(context.req.header("content-type") ?? "").startsWith("application/json")) throw new HttpError(415, "unsupported-media-type", "Changes must be sent as application/json");
1619
+ await next();
1620
+ };
1621
+ }
1622
+ function ownOrigins(port) {
1623
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
1624
+ }
1625
+ function isExpectedToken(authorization, token) {
1626
+ const expected = Buffer.from(`Bearer ${token}`);
1627
+ const actual = Buffer.from(authorization ?? "");
1628
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
1629
+ }
1630
+ //#endregion
1631
+ //#region src/server/http/shellRoutes.ts
1632
+ var assetContentTypes = {
1633
+ ".css": "text/css; charset=utf-8",
1634
+ ".js": "text/javascript; charset=utf-8"
1635
+ };
1636
+ var contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
1637
+ function registerShellRoutes(app, { webDirectory }) {
1638
+ const serveShell = async (context) => {
1639
+ const html = await readTextFileOrNull(path.join(webDirectory, "index.html"));
1640
+ if (html === null) throw new HttpError(500, "missing-web-app", "This build of markdown-review has no web app");
1641
+ return context.html(html, 200, { "Content-Security-Policy": contentSecurityPolicy });
1642
+ };
1643
+ app.get("/", serveShell);
1644
+ app.get("/document/*", serveShell);
1645
+ app.get("/assets/*", async (context) => {
1646
+ const name = context.req.path.slice(8);
1647
+ if (!isRepositoryRelativePath(name)) throw new HttpError(404, "missing-file", `${name} is not an asset`);
1648
+ let contents;
1649
+ try {
1650
+ contents = await readFile(path.join(webDirectory, "assets", ...name.split("/")));
1651
+ } catch (error) {
1652
+ if (isFileNotFound(error)) throw new HttpError(404, "missing-file", `${name} is not an asset`);
1653
+ throw error;
1654
+ }
1655
+ return context.body(new Uint8Array(contents), 200, {
1656
+ "Content-Type": assetContentTypes[path.extname(name)] ?? "application/octet-stream",
1657
+ "X-Content-Type-Options": "nosniff"
1658
+ });
1659
+ });
1660
+ }
1661
+ //#endregion
1662
+ //#region src/server/http/createApp.ts
1663
+ var statusForStoreError = {
1664
+ "invalid-file": 500,
1665
+ "invalid-input": 400,
1666
+ "invalid-state": 409,
1667
+ "missing-document": 404,
1668
+ "unknown-thread": 404
1669
+ };
1670
+ /**
1671
+ * Builds the server's HTTP app: the agent's API, the browser's API, the event stream, repo files and the app shell
1672
+ *
1673
+ * @param dependencies the store and the server's shared state
1674
+ * @returns the app, ready to be served on 127.0.0.1
1675
+ */
1676
+ function createApp(dependencies) {
1677
+ const { activity, logger, port, token } = dependencies;
1678
+ const app = new Hono();
1679
+ app.use("*", requireLocalHost(port));
1680
+ app.use("*", refuseOtherOrigins(port));
1681
+ app.use("*", async (_context, next) => {
1682
+ activity.touch();
1683
+ await next();
1684
+ });
1685
+ for (const agentPath of [
1686
+ "/api/agent/*",
1687
+ "/api/inbox",
1688
+ "/api/poll",
1689
+ "/api/shutdown"
1690
+ ]) app.use(agentPath, requireAgentToken(token));
1691
+ for (const browserPath of [
1692
+ "/api/threads",
1693
+ "/api/threads/*",
1694
+ "/api/submit"
1695
+ ]) app.use(browserPath, requireBrowserOrigin(port));
1696
+ app.onError((error, context) => {
1697
+ if (error instanceof HttpError) return context.json({ error: {
1698
+ message: error.message,
1699
+ reason: error.reason
1700
+ } }, error.status);
1701
+ if (error instanceof StoreError) return context.json({ error: {
1702
+ message: error.message,
1703
+ reason: error.reason
1704
+ } }, statusForStoreError[error.reason]);
1705
+ logger.error(`${context.req.method} ${context.req.path} failed`, error);
1706
+ return context.json({ error: {
1707
+ message: "The server failed to handle the request; see .markdown-review/server.log",
1708
+ reason: "internal"
1709
+ } }, 500);
1710
+ });
1711
+ registerHealthRoutes(app, dependencies);
1712
+ registerAgentRoutes(app, dependencies);
1713
+ registerPollRoutes(app, dependencies);
1714
+ registerBrowserRoutes(app, dependencies);
1715
+ registerEventRoutes(app, dependencies);
1716
+ registerFileRoutes(app, dependencies);
1717
+ registerShellRoutes(app, dependencies);
1718
+ return app;
1719
+ }
1720
+ //#endregion
1721
+ //#region src/server/http/RecentDocuments.ts
1722
+ var recentLimit = 10;
1723
+ /**
1724
+ * The docs most recently opened by the agent or shown in a browser, newest first
1725
+ */
1726
+ var RecentDocuments = class {
1727
+ documents = [];
1728
+ add(document) {
1729
+ this.documents = [document, ...this.documents.filter((existing) => existing !== document)].slice(0, recentLimit);
1730
+ }
1731
+ list() {
1732
+ return [...this.documents];
1733
+ }
1734
+ };
1735
+ //#endregion
1736
+ //#region src/shared/markdown/fenceLanguage.ts
1737
+ /**
1738
+ * Reads the language of a fenced code block from its info string
1739
+ *
1740
+ * @param info the text after the opening fence, e.g. "ts title=example"
1741
+ * @returns the first word of the info string, or an empty string when there is none
1742
+ */
1743
+ function fenceLanguage(info) {
1744
+ return info.trim().split(/\s+/)[0] ?? "";
1745
+ }
1746
+ //#endregion
1747
+ //#region src/shared/markdown/findLeafBlocks.ts
1748
+ var leafBlockKinds = {
1749
+ code_block: "codeBlock",
1750
+ fence: "fence",
1751
+ heading_open: "inline",
1752
+ html_block: "htmlBlock",
1753
+ paragraph_open: "inline",
1754
+ tr_open: "tableRow"
1755
+ };
1756
+ /**
1757
+ * Finds the leaf blocks of a parsed doc
1758
+ *
1759
+ * @param tokens the token stream markdown-it parsed from `source`
1760
+ * @param source the markdown source
1761
+ * @returns the leaf blocks in document order
1762
+ */
1763
+ function findLeafBlocks(tokens, source) {
1764
+ const sourceLines = source.split(/\r\n?|\n/);
1765
+ const leafBlocks = [];
1766
+ tokens.forEach((token, tokenIndex) => {
1767
+ const kind = leafBlockKinds[token.type];
1768
+ if (kind === void 0 || token.map === null) return;
1769
+ const [lineBegin, lineEnd] = token.map;
1770
+ leafBlocks.push({
1771
+ endLine: lastLineWithText(sourceLines, lineBegin, lineEnd),
1772
+ kind,
1773
+ startLine: lineBegin + 1,
1774
+ token,
1775
+ tokenIndex
1776
+ });
1777
+ });
1778
+ return leafBlocks;
1779
+ }
1780
+ function lastLineWithText(sourceLines, lineBegin, lineEnd) {
1781
+ let endLine = lineEnd;
1782
+ while (endLine > lineBegin + 1 && (sourceLines[endLine - 1] ?? "").trim() === "") endLine -= 1;
1783
+ return endLine;
1784
+ }
1785
+ //#endregion
1786
+ //#region src/shared/markdown/tokenAt.ts
1787
+ /**
1788
+ * Reads a token that markdown-it guarantees is present, such as the token a render rule was called for
1789
+ *
1790
+ * @param tokens the token stream
1791
+ * @param index the token's position in the stream
1792
+ * @returns the token
1793
+ * @throws RangeError when no token is at that position
1794
+ */
1795
+ function tokenAt(tokens, index) {
1796
+ const token = tokens[index];
1797
+ if (token === void 0) throw new RangeError(`No markdown token at index ${index} of ${tokens.length}`);
1798
+ return token;
1799
+ }
1800
+ //#endregion
1801
+ //#region src/shared/markdown/withoutFinalNewline.ts
1802
+ /**
1803
+ * Removes one trailing newline, as markdown-it leaves on the content of code and HTML blocks
1804
+ *
1805
+ * @param content the block content
1806
+ * @returns the content without its final newline
1807
+ */
1808
+ function withoutFinalNewline(content) {
1809
+ return content.endsWith("\n") ? content.slice(0, -1) : content;
1810
+ }
1811
+ //#endregion
1812
+ //#region src/shared/markdown/createMarkdownIt.ts
1813
+ /**
1814
+ * Creates the markdown-it instance the server and the browser share, so both find the same blocks
1815
+ *
1816
+ * @param options how rendering highlights fenced code; parsing does not use it
1817
+ * @returns an instance whose rendered leaf block elements carry `data-md-block`, `data-md-start` and `data-md-end`
1818
+ */
1819
+ function createMarkdownIt({ highlight } = {}) {
1820
+ const markdown = new markdownIt({ html: true }).use(tasklist);
1821
+ const escapeHtml = markdown.utils.escapeHtml;
1822
+ const renderFence = (tokens, index, _options, _environment, renderer) => {
1823
+ const token = tokenAt(tokens, index);
1824
+ const code = withoutFinalNewline(token.content);
1825
+ const language = fenceLanguage(token.info);
1826
+ const languageClass = language === "" ? "" : ` class="language-${escapeHtml(language)}"`;
1827
+ const highlighted = highlight?.(code, language) ?? `<pre><code${languageClass}>${escapeHtml(code)}</code></pre>`;
1828
+ return `<div${renderer.renderAttrs(token)}>${highlighted}</div>\n`;
1829
+ };
1830
+ const renderCodeBlock = (tokens, index, _options, _environment, renderer) => {
1831
+ const token = tokenAt(tokens, index);
1832
+ return `<div${renderer.renderAttrs(token)}><pre><code>${escapeHtml(withoutFinalNewline(token.content))}</code></pre></div>\n`;
1833
+ };
1834
+ markdown.core.ruler.push("tag_leaf_blocks", tagLeafBlocks);
1835
+ markdown.renderer.rules.paragraph_open = renderParagraphOpen;
1836
+ markdown.renderer.rules.paragraph_close = renderParagraphClose;
1837
+ markdown.renderer.rules.fence = renderFence;
1838
+ markdown.renderer.rules.code_block = renderCodeBlock;
1839
+ markdown.renderer.rules.html_block = renderHtmlBlock;
1840
+ return markdown;
1841
+ }
1842
+ function tagLeafBlocks(state) {
1843
+ findLeafBlocks(state.tokens, state.src).forEach(({ endLine, startLine, token }, blockIndex) => {
1844
+ token.attrSet("data-md-block", blockIndex);
1845
+ token.attrSet("data-md-start", startLine);
1846
+ token.attrSet("data-md-end", endLine);
1847
+ });
1848
+ }
1849
+ var renderParagraphOpen = (tokens, index, options, _environment, renderer) => {
1850
+ const token = tokenAt(tokens, index);
1851
+ return token.hidden ? `<span${renderer.renderAttrs(token)}>` : renderer.renderToken(tokens, index, options);
1852
+ };
1853
+ var renderParagraphClose = (tokens, index, options, _environment, renderer) => tokenAt(tokens, index).hidden ? "</span>" : renderer.renderToken(tokens, index, options);
1854
+ var renderHtmlBlock = (tokens, index, _options, _environment, renderer) => {
1855
+ const token = tokenAt(tokens, index);
1856
+ return `<div${renderer.renderAttrs(token)}>${token.content}</div>\n`;
1857
+ };
1858
+ //#endregion
1859
+ //#region src/shared/markdown/hasStructuralHtml.ts
1860
+ var phrasingTagNames = /* @__PURE__ */ new Set([
1861
+ "a",
1862
+ "abbr",
1863
+ "b",
1864
+ "bdi",
1865
+ "bdo",
1866
+ "br",
1867
+ "cite",
1868
+ "code",
1869
+ "data",
1870
+ "del",
1871
+ "dfn",
1872
+ "em",
1873
+ "i",
1874
+ "img",
1875
+ "ins",
1876
+ "kbd",
1877
+ "mark",
1878
+ "q",
1879
+ "s",
1880
+ "samp",
1881
+ "small",
1882
+ "span",
1883
+ "strong",
1884
+ "sub",
1885
+ "sup",
1886
+ "time",
1887
+ "u",
1888
+ "var",
1889
+ "wbr"
1890
+ ]);
1891
+ /**
1892
+ * Tells whether a paragraph or heading holds inline HTML that the browser may move out of the block, or that the
1893
+ * sanitizer may remove with its content, so the rendered text can differ from the canonical text
1894
+ *
1895
+ * @param children the children of the block's inline token
1896
+ * @returns true when an inline HTML tag is not one of the phrasing elements such as `span`, `b` or `a`
1897
+ */
1898
+ function hasStructuralHtml(children) {
1899
+ return children.some((child) => {
1900
+ if (child.type !== "html_inline") return false;
1901
+ const tagName = /^<\/?([a-z][a-z0-9-]*)/i.exec(child.content)?.[1];
1902
+ return tagName !== void 0 && !phrasingTagNames.has(tagName.toLowerCase());
1903
+ });
1904
+ }
1905
+ //#endregion
1906
+ //#region src/shared/markdown/inlineText.ts
1907
+ var textTokenTypes = /* @__PURE__ */ new Set([
1908
+ "text",
1909
+ "text_special",
1910
+ "code_inline"
1911
+ ]);
1912
+ var lineBreakTokenTypes = /* @__PURE__ */ new Set(["softbreak", "hardbreak"]);
1913
+ /**
1914
+ * Reads the canonical text of a paragraph, heading or table cell
1915
+ *
1916
+ * @param children the children of the block's inline token
1917
+ * @returns the text as the browser shows it, with each line break as "\n"; images and HTML tags contribute nothing
1918
+ */
1919
+ function inlineText(children) {
1920
+ let text = "";
1921
+ const lineStartOffsets = [];
1922
+ for (const child of children) if (textTokenTypes.has(child.type)) text += child.content;
1923
+ else if (lineBreakTokenTypes.has(child.type)) {
1924
+ text += "\n";
1925
+ lineStartOffsets.push(text.length);
1926
+ }
1927
+ return {
1928
+ lineStartOffsets,
1929
+ text
1930
+ };
1931
+ }
1932
+ //#endregion
1933
+ //#region src/shared/markdown/parseBlocks.ts
1934
+ var markdown = createMarkdownIt();
1935
+ /**
1936
+ * Splits a doc into the leaf blocks comments anchor to
1937
+ *
1938
+ * @param source the markdown source
1939
+ * @returns the leaf blocks in document order, each with its canonical text; a block with no visible text, such as an
1940
+ * image on its own, takes whole-block comments only and its text is its source
1941
+ */
1942
+ function parseBlocks(source) {
1943
+ const tokens = markdown.parse(source, {});
1944
+ const sourceLines = source.split(/\r\n?|\n/);
1945
+ return findLeafBlocks(tokens, source).map((leafBlock) => {
1946
+ const block = toMarkdownBlock(tokens, leafBlock);
1947
+ return block.text === "" ? toSourceTextBlock(block, sourceLines) : block;
1948
+ });
1949
+ }
1950
+ function toSourceTextBlock({ endLine, startLine }, sourceLines) {
1951
+ const text = sourceLines.slice(startLine - 1, endLine).join("\n");
1952
+ return {
1953
+ endLine,
1954
+ exactLines: true,
1955
+ lineOffsets: codeLineOffsets(text, startLine, endLine),
1956
+ startLine,
1957
+ text,
1958
+ wholeBlockOnly: true
1959
+ };
1960
+ }
1961
+ function toMarkdownBlock(tokens, leafBlock) {
1962
+ const { endLine, kind, startLine, token, tokenIndex } = leafBlock;
1963
+ switch (kind) {
1964
+ case "inline": {
1965
+ const children = tokenAt(tokens, tokenIndex + 1).children ?? [];
1966
+ const { lineStartOffsets, text } = inlineText(children);
1967
+ const lineOffsets = [0, ...lineStartOffsets].map((offset, index) => ({
1968
+ line: startLine + index,
1969
+ offset
1970
+ }));
1971
+ const isSetextHeading = token.markup === "=" || token.markup === "-";
1972
+ const textLineCount = endLine - startLine + (isSetextHeading ? 0 : 1);
1973
+ return {
1974
+ endLine,
1975
+ exactLines: lineOffsets.length === textLineCount,
1976
+ lineOffsets,
1977
+ startLine,
1978
+ text,
1979
+ wholeBlockOnly: hasStructuralHtml(children)
1980
+ };
1981
+ }
1982
+ case "tableRow": {
1983
+ const text = tableRowCells(tokens, tokenIndex).join(" ");
1984
+ return {
1985
+ endLine,
1986
+ exactLines: true,
1987
+ lineOffsets: [{
1988
+ line: startLine,
1989
+ offset: 0
1990
+ }],
1991
+ startLine,
1992
+ text,
1993
+ wholeBlockOnly: false
1994
+ };
1995
+ }
1996
+ case "fence": {
1997
+ const text = withoutFinalNewline(token.content);
1998
+ const wholeBlockOnly = fenceLanguage(token.info) === "mermaid";
1999
+ return {
2000
+ endLine,
2001
+ exactLines: true,
2002
+ lineOffsets: codeLineOffsets(text, startLine + 1, endLine),
2003
+ startLine,
2004
+ text,
2005
+ wholeBlockOnly
2006
+ };
2007
+ }
2008
+ case "codeBlock":
2009
+ case "htmlBlock": {
2010
+ const text = withoutFinalNewline(token.content);
2011
+ const wholeBlockOnly = kind === "htmlBlock";
2012
+ return {
2013
+ endLine,
2014
+ exactLines: true,
2015
+ lineOffsets: codeLineOffsets(text, startLine, endLine),
2016
+ startLine,
2017
+ text,
2018
+ wholeBlockOnly
2019
+ };
2020
+ }
2021
+ }
2022
+ }
2023
+ function tableRowCells(tokens, rowTokenIndex) {
2024
+ const cells = [];
2025
+ for (let index = rowTokenIndex + 1; index < tokens.length; index++) {
2026
+ const token = tokenAt(tokens, index);
2027
+ if (token.type === "tr_close") break;
2028
+ if (token.type === "inline") cells.push(inlineText(token.children ?? []).text);
2029
+ }
2030
+ return cells;
2031
+ }
2032
+ function codeLineOffsets(text, firstLine, lastLine) {
2033
+ if (text === "") return [];
2034
+ const lineOffsets = [{
2035
+ line: firstLine,
2036
+ offset: 0
2037
+ }];
2038
+ for (let offset = text.indexOf("\n"); offset !== -1; offset = text.indexOf("\n", offset + 1)) lineOffsets.push({
2039
+ line: firstLine + lineOffsets.length,
2040
+ offset: offset + 1
2041
+ });
2042
+ return lineOffsets.filter(({ line }) => line <= lastLine);
2043
+ }
2044
+ //#endregion
2045
+ //#region src/shared/markdown/createDocumentText.ts
2046
+ /**
2047
+ * Builds the canonical text of a doc
2048
+ *
2049
+ * @param source the markdown source
2050
+ * @returns the doc's blocks, their joined text and where each block starts in it
2051
+ */
2052
+ function createDocumentText(source) {
2053
+ const blocks = parseBlocks(source);
2054
+ const blockStartOffsets = [];
2055
+ let offset = 0;
2056
+ for (const block of blocks) {
2057
+ blockStartOffsets.push(offset);
2058
+ offset += block.text.length + 1;
2059
+ }
2060
+ return {
2061
+ blockStartOffsets,
2062
+ blocks,
2063
+ text: blocks.map((block) => block.text).join("\n")
2064
+ };
2065
+ }
2066
+ //#endregion
2067
+ //#region src/shared/markdown/blockAtOffset.ts
2068
+ /**
2069
+ * Finds the block holding a character of a doc's canonical text
2070
+ *
2071
+ * @param documentText the doc's canonical text
2072
+ * @param offset a position in the canonical text; the "\n" between two blocks belongs to the earlier block
2073
+ * @returns the block and the position within its text, clamped to the text's length, or null when the doc has no
2074
+ * blocks
2075
+ */
2076
+ function blockAtOffset(documentText, offset) {
2077
+ const { blocks, blockStartOffsets } = documentText;
2078
+ let blockIndex = blocks.length - 1;
2079
+ while (blockIndex > 0 && (blockStartOffsets[blockIndex] ?? 0) > offset) blockIndex -= 1;
2080
+ const block = blocks[blockIndex];
2081
+ if (block === void 0) return null;
2082
+ return {
2083
+ block,
2084
+ offsetInBlock: Math.min(Math.max(0, offset - (blockStartOffsets[blockIndex] ?? 0)), block.text.length)
2085
+ };
2086
+ }
2087
+ //#endregion
2088
+ //#region src/shared/markdown/lineAtOffset.ts
2089
+ /**
2090
+ * Finds the source line holding a character of a doc's canonical text
2091
+ *
2092
+ * @param documentText the doc's canonical text
2093
+ * @param offset a position in the canonical text; the "\n" between two blocks belongs to the earlier block
2094
+ * @returns the 1-based source line, or 1 when the doc has no blocks; never later than the true line
2095
+ */
2096
+ function lineAtOffset(documentText, offset) {
2097
+ const position = blockAtOffset(documentText, offset);
2098
+ if (position === null) return 1;
2099
+ const { block, offsetInBlock } = position;
2100
+ return block.lineOffsets.findLast((lineOffset) => lineOffset.offset <= offsetInBlock)?.line ?? block.startLine;
2101
+ }
2102
+ //#endregion
2103
+ //#region src/shared/markdown/linesForRange.ts
2104
+ /**
2105
+ * Finds the source lines holding a range of a doc's canonical text
2106
+ *
2107
+ * @param documentText the doc's canonical text
2108
+ * @param startOffset where the range starts
2109
+ * @param endOffset where the range ends, exclusive
2110
+ * @returns the lines holding the first and last characters of the range; when a block's lines are not exact, the range
2111
+ * runs to the end of that block, so it always contains the text
2112
+ */
2113
+ function linesForRange(documentText, startOffset, endOffset) {
2114
+ const lastOffset = Math.max(startOffset, endOffset - 1);
2115
+ const lastBlock = blockAtOffset(documentText, lastOffset)?.block;
2116
+ return {
2117
+ endLine: lastBlock?.exactLines === false ? lastBlock.endLine : lineAtOffset(documentText, lastOffset),
2118
+ startLine: lineAtOffset(documentText, startOffset)
2119
+ };
2120
+ }
2121
+ //#endregion
2122
+ //#region src/server/anchoring/reanchorPassage.ts
2123
+ var maxErrorRatio = .2;
2124
+ /**
2125
+ * Moves a passage anchor to where its text is in the current version of its doc
2126
+ *
2127
+ * @param anchor the anchor as last computed
2128
+ * @param documentText the canonical text of the doc as it is now
2129
+ * @returns the anchor on the best exact or approximate match, or the anchor marked outdated, with its old position
2130
+ * clamped to the doc, when nothing matches
2131
+ */
2132
+ function reanchorPassage(anchor, documentText) {
2133
+ const candidates = exactCandidates(anchor.anchoredText, documentText.text);
2134
+ const best = chooseCandidate(candidates.length > 0 ? candidates : approximateCandidates(anchor.anchoredText, documentText.text), anchor, documentText.text);
2135
+ if (best === void 0) return {
2136
+ ...anchor,
2137
+ endOffset: Math.min(anchor.endOffset, documentText.text.length),
2138
+ outdated: true,
2139
+ startOffset: Math.min(anchor.startOffset, documentText.text.length)
2140
+ };
2141
+ const { end, start } = best;
2142
+ return {
2143
+ ...anchor,
2144
+ ...linesForRange(documentText, start, end),
2145
+ anchoredText: documentText.text.slice(start, end),
2146
+ endOffset: end,
2147
+ outdated: false,
2148
+ prefix: documentText.text.slice(Math.max(0, start - 32), start),
2149
+ startOffset: start,
2150
+ suffix: documentText.text.slice(end, end + 32)
2151
+ };
2152
+ }
2153
+ function exactCandidates(pattern, text) {
2154
+ if (pattern === "") return [];
2155
+ const candidates = [];
2156
+ for (let start = text.indexOf(pattern); start !== -1; start = text.indexOf(pattern, start + 1)) candidates.push({
2157
+ end: start + pattern.length,
2158
+ errors: 0,
2159
+ start
2160
+ });
2161
+ return candidates;
2162
+ }
2163
+ function approximateCandidates(pattern, text) {
2164
+ const maxErrors = Math.floor(maxErrorRatio * pattern.length);
2165
+ return maxErrors === 0 ? [] : search(text, pattern, maxErrors);
2166
+ }
2167
+ function chooseCandidate(candidates, anchor, text) {
2168
+ const ranked = candidates.map((candidate) => ({
2169
+ candidate,
2170
+ contextScore: contextScore(candidate, anchor, text),
2171
+ distance: Math.abs(candidate.start - anchor.startOffset)
2172
+ }));
2173
+ ranked.sort((left, right) => left.candidate.errors - right.candidate.errors || right.contextScore - left.contextScore || left.distance - right.distance);
2174
+ return ranked[0]?.candidate;
2175
+ }
2176
+ function contextScore({ end, start }, { prefix, suffix }, text) {
2177
+ const textBefore = text.slice(Math.max(0, start - prefix.length), start);
2178
+ const textAfter = text.slice(end, end + suffix.length);
2179
+ return commonSuffixLength(prefix, textBefore) + commonPrefixLength(suffix, textAfter);
2180
+ }
2181
+ function commonPrefixLength(left, right) {
2182
+ let length = 0;
2183
+ while (length < left.length && left[length] === right[length]) length += 1;
2184
+ return length;
2185
+ }
2186
+ function commonSuffixLength(left, right) {
2187
+ let length = 0;
2188
+ while (length < left.length && left[left.length - 1 - length] === right[right.length - 1 - length]) length += 1;
2189
+ return length;
2190
+ }
2191
+ //#endregion
2192
+ //#region src/server/anchoring/anchorNewPassage.ts
2193
+ /**
2194
+ * Anchors a passage the user has just selected
2195
+ *
2196
+ * @param newAnchor the selection, as offsets and text in the doc the browser rendered
2197
+ * @param source the doc's current source
2198
+ * @param renderedHash the hash of the source the browser rendered, if it sent one
2199
+ * @returns the anchor at the selected offsets when the browser rendered the current source, otherwise the anchor
2200
+ * re-anchored against the current source
2201
+ */
2202
+ function anchorNewPassage(newAnchor, source, renderedHash) {
2203
+ const documentText = createDocumentText(source);
2204
+ const { endOffset, quote, startOffset } = newAnchor;
2205
+ const anchor = {
2206
+ ...newAnchor,
2207
+ ...linesForRange(documentText, startOffset, endOffset),
2208
+ anchoredText: quote,
2209
+ outdated: false
2210
+ };
2211
+ return renderedHash === hashSource(source) && documentText.text.slice(startOffset, endOffset) === quote ? anchor : reanchorPassage(anchor, documentText);
2212
+ }
2213
+ //#endregion
2214
+ //#region src/server/anchoring/reanchorDocumentThreads.ts
2215
+ /**
2216
+ * Brings the passage threads of a doc up to date with its source
2217
+ *
2218
+ * @param threads the doc's threads, of any status
2219
+ * @param source the doc's current source, or null when the doc no longer exists
2220
+ * @returns the threads with every passage anchor moved to its text, or marked outdated; other threads unchanged
2221
+ */
2222
+ function reanchorDocumentThreads(threads, source) {
2223
+ const documentText = source === null ? null : createDocumentText(source);
2224
+ return threads.map((thread) => {
2225
+ if (thread.anchor.kind !== "passage") return thread;
2226
+ const anchor = documentText === null ? {
2227
+ ...thread.anchor,
2228
+ outdated: true
2229
+ } : reanchorPassage(thread.anchor, documentText);
2230
+ return {
2231
+ ...thread,
2232
+ anchor
2233
+ };
2234
+ });
2235
+ }
2236
+ //#endregion
2237
+ //#region src/server/store/emptyReviewFile.ts
2238
+ var emptyReviewFile = {
2239
+ approvedAt: null,
2240
+ requestedAt: null,
2241
+ threads: [],
2242
+ version: 1
2243
+ };
2244
+ //#endregion
2245
+ //#region src/server/store/needsAgent.ts
2246
+ /**
2247
+ * Tells whether a thread is waiting for the agent
2248
+ *
2249
+ * @param thread the thread
2250
+ * @returns true when the thread is open and the user had the last word
2251
+ */
2252
+ function needsAgent(thread) {
2253
+ return thread.status === "open" && thread.messages.at(-1)?.author === "user";
2254
+ }
2255
+ //#endregion
2256
+ //#region src/server/store/OperationQueue.ts
2257
+ /**
2258
+ * Serialises asynchronous operations so they run one at a time in call order
2259
+ */
2260
+ var OperationQueue = class {
2261
+ queue = Promise.resolve();
2262
+ /**
2263
+ * Runs a task after all previous tasks in this queue have settled
2264
+ *
2265
+ * @param task the asynchronous operation to run
2266
+ * @returns a promise that resolves to the task's result
2267
+ */
2268
+ enqueue(task) {
2269
+ const result = this.queue.then(task);
2270
+ this.queue = result.then(() => void 0, () => void 0);
2271
+ return result;
2272
+ }
2273
+ };
2274
+ //#endregion
2275
+ //#region src/server/store/renumberDuplicateThreads.ts
2276
+ /**
2277
+ * Gives every thread a unique ID after a merge has brought in threads that share one
2278
+ *
2279
+ * @param files the threads of every store file
2280
+ * @returns the files with the later-created thread of each clash moved to the next free ID, and what was moved
2281
+ */
2282
+ function renumberDuplicateThreads(files) {
2283
+ const entries = files.flatMap((file, fileIndex) => file.threads.map((thread) => ({
2284
+ fileIndex,
2285
+ thread
2286
+ })));
2287
+ entries.sort((left, right) => left.thread.id - right.thread.id || Date.parse(left.thread.createdAt) - Date.parse(right.thread.createdAt) || left.fileIndex - right.fileIndex);
2288
+ let nextId = Math.max(0, ...entries.map(({ thread }) => thread.id)) + 1;
2289
+ const newIds = /* @__PURE__ */ new Map();
2290
+ entries.forEach(({ thread }, index) => {
2291
+ if (entries[index - 1]?.thread.id === thread.id) {
2292
+ newIds.set(thread, nextId);
2293
+ nextId += 1;
2294
+ }
2295
+ });
2296
+ const renumberings = [];
2297
+ return {
2298
+ files: files.map((file) => ({
2299
+ ...file,
2300
+ threads: file.threads.map((thread) => {
2301
+ const to = newIds.get(thread);
2302
+ if (to === void 0) return thread;
2303
+ renumberings.push({
2304
+ document: file.document,
2305
+ from: thread.id,
2306
+ to
2307
+ });
2308
+ return {
2309
+ ...thread,
2310
+ id: to
2311
+ };
2312
+ })
2313
+ })),
2314
+ renumberings
2315
+ };
2316
+ }
2317
+ //#endregion
2318
+ //#region src/shared/review/documentThreadsFileSchema.ts
2319
+ var documentThreadsFileSchema = z.strictObject({
2320
+ document: documentPathSchema,
2321
+ sourceHash: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
2322
+ threads: z.array(threadSchema),
2323
+ version: z.literal(1)
2324
+ }).refine((file) => file.threads.every((thread) => thread.anchor.kind !== "review" && thread.anchor.document === file.document), { message: "must hold only threads anchored to its own doc" });
2325
+ //#endregion
2326
+ //#region src/shared/review/reviewFileSchema.ts
2327
+ var reviewFileSchema = z.strictObject({
2328
+ approvedAt: z.iso.datetime().nullable(),
2329
+ requestedAt: z.iso.datetime().nullable(),
2330
+ threads: z.array(threadSchema),
2331
+ version: z.literal(1)
2332
+ }).refine((file) => file.threads.every((thread) => thread.anchor.kind === "review"), { message: "must hold only threads on the whole review" });
2333
+ //#endregion
2334
+ //#region src/server/files/writeTextFileUnlessExists.ts
2335
+ /**
2336
+ * Creates a UTF-8 text file, leaving any file already at that path alone
2337
+ *
2338
+ * @param filePath the file to create
2339
+ * @param contents the text to write
2340
+ * @returns true when the file was created; false when a file already existed, so nothing was written
2341
+ * @throws the file system error for any other failure
2342
+ */
2343
+ async function writeTextFileUnlessExists(filePath, contents) {
2344
+ try {
2345
+ await writeFile(filePath, contents, {
2346
+ encoding: "utf8",
2347
+ flag: "wx"
2348
+ });
2349
+ return true;
2350
+ } catch (error) {
2351
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
2352
+ throw error;
2353
+ }
2354
+ }
2355
+ //#endregion
2356
+ //#region src/server/store/StoreFiles.ts
2357
+ /**
2358
+ * Reads and writes the files of one root's `.markdown-review/` directory and the docs they refer to
2359
+ */
2360
+ var StoreFiles = class {
2361
+ root;
2362
+ constructor(root) {
2363
+ this.root = root;
2364
+ }
2365
+ /**
2366
+ * Creates the store directory, and a `.gitignore` that keeps it out of git unless the user has written their own
2367
+ */
2368
+ async ensureStoreDirectory() {
2369
+ await mkdir(storeDirectory(this.root), { recursive: true });
2370
+ await writeTextFileUnlessExists(path.join(storeDirectory(this.root), ".gitignore"), "*\n");
2371
+ }
2372
+ /**
2373
+ * @returns the review file, or an empty one when it does not exist yet
2374
+ */
2375
+ async readReviewFile() {
2376
+ const result = await readStoreFile(reviewFilePath(this.root), reviewFileSchema);
2377
+ return result.kind === "valid" ? {
2378
+ kind: "valid",
2379
+ value: result.value ?? emptyReviewFile
2380
+ } : result;
2381
+ }
2382
+ /**
2383
+ * @throws StoreError "invalid-input" when the file would not pass validation when read back
2384
+ */
2385
+ async writeReviewFile(file) {
2386
+ await writeJsonAtomically(reviewFilePath(this.root), parseStoreInput(reviewFileSchema, file, "The review file"));
2387
+ }
2388
+ /**
2389
+ * @returns the repo-relative paths of the docs that have a threads file
2390
+ */
2391
+ async listDocuments() {
2392
+ let entries;
2393
+ try {
2394
+ entries = await readdir(documentsDirectory(this.root), { recursive: true });
2395
+ } catch (error) {
2396
+ if (isFileNotFound(error)) return [];
2397
+ throw error;
2398
+ }
2399
+ return entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -5).split(path.sep).join("/")).sort();
2400
+ }
2401
+ /**
2402
+ * @param document a repo-relative POSIX path
2403
+ * @returns the doc's threads file as stored, or null when the doc has none
2404
+ */
2405
+ readDocumentFile(document) {
2406
+ return readStoreFile(documentThreadsFilePath(this.root, document), documentThreadsFileSchema);
2407
+ }
2408
+ /**
2409
+ * @throws StoreError "invalid-input" when the file would not pass validation when read back
2410
+ */
2411
+ async writeDocumentFile(file) {
2412
+ const valid = parseStoreInput(documentThreadsFileSchema, file, `The threads file for ${file.document}`);
2413
+ await writeJsonAtomically(documentThreadsFilePath(this.root, valid.document), valid);
2414
+ }
2415
+ /**
2416
+ * @param document a repo-relative POSIX path
2417
+ * @returns the doc's markdown source, or null when it does not exist
2418
+ */
2419
+ readDocumentSource(document) {
2420
+ return readTextFileOrNull(documentSourcePath(this.root, document));
2421
+ }
2422
+ };
2423
+ //#endregion
2424
+ //#region src/server/store/threadTransitions.ts
2425
+ /**
2426
+ * Starts a thread holding the user's comment as a draft
2427
+ *
2428
+ * @param id the new thread's ID
2429
+ * @param anchor what the comment is on
2430
+ * @param body the comment
2431
+ * @param at the current time, ISO 8601
2432
+ * @returns the draft thread
2433
+ */
2434
+ function createDraftThread(id, anchor, body, at) {
2435
+ return {
2436
+ anchor,
2437
+ createdAt: at,
2438
+ draft: {
2439
+ at,
2440
+ body
2441
+ },
2442
+ id,
2443
+ messages: [],
2444
+ status: "draft",
2445
+ updatedAt: at
2446
+ };
2447
+ }
2448
+ /**
2449
+ * Sets the user's unsubmitted text: the comment on a draft thread, otherwise a reply
2450
+ *
2451
+ * @param thread the thread
2452
+ * @param body the text
2453
+ * @param at the current time, ISO 8601
2454
+ * @returns the thread with its draft replaced
2455
+ */
2456
+ function writeDraft(thread, body, at) {
2457
+ return {
2458
+ ...thread,
2459
+ draft: {
2460
+ at,
2461
+ body
2462
+ },
2463
+ updatedAt: at
2464
+ };
2465
+ }
2466
+ /**
2467
+ * Discards the user's unsubmitted text
2468
+ *
2469
+ * @param thread the thread
2470
+ * @param at the current time, ISO 8601
2471
+ * @returns the thread without its draft, or null when it was a draft thread, which no longer exists
2472
+ * @throws StoreError "invalid-state" when the thread has no draft
2473
+ */
2474
+ function deleteDraft(thread, at) {
2475
+ if (thread.draft === void 0) throw new StoreError("invalid-state", `Thread #${thread.id} has no draft to delete`);
2476
+ if (thread.status === "draft") return null;
2477
+ return {
2478
+ ...withoutDraft(thread),
2479
+ updatedAt: at
2480
+ };
2481
+ }
2482
+ /**
2483
+ * Resolves a thread at the user's request; a pending draft reply is kept
2484
+ *
2485
+ * @param thread the thread
2486
+ * @param at the current time, ISO 8601
2487
+ * @returns the resolved thread, unchanged when it was already resolved
2488
+ * @throws StoreError "invalid-state" when the thread is a draft
2489
+ */
2490
+ function resolveAsUser(thread, at) {
2491
+ if (thread.status === "draft") throw new StoreError("invalid-state", `Thread #${thread.id} is a draft; delete it instead of resolving it`);
2492
+ if (thread.status === "resolved") return thread;
2493
+ return {
2494
+ ...thread,
2495
+ status: "resolved",
2496
+ updatedAt: at
2497
+ };
2498
+ }
2499
+ /**
2500
+ * Turns the user's draft into a submitted message, opening the thread
2501
+ *
2502
+ * @param thread the thread
2503
+ * @param at the submit time, ISO 8601, which the message is stamped with
2504
+ * @returns the open thread with the draft appended as a user message, or the thread unchanged when it has no draft
2505
+ */
2506
+ function submitDraft(thread, at) {
2507
+ if (thread.draft === void 0) return thread;
2508
+ return {
2509
+ ...withoutDraft(thread),
2510
+ messages: [...thread.messages, {
2511
+ at,
2512
+ author: "user",
2513
+ body: thread.draft.body
2514
+ }],
2515
+ status: "open",
2516
+ updatedAt: at
2517
+ };
2518
+ }
2519
+ /**
2520
+ * Adds the agent's answer to a thread without changing its status
2521
+ *
2522
+ * @param thread a thread that is not a draft
2523
+ * @param body the answer
2524
+ * @param at the current time, ISO 8601
2525
+ * @returns the thread with the agent message appended
2526
+ */
2527
+ function replyAsAgent(thread, body, at) {
2528
+ return {
2529
+ ...thread,
2530
+ messages: [...thread.messages, {
2531
+ at,
2532
+ author: "agent",
2533
+ body
2534
+ }],
2535
+ updatedAt: at
2536
+ };
2537
+ }
2538
+ /**
2539
+ * Resolves a thread at the agent's request
2540
+ *
2541
+ * @param thread a thread that is not a draft
2542
+ * @param body what the agent changed, or null to resolve without a message
2543
+ * @param at the current time, ISO 8601
2544
+ * @returns the resolved thread
2545
+ */
2546
+ function resolveAsAgent(thread, body, at) {
2547
+ return {
2548
+ ...body === null ? thread : replyAsAgent(thread, body, at),
2549
+ status: "resolved",
2550
+ updatedAt: at
2551
+ };
2552
+ }
2553
+ function withoutDraft(thread) {
2554
+ const next = { ...thread };
2555
+ delete next.draft;
2556
+ return next;
2557
+ }
2558
+ //#endregion
2559
+ //#region src/server/store/toReviewState.ts
2560
+ /**
2561
+ * Works out whether the current review round is approved
2562
+ *
2563
+ * @param reviewFile the review's stored timestamps
2564
+ * @returns the timestamps and whether the approval came after the latest request
2565
+ */
2566
+ function toReviewState({ approvedAt, requestedAt }) {
2567
+ return {
2568
+ approved: approvedAt !== null && (requestedAt === null || Date.parse(approvedAt) > Date.parse(requestedAt)),
2569
+ approvedAt,
2570
+ requestedAt
2571
+ };
2572
+ }
2573
+ //#endregion
2574
+ //#region src/server/store/ReviewStore.ts
2575
+ /**
2576
+ * Holds one root's review threads and carries out every change to them, one at a time
2577
+ */
2578
+ var ReviewStore = class {
2579
+ files;
2580
+ logger;
2581
+ queue = new OperationQueue();
2582
+ /**
2583
+ * @param root the repo root whose `.markdown-review/` directory holds the threads
2584
+ * @param logger where store files that cannot be read, and repairs to the store, are reported
2585
+ */
2586
+ constructor(root, logger) {
2587
+ this.files = new StoreFiles(root);
2588
+ this.logger = logger;
2589
+ }
2590
+ /**
2591
+ * Creates the store directory if needed and gives threads that share an ID, after a merge, unique IDs
2592
+ */
2593
+ initialize() {
2594
+ return this.queue.enqueue(async () => {
2595
+ await this.files.ensureStoreDirectory();
2596
+ const { problems } = await this.readAllStoredThreads();
2597
+ problems.forEach((problem) => this.logger.warn(problem));
2598
+ });
2599
+ }
2600
+ /**
2601
+ * Starts a review round, so an earlier approval no longer counts
2602
+ *
2603
+ * @returns the review state after the request
2604
+ */
2605
+ requestReview() {
2606
+ return this.queue.enqueue(async () => {
2607
+ const next = {
2608
+ ...requireValid(await this.files.readReviewFile()),
2609
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString()
2610
+ };
2611
+ await this.files.writeReviewFile(next);
2612
+ return toReviewState(next);
2613
+ });
2614
+ }
2615
+ /**
2616
+ * Saves a new comment as a draft thread
2617
+ *
2618
+ * @param newThread the comment and what it is on
2619
+ * @returns the draft thread
2620
+ * @throws StoreError "invalid-input" when the comment is malformed, or "missing-document" when its doc does not exist
2621
+ */
2622
+ createDraftThread(newThread) {
2623
+ return this.queue.enqueue(async () => {
2624
+ const { anchor, body, renderedHash } = parseStoreInput(newThreadSchema, newThread, "The new thread");
2625
+ const at = (/* @__PURE__ */ new Date()).toISOString();
2626
+ const id = await this.nextThreadId();
2627
+ if (anchor.kind === "review") {
2628
+ const review = requireValid(await this.files.readReviewFile());
2629
+ const thread = createDraftThread(id, anchor, body, at);
2630
+ await this.files.writeReviewFile({
2631
+ ...review,
2632
+ threads: [...review.threads, thread]
2633
+ });
2634
+ return thread;
2635
+ }
2636
+ const { file, source } = await this.readCurrentDocumentFile(anchor.document);
2637
+ if (source === null) throw new StoreError("missing-document", `${anchor.document} does not exist`);
2638
+ const thread = createDraftThread(id, anchor.kind === "document" ? anchor : anchorNewPassage(anchor, source, renderedHash), body, at);
2639
+ await this.files.writeDocumentFile({
2640
+ ...file,
2641
+ threads: [...file.threads, thread]
2642
+ });
2643
+ return thread;
2644
+ });
2645
+ }
2646
+ /**
2647
+ * Sets the user's unsubmitted comment or reply on a thread
2648
+ *
2649
+ * @throws StoreError "unknown-thread" when no thread has the ID, or "invalid-input" when the text is blank
2650
+ */
2651
+ writeDraft(id, body) {
2652
+ return this.changeThread(id, "user", (thread, at) => writeDraft(thread, parseStoreInput(messageBodySchema, body, "The draft"), at));
2653
+ }
2654
+ /**
2655
+ * Discards the user's unsubmitted comment or reply on a thread
2656
+ *
2657
+ * @returns the thread without its draft, or null when it was a draft thread and has been deleted
2658
+ * @throws StoreError "unknown-thread", or "invalid-state" when the thread has no draft
2659
+ */
2660
+ deleteDraft(id) {
2661
+ return this.changeThread(id, "user", deleteDraft);
2662
+ }
2663
+ /**
2664
+ * @throws StoreError "unknown-thread", or "invalid-state" when the thread is a draft
2665
+ */
2666
+ resolveAsUser(id) {
2667
+ return this.changeThread(id, "user", resolveAsUser);
2668
+ }
2669
+ /**
2670
+ * Submits every draft in the repo and records the user's verdict on the review round
2671
+ *
2672
+ * @param verdict "approve" ends the round; "request-changes" needs at least one draft
2673
+ * @returns the IDs of the threads that received a submitted message, and the review state after the submit
2674
+ * @throws StoreError "invalid-state" when requesting changes with no drafts
2675
+ */
2676
+ submit(verdict) {
2677
+ return this.queue.enqueue(async () => {
2678
+ const at = (/* @__PURE__ */ new Date()).toISOString();
2679
+ const review = requireValid(await this.files.readReviewFile());
2680
+ const documentFiles = await this.readDocumentFilesWithDrafts();
2681
+ const submittedThreadIds = [...review.threads, ...documentFiles.flatMap((file) => file.threads)].filter((thread) => thread.draft !== void 0).map((thread) => thread.id).sort((left, right) => left - right);
2682
+ if (verdict === "request-changes" && submittedThreadIds.length === 0) throw new StoreError("invalid-state", "There are no drafts to submit");
2683
+ for (const file of documentFiles) await this.files.writeDocumentFile({
2684
+ ...file,
2685
+ threads: file.threads.map((thread) => submitDraft(thread, at))
2686
+ });
2687
+ const next = {
2688
+ ...review,
2689
+ approvedAt: verdict === "approve" ? at : null,
2690
+ threads: review.threads.map((thread) => submitDraft(thread, at))
2691
+ };
2692
+ await this.files.writeReviewFile(next);
2693
+ return {
2694
+ review: toReviewState(next),
2695
+ submittedThreadIds
2696
+ };
2697
+ });
2698
+ }
2699
+ /**
2700
+ * @throws StoreError "unknown-thread" when no thread has the ID or the thread is a draft, or "invalid-input" when
2701
+ * the reply is blank
2702
+ */
2703
+ replyAsAgent(id, body) {
2704
+ return this.changeThread(id, "agent", (thread, at) => replyAsAgent(thread, parseStoreInput(messageBodySchema, body, "The reply"), at));
2705
+ }
2706
+ /**
2707
+ * @param body what the agent changed, or null to resolve without a message
2708
+ * @throws StoreError "unknown-thread" when no thread has the ID or the thread is a draft, or "invalid-input" when
2709
+ * the message is blank
2710
+ */
2711
+ resolveAsAgent(id, body) {
2712
+ return this.changeThread(id, "agent", (thread, at) => resolveAsAgent(thread, body === null ? null : parseStoreInput(messageBodySchema, body, "The message"), at));
2713
+ }
2714
+ /**
2715
+ * Reads threads for the browser, first re-anchoring any doc that changed since its threads were last anchored
2716
+ *
2717
+ * @param document the doc whose threads to read, alongside the review's, or null for every thread
2718
+ * @returns the review state, the threads in ID order, and any store files that could not be read
2719
+ * @throws StoreError "invalid-input" when the doc path is not a repo-relative POSIX path
2720
+ */
2721
+ readThreads(document) {
2722
+ return this.queue.enqueue(() => this.readSnapshot(document));
2723
+ }
2724
+ /**
2725
+ * Reads what the agent has to act on, first re-anchoring any doc that changed since its threads were last anchored
2726
+ *
2727
+ * @param document the doc to limit the threads to, or null for every thread including the review's
2728
+ * @returns the review state and the threads that need the agent
2729
+ * @throws StoreError "invalid-input" when the doc path is not a repo-relative POSIX path
2730
+ */
2731
+ readInbox(document) {
2732
+ return this.queue.enqueue(async () => {
2733
+ const snapshot = await this.readSnapshot(document);
2734
+ const threads = snapshot.threads.filter((thread) => needsAgent(thread) && (document === null || thread.anchor.kind !== "review"));
2735
+ return {
2736
+ ...snapshot,
2737
+ threads
2738
+ };
2739
+ });
2740
+ }
2741
+ async readSnapshot(document) {
2742
+ const documents = document === null ? await this.files.listDocuments() : [parseStoreInput(documentPathSchema, document, "The doc path")];
2743
+ await this.readAllStoredThreads();
2744
+ const problems = [];
2745
+ const review = await this.readReviewFileOrReport(problems);
2746
+ const threads = [...review.threads];
2747
+ for (const name of documents) threads.push(...await this.readDocumentThreadsOrReport(name, problems));
2748
+ return {
2749
+ problems,
2750
+ review: toReviewState(review),
2751
+ threads: threads.sort((left, right) => left.id - right.id)
2752
+ };
2753
+ }
2754
+ async readReviewFileOrReport(problems) {
2755
+ const result = await this.files.readReviewFile();
2756
+ if (result.kind === "invalid") {
2757
+ problems.push(result.problem);
2758
+ return emptyReviewFile;
2759
+ }
2760
+ return result.value;
2761
+ }
2762
+ async readDocumentThreadsOrReport(document, problems) {
2763
+ try {
2764
+ return (await this.readCurrentDocumentFile(document)).file.threads;
2765
+ } catch (error) {
2766
+ if (error instanceof StoreError && error.reason === "invalid-file") {
2767
+ problems.push(error.message);
2768
+ return [];
2769
+ }
2770
+ throw error;
2771
+ }
2772
+ }
2773
+ changeThread(id, actor, change) {
2774
+ return this.queue.enqueue(async () => {
2775
+ const document = await this.locateThread(id);
2776
+ const threads = document === null ? requireValid(await this.files.readReviewFile()).threads : (await this.readCurrentDocumentFile(document)).file.threads;
2777
+ const thread = threads.find((candidate) => candidate.id === id);
2778
+ if (thread === void 0 || actor === "agent" && thread.status === "draft") throw new StoreError("unknown-thread", `No thread #${id}`);
2779
+ const changed = change(thread, (/* @__PURE__ */ new Date()).toISOString());
2780
+ await this.replaceThreads(document, replaceThread(threads, id, changed));
2781
+ return changed;
2782
+ });
2783
+ }
2784
+ /**
2785
+ * @returns the doc whose threads file holds the thread, or null when `review.json` does
2786
+ * @throws StoreError "unknown-thread" when no readable store file holds it
2787
+ */
2788
+ async locateThread(id) {
2789
+ const { files } = await this.readAllStoredThreads();
2790
+ const file = files.find((candidate) => candidate.threads.some((thread) => thread.id === id));
2791
+ if (file === void 0) throw new StoreError("unknown-thread", `No thread #${id}`);
2792
+ return file.document;
2793
+ }
2794
+ async nextThreadId() {
2795
+ const { files } = await this.readAllStoredThreads();
2796
+ return Math.max(0, ...files.flatMap((file) => file.threads.map((thread) => thread.id))) + 1;
2797
+ }
2798
+ /**
2799
+ * Reads the threads of every readable store file, first giving threads that share an ID, after a merge or a pull,
2800
+ * unique IDs, so an ID always names one thread
2801
+ */
2802
+ async readAllStoredThreads() {
2803
+ const { files, problems } = await this.readStoredThreadFiles();
2804
+ const { files: renumbered, renumberings } = renumberDuplicateThreads(files);
2805
+ const changedDocuments = new Set(renumberings.map(({ document }) => document));
2806
+ for (const { document, threads } of renumbered) if (changedDocuments.has(document)) await this.replaceThreads(document, threads);
2807
+ for (const { document, from, to } of renumberings) this.logger.info(`Thread #${from} in ${document ?? "the review"} was renumbered to #${to} because its ID was taken`);
2808
+ return {
2809
+ files: renumbered,
2810
+ problems
2811
+ };
2812
+ }
2813
+ async readStoredThreadFiles() {
2814
+ const files = [];
2815
+ const problems = [];
2816
+ const review = await this.files.readReviewFile();
2817
+ if (review.kind === "valid") files.push({
2818
+ document: null,
2819
+ threads: review.value.threads
2820
+ });
2821
+ else problems.push(review.problem);
2822
+ for (const document of await this.files.listDocuments()) {
2823
+ const result = await this.files.readDocumentFile(document);
2824
+ if (result.kind === "invalid") problems.push(result.problem);
2825
+ else if (result.value !== null) files.push({
2826
+ document,
2827
+ threads: result.value.threads
2828
+ });
2829
+ }
2830
+ return {
2831
+ files,
2832
+ problems
2833
+ };
2834
+ }
2835
+ async readDocumentFilesWithDrafts() {
2836
+ const files = [];
2837
+ for (const document of await this.files.listDocuments()) {
2838
+ const result = await this.files.readDocumentFile(document);
2839
+ if (result.kind === "invalid") this.logger.warn(`Drafts in ${document} were not submitted. ${result.problem}`);
2840
+ else if (result.value?.threads.some((thread) => thread.draft !== void 0)) files.push((await this.readCurrentDocumentFile(document)).file);
2841
+ }
2842
+ return files;
2843
+ }
2844
+ /**
2845
+ * Reads a doc's threads file, first re-anchoring its passage threads and saving them when the doc has changed since
2846
+ * they were last anchored
2847
+ */
2848
+ async readCurrentDocumentFile(document) {
2849
+ const stored = requireValid(await this.files.readDocumentFile(document));
2850
+ if (stored !== null && stored.document !== document) throw new StoreError("invalid-input", `${document} has its threads stored as ${stored.document}; use that name`);
2851
+ const source = await this.files.readDocumentSource(document);
2852
+ const sourceHash = source === null ? null : hashSource(source);
2853
+ if (stored === null) return {
2854
+ file: {
2855
+ document,
2856
+ sourceHash,
2857
+ threads: [],
2858
+ version: 1
2859
+ },
2860
+ source
2861
+ };
2862
+ if (stored.sourceHash === sourceHash) return {
2863
+ file: stored,
2864
+ source
2865
+ };
2866
+ const file = {
2867
+ ...stored,
2868
+ sourceHash,
2869
+ threads: reanchorDocumentThreads(stored.threads, source)
2870
+ };
2871
+ await this.files.writeDocumentFile(file);
2872
+ return {
2873
+ file,
2874
+ source
2875
+ };
2876
+ }
2877
+ async replaceThreads(document, threads) {
2878
+ if (document === null) {
2879
+ const review = requireValid(await this.files.readReviewFile());
2880
+ await this.files.writeReviewFile({
2881
+ ...review,
2882
+ threads
2883
+ });
2884
+ return;
2885
+ }
2886
+ const file = requireValid(await this.files.readDocumentFile(document));
2887
+ if (file === null) throw new StoreError("invalid-state", `${document} has no threads file to update`);
2888
+ await this.files.writeDocumentFile({
2889
+ ...file,
2890
+ threads
2891
+ });
2892
+ }
2893
+ };
2894
+ function replaceThread(threads, id, replacement) {
2895
+ return threads.flatMap((thread) => {
2896
+ if (thread.id !== id) return [thread];
2897
+ return replacement === null ? [] : [replacement];
2898
+ });
2899
+ }
2900
+ function requireValid(result) {
2901
+ if (result.kind === "invalid") throw new StoreError("invalid-file", result.problem);
2902
+ return result.value;
2903
+ }
2904
+ //#endregion
2905
+ //#region src/server/runtime/ActivityMonitor.ts
2906
+ /**
2907
+ * Tracks connected tabs and waiting polls, reports whether the agent is waiting, and notices when the server is idle
2908
+ */
2909
+ var ActivityMonitor = class {
2910
+ browsers = 0;
2911
+ events;
2912
+ idleMilliseconds;
2913
+ idleTimer;
2914
+ onIdle;
2915
+ polls = 0;
2916
+ constructor({ events, idleMilliseconds, onIdle }) {
2917
+ this.events = events;
2918
+ this.idleMilliseconds = idleMilliseconds;
2919
+ this.onIdle = onIdle;
2920
+ this.restartIdleTimer();
2921
+ }
2922
+ get agentWaiting() {
2923
+ return this.polls > 0;
2924
+ }
2925
+ /**
2926
+ * Records a request, which restarts the idle countdown
2927
+ */
2928
+ touch() {
2929
+ this.restartIdleTimer();
2930
+ }
2931
+ /**
2932
+ * @returns a function to call when the tab disconnects
2933
+ */
2934
+ beginBrowser() {
2935
+ this.browsers += 1;
2936
+ this.restartIdleTimer();
2937
+ return this.once(() => {
2938
+ this.browsers -= 1;
2939
+ this.restartIdleTimer();
2940
+ });
2941
+ }
2942
+ /**
2943
+ * @returns a function to call when the poll ends
2944
+ */
2945
+ beginPoll() {
2946
+ this.polls += 1;
2947
+ this.restartIdleTimer();
2948
+ if (this.polls === 1) this.events.publish({
2949
+ agentWaiting: true,
2950
+ type: "presence"
2951
+ });
2952
+ return this.once(() => {
2953
+ this.polls -= 1;
2954
+ this.restartIdleTimer();
2955
+ if (this.polls === 0) this.events.publish({
2956
+ agentWaiting: false,
2957
+ type: "presence"
2958
+ });
2959
+ });
2960
+ }
2961
+ stop() {
2962
+ clearTimeout(this.idleTimer);
2963
+ }
2964
+ restartIdleTimer() {
2965
+ clearTimeout(this.idleTimer);
2966
+ this.idleTimer = this.browsers === 0 && this.polls === 0 ? setTimeout(this.onIdle, this.idleMilliseconds).unref() : void 0;
2967
+ }
2968
+ once(end) {
2969
+ let ended = false;
2970
+ return () => {
2971
+ if (!ended) {
2972
+ ended = true;
2973
+ end();
2974
+ }
2975
+ };
2976
+ }
2977
+ };
2978
+ //#endregion
2979
+ //#region src/server/runtime/DocumentWatcher.ts
2980
+ /**
2981
+ * Watches docs for edits, so connected tabs can be told as soon as the agent saves one
2982
+ */
2983
+ var DocumentWatcher = class {
2984
+ documents = /* @__PURE__ */ new Map();
2985
+ root;
2986
+ watcher;
2987
+ /**
2988
+ * @param root the repo root
2989
+ * @param onChange called with a doc's repo-relative path when it is written, created or deleted
2990
+ */
2991
+ constructor(root, onChange) {
2992
+ this.root = root;
2993
+ this.watcher = watch([], {
2994
+ atomic: true,
2995
+ awaitWriteFinish: {
2996
+ pollInterval: 20,
2997
+ stabilityThreshold: 50
2998
+ },
2999
+ ignoreInitial: true
3000
+ });
3001
+ this.watcher.on("all", (_event, changedPath) => {
3002
+ const document = this.documents.get(changedPath);
3003
+ if (document !== void 0) onChange(document);
3004
+ });
3005
+ }
3006
+ /**
3007
+ * @param document a repo-relative POSIX path; watching a doc twice has no further effect
3008
+ */
3009
+ watch(document) {
3010
+ const absolutePath = path.join(this.root, ...document.split("/"));
3011
+ if (!this.documents.has(absolutePath)) {
3012
+ this.documents.set(absolutePath, document);
3013
+ this.watcher.add(absolutePath);
3014
+ }
3015
+ }
3016
+ close() {
3017
+ return this.watcher.close();
3018
+ }
3019
+ };
3020
+ //#endregion
3021
+ //#region src/server/runtime/preferredPort.ts
3022
+ var firstDynamicPort = 49152;
3023
+ var dynamicPortCount = 16384;
3024
+ /**
3025
+ * Picks the port a root's server tries first, so a restarted server usually comes back on the same port
3026
+ *
3027
+ * @param root the repo root
3028
+ * @returns a port in the dynamic range 49152-65535, the same for the same root
3029
+ */
3030
+ function preferredPort(root) {
3031
+ return firstDynamicPort + createHash("sha256").update(root, "utf8").digest().readUInt32BE(0) % dynamicPortCount;
3032
+ }
3033
+ //#endregion
3034
+ //#region src/server/runtime/runServer.ts
3035
+ /**
3036
+ * Starts the root's review server on 127.0.0.1 and records it in `.markdown-review/server.json`
3037
+ *
3038
+ * @returns the running server; it removes `server.json` when it stops
3039
+ */
3040
+ async function runServer({ idleMilliseconds = 18e5, logger, pollKeepAliveMilliseconds = 15e3, root, webDirectory }) {
3041
+ const store = new ReviewStore(root, logger);
3042
+ await store.initialize();
3043
+ const events = new ServerEvents();
3044
+ let port = 0;
3045
+ let stopping;
3046
+ let markClosed = () => {};
3047
+ const closed = new Promise((resolve) => {
3048
+ markClosed = resolve;
3049
+ });
3050
+ const close = () => {
3051
+ stopping ??= stop();
3052
+ return stopping;
3053
+ };
3054
+ const closeInBackground = () => {
3055
+ close().catch((error) => logger.error("The server did not stop cleanly", error));
3056
+ };
3057
+ const activity = new ActivityMonitor({
3058
+ events,
3059
+ idleMilliseconds,
3060
+ onIdle: closeInBackground
3061
+ });
3062
+ const watcher = new DocumentWatcher(root, (document) => {
3063
+ store.readThreads(document).then(() => {
3064
+ events.publish({
3065
+ document,
3066
+ type: "document-changed"
3067
+ });
3068
+ events.publish({ type: "threads-changed" });
3069
+ }).catch((error) => logger.warn(`Could not re-anchor ${document} after it changed`, error));
3070
+ });
3071
+ const token = randomBytes(32).toString("hex");
3072
+ const app = createApp({
3073
+ activity,
3074
+ events,
3075
+ logger,
3076
+ pollKeepAliveMilliseconds,
3077
+ port: () => port,
3078
+ recentDocuments: new RecentDocuments(),
3079
+ root,
3080
+ shutdown: closeInBackground,
3081
+ store,
3082
+ tabs: new BrowserTabs(),
3083
+ token,
3084
+ version: packageVersion,
3085
+ watchDocument: (document) => watcher.watch(document),
3086
+ webDirectory
3087
+ });
3088
+ const server = createServer(getRequestListener(app.fetch));
3089
+ port = await listen(server, preferredPort(root));
3090
+ const serverFile = {
3091
+ pid: process.pid,
3092
+ port,
3093
+ protocol: 1,
3094
+ root,
3095
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3096
+ token,
3097
+ version: packageVersion
3098
+ };
3099
+ await writeJsonAtomically(serverFilePath(root), serverFile, 384);
3100
+ for (const thread of (await store.readThreads(null)).threads) if (thread.anchor.kind !== "review") watcher.watch(thread.anchor.document);
3101
+ logger.info(`Serving ${root} on http://127.0.0.1:${port}`);
3102
+ async function stop() {
3103
+ activity.stop();
3104
+ await watcher.close();
3105
+ await closeServer(server);
3106
+ await removeServerFile(root);
3107
+ logger.info("Stopped");
3108
+ markClosed();
3109
+ }
3110
+ return {
3111
+ close,
3112
+ closed,
3113
+ port
3114
+ };
3115
+ }
3116
+ async function listen(server, port) {
3117
+ try {
3118
+ return await listenOn(server, port);
3119
+ } catch (error) {
3120
+ if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") return listenOn(server, 0);
3121
+ throw error;
3122
+ }
3123
+ }
3124
+ function listenOn(server, port) {
3125
+ return new Promise((resolve, reject) => {
3126
+ const onError = (error) => {
3127
+ server.off("listening", onListening);
3128
+ reject(error);
3129
+ };
3130
+ const onListening = () => {
3131
+ server.off("error", onError);
3132
+ const address = server.address();
3133
+ if (address === null || typeof address === "string") {
3134
+ reject(/* @__PURE__ */ new TypeError("The server is not listening on a TCP port"));
3135
+ return;
3136
+ }
3137
+ resolve(address.port);
3138
+ };
3139
+ server.once("error", onError);
3140
+ server.once("listening", onListening);
3141
+ server.listen(port, "127.0.0.1");
3142
+ });
3143
+ }
3144
+ function closeServer(server) {
3145
+ return new Promise((resolve, reject) => {
3146
+ server.close((error) => error === void 0 ? resolve() : reject(error));
3147
+ server.closeAllConnections();
3148
+ });
3149
+ }
3150
+ /**
3151
+ * Removes `server.json` unless a newer server has already replaced it
3152
+ */
3153
+ async function removeServerFile(root) {
3154
+ const contents = await readTextFileOrNull(serverFilePath(root));
3155
+ if (contents === null) return;
3156
+ let recorded;
3157
+ try {
3158
+ recorded = JSON.parse(contents);
3159
+ } catch {
3160
+ return;
3161
+ }
3162
+ if (serverFileSchema.safeParse(recorded).data?.pid === process.pid) await rm(serverFilePath(root), { force: true });
3163
+ }
3164
+ //#endregion
3165
+ //#region src/cli/commands/serveCommand.ts
3166
+ /**
3167
+ * Runs the review server in this process until it stops; the CLI starts it detached as `serve --root <root>`
3168
+ */
3169
+ async function serveCommand(args, { cliPath }) {
3170
+ const { values } = parseArgs({
3171
+ args,
3172
+ options: { root: { type: "string" } }
3173
+ });
3174
+ if (values.root === void 0) throw new CliError("serve needs --root <path>", "Run `markdown-review serve --root <repository root>`.", 2);
3175
+ const server = await runServer({
3176
+ logger: createConsoleLogger(),
3177
+ root: values.root,
3178
+ webDirectory: path.join(path.dirname(cliPath), "web")
3179
+ });
3180
+ const stop = () => {
3181
+ server.close().catch((error) => process.stderr.write(`The server did not stop cleanly: ${String(error)}\n`));
3182
+ };
3183
+ process.once("SIGINT", stop);
3184
+ process.once("SIGTERM", stop);
3185
+ await server.closed;
3186
+ return 0;
3187
+ }
3188
+ //#endregion
3189
+ //#region src/cli/commands/stopCommand.ts
3190
+ async function stopCommand(args, { terminal }) {
3191
+ parseArgs({
3192
+ args,
3193
+ options: {}
3194
+ });
3195
+ const root = await findRoot(terminal.workingDirectory);
3196
+ const found = await findServer(root);
3197
+ if (found.kind !== "ready" && found.kind !== "older") {
3198
+ terminal.stdout(`No review server is running for ${root} that this markdown-review can stop.\n\nnext_step: None.\n`);
3199
+ return 0;
3200
+ }
3201
+ await found.client.shutdown();
3202
+ await waitUntilStopped(found.client);
3203
+ terminal.stdout(`Stopped the review server for ${root}.\n\nnext_step: None; any markdown-review command starts it again.\n`);
3204
+ return 0;
3205
+ }
3206
+ //#endregion
3207
+ //#region src/cli/helpText.ts
3208
+ var commandHelp = {
3209
+ inbox: ["markdown-review inbox [--document <path>]", " Print whether the review is approved and the threads that need you, then return at once."].join("\n"),
3210
+ "install-skill": [
3211
+ "markdown-review install-skill [--global]",
3212
+ " Install the skill that teaches agents this workflow in this repository's .claude/skills, or with --global in",
3213
+ " ~/.claude/skills. Claude Code and OpenCode both read skills from there."
3214
+ ].join("\n"),
3215
+ open: ["markdown-review open [path]", " Start a review round and show the doc (or the docs list) in the user's browser. Prints the URL."].join("\n"),
3216
+ poll: [
3217
+ "markdown-review poll [--document <path>] [--timeout <seconds>]",
3218
+ " Wait until the user submits or approves, then print what needs you. The timeout defaults to 540 seconds.",
3219
+ " Give the shell command a timeout of at least 600000 ms, or in Claude Code run it with run_in_background",
3220
+ " and --timeout 7080."
3221
+ ].join("\n"),
3222
+ reply: [
3223
+ "markdown-review reply <id> <text>",
3224
+ "markdown-review reply <id> --file <path> (--file - reads standard input)",
3225
+ " Answer a thread without resolving it, for example to ask the user a question."
3226
+ ].join("\n"),
3227
+ resolve: [
3228
+ "markdown-review resolve <id> [text]",
3229
+ "markdown-review resolve <id> --file <path> (--file - reads standard input)",
3230
+ " Resolve a thread, optionally saying what changed."
3231
+ ].join("\n"),
3232
+ stop: ["markdown-review stop", " Stop the review server for this repository."].join("\n")
3233
+ };
3234
+ /**
3235
+ * @param command a command name, or undefined for every command
3236
+ * @returns usage text for the command, or for the whole CLI
3237
+ */
3238
+ function helpText(command) {
3239
+ const named = command === void 0 ? void 0 : commandHelp[command];
3240
+ if (named !== void 0) return `${named}\n`;
3241
+ return [
3242
+ "Markdown Review: the user reviews your markdown in a browser; you read and answer their comments here.",
3243
+ "",
3244
+ ...Object.values(commandHelp),
3245
+ "",
3246
+ "Run `markdown-review <command> --help` for one command. Every command ends with a next_step line.",
3247
+ ""
3248
+ ].join("\n");
3249
+ }
3250
+ //#endregion
3251
+ //#region src/cli/runCli.ts
3252
+ var commands = {
3253
+ inbox: inboxCommand,
3254
+ "install-skill": installSkillCommand,
3255
+ open: openCommand,
3256
+ poll: pollCommand,
3257
+ reply: replyCommand,
3258
+ resolve: resolveCommand,
3259
+ serve: serveCommand,
3260
+ stop: stopCommand
3261
+ };
3262
+ /**
3263
+ * Runs one CLI invocation
3264
+ *
3265
+ * @param commandLine the arguments after the program name
3266
+ * @param context where to write output and how to start the server
3267
+ * @returns the process exit code: 0 on success, 1 when the command failed, 2 for a usage error
3268
+ */
3269
+ async function runCli(commandLine, context) {
3270
+ const { terminal } = context;
3271
+ const [name, ...args] = commandLine;
3272
+ if (name === "--version") {
3273
+ terminal.stdout(`${packageVersion}\n`);
3274
+ return 0;
3275
+ }
3276
+ if (name === "--help" || name === "-h" || name === "help") {
3277
+ terminal.stdout(helpText());
3278
+ return 0;
3279
+ }
3280
+ if (name === void 0) {
3281
+ terminal.stderr(`error: no command given\n\n${helpText()}\nnext_step: Run one of the commands above.\n`);
3282
+ return 2;
3283
+ }
3284
+ const command = commands[name];
3285
+ if (command === void 0) {
3286
+ terminal.stderr(`error: unknown command ${name}\n\n${helpText()}\nnext_step: Run one of the commands above.\n`);
3287
+ return 2;
3288
+ }
3289
+ if (args.includes("--help") || args.includes("-h")) {
3290
+ terminal.stdout(helpText(name));
3291
+ return 0;
3292
+ }
3293
+ try {
3294
+ return await command(args, context);
3295
+ } catch (error) {
3296
+ return reportFailure(error, name, context);
3297
+ }
3298
+ }
3299
+ function reportFailure(error, name, { terminal }) {
3300
+ if (error instanceof CliError) {
3301
+ terminal.stderr(`error: ${error.message}\n\nnext_step: ${error.nextStep}\n`);
3302
+ return error.exitCode;
3303
+ }
3304
+ if (error instanceof ServerRequestError) {
3305
+ terminal.stderr(`error: the review server refused the request: ${error.message}\n\nnext_step: Fix what it reports and run the command again.\n`);
3306
+ return 1;
3307
+ }
3308
+ if (error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS")) {
3309
+ terminal.stderr(`error: ${error.message}\n\nnext_step: Run \`markdown-review ${name} --help\`.\n`);
3310
+ return 2;
3311
+ }
3312
+ terminal.stderr(`error: ${error instanceof Error ? error.message : String(error)}\n\nnext_step: Run the command again; if it keeps failing, read .markdown-review/server.log.\n`);
3313
+ return 1;
3314
+ }
3315
+ //#endregion
3316
+ //#region src/cli/main.ts
3317
+ process.exitCode = await runCli(process.argv.slice(2), {
3318
+ cliPath: fileURLToPath(import.meta.url),
3319
+ terminal: createProcessTerminal()
3320
+ });
3321
+ //#endregion
3322
+ export {};