@tequila99/githuman 0.2.2 → 0.3.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 (248) hide show
  1. package/README.md +27 -13
  2. package/dist/cli/commands/export.js +4 -3
  3. package/dist/cli/index.js +7 -4
  4. package/dist/cli/server-runtime.js +13 -1
  5. package/dist/cli/startup-message.js +3 -2
  6. package/dist/server/adapters/agents/event-mapper.js +140 -0
  7. package/dist/server/app.js +27 -14
  8. package/dist/server/config/agents.js +141 -0
  9. package/dist/server/errors/agents.js +12 -0
  10. package/dist/server/errors/comments.js +2 -0
  11. package/dist/server/errors/export.js +2 -0
  12. package/dist/server/errors/http.js +4 -0
  13. package/dist/server/errors/reviews.js +3 -0
  14. package/dist/server/hooks/agents/local-origin.js +33 -0
  15. package/dist/server/repositories/review.repo.js +1 -3
  16. package/dist/server/routes/agent.js +135 -0
  17. package/dist/server/routes/comments.js +25 -72
  18. package/dist/server/routes/diff.js +5 -6
  19. package/dist/server/routes/events.js +9 -3
  20. package/dist/server/routes/export.js +7 -16
  21. package/dist/server/routes/git.js +12 -21
  22. package/dist/server/routes/reviews.js +11 -43
  23. package/dist/server/services/agent/agent-session.js +497 -0
  24. package/dist/server/services/agent/context-builder.js +137 -0
  25. package/dist/server/services/agent/file-index.js +58 -0
  26. package/dist/server/services/agent/session-registry.js +100 -0
  27. package/dist/server/services/comment.service.js +1 -2
  28. package/dist/server/services/diff.service.js +2 -1
  29. package/dist/server/services/export.service.js +208 -28
  30. package/dist/server/services/git.service.js +9 -7
  31. package/dist/server/services/review.service.js +3 -3
  32. package/dist/server/utils/executable.js +26 -0
  33. package/dist/server/utils/file-name.js +6 -0
  34. package/dist/server/utils/sse.js +11 -0
  35. package/dist/server/utils/text.js +29 -0
  36. package/dist/shared/agents/chat-names.js +17 -0
  37. package/dist/shared/agents/constants.js +6 -0
  38. package/dist/shared/agents/context-format.js +17 -0
  39. package/dist/shared/agents/file-search.js +48 -0
  40. package/dist/shared/agents/schemas.js +51 -0
  41. package/dist/shared/agents/types.js +11 -0
  42. package/dist/shared/app/types.js +1 -0
  43. package/dist/shared/comments/types.js +1 -0
  44. package/dist/shared/events/types.js +14 -0
  45. package/dist/shared/git/types.js +1 -0
  46. package/dist/shared/http/types.js +1 -0
  47. package/dist/shared/network/loopback.js +4 -0
  48. package/dist/shared/reviews/types.js +1 -0
  49. package/dist/shared/utils/error-message.js +3 -0
  50. package/dist/shared/utils/guards.js +4 -0
  51. package/dist/web/assets/AppPage-CSb8txs5.js +1 -0
  52. package/dist/web/assets/CreateReviewFabButton-B18DLuH6.js +1 -0
  53. package/dist/web/assets/CreateReviewFabButton-Cufd8hVJ.css +1 -0
  54. package/dist/web/assets/{ErrorNotFound-DMYc7a3H.js → ErrorNotFound-B5uYICL4.js} +1 -1
  55. package/dist/web/assets/IndexPage-B-hkrnvt.css +1 -0
  56. package/dist/web/assets/IndexPage-Cm_ssBeC.js +1 -0
  57. package/dist/web/assets/MainLayout-ChxXEt_Q.css +1 -0
  58. package/dist/web/assets/MainLayout-ClBPF545.js +7 -0
  59. package/dist/web/assets/QBtn-E32fUOKx.js +1 -0
  60. package/dist/web/assets/QItemLabel-Co6oCEUr.js +1 -0
  61. package/dist/web/assets/QMenu-BZ1eK_rS.js +1 -0
  62. package/dist/web/assets/QSelect-D1V4tbDr.js +1 -0
  63. package/dist/web/assets/ReviewDetailPage-BWa3EEkv.css +1 -0
  64. package/dist/web/assets/ReviewDetailPage-Ct0hXwZL.js +1 -0
  65. package/dist/web/assets/ReviewsPage-CbLAU6OM.js +1 -0
  66. package/dist/web/assets/ReviewsPage-Lb74-4L2.css +1 -0
  67. package/dist/web/assets/StatusSwitcher-C0k4efwK.js +4 -0
  68. package/dist/web/assets/StatusSwitcher-CZYtI0ID.css +1 -0
  69. package/dist/web/assets/abnfDiagram-O67JEVCF-DkOrqZLn.js +1 -0
  70. package/dist/web/assets/agent-icon-BKoq2a6T.js +17 -0
  71. package/dist/web/assets/agent-icon-BUd-vCRS.css +1 -0
  72. package/dist/web/assets/agent-store-CEQ6MmTC.js +1 -0
  73. package/dist/web/assets/app-info-store-CIqmSeWK.js +1 -0
  74. package/dist/web/assets/arc-BALAYY3f.js +1 -0
  75. package/dist/web/assets/architecture-7GRP2DOG-CTkghkmX.js +1 -0
  76. package/dist/web/assets/architectureDiagram-NJMV4G6O-C-xtrMea.js +36 -0
  77. package/dist/web/assets/array-BifhSqXX.js +1 -0
  78. package/dist/web/assets/blockDiagram-BEXU5L5S-DYo9Os-m.js +140 -0
  79. package/dist/web/assets/c4Diagram-YGBWAQC7-BpSFjUAX.js +38 -0
  80. package/dist/web/assets/channel-f9cE6NV9.js +1 -0
  81. package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/web/assets/chunk-3FUC2YCW-DtUqbu5K.js +2 -0
  83. package/dist/web/assets/chunk-5DYCD2WN-DeYTIM8k.js +1 -0
  84. package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  85. package/dist/web/assets/chunk-742MDFTN-8BfKAsu6.js +1 -0
  86. package/dist/web/assets/chunk-7INBJB4K-PXK092EZ.js +62 -0
  87. package/dist/web/assets/chunk-7M6MHVWA-Db9reT-V.js +213 -0
  88. package/dist/web/assets/chunk-7PRAP22T-CNFpjNaH.js +1 -0
  89. package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  90. package/dist/web/assets/chunk-FOHPRMQF-CoA4358r.js +161 -0
  91. package/dist/web/assets/chunk-GTNCS2PH-C5gqOp1W.js +88 -0
  92. package/dist/web/assets/chunk-GWA4HPMP-BquHLG8L.js +1 -0
  93. package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  94. package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  95. package/dist/web/assets/chunk-MBY4JIJT-DyOEFgCh.js +72 -0
  96. package/dist/web/assets/chunk-NETBCI7D-Bd3ij3-b.js +1 -0
  97. package/dist/web/assets/chunk-O7XYJQB3-DQvIvDe6.js +126 -0
  98. package/dist/web/assets/chunk-UA2S7LBM-BDIHwgCZ.js +1 -0
  99. package/dist/web/assets/chunk-WEXAMYUT-DwQKTsK-.js +1 -0
  100. package/dist/web/assets/chunk-XXDRQBXY-DTdiON6C.js +1 -0
  101. package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/web/assets/chunk-Z7XXMR3K-CIvnaGnU.js +10 -0
  103. package/dist/web/assets/chunk-ZIGJFQKS-BdT_T5CV.js +2 -0
  104. package/dist/web/assets/classDiagram-v2-NBCMYWYE-BUoclHlO.js +217 -0
  105. package/dist/web/assets/{core-Ruj7F7dx.js → core-B8kUiZoM.js} +1 -1
  106. package/dist/web/assets/cose-bilkent-JH36ORCC-C16eW6pc.js +1 -0
  107. package/dist/web/assets/create-B9ZqyTxV.js +1 -0
  108. package/dist/web/assets/cynefin-OW5HDTMX-1mS7ZeoM.js +1 -0
  109. package/dist/web/assets/cynefinDiagram-VND7K2PF-D1PC4YT4.js +62 -0
  110. package/dist/web/assets/cytoscape.esm-B-1iW2ft.js +321 -0
  111. package/dist/web/assets/dagre-6A5THRUB-amODOP74.js +4 -0
  112. package/dist/web/assets/debounce-CDIE7CV6.js +1 -0
  113. package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  114. package/dist/web/assets/diagram-22UHCM2B-wUvcsN_B.js +200 -0
  115. package/dist/web/assets/diagram-3UASUU5V-CLiOVs2l.js +24 -0
  116. package/dist/web/assets/diagram-ATOU4E4O-Cs5cFGgz.js +3 -0
  117. package/dist/web/assets/diagram-CDSNMT55-C8Jb-YeE.js +30 -0
  118. package/dist/web/assets/diagram-MLGK6HIB-DA6C9fVB.js +24 -0
  119. package/dist/web/assets/diagram-MPIPVDR6-BOaFKuUi.js +41 -0
  120. package/dist/web/assets/dist-DuFVAX6Y.js +1 -0
  121. package/dist/web/assets/ebnfDiagram-ZINNZB2B-e5jvn-EE.js +1 -0
  122. package/dist/web/assets/elk-276RUBZZ-D7Gcu17D.js +27 -0
  123. package/dist/web/assets/engine-oniguruma-HbxpIAu4.js +1 -0
  124. package/dist/web/assets/erDiagram-OPXOYQCR-Cn1bBUF8.js +99 -0
  125. package/dist/web/assets/event-rR76ioXz.js +1 -0
  126. package/dist/web/assets/eventmodeling-NTZA5JFV-BjGpd42n.js +1 -0
  127. package/dist/web/assets/export-file-Di_2jvjP.js +1 -0
  128. package/dist/web/assets/fira-code-symbols2-400-normal-9X26I2LK.woff2 +0 -0
  129. package/dist/web/assets/fira-code-symbols2-500-normal-B5s_njw3.woff2 +0 -0
  130. package/dist/web/assets/fira-code-symbols2-600-normal-Bu3RnO39.woff2 +0 -0
  131. package/dist/web/assets/flowDiagram-KWPJA3E3-a3N8MVB8.js +1 -0
  132. package/dist/web/assets/ganttDiagram-FUAMR5RP-C3U8jeJB.js +292 -0
  133. package/dist/web/assets/gitGraph-4MIJSDKK-C165DC0H.js +1 -0
  134. package/dist/web/assets/gitGraphDiagram-X574FWY7-CVHdT1OC.js +106 -0
  135. package/dist/web/assets/golos-text-cyrillic-400-normal-BwL4n7Pb.woff +0 -0
  136. package/dist/web/assets/golos-text-cyrillic-400-normal-C7us6pn1.woff2 +0 -0
  137. package/dist/web/assets/golos-text-cyrillic-500-normal-BSLQUuP1.woff2 +0 -0
  138. package/dist/web/assets/golos-text-cyrillic-500-normal-hXinzVVQ.woff +0 -0
  139. package/dist/web/assets/golos-text-cyrillic-ext-400-normal-Bo67NYBq.woff2 +0 -0
  140. package/dist/web/assets/golos-text-cyrillic-ext-400-normal-D592xEk3.woff +0 -0
  141. package/dist/web/assets/golos-text-cyrillic-ext-500-normal-CLsu_eDI.woff2 +0 -0
  142. package/dist/web/assets/golos-text-cyrillic-ext-500-normal-InHNvB7v.woff +0 -0
  143. package/dist/web/assets/golos-text-latin-400-normal-Coi1FYaD.woff2 +0 -0
  144. package/dist/web/assets/golos-text-latin-400-normal-DOuJOmdK.woff +0 -0
  145. package/dist/web/assets/golos-text-latin-500-normal-BQo4s7Kn.woff +0 -0
  146. package/dist/web/assets/golos-text-latin-500-normal-BznAvurO.woff2 +0 -0
  147. package/dist/web/assets/golos-text-latin-ext-400-normal-BXgyX_iE.woff +0 -0
  148. package/dist/web/assets/golos-text-latin-ext-400-normal-BjKcJWKY.woff2 +0 -0
  149. package/dist/web/assets/golos-text-latin-ext-500-normal-9vZPKadr.woff2 +0 -0
  150. package/dist/web/assets/golos-text-latin-ext-500-normal-BmtMVntR.woff +0 -0
  151. package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
  152. package/dist/web/assets/i18n-CyUPETur.js +1 -0
  153. package/dist/web/assets/{index-JAYe_SFI.css → index-C6ArNnKs.css} +1 -1
  154. package/dist/web/assets/index-C_B6Z2lc.js +2 -0
  155. package/dist/web/assets/info-A6RAGUB7-cbWtF3FJ.js +1 -0
  156. package/dist/web/assets/infoDiagram-VRGFBTTK-C_kWn3Ai.js +2 -0
  157. package/dist/web/assets/init-C-OQMol4.js +1 -0
  158. package/dist/web/assets/init-app-DnfYD-0k.js +1 -0
  159. package/dist/web/assets/is-ChHvR1Z-.js +1 -0
  160. package/dist/web/assets/ishikawaDiagram-OU5B5YK6-Xb9cDRr7.js +70 -0
  161. package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
  162. package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
  163. package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-C9ShMxGR.woff2 +0 -0
  164. package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-CEVyPk4Y.woff +0 -0
  165. package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-DVvC-yKp.woff +0 -0
  166. package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-Dj6eZy5f.woff2 +0 -0
  167. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
  168. package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-BvH7FW2L.woff2 +0 -0
  169. package/dist/web/assets/jetbrains-mono-vietnamese-600-normal-Dtizs43-.woff2 +0 -0
  170. package/dist/web/assets/journeyDiagram-ZHPQQLJL-6eZcefap.js +139 -0
  171. package/dist/web/assets/kanban-definition-PNTS6WVX-D_z_AsKR.js +89 -0
  172. package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
  173. package/dist/web/assets/line-v7JtZ-Za.js +1 -0
  174. package/dist/web/assets/linear-hp1KWmiC.js +1 -0
  175. package/dist/web/assets/mermaid-parser.core-Cwwk5NqO.js +7 -0
  176. package/dist/web/assets/mermaid.core-BjPJhwW0.js +44 -0
  177. package/dist/web/assets/mindmap-definition-NLK3R4M7-lQohhDXq.js +96 -0
  178. package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
  179. package/dist/web/assets/packet-AYTQ26CC-DxiGe6nt.js +1 -0
  180. package/dist/web/assets/path-fybaL0A-.js +1 -0
  181. package/dist/web/assets/pegDiagram-GJSIUBJH-XZRpQTLx.js +1 -0
  182. package/dist/web/assets/pie-WAS4IAKB-fxN0O90k.js +1 -0
  183. package/dist/web/assets/pieDiagram-5QR66LMP-M_bBKE-n.js +39 -0
  184. package/dist/web/assets/pinia-0B8sIOgD.js +1 -0
  185. package/dist/web/assets/pinia-DbmXdZbT.js +1 -0
  186. package/dist/web/assets/quadrantDiagram-O4NWA36T-BNxVPrq3.js +7 -0
  187. package/dist/web/assets/radar-RG4KPBEZ-Cd-5aokk.js +1 -0
  188. package/dist/web/assets/railroad-74A4TZTK-BvSKL8Ze.js +1 -0
  189. package/dist/web/assets/railroad-abnf-HS5TGJTU-CB32K25L.js +1 -0
  190. package/dist/web/assets/railroad-ebnf-LZEXJU2U-CqQR3zRl.js +1 -0
  191. package/dist/web/assets/railroad-peg-WCYAUIDC-DRSdB-oL.js +1 -0
  192. package/dist/web/assets/railroadDiagram-XR7U4H2S-D664lPSL.js +1 -0
  193. package/dist/web/assets/requirementDiagram-PLB6GJNP-B_1Dl0-3.js +84 -0
  194. package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  195. package/dist/web/assets/runtime-core.esm-bundler-Carm8LUR.js +1 -0
  196. package/dist/web/assets/safe-storage-CWaD6Dq3.js +1 -0
  197. package/dist/web/assets/sankeyDiagram-IPEJSGJF-BvFmMC_F.js +40 -0
  198. package/dist/web/assets/sequenceDiagram-PO4LG4MO-DMK1ey4g.js +169 -0
  199. package/dist/web/assets/single-flight-r8B7stt1.js +1 -0
  200. package/dist/web/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  201. package/dist/web/assets/src-Cy0hT8a0.js +1 -0
  202. package/dist/web/assets/stateDiagram-v2-GCMORJYK-VVElU-_E.js +291 -0
  203. package/dist/web/assets/swimlanes-2SLR337P-DyfYJvHL.js +1 -0
  204. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-BQ0mK2BI.js +8 -0
  205. package/dist/web/assets/theme-BFceB3b9.js +1 -0
  206. package/dist/web/assets/timeline-definition-EJHVYXUP-DBoRb7uC.js +120 -0
  207. package/dist/web/assets/treeView-Q6P3EWNA-D8AoZg1Y.js +1 -0
  208. package/dist/web/assets/treemap-WGGIJYW6-DhcVtZsu.js +1 -0
  209. package/dist/web/assets/use-app-theme-DxZW-eOR.js +1 -0
  210. package/dist/web/assets/use-panel-width-CY75Fomr.js +1 -0
  211. package/dist/web/assets/usecaseDiagram-POWQR4AR-BCfvWOH4.js +328 -0
  212. package/dist/web/assets/vennDiagram-UO4OBE2U-BIk6-FLo.js +34 -0
  213. package/dist/web/assets/{vue-i18n.runtime-DSeWNwrW.js → vue-i18n.runtime-BQwnBdmC.js} +2 -2
  214. package/dist/web/assets/wardley-WFR3VGLG-BMHH70A3.js +1 -0
  215. package/dist/web/assets/wardleyDiagram-VNRHLVJA-DB-cxlSq.js +78 -0
  216. package/dist/web/assets/xychartDiagram-PMCCYNJV-BejE-3gi.js +7 -0
  217. package/dist/web/index.html +8 -6
  218. package/package.json +18 -8
  219. package/dist/web/assets/AppPage-BeqKILJT.js +0 -1
  220. package/dist/web/assets/CreateReviewFabButton-BqWdgfEf.css +0 -1
  221. package/dist/web/assets/CreateReviewFabButton-wjxKeOL1.js +0 -1
  222. package/dist/web/assets/IndexPage-BQ0T9rLY.css +0 -1
  223. package/dist/web/assets/IndexPage-CSaToIzA.js +0 -1
  224. package/dist/web/assets/MainLayout-BRPoh8y0.css +0 -1
  225. package/dist/web/assets/MainLayout-xQ92tgGe.js +0 -1
  226. package/dist/web/assets/Platform-CEDL_PgC.js +0 -1
  227. package/dist/web/assets/QBtn-Cf6WtiKt.js +0 -1
  228. package/dist/web/assets/QTooltip-IUM54Nhj.js +0 -1
  229. package/dist/web/assets/ReviewDetailPage-DJHzinT1.css +0 -1
  230. package/dist/web/assets/ReviewDetailPage-DdufL7Rh.js +0 -1
  231. package/dist/web/assets/ReviewsPage-D8u1IeQ4.js +0 -1
  232. package/dist/web/assets/ReviewsPage-D9TFy2OP.css +0 -1
  233. package/dist/web/assets/StatusSwitcher-7Yqs8azq.css +0 -1
  234. package/dist/web/assets/StatusSwitcher-CUOMaD34.js +0 -3
  235. package/dist/web/assets/app-info-store-BpeVVjNB.js +0 -1
  236. package/dist/web/assets/client-De34x_P_.js +0 -1
  237. package/dist/web/assets/engine-oniguruma-Bon2Icja.js +0 -1
  238. package/dist/web/assets/event-BQt_DXaR.js +0 -1
  239. package/dist/web/assets/format-BWCY39oA.js +0 -1
  240. package/dist/web/assets/i18n-DHrLpaJV.js +0 -1
  241. package/dist/web/assets/index-DphTOTJs.js +0 -2
  242. package/dist/web/assets/init-app-5wT4mURp.js +0 -1
  243. package/dist/web/assets/pinia-CrBjT8X4.js +0 -1
  244. package/dist/web/assets/pinia-DK4OjQ7o.js +0 -1
  245. package/dist/web/assets/runtime-core.esm-bundler-Wxr8ezGp.js +0 -1
  246. package/dist/web/assets/theme-CrTbazqt.js +0 -1
  247. package/dist/web/assets/use-app-theme-CsAmLZfp.js +0 -1
  248. /package/dist/shared/{types.js → diff/types.js} +0 -0
