@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
@@ -0,0 +1,497 @@
1
+ import { AgentSessionError, AgentSessionStateError, AgentConfigError } from "../../errors/agents.js";
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { Readable, Writable } from 'node:stream';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { errorMessage } from "../../../shared/utils/error-message.js";
10
+ import * as acp from '@agentclientprotocol/sdk';
11
+ import { mapConfigOptions, mapPermissionRequest, mapSessionUpdate } from "../../adapters/agents/event-mapper.js";
12
+ /** `npx` may have to download the adapter first. */
13
+ const START_TIMEOUT_MS = 90_000;
14
+ const MAX_BUFFERED_EVENTS = 5_000;
15
+ const STDERR_TAIL_CHARS = 2_000;
16
+ /** How long a closed connection waits for the `exit` event that carries the reason. */
17
+ const EXIT_GRACE_MS = 500;
18
+ /** The option the server picks for an auto-approved request; never a rejection. */
19
+ function autoApproveChoice(options) {
20
+ return (options.find(o => o.kind === 'allow_once') ??
21
+ options.find(o => o.kind === 'allow_always'));
22
+ }
23
+ /**
24
+ * One ACP agent subprocess plus its single session. The server is the only
25
+ * ACP client (browsers can't speak stdio); everything the UI sees goes
26
+ * through `subscribe`, which replays the buffered history so a second tab or
27
+ * a reconnect catches up (ADR 0023). Not declaring the `fs`/`terminal` client
28
+ * capabilities means agents read, write and run things on their own.
29
+ */
30
+ export class AgentSession {
31
+ id = randomUUID();
32
+ /** Binary attachments of this session's prompts (see context-builder); removed on close. */
33
+ attachmentDir = join(tmpdir(), 'githuman-agent', this.id);
34
+ presetId;
35
+ name;
36
+ reviewId;
37
+ #status = 'starting';
38
+ /** Set when the user (or the server) ends the session: its exit is no failure. */
39
+ #closing = false;
40
+ #error = null;
41
+ #autoApprove = false;
42
+ #child;
43
+ #connection;
44
+ #acpSessionId;
45
+ #embeddedContext = false;
46
+ #image = false;
47
+ #config = [];
48
+ #stderrTail = '';
49
+ /** Settles when the agent process has exited. */
50
+ #exited = Promise.resolve();
51
+ #ownEventId = 0;
52
+ #buffer = [];
53
+ /** Highest id dropped from the buffer; a client that last saw less has lost events. */
54
+ #evictedId = 0;
55
+ #listeners = new Set();
56
+ #pendingPermissions = new Map();
57
+ #options;
58
+ constructor(options) {
59
+ this.#options = options;
60
+ this.presetId = options.preset.id;
61
+ this.name = options.name;
62
+ this.reviewId = options.reviewId ?? null;
63
+ }
64
+ get status() {
65
+ return this.#status;
66
+ }
67
+ /** Whether the agent advertised `promptCapabilities.embeddedContext`. */
68
+ get supportsEmbeddedContext() {
69
+ return this.#embeddedContext;
70
+ }
71
+ /** Whether the agent advertised `promptCapabilities.image`. */
72
+ get supportsImages() {
73
+ return this.#image;
74
+ }
75
+ info() {
76
+ return {
77
+ id: this.id,
78
+ presetId: this.presetId,
79
+ name: this.name,
80
+ status: this.#status,
81
+ reviewId: this.reviewId,
82
+ autoApprove: this.#autoApprove,
83
+ error: this.#error
84
+ };
85
+ }
86
+ /** What the session is now; see `AgentSessionState`. */
87
+ state() {
88
+ return {
89
+ sessionId: this.id,
90
+ status: this.#status,
91
+ autoApprove: this.#autoApprove,
92
+ config: this.#config,
93
+ permissions: [...this.#pendingPermissions.values()].map(p => p.request)
94
+ };
95
+ }
96
+ /**
97
+ * Spawns the agent and runs the handshake. Never rejects: a session that
98
+ * cannot start ends up `closed` with the reason in `info().error` and an
99
+ * `error` event, so the UI can show it on the chat's tab.
100
+ */
101
+ async start() {
102
+ try {
103
+ await this.#start();
104
+ }
105
+ catch (error) {
106
+ // An exit during the start already reported itself (`#onExit`).
107
+ if (this.#error === null && !this.#closing) {
108
+ const message = errorMessage(error);
109
+ this.#error = message;
110
+ this.#emit({ type: 'error', message });
111
+ }
112
+ this.close();
113
+ }
114
+ }
115
+ async #start() {
116
+ const { preset, cwd } = this.#options;
117
+ const child = spawn(preset.command, preset.args, {
118
+ cwd,
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ env: { ...process.env, ...preset.env }
121
+ });
122
+ this.#child = child;
123
+ this.#exited = new Promise(resolve => child.once('exit', resolve));
124
+ child.stderr.setEncoding('utf-8');
125
+ child.stderr.on('data', (chunk) => {
126
+ this.#stderrTail = (this.#stderrTail + chunk).slice(-STDERR_TAIL_CHARS);
127
+ });
128
+ // Swallow stdin EPIPE after the agent died; the closed connection reports it.
129
+ child.stdin.on('error', () => { });
130
+ const died = new Promise((_, reject) => {
131
+ child.once('error', error => {
132
+ reject(new AgentSessionError(`Cannot start "${preset.command}": ${error.message}`));
133
+ });
134
+ child.once('exit', (code, signal) => {
135
+ reject(new AgentSessionError(this.#exitMessage(code, signal)));
136
+ });
137
+ });
138
+ died.catch(() => { });
139
+ child.once('exit', (code, signal) => {
140
+ this.#onExit(code, signal);
141
+ });
142
+ const timer = new AbortController();
143
+ const timedOut = delay(START_TIMEOUT_MS, undefined, {
144
+ signal: timer.signal
145
+ }).then(() => {
146
+ throw new AgentSessionError(`Agent did not become ready within ${START_TIMEOUT_MS / 1000}s`);
147
+ });
148
+ timedOut.catch(() => { });
149
+ try {
150
+ await Promise.race([this.#handshake(child), died, timedOut]);
151
+ }
152
+ finally {
153
+ timer.abort();
154
+ }
155
+ this.#setStatus('ready');
156
+ }
157
+ async #handshake(child) {
158
+ if (child.stdin === null || child.stdout === null) {
159
+ throw new AgentSessionError('Agent process has no stdio');
160
+ }
161
+ const stream = acp.ndJsonStream(Writable.toWeb(child.stdin),
162
+ // Node's web-stream typing differs from the DOM one the SDK expects.
163
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same stream, differing lib typings
164
+ Readable.toWeb(child.stdout));
165
+ const connection = acp
166
+ .client({ name: 'githuman' })
167
+ .onRequest(acp.methods.client.session.requestPermission, ctx => this.#requestPermission(ctx.params))
168
+ .onNotification(acp.methods.client.session.update, ctx => {
169
+ this.#onUpdate(ctx.params.update);
170
+ })
171
+ .connect(stream);
172
+ this.#connection = connection;
173
+ void connection.closed.finally(() => {
174
+ this.#onConnectionClosed();
175
+ });
176
+ const init = await connection.agent.request(acp.methods.agent.initialize, {
177
+ protocolVersion: acp.PROTOCOL_VERSION,
178
+ clientCapabilities: {}
179
+ });
180
+ const prompt = init.agentCapabilities?.promptCapabilities;
181
+ this.#embeddedContext = prompt?.embeddedContext === true;
182
+ this.#image = prompt?.image === true;
183
+ const created = await connection.agent.request(acp.methods.agent.session.new, {
184
+ cwd: this.#options.cwd,
185
+ mcpServers: []
186
+ });
187
+ this.#acpSessionId = created.sessionId;
188
+ this.#setConfig(mapConfigOptions(created.configOptions));
189
+ }
190
+ #setConfig(options) {
191
+ this.#config = options;
192
+ this.#emit({ type: 'config', options });
193
+ }
194
+ /**
195
+ * Changes a session setting (model, mode, …) and returns the agent's new
196
+ * full set of options. Validated against what the agent itself offered, so
197
+ * an unknown id or value is rejected here rather than sent on.
198
+ */
199
+ async setConfig(configId, value) {
200
+ const connection = this.#connection;
201
+ const sessionId = this.#acpSessionId;
202
+ if (this.#status !== 'ready' || !connection || !sessionId) {
203
+ throw new AgentSessionStateError(this.#status === 'busy'
204
+ ? 'Settings cannot be changed while the agent is answering'
205
+ : `The session is ${this.#status}`);
206
+ }
207
+ const option = this.#config.find(o => o.id === configId);
208
+ if (!option) {
209
+ throw new AgentConfigError(`Unknown setting "${configId}"`);
210
+ }
211
+ if (option.type === 'boolean'
212
+ ? typeof value !== 'boolean'
213
+ : !option.options?.some(c => c.value === value)) {
214
+ throw new AgentConfigError(`Invalid value for "${option.name}"`);
215
+ }
216
+ try {
217
+ const response = await connection.agent.request(acp.methods.agent.session.setConfigOption, typeof value === 'boolean'
218
+ ? { sessionId, configId, type: 'boolean', value }
219
+ : { sessionId, configId, value });
220
+ this.#setConfig(mapConfigOptions(response.configOptions));
221
+ }
222
+ catch (error) {
223
+ throw new AgentSessionError(errorMessage(error), { cause: error });
224
+ }
225
+ }
226
+ /** The agent process ended: report why, unless the session itself ended it. */
227
+ #onExit(code, signal) {
228
+ if (!this.#closing && this.#error === null && this.#status !== 'closed') {
229
+ this.#error = this.#exitMessage(code, signal);
230
+ this.#emit({ type: 'error', message: this.#error });
231
+ }
232
+ this.#markClosed();
233
+ }
234
+ /**
235
+ * The connection often closes before the `exit` event, and only that event
236
+ * has the exit code. Wait a short time for it before closing without a reason.
237
+ */
238
+ #onConnectionClosed() {
239
+ const child = this.#child;
240
+ if (this.#closing ||
241
+ !child ||
242
+ child.exitCode !== null ||
243
+ child.signalCode !== null) {
244
+ this.#markClosed();
245
+ return;
246
+ }
247
+ void this.#closeAfterGrace();
248
+ }
249
+ async #closeAfterGrace() {
250
+ await delay(EXIT_GRACE_MS, undefined, { ref: false });
251
+ if (!this.#isClosed() && this.#error === null) {
252
+ this.#error = 'Agent closed the connection';
253
+ this.#emit({ type: 'error', message: this.#error });
254
+ }
255
+ this.#markClosed();
256
+ }
257
+ #exitMessage(code, signal) {
258
+ const tail = this.#stderrTail.trim();
259
+ return (`Agent exited (${signal ?? `code ${code}`})` +
260
+ (tail === '' ? '' : `: ${tail.slice(-400)}`));
261
+ }
262
+ /**
263
+ * Sends a prompt and returns immediately; progress arrives as events and the
264
+ * turn ends with a `stop` event (or `error`). One prompt at a time.
265
+ */
266
+ prompt(blocks, display) {
267
+ const { connection, sessionId } = this.#readyChannel();
268
+ this.#setStatus('busy');
269
+ this.#emit({
270
+ type: 'user',
271
+ text: display.text,
272
+ context: display.context ?? []
273
+ });
274
+ void this.#runPrompt(connection, sessionId, blocks);
275
+ }
276
+ /**
277
+ * Throws when the session cannot take a prompt now. The route calls this
278
+ * before it builds the context, which can be slow and writes attachments.
279
+ */
280
+ assertCanPrompt() {
281
+ this.#readyChannel();
282
+ }
283
+ #readyChannel() {
284
+ const connection = this.#connection;
285
+ const sessionId = this.#acpSessionId;
286
+ if (this.#status !== 'ready' || !connection || !sessionId) {
287
+ throw new AgentSessionStateError(this.#status === 'busy'
288
+ ? 'The agent is still answering the previous prompt'
289
+ : `The session is ${this.#status}`);
290
+ }
291
+ return { connection, sessionId };
292
+ }
293
+ async #runPrompt(connection, sessionId, blocks) {
294
+ try {
295
+ const response = await connection.agent.request(acp.methods.agent.session.prompt, { sessionId, prompt: blocks });
296
+ this.#emit({ type: 'stop', stopReason: response.stopReason });
297
+ }
298
+ catch (error) {
299
+ // A failure of the transport comes before the `exit` event, and only
300
+ // that event can say why the agent died: let it report first.
301
+ if (!(error instanceof acp.RequestError)) {
302
+ await Promise.race([
303
+ this.#exited,
304
+ delay(EXIT_GRACE_MS, undefined, { ref: false })
305
+ ]);
306
+ }
307
+ if (!this.#isClosed() && this.#error === null) {
308
+ this.#emit({ type: 'error', message: errorMessage(error) });
309
+ }
310
+ }
311
+ finally {
312
+ // A request still open once the turn is over (it ended in an error, say)
313
+ // is dead: answering it later would still make the agent act on it.
314
+ this.#cancelPendingPermissions();
315
+ if (this.#status === 'busy') {
316
+ this.#setStatus('ready');
317
+ }
318
+ }
319
+ }
320
+ /** A method, so TypeScript does not keep a stale narrowing across an `await`. */
321
+ #isClosed() {
322
+ return this.#status === 'closed';
323
+ }
324
+ async cancel() {
325
+ if (this.#status !== 'busy' || !this.#connection || !this.#acpSessionId) {
326
+ return;
327
+ }
328
+ try {
329
+ await this.#connection.agent.notify(acp.methods.agent.session.cancel, {
330
+ sessionId: this.#acpSessionId
331
+ });
332
+ }
333
+ catch (error) {
334
+ // The connection closed since the check above: the turn is over anyway.
335
+ if (!this.#isClosed()) {
336
+ throw new AgentSessionError(errorMessage(error), { cause: error });
337
+ }
338
+ }
339
+ }
340
+ /**
341
+ * Returns false when no such request is pending (already answered or
342
+ * cancelled). Throws when `optionId` is not one the agent offered.
343
+ */
344
+ answerPermission(requestId, optionId) {
345
+ const pending = this.#pendingPermissions.get(requestId);
346
+ if (!pending) {
347
+ return false;
348
+ }
349
+ if (optionId !== undefined &&
350
+ !pending.request.options.some(option => option.optionId === optionId)) {
351
+ throw new AgentConfigError(`Unknown option "${optionId}"`);
352
+ }
353
+ pending.resolve(optionId === undefined
354
+ ? { outcome: { outcome: 'cancelled' } }
355
+ : { outcome: { outcome: 'selected', optionId } });
356
+ return true;
357
+ }
358
+ /**
359
+ * Switches the server answering the agent's permission requests itself (see
360
+ * `autoApproveChoice`), requests already waiting included. Off by default;
361
+ * the UI only turns it on after warning the user.
362
+ */
363
+ setAutoApprove(enabled) {
364
+ if (this.#status === 'closed') {
365
+ throw new AgentSessionStateError('The session is closed');
366
+ }
367
+ if (this.#autoApprove === enabled) {
368
+ return;
369
+ }
370
+ this.#autoApprove = enabled;
371
+ this.#emit({ type: 'auto-approve', enabled });
372
+ if (!enabled) {
373
+ return;
374
+ }
375
+ // Resolving removes the entry being visited, which Map iteration allows.
376
+ for (const pending of this.#pendingPermissions.values()) {
377
+ const response = this.#autoApprovePermission(pending.request);
378
+ if (response) {
379
+ pending.resolve(response);
380
+ }
381
+ }
382
+ }
383
+ #autoApprovePermission(request) {
384
+ const choice = autoApproveChoice(request.options);
385
+ if (!choice) {
386
+ return undefined;
387
+ }
388
+ this.#emit({ type: 'auto-approved', title: request.title });
389
+ return { outcome: { outcome: 'selected', optionId: choice.optionId } };
390
+ }
391
+ #requestPermission(params) {
392
+ // Requests belong to a turn; one arriving outside it has nobody to answer it.
393
+ if (this.#status !== 'busy') {
394
+ return Promise.resolve({ outcome: { outcome: 'cancelled' } });
395
+ }
396
+ const requestId = randomUUID();
397
+ const request = mapPermissionRequest(requestId, params);
398
+ if (this.#autoApprove) {
399
+ const response = this.#autoApprovePermission(request);
400
+ if (response) {
401
+ return Promise.resolve(response);
402
+ }
403
+ }
404
+ return new Promise(resolve => {
405
+ this.#pendingPermissions.set(requestId, {
406
+ request,
407
+ resolve: response => {
408
+ this.#pendingPermissions.delete(requestId);
409
+ this.#emit({ type: 'permission-resolved', requestId });
410
+ resolve(response);
411
+ }
412
+ });
413
+ this.#emit(request);
414
+ });
415
+ }
416
+ #onUpdate(update) {
417
+ // Agents may speak outside a turn — pi greets with its version and skill
418
+ // list right after session/new. That is noise in a chat transcript.
419
+ if (this.#status !== 'busy' &&
420
+ update.sessionUpdate === 'agent_message_chunk') {
421
+ return;
422
+ }
423
+ const event = mapSessionUpdate(update);
424
+ if (event?.type === 'config') {
425
+ // Keep `#config` current: `setConfig` checks values against it.
426
+ this.#setConfig(event.options);
427
+ }
428
+ else if (event) {
429
+ this.#emit(event);
430
+ }
431
+ }
432
+ /**
433
+ * Buffered events newer than `afterId`. `gap` means the buffer no longer
434
+ * reaches back to `afterId`: the client missed events for good and should
435
+ * rebuild this chat from what is left.
436
+ */
437
+ replay(afterId) {
438
+ return {
439
+ envelopes: this.#buffer.filter(envelope => envelope.id > afterId),
440
+ gap: afterId > 0 && this.#evictedId > afterId
441
+ };
442
+ }
443
+ /** Delivers live events (no replay — see `replay`). Returns an unsubscribe function. */
444
+ onEvent(listener) {
445
+ this.#listeners.add(listener);
446
+ return () => this.#listeners.delete(listener);
447
+ }
448
+ #emit(event) {
449
+ const id = this.#options.nextEventId?.() ?? ++this.#ownEventId;
450
+ const envelope = { id, event };
451
+ this.#buffer.push(envelope);
452
+ const limit = this.#options.bufferLimit ?? MAX_BUFFERED_EVENTS;
453
+ if (this.#buffer.length > limit) {
454
+ const dropped = this.#buffer.splice(0, this.#buffer.length - limit);
455
+ this.#evictedId = dropped.at(-1)?.id ?? this.#evictedId;
456
+ }
457
+ for (const listener of this.#listeners) {
458
+ listener(envelope);
459
+ }
460
+ }
461
+ #setStatus(status, error) {
462
+ if (this.#status === status) {
463
+ return;
464
+ }
465
+ this.#status = status;
466
+ this.#emit({
467
+ type: 'status',
468
+ status,
469
+ ...(error === undefined ? {} : { error })
470
+ });
471
+ }
472
+ #markClosed() {
473
+ if (this.#status === 'closed') {
474
+ return;
475
+ }
476
+ this.#setStatus('closed', this.#error ?? undefined);
477
+ void rm(this.attachmentDir, { recursive: true, force: true }).catch(() => { });
478
+ // Unanswered permission requests must not leave the agent blocked forever.
479
+ this.#cancelPendingPermissions();
480
+ }
481
+ #cancelPendingPermissions() {
482
+ // Each resolver removes itself from the map, which Map iteration allows.
483
+ for (const pending of this.#pendingPermissions.values()) {
484
+ pending.resolve({ outcome: { outcome: 'cancelled' } });
485
+ }
486
+ }
487
+ close() {
488
+ this.#closing = true;
489
+ this.#markClosed();
490
+ this.#connection?.close();
491
+ if (this.#child &&
492
+ this.#child.exitCode === null &&
493
+ this.#child.signalCode === null) {
494
+ this.#child.kill();
495
+ }
496
+ }
497
+ }
@@ -0,0 +1,137 @@
1
+ import { MAX_ATTACHMENT_BYTES } from "../../../shared/agents/constants.js";
2
+ import { AgentContextError } from "../../errors/agents.js";
3
+ import { errorMessage } from "../../../shared/utils/error-message.js";
4
+ import { formatDiffFile } from "../../../shared/agents/context-format.js";
5
+ import { randomUUID } from 'node:crypto';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { getStagedDiff, getUnstagedDiff } from "../diff.service.js";
10
+ import { exportAsMarkdown } from "../export.service.js";
11
+ import { resolveWithinRepo } from "../git.service.js";
12
+ import { safeFileName } from "../../utils/file-name.js";
13
+ import { decodeText, truncateText } from "../../utils/text.js";
14
+ /** Per attached item. Bigger contexts are cut with a visible marker, never silently. */
15
+ export const MAX_EMBEDDED_CHARS = 200_000;
16
+ /** What ACP agents (and the models behind them) accept as an image block. */
17
+ const IMAGE_MIME_TYPES = new Set([
18
+ 'image/png',
19
+ 'image/jpeg',
20
+ 'image/gif',
21
+ 'image/webp'
22
+ ]);
23
+ function diffFilePath(file) {
24
+ return file.newPath || file.oldPath;
25
+ }
26
+ function fenceFor(text) {
27
+ const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length));
28
+ return '`'.repeat(Math.max(3, longest + 1));
29
+ }
30
+ /**
31
+ * Text the agent cannot fetch on its own (a diff of staged content, a review)
32
+ * goes in as an embedded resource when the agent supports it, otherwise as a
33
+ * fenced block in a plain text part — same content either way.
34
+ */
35
+ function embed(deps, uri, title, mimeType, text) {
36
+ const body = truncateText(text, MAX_EMBEDDED_CHARS, omitted => `\n… [truncated by githuman: ${omitted} more characters]`);
37
+ if (deps.embeddedContext) {
38
+ return { type: 'resource', resource: { uri, mimeType, text: body } };
39
+ }
40
+ const fence = fenceFor(body);
41
+ return { type: 'text', text: `${title}\n${fence}\n${body}\n${fence}` };
42
+ }
43
+ function asContextError(error) {
44
+ return new AgentContextError(errorMessage(error), { cause: error });
45
+ }
46
+ async function buildDiffItem(item, deps) {
47
+ const files = item.source === 'staged'
48
+ ? await getStagedDiff(deps.repositoryPath)
49
+ : await getUnstagedDiff(deps.repositoryPath);
50
+ const selected = item.path === undefined
51
+ ? files
52
+ : files.filter(file => diffFilePath(file) === item.path);
53
+ if (selected.length === 0) {
54
+ throw new AgentContextError(item.path === undefined
55
+ ? `No ${item.source} changes to attach`
56
+ : `No ${item.source} changes in "${item.path}"`);
57
+ }
58
+ return embed(deps, `githuman://diff/${item.source}/${item.path ?? ''}`, `Diff (${item.source}${item.path === undefined ? '' : `, ${item.path}`}):`, 'text/x-diff', selected.map(formatDiffFile).join('\n'));
59
+ }
60
+ async function buildFileItem(item, deps) {
61
+ let absolute;
62
+ try {
63
+ absolute = resolveWithinRepo(deps.repositoryPath, item.path);
64
+ }
65
+ catch (error) {
66
+ throw asContextError(error);
67
+ }
68
+ const info = await stat(absolute).catch(() => null);
69
+ if (!info?.isFile()) {
70
+ throw new AgentContextError(`"${item.path}" is not a file in the repository`);
71
+ }
72
+ return {
73
+ type: 'resource_link',
74
+ uri: pathToFileURL(absolute).href,
75
+ name: item.path
76
+ };
77
+ }
78
+ async function buildReviewItem(item, deps) {
79
+ let markdown;
80
+ try {
81
+ markdown = await exportAsMarkdown(deps.db, item.reviewId, deps.repositoryPath);
82
+ }
83
+ catch (error) {
84
+ throw asContextError(error);
85
+ }
86
+ return embed(deps, `githuman://review/${item.reviewId}`, 'Code review with comments:', 'text/markdown', markdown);
87
+ }
88
+ /**
89
+ * Images become image blocks, UTF-8 text is embedded. Anything else (PDF,
90
+ * archives, …) is written to disk and passed as a link the agent opens with
91
+ * its own tools: adapters don't hand ACP blob resources to the model as files
92
+ * — codex-acp turns them into a base64 text dump the model can't read.
93
+ */
94
+ async function buildAttachmentItem(item, deps) {
95
+ const bytes = Buffer.from(item.data, 'base64');
96
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
97
+ throw new AgentContextError(`"${item.name}" is larger than ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`);
98
+ }
99
+ if (IMAGE_MIME_TYPES.has(item.mimeType)) {
100
+ if (!deps.image) {
101
+ throw new AgentContextError('This agent does not accept images');
102
+ }
103
+ return { type: 'image', mimeType: item.mimeType, data: item.data };
104
+ }
105
+ const text = decodeText(bytes);
106
+ if (text !== null) {
107
+ return embed(deps, `githuman://attachment/${encodeURIComponent(item.name)}`, `Attached file ${item.name}:`, item.mimeType || 'text/plain', text);
108
+ }
109
+ // A directory per attachment keeps the user's file name without collisions.
110
+ const dir = join(deps.attachmentDir, randomUUID());
111
+ const path = join(dir, safeFileName(item.name));
112
+ await mkdir(dir, { recursive: true });
113
+ await writeFile(path, bytes);
114
+ return {
115
+ type: 'resource_link',
116
+ uri: pathToFileURL(path).href,
117
+ name: item.name,
118
+ ...(item.mimeType === '' ? {} : { mimeType: item.mimeType })
119
+ };
120
+ }
121
+ function buildItem(item, deps) {
122
+ if (item.kind === 'diff') {
123
+ return buildDiffItem(item, deps);
124
+ }
125
+ if (item.kind === 'file') {
126
+ return buildFileItem(item, deps);
127
+ }
128
+ if (item.kind === 'attachment') {
129
+ return buildAttachmentItem(item, deps);
130
+ }
131
+ return buildReviewItem(item, deps);
132
+ }
133
+ /** The user's text first, then each attached item as its own content block. */
134
+ export async function buildPromptBlocks(request, deps) {
135
+ const items = await Promise.all((request.context ?? []).map(item => buildItem(item, deps)));
136
+ return [{ type: 'text', text: request.text }, ...items];
137
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getFilesAtRef } from "../git.service.js";
4
+ import { DEFAULT_FILE_SEARCH_LIMIT } from "../../../shared/agents/constants.js";
5
+ import { rankFiles } from "../../../shared/agents/file-search.js";
6
+ /**
7
+ * The repository's file list for `@` mentions. Read once from `git ls-files`
8
+ * and filtered in memory — the query never reaches a git argument — and
9
+ * dropped whenever the working tree changes.
10
+ */
11
+ export function createFileIndex(repositoryPath, eventBus) {
12
+ let cached = null;
13
+ let loading = null;
14
+ // A change during a load makes that load's result stale on arrival.
15
+ let generation = 0;
16
+ const unsubscribe = eventBus.subscribe(event => {
17
+ if (event.type === 'files:changed') {
18
+ generation++;
19
+ cached = null;
20
+ }
21
+ });
22
+ function files() {
23
+ if (cached) {
24
+ return Promise.resolve(cached);
25
+ }
26
+ if (!loading) {
27
+ const started = generation;
28
+ loading = getFilesAtRef(repositoryPath, 'WORKTREE')
29
+ .then(list => {
30
+ if (started === generation) {
31
+ cached = list;
32
+ }
33
+ return list;
34
+ })
35
+ .finally(() => {
36
+ loading = null;
37
+ });
38
+ }
39
+ return loading;
40
+ }
41
+ return {
42
+ async search(query, limit = DEFAULT_FILE_SEARCH_LIMIT) {
43
+ const ranked = rankFiles(await files(), query);
44
+ const found = [];
45
+ for (const path of ranked) {
46
+ // `ls-files --cached` still lists files deleted from the working tree.
47
+ if (existsSync(join(repositoryPath, path))) {
48
+ found.push(path);
49
+ if (found.length >= limit) {
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ return found;
55
+ },
56
+ close: unsubscribe
57
+ };
58
+ }