package/README.md CHANGED
@@ -18,23 +18,14 @@ staged/unstaged diff, сравнение веток и коммитов, бра
18
18
  синтаксиса (shiki).
19
19
  - Live-обновления через SSE: интерфейс сам обновляется при изменении
20
20
  файлов в репозитории — удобно смотреть, как редактирует код AI-агент.
21
+ После перезапуска `serve` уже открытые вкладки перезагружаются сами.
22
+ - Чаты с ACP-агентами (Claude Code, Cursor, Codex, pi): до 5 чатов во
23
+ вкладках, контекст — diff, файл целиком, ревью, вложения и `@`-файлы из
24
+ репозитория. Подробнее — в разделе «Агенты» ниже.
21
25
  - Ручной/авто выбор тёмной-светлой темы.
22
26
  - CLI: `serve` (поднять сервер), `list`/`export` (быстрый доступ к
23
27
  сохранённым ревью без веб-интерфейса).
24
28
 
25
- ## Известное ограничение (WIP)
26
-
27
- Бэкенд полностью поддерживает модель "ревью" — создание ревью
28
- (staged/branch/commits), инлайн-комментарии, статусы
29
- (`in_progress`/`approved`/`changes_requested`), экспорт в Markdown/JSON — всё
30
- это покрыто тестами (`src/server/routes/reviews.ts`, `comments.ts`,
31
- `export.ts`). **Соответствующий экран во фронтенде пока не реализован** —
32
- `ReviewsPage.vue` сейчас заглушка. Работает только просмотр diff/файлов;
33
- создание ревью и комментирование через UI недоступно. Это следующий
34
- логический кусок работы, не забытая функциональность — трекается в
35
- `.claude/docs/adr/0014-quasar-frontend-migration.md` (не публикуется, файл
36
- только для локальной разработки).
37
-
38
29
  ## Установка и запуск
39
30
 
40
31
  Требуется Node.js ≥ 24 (используется нативный `node:sqlite`).
@@ -79,6 +70,29 @@ node dist/cli/index.js export last --format markdown -o review.md
79
70
  > аутентификации. Открывая его в локальной сети, любой в этой сети сможет
80
71
  > просматривать содержимое репозитория.
81
72
 
73
+ ### Агенты
74
+
75
+ Иконка робота в шапке открывает панель чатов. Агенты запускаются как
76
+ подпроцессы по протоколу [ACP](https://agentclientprotocol.com) в корне
77
+ репозитория; нужные адаптеры подтягиваются через `npx` при первом запуске.
78
+
79
+ - **Контекст.** «Add to chat» в меню файла и diff, кнопка у активного
80
+ ревью; в поле ввода `@` + часть имени + Tab вставляет файл из репозитория.
81
+ Shift+Tab переключает режим агента, модель и режим выбираются под полем.
82
+ - **Подтверждения.** Запросы разрешений приходят карточкой в чат. Для
83
+ отдельного чата можно включить автоподтверждение (кнопка-щит) — агент
84
+ тогда меняет файлы и выполняет команды без вашего ответа; включайте его
85
+ только для работы, которую готовы проверить и откатить. pi вообще не
86
+ спрашивает.
87
+ - **Свои команды запуска** — `<репозиторий>/.githuman/ght-agents.json`:
88
+ `{ "cursor": { "args": ["--model", "<модель>", "acp"] } }`. Допустимы поля
89
+ `command`, `args`, `env`; любое другое (в том числе опечатка) — ошибка
90
+ при запуске сервера.
91
+ - **Только локально.** Агентские маршруты работают лишь при `--host`
92
+ localhost/127.0.0.1/::1 (иначе это удалённое выполнение команд для всей
93
+ сети) и отклоняют чужие `Origin`/`Host`. Открывайте приложение по
94
+ `http://localhost:<порт>`.
95
+
82
96
  ### Данные и совместимость с оригинальным mcollina/githuman
83
97
 
84
98
  Ревью хранятся в `.githuman/ght-reviews.db` (SQLite) в корне анализируемого
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { exportAsJson, exportAsMarkdown, ExportNotFoundError } from "../../server/services/export.service.js";
2
+ import { exportAsJson, exportAsMarkdown } from "../../server/services/export.service.js";
3
+ import { ExportNotFoundError } from "../../server/errors/export.js";
3
4
  import { getReviews } from "../../server/services/review.service.js";
4
5
  import { resolveDbPrefix } from "../config.js";
5
6
  export class ExportCliError extends Error {
@@ -39,12 +40,12 @@ function resolveReviewId(db, id) {
39
40
  }
40
41
  return latest.id;
41
42
  }
42
- export function runExport(db, id, format = 'markdown') {
43
+ export async function runExport(db, id, format, repositoryPath) {
43
44
  const reviewId = resolveReviewId(db, id);
44
45
  try {
45
46
  return format === 'json'
46
47
  ? JSON.stringify(exportAsJson(db, reviewId), null, 2)
47
- : exportAsMarkdown(db, reviewId);
48
+ : await exportAsMarkdown(db, reviewId, repositoryPath);
48
49
  }
49
50
  catch (error) {
50
51
  if (error instanceof ExportNotFoundError) {
package/dist/cli/index.js CHANGED
@@ -10,7 +10,10 @@ import { getAppVersion } from "../server/app-version.js";
10
10
  /** Opens the reviews DB for the repo at cwd, shared by `list` and `export`. */
11
11
  async function openReviewsDb(dbPrefix) {
12
12
  const repositoryPath = await resolveRepositoryPath(process.cwd());
13
- return createFileDatabase(resolveReviewsDbPath(repositoryPath, dbPrefix));
13
+ return {
14
+ db: createFileDatabase(resolveReviewsDbPath(repositoryPath, dbPrefix)),
15
+ repositoryPath
16
+ };
14
17
  }
15
18
  async function main(argv) {
16
19
  console.log(`githuman v${getAppVersion()}`);
@@ -24,14 +27,14 @@ async function main(argv) {
24
27
  }
25
28
  case 'list': {
26
29
  const options = parseListArgs(rest);
27
- const db = await openReviewsDb(options.dbPrefix);
30
+ const { db } = await openReviewsDb(options.dbPrefix);
28
31
  console.log(runList(db, options));
29
32
  return;
30
33
  }
31
34
  case 'export': {
32
35
  const options = parseExportArgs(rest);
33
- const db = await openReviewsDb(options.dbPrefix);
34
- const output = runExport(db, options.id, options.format);
36
+ const { db, repositoryPath } = await openReviewsDb(options.dbPrefix);
37
+ const output = await runExport(db, options.id, options.format, repositoryPath);
35
38
  if (options.output) {
36
39
  writeFileSync(options.output, output);
37
40
  }
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
6
6
  import { buildApp } from "../server/app.js";
7
7
  import { createFileDatabase } from "../server/db/index.js";
8
8
  import { resolveDbPrefix } from "./config.js";
9
+ import { isLoopbackHost } from "../shared/network/loopback.js";
10
+ import { loadAgentPresets } from "../server/config/agents.js";
9
11
  const execFileAsync = promisify(execFile);
10
12
  function resolveStaticRoot() {
11
13
  // Compiled layout: dist/cli/server-runtime.js sits next to dist/web/.
@@ -49,6 +51,10 @@ export function resolveReviewsDbPath(repositoryPath, dbPrefix) {
49
51
  const prefix = resolveDbPrefix(dbPrefix);
50
52
  return join(repositoryPath, '.githuman', `${prefix}reviews.db`);
51
53
  }
54
+ /** `.githuman/<prefix>agents.json` — the user's agent launch-command overrides (ADR 0023). */
55
+ export function resolveAgentsConfigPath(repositoryPath, dbPrefix) {
56
+ return join(repositoryPath, '.githuman', `${resolveDbPrefix(dbPrefix)}agents.json`);
57
+ }
52
58
  export async function startServer(options) {
53
59
  const repositoryPath = await resolveRepositoryPath(process.cwd());
54
60
  const db = createFileDatabase(resolveReviewsDbPath(repositoryPath, options.dbPrefix));
@@ -56,7 +62,13 @@ export async function startServer(options) {
56
62
  staticRoot: resolveStaticRoot(),
57
63
  repositoryPath,
58
64
  db,
59
- watchFiles: true
65
+ watchFiles: true,
66
+ // Agents can run commands, so they are only offered on a loopback bind.
67
+ ...(isLoopbackHost(options.host)
68
+ ? {
69
+ agentPresets: loadAgentPresets(resolveAgentsConfigPath(repositoryPath, options.dbPrefix))
70
+ }
71
+ : {})
60
72
  });
61
73
  const address = await app.listen({ port: options.port, host: options.host });
62
74
  if (options.open) {
@@ -1,7 +1,8 @@
1
+ import { isLoopbackHost } from "../shared/network/loopback.js";
1
2
  export function formatStartupMessage(url, host) {
2
3
  const lines = [`githuman listening on ${url}`];
3
- if (host !== 'localhost' && host !== '127.0.0.1') {
4
- lines.push('Warning: server is reachable from your local network without authentication.');
4
+ if (!isLoopbackHost(host)) {
5
+ lines.push('Warning: server is reachable from your local network without authentication.', 'Agent chat is disabled: it is only available when listening on localhost.');
5
6
  }
6
7
  return lines.join('\n');
7
8
  }
@@ -0,0 +1,140 @@
1
+ import { toAgentToolStatus } from "../../../shared/agents/types.js";
2
+ import { truncateText } from "../../utils/text.js";
3
+ /** Cap on tool output forwarded to the browser; agents can dump whole files. */
4
+ const MAX_TOOL_OUTPUT_CHARS = 20_000;
5
+ /** Cap on each text of a diff in a tool event. The chat shows 200 lines at most. */
6
+ const MAX_TOOL_DIFF_CHARS = 20_000;
7
+ /** Cap for a permission request: the user decides on what the diff shows. */
8
+ const MAX_PERMISSION_DIFF_CHARS = 100_000;
9
+ /** Flattens ACP select options (plain or grouped) into one list. */
10
+ function flattenChoices(options) {
11
+ const choices = [];
12
+ for (const entry of options) {
13
+ const members = 'group' in entry ? entry.options : [entry];
14
+ const group = 'group' in entry ? entry.name : undefined;
15
+ for (const member of members) {
16
+ choices.push({
17
+ value: member.value,
18
+ name: member.name,
19
+ ...(member.description ? { description: member.description } : {}),
20
+ ...(group === undefined ? {} : { group })
21
+ });
22
+ }
23
+ }
24
+ return choices;
25
+ }
26
+ export function mapConfigOptions(options) {
27
+ return (options ?? []).map(option => ({
28
+ id: option.id,
29
+ name: option.name,
30
+ ...(option.category ? { category: option.category } : {}),
31
+ ...(option.description ? { description: option.description } : {}),
32
+ type: option.type,
33
+ currentValue: option.currentValue,
34
+ ...(option.type === 'select'
35
+ ? { options: flattenChoices(option.options) }
36
+ : {})
37
+ }));
38
+ }
39
+ /** Cuts both texts of a diff; `truncated` tells the reader that the diff is partial. */
40
+ function capDiff(diff, max) {
41
+ const oldText = diff.oldText === null ? null : diff.oldText.slice(0, max);
42
+ const newText = diff.newText.slice(0, max);
43
+ return oldText === diff.oldText && newText === diff.newText
44
+ ? diff
45
+ : { ...diff, oldText, newText, truncated: true };
46
+ }
47
+ export function extractToolContent(content, maxDiffChars = MAX_TOOL_DIFF_CHARS) {
48
+ const diffs = [];
49
+ const texts = [];
50
+ for (const item of content ?? []) {
51
+ if (item.type === 'diff') {
52
+ diffs.push(capDiff({
53
+ path: item.path,
54
+ oldText: item.oldText ?? null,
55
+ newText: item.newText
56
+ }, maxDiffChars));
57
+ }
58
+ else if (item.type === 'content' && item.content.type === 'text') {
59
+ texts.push(item.content.text);
60
+ }
61
+ // 'terminal' content needs the terminal capability, which we don't declare.
62
+ }
63
+ return {
64
+ diffs,
65
+ output: texts.length > 0
66
+ ? truncateText(texts.join('\n'), MAX_TOOL_OUTPUT_CHARS)
67
+ : undefined
68
+ };
69
+ }
70
+ /**
71
+ * Maps one ACP `session/update` to a chat event, or null for updates the UI
72
+ * doesn't show. Sticks to the standard fields (`kind`, `status`, `locations`,
73
+ * `content`) — `_meta` differs per agent (ADR 0023 spike).
74
+ */
75
+ export function mapSessionUpdate(update) {
76
+ switch (update.sessionUpdate) {
77
+ case 'agent_message_chunk':
78
+ case 'agent_thought_chunk':
79
+ return {
80
+ type: 'message',
81
+ role: update.sessionUpdate === 'agent_message_chunk' ? 'agent' : 'thought',
82
+ text: update.content.type === 'text'
83
+ ? update.content.text
84
+ : `[${update.content.type}]`
85
+ };
86
+ case 'tool_call':
87
+ case 'tool_call_update': {
88
+ const { diffs, output } = extractToolContent(update.content);
89
+ const status = toAgentToolStatus(update.status);
90
+ return {
91
+ type: 'tool',
92
+ toolCallId: update.toolCallId,
93
+ ...(update.title ? { title: update.title } : {}),
94
+ ...(update.kind ? { kind: update.kind } : {}),
95
+ ...(status ? { status } : {}),
96
+ ...(update.locations
97
+ ? { locations: update.locations.map(l => l.path) }
98
+ : {}),
99
+ ...(diffs.length > 0 ? { diffs } : {}),
100
+ ...(output === undefined ? {} : { output })
101
+ };
102
+ }
103
+ case 'plan':
104
+ return {
105
+ type: 'plan',
106
+ entries: update.entries.map(e => ({
107
+ content: e.content,
108
+ status: e.status
109
+ }))
110
+ };
111
+ case 'usage_update':
112
+ return {
113
+ type: 'usage',
114
+ used: update.used,
115
+ size: update.size,
116
+ ...(update.cost ? { cost: update.cost.amount } : {})
117
+ };
118
+ case 'config_option_update':
119
+ return { type: 'config', options: mapConfigOptions(update.configOptions) };
120
+ default:
121
+ // user_message_chunk (we already know what the user typed), command/mode/
122
+ // config/session-info updates, …
123
+ return null;
124
+ }
125
+ }
126
+ export function mapPermissionRequest(requestId, params) {
127
+ const { diffs } = extractToolContent(params.toolCall.content, MAX_PERMISSION_DIFF_CHARS);
128
+ return {
129
+ type: 'permission',
130
+ requestId,
131
+ toolCallId: params.toolCall.toolCallId,
132
+ title: params.toolCall.title ?? 'Tool call',
133
+ options: params.options.map(o => ({
134
+ optionId: o.optionId,
135
+ name: o.name,
136
+ kind: o.kind
137
+ })),
138
+ diffs
139
+ };
140
+ }
@@ -1,3 +1,5 @@
1
+ import { NotFoundError } from "./errors/http.js";
2
+ import { randomUUID } from 'node:crypto';
1
3
  import { readFileSync } from 'node:fs';
2
4
  import { join } from 'node:path';
3
5
  import Fastify from 'fastify';
@@ -14,6 +16,8 @@ import { reviewRoutes } from "./routes/reviews.js";
14
16
  import { commentRoutes } from "./routes/comments.js";
15
17
  import { exportRoutes } from "./routes/export.js";
16
18
  import { eventRoutes } from "./routes/events.js";
19
+ import { agentRoutes } from "./routes/agent.js";
20
+ import { createSessionRegistry } from "./services/agent/session-registry.js";
17
21
  import { createTestDatabase } from "./db/index.js";
18
22
  import { createEventBus } from "./event-bus.js";
19
23
  import { watchRepository } from "./services/file-watcher.service.js";
@@ -31,8 +35,22 @@ export function buildApp(options = {}) {
31
35
  app.register(gitRoutes, { repositoryPath });
32
36
  app.register(reviewRoutes, { repositoryPath, db, eventBus });
33
37
  app.register(commentRoutes, { db, eventBus });
34
- app.register(exportRoutes, { db });
35
- app.register(eventRoutes, { eventBus });
38
+ app.register(exportRoutes, { db, repositoryPath });
39
+ app.register(eventRoutes, {
40
+ eventBus,
41
+ instanceId: options.instanceId ?? randomUUID()
42
+ });
43
+ if (options.agentPresets) {
44
+ app.register(agentRoutes, {
45
+ registry: createSessionRegistry({
46
+ presets: options.agentPresets,
47
+ repositoryPath
48
+ }),
49
+ db,
50
+ repositoryPath,
51
+ eventBus
52
+ });
53
+ }
36
54
  if (options.watchFiles) {
37
55
  const watcher = watchRepository(repositoryPath, () => {
38
56
  eventBus.publish({ type: 'files:changed' });
@@ -47,18 +65,13 @@ export function buildApp(options = {}) {
47
65
  root: staticRoot,
48
66
  wildcard: false
49
67
  });
50
- app.setNotFoundHandler((request, reply) => {
51
- if (request.url.startsWith('/api/')) {
52
- reply.code(404).send({
53
- error: 'Not Found',
54
- message: `Route ${request.url} not found`,
55
- statusCode: 404
56
- });
57
- return;
58
- }
59
- const indexHtml = readFileSync(join(staticRoot, 'index.html'), 'utf-8');
60
- reply.code(200).type('text/html').send(indexHtml);
61
- });
62
68
  }
69
+ app.setNotFoundHandler((request, reply) => {
70
+ if (request.url.startsWith('/api/') || !options.staticRoot) {
71
+ throw new NotFoundError(`Route ${request.url} not found`);
72
+ }
73
+ const indexHtml = readFileSync(join(options.staticRoot, 'index.html'), 'utf-8');
74
+ reply.code(200).type('text/html').send(indexHtml);
75
+ });
63
76
  return app;
64
77
  }
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { Type } from '@sinclair/typebox';
3
+ import { Value } from '@sinclair/typebox/value';
4
+ import { isOnPath } from "../utils/executable.js";
5
+ import { isRecord } from "../../shared/utils/guards.js";
6
+ /**
7
+ * Packages are pinned to the versions the ADR 0023 spike was run against —
8
+ * `npx -y` otherwise executes whatever was published last. Cursor and Codex
9
+ * are not verified yet (Codex's adapter moved to the agentclientprotocol org
10
+ * after zed-industries/codex-acp was archived) and stay unpinned.
11
+ */
12
+ export const DEFAULT_AGENT_PRESETS = [
13
+ {
14
+ id: 'claude',
15
+ title: 'Claude Code',
16
+ command: 'npx',
17
+ args: ['-y', '@agentclientprotocol/claude-agent-acp@0.84.0'],
18
+ autoApprovesEdits: false
19
+ },
20
+ {
21
+ id: 'pi',
22
+ title: 'pi',
23
+ command: 'npx',
24
+ args: ['-y', 'pi-acp@0.0.34'],
25
+ requires: 'pi',
26
+ autoApprovesEdits: true
27
+ },
28
+ {
29
+ id: 'cursor',
30
+ title: 'Cursor',
31
+ command: 'agent',
32
+ args: ['acp'],
33
+ autoApprovesEdits: false
34
+ },
35
+ {
36
+ id: 'codex',
37
+ title: 'Codex',
38
+ command: 'npx',
39
+ args: ['-y', '@agentclientprotocol/codex-acp'],
40
+ autoApprovesEdits: false
41
+ }
42
+ ];
43
+ /** The keys the schema allows; named in the "unknown field" message. */
44
+ const OVERRIDE_FIELDS = ['command', 'args', 'env'];
45
+ /**
46
+ * One entry of the user's overrides file. Closed on purpose: a misspelt key
47
+ * (`arg` for `args`) must fail loudly, not be ignored.
48
+ */
49
+ const AgentOverrideSchema = Type.Object({
50
+ command: Type.Optional(Type.String({ minLength: 1 })),
51
+ args: Type.Optional(Type.Array(Type.String())),
52
+ env: Type.Optional(Type.Record(Type.String(), Type.String()))
53
+ }, { additionalProperties: false });
54
+ export function describePreset(preset) {
55
+ // The agent starts with `preset.env` on top of ours, so look in its PATH.
56
+ const pathEnv = preset.env?.PATH ?? process.env.PATH;
57
+ return {
58
+ id: preset.id,
59
+ title: preset.title,
60
+ available: isOnPath(preset.command, pathEnv) &&
61
+ (preset.requires === undefined || isOnPath(preset.requires, pathEnv)),
62
+ autoApprovesEdits: preset.autoApprovesEdits
63
+ };
64
+ }
65
+ /** A JSON Pointer segment (`~1` is `/`, `~0` is `~`) as the plain field name. */
66
+ function fieldOf(pointer) {
67
+ const [segment = ''] = pointer.split('/').filter(part => part !== '');
68
+ return segment.replaceAll('~1', '/').replaceAll('~0', '~');
69
+ }
70
+ /** Names the first schema violation of one override entry; `pointer` is relative to that entry. */
71
+ function overrideProblem(where, pointer) {
72
+ const field = fieldOf(pointer);
73
+ if (field === 'command')
74
+ return `${where} needs a "command" string`;
75
+ if (field === 'args')
76
+ return `${where}: "args" must be an array of strings`;
77
+ if (field === 'env')
78
+ return `${where}: "env" must map names to strings`;
79
+ // Any other field name in the path is a key the schema does not allow.
80
+ return `${where} has an unknown field "${field}" (allowed: ${OVERRIDE_FIELDS.join(', ')})`;
81
+ }
82
+ function readOverrides(overridesPath) {
83
+ let raw;
84
+ try {
85
+ raw = readFileSync(overridesPath, 'utf-8');
86
+ }
87
+ catch (error) {
88
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
89
+ return {};
90
+ }
91
+ throw error;
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ }
97
+ catch (error) {
98
+ throw new Error(`Invalid JSON in ${overridesPath}`, { cause: error });
99
+ }
100
+ if (!isRecord(parsed)) {
101
+ throw new Error(`${overridesPath} must contain a JSON object keyed by agent id`);
102
+ }
103
+ return parsed;
104
+ }
105
+ /**
106
+ * Applies the user's overrides file (`.githuman/<prefix>agents.json`) over
107
+ * the built-in presets: `{ "<id>": { "command": "...", "args": [...], "env": {...} } }`.
108
+ * A preset id not among the defaults defines a new agent. This file is the
109
+ * only place a launch command can come from besides the defaults — the HTTP
110
+ * API only ever selects a preset by id (ADR 0023).
111
+ */
112
+ export function loadAgentPresets(overridesPath, defaults = DEFAULT_AGENT_PRESETS) {
113
+ const merged = new Map(defaults.map(preset => [preset.id, { ...preset }]));
114
+ for (const [id, override] of Object.entries(readOverrides(overridesPath))) {
115
+ const where = `Agent "${id}" in ${overridesPath}`;
116
+ if (!isRecord(override)) {
117
+ throw new Error(`${where} must be an object`);
118
+ }
119
+ if (!Value.Check(AgentOverrideSchema, override)) {
120
+ const problem = Value.Errors(AgentOverrideSchema, override).First();
121
+ throw new Error(overrideProblem(where, problem?.path ?? ''));
122
+ }
123
+ const base = merged.get(id);
124
+ const command = override.command ?? base?.command;
125
+ if (command === undefined) {
126
+ throw new Error(`${where} needs a "command" string`);
127
+ }
128
+ const args = override.args ?? base?.args ?? [];
129
+ const env = override.env ?? base?.env;
130
+ merged.set(id, {
131
+ id,
132
+ title: base?.title ?? id,
133
+ command,
134
+ args,
135
+ ...(base?.requires === undefined ? {} : { requires: base.requires }),
136
+ autoApprovesEdits: base?.autoApprovesEdits ?? false,
137
+ ...(env === undefined ? {} : { env })
138
+ });
139
+ }
140
+ return [...merged.values()];
141
+ }
@@ -0,0 +1,12 @@
1
+ import createError from '@fastify/error';
2
+ export const AgentSessionError = createError('GHT_AGENT_SESSION', '%s', 502);
3
+ export const AgentSessionStateError = createError('GHT_AGENT_STATE', '%s', 409,
4
+ // Node's ErrorConstructor requires static fields; createError only reads Base.prototype.
5
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- @fastify/error's Base type is stricter than its runtime contract
6
+ AgentSessionError);
7
+ export const AgentConfigError = createError('GHT_AGENT_CONFIG', '%s', 400,
8
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same Base.prototype contract as AgentSessionStateError
9
+ AgentSessionError);
10
+ export const AgentContextError = createError('GHT_AGENT_CONTEXT', '%s', 400);
11
+ export const UnknownAgentPresetError = createError('GHT_AGENT_PRESET_NOT_FOUND', '%s', 404);
12
+ export const TooManyAgentSessionsError = createError('GHT_AGENT_SESSION_LIMIT', '%s', 409);
@@ -0,0 +1,2 @@
1
+ import createError from '@fastify/error';
2
+ export const ValidationError = createError('GHT_COMMENT_VALIDATION', '%s', 400);
@@ -0,0 +1,2 @@
1
+ import createError from '@fastify/error';
2
+ export const ExportNotFoundError = createError('GHT_EXPORT_NOT_FOUND', '%s', 404);
@@ -0,0 +1,4 @@
1
+ import createError from '@fastify/error';
2
+ export const BadRequestError = createError('GHT_HTTP_BAD_REQUEST', '%s', 400);
3
+ export const NotFoundError = createError('GHT_HTTP_NOT_FOUND', '%s', 404);
4
+ export const ForbiddenError = createError('GHT_HTTP_FORBIDDEN', '%s', 403);
@@ -0,0 +1,3 @@
1
+ import createError from '@fastify/error';
2
+ export const ValidationError = createError('GHT_REVIEW_VALIDATION', '%s', 400);
3
+ export const UniqueNameError = createError('GHT_REVIEW_NAME_CONFLICT', '%s', 409);
@@ -0,0 +1,33 @@
1
+ import { ForbiddenError } from "../../errors/http.js";
2
+ import { isLoopbackHost } from "../../../shared/network/loopback.js";
3
+ function hostnameOf(hostWithPort) {
4
+ // "[::1]:3847" → "[::1]"; "localhost:3847" → "localhost"
5
+ return hostWithPort.startsWith('[')
6
+ ? hostWithPort.slice(0, hostWithPort.indexOf(']') + 1)
7
+ : (hostWithPort.split(':')[0] ?? '');
8
+ }
9
+ /**
10
+ * `onRequest` hook for the agent routes (ADR 0023). They are an RCE surface,
11
+ * so besides only being registered on a loopback bind, each request must
12
+ * prove it came from a page served by this very server: a loopback `Host`
13
+ * (defeats DNS rebinding, where a hostile domain resolves to 127.0.0.1) and,
14
+ * when the browser sends an `Origin`, one identical to that `Host`.
15
+ * Non-browser clients (curl, tests) send no `Origin` and pass.
16
+ */
17
+ export async function requireLocalOrigin(request) {
18
+ const host = request.headers.host ?? '';
19
+ const origin = request.headers.origin;
20
+ let ok = isLoopbackHost(hostnameOf(host));
21
+ if (ok && origin !== undefined) {
22
+ try {
23
+ ok = new URL(origin).host === host;
24
+ }
25
+ catch {
26
+ ok = false;
27
+ }
28
+ }
29
+ if (ok) {
30
+ return;
31
+ }
32
+ throw new ForbiddenError(`Agent endpoints only accept requests from this local server (Host: ${host || '-'}, Origin: ${origin ?? '-'}). Open the app via localhost.`);
33
+ }
@@ -1,3 +1,4 @@
1
+ import { UniqueNameError } from "../errors/reviews.js";
1
2
  function rowToReview(row) {
2
3
  return {
3
4
  id: row.id,
@@ -13,9 +14,6 @@ function rowToReview(row) {
13
14
  updatedAt: row.updated_at
14
15
  };
15
16
  }
16
- /** Thrown by `createReview` when `(name, branch)` collides with an existing review — see ADR 0017. */
17
- export class UniqueNameError extends Error {
18
- }
19
17
  export function createReview(db, review) {
20
18
  try {
21
19
  db.prepare(`INSERT INTO reviews (id, repository_path, base_ref, source_type, source_ref, snapshot_data, status, name, branch, created_at, updated_at)