excaliagent 0.0.0-stage → 0.2.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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/bin/excaliagent.js +2 -0
  4. package/dist/server/args.js +52 -0
  5. package/dist/server/cli.js +100 -0
  6. package/dist/server/desktop/desktop.js +150 -0
  7. package/dist/server/desktop/exec.js +27 -0
  8. package/dist/server/desktop/process-table.js +167 -0
  9. package/dist/server/http.js +240 -0
  10. package/dist/server/hub.js +101 -0
  11. package/dist/server/panes/herdr-client.js +220 -0
  12. package/dist/server/panes/herdr-clients.js +37 -0
  13. package/dist/server/panes/herdr.js +248 -0
  14. package/dist/server/panes/none.js +10 -0
  15. package/dist/server/panes/types.js +1 -0
  16. package/dist/server/scene-store.js +61 -0
  17. package/dist/server/sources/claude.js +292 -0
  18. package/dist/server/sources/git.js +65 -0
  19. package/dist/server/sources/log.js +10 -0
  20. package/dist/server/sources/registry.js +79 -0
  21. package/dist/server/sources/transcript.js +204 -0
  22. package/dist/server/sources/types.js +1 -0
  23. package/dist/shared/types.js +9 -0
  24. package/dist/web/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  25. package/dist/web/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  26. package/dist/web/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  27. package/dist/web/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  28. package/dist/web/assets/abnfDiagram-VCTEODGH-tmjYj3VY.js +1 -0
  29. package/dist/web/assets/ar-SA-G6X2FPQ2-8ZGGBDRT.js +10 -0
  30. package/dist/web/assets/arc-Y1ARJVZ4.js +1 -0
  31. package/dist/web/assets/architecture-7GRP2DOG-CsvDkWt3.js +1 -0
  32. package/dist/web/assets/architectureDiagram-5GKGNRK7-Bq-1CDhU.js +36 -0
  33. package/dist/web/assets/array-BifhSqXX.js +1 -0
  34. package/dist/web/assets/az-AZ-76LH7QW2-DbdS_q-w.js +1 -0
  35. package/dist/web/assets/bg-BG-XCXSNQG7-CANqXhG1.js +5 -0
  36. package/dist/web/assets/blockDiagram-I7D4REHJ-DtYn-xOl.js +129 -0
  37. package/dist/web/assets/bn-BD-2XOGV67Q-DJdq0B1R.js +5 -0
  38. package/dist/web/assets/c4Diagram-7LVT6UL2-DW6Pzj1e.js +38 -0
  39. package/dist/web/assets/ca-ES-6MX7JW3Y-B7pUqerV.js +8 -0
  40. package/dist/web/assets/channel-gFr9jEgP.js +1 -0
  41. package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  42. package/dist/web/assets/chunk-4HAMMTFA-_AKNu1--.js +62 -0
  43. package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  44. package/dist/web/assets/chunk-75Z2AOVW-BoMl8Mqw.js +2 -0
  45. package/dist/web/assets/chunk-DU6HZSFF-DYHa8ENH.js +127 -0
  46. package/dist/web/assets/chunk-EIO257PC--adgQBo2.js +22 -0
  47. package/dist/web/assets/chunk-F27PBJKO-CvQi01qZ.js +1 -0
  48. package/dist/web/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  49. package/dist/web/assets/chunk-GMAD6QVW-ojaxPuMG.js +72 -0
  50. package/dist/web/assets/chunk-GVQU2GXP-DYAeD1IL.js +1 -0
  51. package/dist/web/assets/chunk-IMKFNOWR-oVPWqbpV.js +231 -0
  52. package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  53. package/dist/web/assets/chunk-L3NEJ4N5-BelAXiIN.js +1 -0
  54. package/dist/web/assets/chunk-OSK3NFVY-CATtjN8l.js +10 -0
  55. package/dist/web/assets/chunk-P2QGCYS3-CbKPCENJ.js +1 -0
  56. package/dist/web/assets/chunk-POPQ4Y6H-BfOCSezn.js +1 -0
  57. package/dist/web/assets/chunk-PWAF6VOD-C7O-JWJt.js +1 -0
  58. package/dist/web/assets/chunk-SHT3W25Y-BfEpPCTX.js +168 -0
  59. package/dist/web/assets/chunk-SVP7TREG-CaeKcCSu.js +88 -0
  60. package/dist/web/assets/chunk-TICWLB2K-Bx6AcLB_.js +206 -0
  61. package/dist/web/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
  62. package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  63. package/dist/web/assets/classDiagram-ZZMXUADV-9J2CNLBI.js +1 -0
  64. package/dist/web/assets/classDiagram-v2-VYDZK3BY-9J2CNLBI.js +1 -0
  65. package/dist/web/assets/cose-bilkent-JH36ORCC-lWVj4msc.js +1 -0
  66. package/dist/web/assets/cs-CZ-2BRQDIVT-DF8hZ2mh.js +11 -0
  67. package/dist/web/assets/cynefin-OW5HDTMX-Dol6Weuo.js +1 -0
  68. package/dist/web/assets/cynefinDiagram-5FMLGOSQ-CbOJ2lks.js +62 -0
  69. package/dist/web/assets/cytoscape.esm-BegBNzOV.js +321 -0
  70. package/dist/web/assets/da-DK-5WZEPLOC-CgPdNz6E.js +5 -0
  71. package/dist/web/assets/dagre-D7JM2Yhv.js +1 -0
  72. package/dist/web/assets/dagre-GXQ25YYZ-CcuaBID7.js +4 -0
  73. package/dist/web/assets/de-DE-XR44H4JA-DaVeYWRc.js +8 -0
  74. package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  75. package/dist/web/assets/diagram-S7CK7UJ4-BR2gIsNx.js +30 -0
  76. package/dist/web/assets/diagram-UQ7AKVKN-DwSZsV-Z.js +41 -0
  77. package/dist/web/assets/diagram-VSXAHHWV-BcJpQHbj.js +3 -0
  78. package/dist/web/assets/diagram-VX7I27RA-CvjRTkwu.js +24 -0
  79. package/dist/web/assets/diagram-Z3DM3KII-vg0-r_P4.js +24 -0
  80. package/dist/web/assets/directory-open-01563666-D4xXyWb_.js +1 -0
  81. package/dist/web/assets/directory-open-4ed118d0-1i309Asm.js +1 -0
  82. package/dist/web/assets/dist-CumnGVmJ.js +1 -0
  83. package/dist/web/assets/dist-D-3cz42D.js +62 -0
  84. package/dist/web/assets/ebnfDiagram-PWID7BFC-DZCaMmMd.js +1 -0
  85. package/dist/web/assets/el-GR-BZB4AONW-nYncjO-L.js +10 -0
  86. package/dist/web/assets/en-B4ZKOASM-DqIz0rJz.js +1 -0
  87. package/dist/web/assets/erDiagram-RLTQ6QDP-DgyrjoaZ.js +99 -0
  88. package/dist/web/assets/es-ES-U4NZUMDT-e3WeRDLl.js +9 -0
  89. package/dist/web/assets/eu-ES-A7QVB2H4-CwXv1t13.js +11 -0
  90. package/dist/web/assets/eventmodeling-NTZA5JFV-DCTWpAwP.js +1 -0
  91. package/dist/web/assets/fa-IR-HGAKTJCU-D-9plwKu.js +8 -0
  92. package/dist/web/assets/fi-FI-Z5N7JZ37-BTK1rs8G.js +6 -0
  93. package/dist/web/assets/file-open-002ab408-BHUWm0Sh.js +1 -0
  94. package/dist/web/assets/file-open-7c801643-DZtJT5zp.js +1 -0
  95. package/dist/web/assets/file-save-3189631c-CO9S4HFW.js +1 -0
  96. package/dist/web/assets/file-save-745eba88-Bwdfz6OZ.js +1 -0
  97. package/dist/web/assets/flowDiagram-HODETNUW--2QUGuZ7.js +1 -0
  98. package/dist/web/assets/fr-FR-RHASNOE6-CAuUMPtN.js +9 -0
  99. package/dist/web/assets/ganttDiagram-EL5Y4UJY-bFfu4BMj.js +292 -0
  100. package/dist/web/assets/gitGraph-4MIJSDKK-DIl4-oqK.js +1 -0
  101. package/dist/web/assets/gitGraphDiagram-WWUBYQGX-ho3FT8XP.js +106 -0
  102. package/dist/web/assets/gl-ES-HMX3MZ6V-DlKPdjtk.js +10 -0
  103. package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
  104. package/dist/web/assets/he-IL-6SHJWFNN-CLyP0zBo.js +10 -0
  105. package/dist/web/assets/hi-IN-IWLTKZ5I-kvhBHNzH.js +4 -0
  106. package/dist/web/assets/hu-HU-A5ZG7DT2-Dtd-o-RJ.js +7 -0
  107. package/dist/web/assets/id-ID-SAP4L64H-VZ3IV-8B.js +10 -0
  108. package/dist/web/assets/image-GAAHSSAO-DWvjHHd3.js +1 -0
  109. package/dist/web/assets/image-blob-reduce.esm-Fj7y_Tzy.js +2 -0
  110. package/dist/web/assets/index-B2hGCC2V.js +212 -0
  111. package/dist/web/assets/index-DtRVLazd.css +1 -0
  112. package/dist/web/assets/info-A6RAGUB7-DN8pwhKt.js +1 -0
  113. package/dist/web/assets/infoDiagram-27XIBGKW-i2JHBJKe.js +2 -0
  114. package/dist/web/assets/init-C-OQMol4.js +1 -0
  115. package/dist/web/assets/ishikawaDiagram-5VMMS53U-DLLdu9-W.js +70 -0
  116. package/dist/web/assets/it-IT-JPQ66NNP-6SULWnRA.js +11 -0
  117. package/dist/web/assets/ja-JP-DBVTYXUO-BbgutQYO.js +8 -0
  118. package/dist/web/assets/journeyDiagram-3NMN7TZE-DsysKJxX.js +139 -0
  119. package/dist/web/assets/kaa-6HZHGXH3-Bc6yhZWQ.js +1 -0
  120. package/dist/web/assets/kab-KAB-ZGHBKWFO-BaICxis-.js +8 -0
  121. package/dist/web/assets/kanban-definition-UXKFOSKX-w3k_6RhT.js +89 -0
  122. package/dist/web/assets/katex-DJhrJSbw.js +257 -0
  123. package/dist/web/assets/kk-KZ-P5N5QNE5-BVsfmPoG.js +1 -0
  124. package/dist/web/assets/km-KH-HSX4SM5Z-BS1M1bUx.js +11 -0
  125. package/dist/web/assets/ko-KR-MTYHY66A-C0x16zQm.js +9 -0
  126. package/dist/web/assets/ku-TR-6OUDTVRD-BWI9z5E8.js +9 -0
  127. package/dist/web/assets/line-BRXlgUrO.js +1 -0
  128. package/dist/web/assets/linear-C9iwaMOi.js +1 -0
  129. package/dist/web/assets/lt-LT-XHIRWOB4-CTDq1zFR.js +3 -0
  130. package/dist/web/assets/lv-LV-5QDEKY6T-h8b-E15B.js +7 -0
  131. package/dist/web/assets/mermaid-parser.core-Da_XEVmR.js +7 -0
  132. package/dist/web/assets/mindmap-definition-YA3MSWOX-DUBdgu2g.js +96 -0
  133. package/dist/web/assets/mr-IN-CRQNXWMA-dTdkz007.js +13 -0
  134. package/dist/web/assets/my-MM-5M5IBNSE-CmYwTRil.js +1 -0
  135. package/dist/web/assets/nb-NO-T6EIAALU-DLLYcK-s.js +10 -0
  136. package/dist/web/assets/nl-NL-IS3SIHDZ-mtv15Mmm.js +8 -0
  137. package/dist/web/assets/nn-NO-6E72VCQL-CJDTT3kB.js +8 -0
  138. package/dist/web/assets/oc-FR-POXYY2M6-DdHJTqH4.js +8 -0
  139. package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
  140. package/dist/web/assets/pa-IN-N4M65BXN-DxzzhbLu.js +4 -0
  141. package/dist/web/assets/packet-AYTQ26CC-CzX42SOE.js +1 -0
  142. package/dist/web/assets/path-fybaL0A-.js +1 -0
  143. package/dist/web/assets/pegDiagram-XKGWAZYB-1R2Hd3Th.js +1 -0
  144. package/dist/web/assets/percentages-BXMCSKIN-Br7824d3.js +1 -0
  145. package/dist/web/assets/pica-CXv_OHDX.js +2 -0
  146. package/dist/web/assets/pie-WAS4IAKB-DEI2uslz.js +1 -0
  147. package/dist/web/assets/pieDiagram-E7YTZNPT-DOQnFSrs.js +39 -0
  148. package/dist/web/assets/pl-PL-T2D74RX3-Dpyab0lI.js +9 -0
  149. package/dist/web/assets/pt-BR-5N22H2LF-DOGrUA6Y.js +9 -0
  150. package/dist/web/assets/pt-PT-UZXXM6DQ-DAoNiiOg.js +9 -0
  151. package/dist/web/assets/quadrantDiagram-AXDQQJYC-CQE1OxRJ.js +7 -0
  152. package/dist/web/assets/radar-RG4KPBEZ-B-er2ORT.js +1 -0
  153. package/dist/web/assets/railroad-74A4TZTK-D1Veuu81.js +1 -0
  154. package/dist/web/assets/railroad-abnf-HS5TGJTU-DTxLo5az.js +1 -0
  155. package/dist/web/assets/railroad-ebnf-LZEXJU2U-BmP2IZGe.js +1 -0
  156. package/dist/web/assets/railroad-peg-WCYAUIDC-Do60gMTC.js +1 -0
  157. package/dist/web/assets/railroadDiagram-O6MQD6OU-5I8Qk1dI.js +1 -0
  158. package/dist/web/assets/requirementDiagram-BXWQKSXE-CIIM8YmC.js +84 -0
  159. package/dist/web/assets/ro-RO-JPDTUUEW-DR9yu2MV.js +11 -0
  160. package/dist/web/assets/rolldown-runtime-COiEfhsA.js +1 -0
  161. package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  162. package/dist/web/assets/roundRect-D01gJrlt.js +1 -0
  163. package/dist/web/assets/ru-RU-B4JR7IUQ-B9uIhZUd.js +9 -0
  164. package/dist/web/assets/sankeyDiagram-P5KCCOFB-BdGmiiCQ.js +40 -0
  165. package/dist/web/assets/sequenceDiagram-WJ2MYXX4-BeCmgCpG.js +162 -0
  166. package/dist/web/assets/si-LK-N5RQ5JYF-C7DYCsOB.js +1 -0
  167. package/dist/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  168. package/dist/web/assets/sk-SK-C5VTKIMK-BbBBLLCs.js +6 -0
  169. package/dist/web/assets/sl-SI-NN7IZMDC-BIaW2Iuj.js +6 -0
  170. package/dist/web/assets/src-BWF7jMDv.js +1 -0
  171. package/dist/web/assets/stateDiagram-D77RDMKH-CmENpSfH.js +1 -0
  172. package/dist/web/assets/stateDiagram-v2-MP3YSRHH-BWgn52cG.js +1 -0
  173. package/dist/web/assets/subset-shared.chunk-d2GNuFHV.js +1 -0
  174. package/dist/web/assets/subset-worker.chunk-Bi16Jh4x.js +1 -0
  175. package/dist/web/assets/sv-SE-XGPEYMSR-D5A1U3nk.js +10 -0
  176. package/dist/web/assets/swimlanes-42K2YHIH-_ymwQg8o.js +1 -0
  177. package/dist/web/assets/swimlanesDiagram-VR7AAH4N-fzrjtpfS.js +8 -0
  178. package/dist/web/assets/ta-IN-2NMHFXQM-BDzXUwiE.js +9 -0
  179. package/dist/web/assets/th-TH-HPSO5L25-DdVnWuWW.js +2 -0
  180. package/dist/web/assets/timeline-definition-24CTP7MA-CRcZde4k.js +120 -0
  181. package/dist/web/assets/tr-TR-DEFEU3FU-BQwfGN3o.js +7 -0
  182. package/dist/web/assets/treeView-Q6P3EWNA-BHZZuSXr.js +1 -0
  183. package/dist/web/assets/treemap-WGGIJYW6-DAzotEQZ.js +1 -0
  184. package/dist/web/assets/uk-UA-QMV73CPH-CFYnWqHD.js +6 -0
  185. package/dist/web/assets/vennDiagram-4TSXK5OY-BsNz9Qnn.js +34 -0
  186. package/dist/web/assets/vi-VN-M7AON7JQ-DO3BySmm.js +5 -0
  187. package/dist/web/assets/wardley-WFR3VGLG-Cc2r8Vp8.js +1 -0
  188. package/dist/web/assets/wardleyDiagram-VM6X3IG4-47UvJO4N.js +78 -0
  189. package/dist/web/assets/xychartDiagram-S5SC5T6Z-CVl63zr-.js +7 -0
  190. package/dist/web/assets/zh-CN-LNUGB5OW-DKJ2CF-O.js +10 -0
  191. package/dist/web/assets/zh-HK-E62DVLB3-DHW8OH5j.js +1 -0
  192. package/dist/web/assets/zh-TW-RAJ6MFWO-B9cMDifZ.js +9 -0
  193. package/dist/web/fonts/Assistant/Assistant-Bold.woff2 +0 -0
  194. package/dist/web/fonts/Assistant/Assistant-Medium.woff2 +0 -0
  195. package/dist/web/fonts/Assistant/Assistant-Regular.woff2 +0 -0
  196. package/dist/web/fonts/Assistant/Assistant-SemiBold.woff2 +0 -0
  197. package/dist/web/fonts/Cascadia/CascadiaCode-Regular.woff2 +0 -0
  198. package/dist/web/fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2 +0 -0
  199. package/dist/web/fonts/ComicShanns/ComicShanns-Regular-6e066e8de2ac57ea9283adb9c24d7f0c.woff2 +0 -0
  200. package/dist/web/fonts/ComicShanns/ComicShanns-Regular-dc6a8806fa96795d7b3be5026f989a17.woff2 +0 -0
  201. package/dist/web/fonts/ComicShanns/ComicShanns-Regular-fcb0fc02dcbee4c9846b3e2508668039.woff2 +0 -0
  202. package/dist/web/fonts/Excalifont/Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2 +0 -0
  203. package/dist/web/fonts/Excalifont/Excalifont-Regular-3f2c5db56cc93c5a6873b1361d730c16.woff2 +0 -0
  204. package/dist/web/fonts/Excalifont/Excalifont-Regular-41b173a47b57366892116a575a43e2b6.woff2 +0 -0
  205. package/dist/web/fonts/Excalifont/Excalifont-Regular-623ccf21b21ef6b3a0d87738f77eb071.woff2 +0 -0
  206. package/dist/web/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2 +0 -0
  207. package/dist/web/fonts/Excalifont/Excalifont-Regular-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2 +0 -0
  208. package/dist/web/fonts/Excalifont/Excalifont-Regular-be310b9bcd4f1a43f571c46df7809174.woff2 +0 -0
  209. package/dist/web/fonts/Liberation/LiberationSans-Regular.woff2 +0 -0
  210. package/dist/web/fonts/Lilita/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2 +0 -0
  211. package/dist/web/fonts/Lilita/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2 +0 -0
  212. package/dist/web/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 +0 -0
  213. package/dist/web/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 +0 -0
  214. package/dist/web/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 +0 -0
  215. package/dist/web/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 +0 -0
  216. package/dist/web/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 +0 -0
  217. package/dist/web/fonts/Virgil/Virgil-Regular.woff2 +0 -0
  218. package/dist/web/index.html +15 -0
  219. package/package.json +68 -4
@@ -0,0 +1,248 @@
1
+ /**
2
+ * herdr pane link: pid → pane map built from pane.list + pane.process_info.
3
+ * Rebuilt on pane events, on (re)connect, on events_lost (herdr 0.9.2+), every
4
+ * `resyncMs` (0.9.0 has no events_lost), and on refresh(). Never throws when
5
+ * herdr is absent: available() is false and the socket is retried.
6
+ */
7
+ import { homedir } from "node:os";
8
+ import { basename, join } from "node:path";
9
+ import { createHerdrClient, HerdrError } from "./herdr-client.js";
10
+ import { createProcessTable } from "../desktop/process-table.js";
11
+ import { findHerdrClientPids, herdrSocketPath } from "./herdr-clients.js";
12
+ export { createNoPaneLink } from "./none.js";
13
+ export const HERDR_SUBSCRIPTIONS = [
14
+ { type: "pane.created" },
15
+ { type: "pane.closed" },
16
+ { type: "pane.exited" },
17
+ { type: "pane.agent_detected" },
18
+ // Labels shown on cards: rebuild when they appear, change or go away.
19
+ { type: "workspace.created" },
20
+ { type: "workspace.renamed" },
21
+ { type: "workspace.closed" },
22
+ { type: "tab.created" },
23
+ { type: "tab.renamed" },
24
+ { type: "tab.closed" },
25
+ ];
26
+ /** Every method this link sends (tests check them against the schema). */
27
+ export const HERDR_METHODS = [
28
+ "events.subscribe",
29
+ "pane.list",
30
+ "pane.process_info",
31
+ "pane.focus",
32
+ "workspace.list",
33
+ "tab.list",
34
+ "tab.create",
35
+ "workspace.create",
36
+ "pane.send_text",
37
+ ];
38
+ /** True when path is dir or inside it (separator boundary: /a/b is not inside /a/bc). */
39
+ export function isInside(path, dir) {
40
+ const d = dir.length > 1 ? dir.replace(/\/+$/, "") : dir;
41
+ return path === d || path.startsWith(d === "/" ? "/" : d + "/");
42
+ }
43
+ /** Workspace showing root: the focused pane's workspace if it matches, else the first match. */
44
+ export function pickWorkspace(panes, root) {
45
+ const matches = panes.filter((p) => [p.cwd, p.foreground_cwd].some((c) => typeof c === "string" && isInside(c, root)));
46
+ const pick = matches.find((p) => p.focused) ?? matches[0];
47
+ return pick ? (pick.workspace_id ?? workspaceOf(pick.pane_id)) : undefined;
48
+ }
49
+ export function defaultHerdrSocketPath() {
50
+ return herdrSocketPath(process.env, homedir()) ?? join(homedir(), ".config", "herdr", "herdr.sock");
51
+ }
52
+ const workspaceOf = (paneId) => paneId.split(":")[0] ?? paneId;
53
+ /** pid → pane for every foreground process of every pane; `panes` supplies ids and labels. */
54
+ export function parsePaneMap(infos, panes = new Map()) {
55
+ const map = new Map();
56
+ for (const info of infos) {
57
+ const pane = panes.get(info.pane_id) ?? { paneId: info.pane_id, workspaceId: workspaceOf(info.pane_id) };
58
+ for (const p of info.foreground_processes ?? []) {
59
+ if (typeof p.pid === "number")
60
+ map.set(p.pid, pane);
61
+ }
62
+ }
63
+ return map;
64
+ }
65
+ function sameMap(a, b) {
66
+ if (a.size !== b.size)
67
+ return false;
68
+ for (const [pid, pane] of a) {
69
+ const other = b.get(pid);
70
+ if (!other ||
71
+ other.paneId !== pane.paneId ||
72
+ other.workspaceId !== pane.workspaceId ||
73
+ other.workspaceLabel !== pane.workspaceLabel ||
74
+ other.tabLabel !== pane.tabLabel) {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+ export function createHerdrLink(opts = {}) {
81
+ const { resyncMs = 30_000 } = opts;
82
+ const socketPath = opts.socketPath ?? defaultHerdrSocketPath();
83
+ const table = opts.processTable ?? createProcessTable();
84
+ const clientPids = () => findHerdrClientPids({ socketPath, table });
85
+ let map = new Map();
86
+ const listeners = new Set();
87
+ let closed = false;
88
+ const setMap = (next) => {
89
+ if (sameMap(map, next))
90
+ return;
91
+ map = next;
92
+ for (const cb of [...listeners]) {
93
+ try {
94
+ cb();
95
+ }
96
+ catch {
97
+ // a listener's failure must not break the link
98
+ }
99
+ }
100
+ };
101
+ const client = createHerdrClient({
102
+ socketPath,
103
+ subscriptions: HERDR_SUBSCRIPTIONS,
104
+ requestTimeoutMs: opts.requestTimeoutMs,
105
+ retryMinMs: opts.retryMinMs,
106
+ retryMaxMs: opts.retryMaxMs,
107
+ connect: opts.connect,
108
+ onConnect: () => void rebuild(),
109
+ onEvent: () => void rebuild(),
110
+ onEventsLost: () => void rebuild(),
111
+ onDisconnect: () => setMap(new Map()),
112
+ });
113
+ /** id → label from workspace.list / tab.list; empty when the call fails (labels are optional). */
114
+ async function labels(method, key, id) {
115
+ try {
116
+ const res = (await client.request(method, {}));
117
+ const out = new Map();
118
+ for (const item of res[key] ?? []) {
119
+ const k = item[id];
120
+ if (typeof k === "string" && typeof item.label === "string")
121
+ out.set(k, item.label);
122
+ }
123
+ return out;
124
+ }
125
+ catch {
126
+ return new Map();
127
+ }
128
+ }
129
+ async function build() {
130
+ let list;
131
+ const wsLabels = labels("workspace.list", "workspaces", "workspace_id");
132
+ const tabLabels = labels("tab.list", "tabs", "tab_id");
133
+ try {
134
+ const res = (await client.request("pane.list", {}));
135
+ list = res.panes ?? [];
136
+ }
137
+ catch {
138
+ return; // keep the current map; the next event/timer/reconnect retries
139
+ }
140
+ const [ws, tabs] = await Promise.all([wsLabels, tabLabels]);
141
+ const panes = new Map();
142
+ for (const p of list) {
143
+ const pane = { paneId: p.pane_id, workspaceId: p.workspace_id ?? workspaceOf(p.pane_id) };
144
+ const wl = ws.get(pane.workspaceId);
145
+ const tl = p.tab_id !== undefined ? tabs.get(p.tab_id) : undefined;
146
+ if (wl !== undefined)
147
+ pane.workspaceLabel = wl;
148
+ if (tl !== undefined)
149
+ pane.tabLabel = tl;
150
+ panes.set(p.pane_id, pane);
151
+ }
152
+ const panesList = list;
153
+ const infos = await Promise.all(panesList.map(async (p) => {
154
+ try {
155
+ const res = (await client.request("pane.process_info", { pane_id: p.pane_id }));
156
+ return res.process_info;
157
+ }
158
+ catch {
159
+ return undefined; // pane vanished or herdr hiccuped: skip it
160
+ }
161
+ }));
162
+ if (closed || !client.connected())
163
+ return;
164
+ setMap(parsePaneMap(infos.filter((i) => !!i), panes));
165
+ }
166
+ // Coalesce: at most one build running, plus one queued behind it.
167
+ let running;
168
+ let queued;
169
+ function rebuild() {
170
+ if (closed || !client.connected())
171
+ return Promise.resolve();
172
+ if (!running) {
173
+ running = build().finally(() => {
174
+ running = undefined;
175
+ });
176
+ return running;
177
+ }
178
+ queued ??= running.then(() => {
179
+ queued = undefined;
180
+ return rebuild();
181
+ });
182
+ return queued;
183
+ }
184
+ const timer = setInterval(() => void rebuild(), resyncMs);
185
+ timer.unref?.();
186
+ const pending = new Set();
187
+ async function raise() {
188
+ if (!opts.desktop)
189
+ return;
190
+ try {
191
+ await opts.desktop.raise(await clientPids());
192
+ }
193
+ catch {
194
+ // raising the window is best effort
195
+ }
196
+ }
197
+ return {
198
+ available: () => !closed && client.connected(),
199
+ match: (pid) => map.get(pid),
200
+ async focus(paneId) {
201
+ if (closed || !client.connected())
202
+ return;
203
+ await client.request("pane.focus", { pane_id: paneId });
204
+ await raise();
205
+ },
206
+ async resume(req) {
207
+ if (closed || !client.connected())
208
+ throw new HerdrError("unavailable", "herdr is not connected");
209
+ const root = req.repoRoot ?? req.cwd;
210
+ const { panes = [] } = (await client.request("pane.list", {}));
211
+ const workspaceId = pickWorkspace(panes, root);
212
+ const created = (workspaceId !== undefined
213
+ ? await client.request("tab.create", { workspace_id: workspaceId, cwd: req.cwd, label: req.label, focus: true })
214
+ : await client.request("workspace.create", { cwd: req.cwd, label: basename(root) || root, focus: true }));
215
+ const paneId = created.root_pane?.pane_id;
216
+ if (typeof paneId !== "string")
217
+ throw new HerdrError("bad_response", "herdr did not return the new pane");
218
+ // A fresh shell buffers typed input until it is ready (checked on herdr 0.9.0 with sh and a slow zsh).
219
+ await client.request("pane.send_text", { pane_id: paneId, text: req.command + "\n" });
220
+ await raise();
221
+ const t = setTimeout(() => {
222
+ pending.delete(t);
223
+ void rebuild();
224
+ }, opts.resumeRefreshMs ?? 1500);
225
+ t.unref?.();
226
+ pending.add(t);
227
+ return { paneId };
228
+ },
229
+ clientPids,
230
+ onChange(cb) {
231
+ listeners.add(cb);
232
+ return () => listeners.delete(cb);
233
+ },
234
+ refresh: rebuild,
235
+ close() {
236
+ if (closed)
237
+ return;
238
+ closed = true;
239
+ clearInterval(timer);
240
+ for (const t of pending)
241
+ clearTimeout(t);
242
+ pending.clear();
243
+ client.close();
244
+ listeners.clear();
245
+ map = new Map();
246
+ },
247
+ };
248
+ }
@@ -0,0 +1,10 @@
1
+ /** PaneLink for when herdr is disabled: never available, does nothing. */
2
+ export function createNoPaneLink() {
3
+ return {
4
+ available: () => false,
5
+ match: () => undefined,
6
+ focus: async () => { },
7
+ onChange: () => () => { },
8
+ close: () => { },
9
+ };
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { mkdir, open, readFile, rename } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function defaultStateDir(env = process.env, home = homedir()) {
5
+ const base = env.XDG_STATE_HOME || join(home, ".local", "state");
6
+ return join(base, "excaliagent");
7
+ }
8
+ const empty = () => ({ elements: [], appState: {} });
9
+ /** scene.json in stateDir, written atomically (temp file + fsync + rename), saves serialized. */
10
+ export function createSceneStore(stateDir, log = console) {
11
+ const path = join(stateDir, "scene.json");
12
+ let queue = Promise.resolve();
13
+ let seq = 0;
14
+ async function write(scene) {
15
+ await mkdir(stateDir, { recursive: true });
16
+ const tmp = `${path}.tmp-${process.pid}-${seq++}`;
17
+ const fh = await open(tmp, "w", 0o600);
18
+ try {
19
+ await fh.writeFile(JSON.stringify(scene));
20
+ await fh.sync();
21
+ }
22
+ finally {
23
+ await fh.close();
24
+ }
25
+ await rename(tmp, path);
26
+ }
27
+ return {
28
+ path,
29
+ async load() {
30
+ let raw;
31
+ try {
32
+ raw = await readFile(path, "utf8");
33
+ }
34
+ catch (err) {
35
+ if (err.code === "ENOENT")
36
+ return empty();
37
+ throw err;
38
+ }
39
+ try {
40
+ const scene = JSON.parse(raw);
41
+ if (!Array.isArray(scene.elements))
42
+ throw new Error("no elements array");
43
+ return { ...scene, appState: scene.appState ?? {} };
44
+ }
45
+ catch (err) {
46
+ const aside = `${path}.corrupt-${Date.now()}`;
47
+ await rename(path, aside).catch(() => { });
48
+ log.warn(`excaliagent: ${path} was unreadable (${err.message}); moved to ${aside}`);
49
+ return empty();
50
+ }
51
+ },
52
+ save(scene) {
53
+ if (!scene || !Array.isArray(scene.elements)) {
54
+ return Promise.reject(new Error("scene must have an elements array"));
55
+ }
56
+ const run = queue.then(() => write(scene));
57
+ queue = run.catch(() => { });
58
+ return run;
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,292 @@
1
+ // Claude Code source: live sessions from the registry (<claudeDir>/sessions),
2
+ // enriched from transcripts (<claudeDir>/projects/<slug>/<id>.jsonl), plus
3
+ // dormant sessions from the transcript store.
4
+ import { watch as fsWatch } from "node:fs";
5
+ import { readdir, stat } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { basename, join } from "node:path";
8
+ import { GitReader } from "./git.js";
9
+ import { onceLogger } from "./log.js";
10
+ import { defaultIsAlive, isLive, readRegistry } from "./registry.js";
11
+ import { TranscriptScanner } from "./transcript.js";
12
+ const LIVE_STATUSES = new Set([
13
+ "busy", "idle", "waiting", "needs_user", "needs_trust", "blocked",
14
+ ]);
15
+ export function liveSession(e, t, git) {
16
+ const cwd = e.cwd || t?.cwd || "";
17
+ const s = {
18
+ id: e.sessionId,
19
+ source: "claude",
20
+ cwd,
21
+ repo: basename(cwd),
22
+ status: LIVE_STATUSES.has(e.status) ? e.status : "idle",
23
+ lastActivityAt: Math.max(e.statusUpdatedAt ?? e.updatedAt ?? 0, t?.lastTimestamp ?? 0),
24
+ cacheTtlMs: t?.cacheTtlMs ?? 0,
25
+ cacheExpiresAt: t?.cacheExpiresAt ?? 0,
26
+ live: true,
27
+ pid: e.pid,
28
+ prLinks: t?.prLinks ?? [],
29
+ };
30
+ return withGit(withTranscriptExtras(s, t, e.name, e.waitingFor), git);
31
+ }
32
+ export function dormantSession(f, t, fallbackCwd, git) {
33
+ const cwd = t.cwd ?? fallbackCwd ?? "";
34
+ const s = {
35
+ id: f.id,
36
+ source: "claude",
37
+ cwd,
38
+ repo: cwd ? basename(cwd) : f.slug,
39
+ status: "dormant",
40
+ lastActivityAt: t.lastTimestamp || Math.floor(f.mtimeMs),
41
+ cacheTtlMs: t.cacheTtlMs,
42
+ cacheExpiresAt: t.cacheExpiresAt,
43
+ live: false,
44
+ prLinks: t.prLinks,
45
+ };
46
+ // A parked session keeps the branch it was on; only the useless "HEAD" stamp
47
+ // is replaced by the repo's current branch.
48
+ const out = withTranscriptExtras(s, t, undefined, undefined);
49
+ const recorded = out.branch && out.branch !== "HEAD" ? out.branch : undefined;
50
+ withGit(out, git);
51
+ if (recorded)
52
+ out.branch = recorded;
53
+ return out;
54
+ }
55
+ /** The repo on disk beats the transcript: its gitBranch is stamped once and goes stale. */
56
+ function withGit(s, git) {
57
+ if (!git)
58
+ return s;
59
+ s.repo = basename(git.root);
60
+ s.repoRoot = git.root;
61
+ s.branch = git.branch;
62
+ return s;
63
+ }
64
+ function withTranscriptExtras(s, t, name, waitingFor) {
65
+ const title = t?.aiTitle ?? t?.agentName;
66
+ if (t?.gitBranch !== undefined)
67
+ s.branch = t.gitBranch;
68
+ if (title !== undefined)
69
+ s.title = title;
70
+ if (name !== undefined)
71
+ s.name = name;
72
+ if (waitingFor !== undefined)
73
+ s.waitingFor = waitingFor;
74
+ if (t?.continuedIn !== undefined)
75
+ s.continuedIn = t.continuedIn;
76
+ return s;
77
+ }
78
+ /** One entry per session id: the most recently updated live registry file wins. */
79
+ function dedupe(entries) {
80
+ const best = new Map();
81
+ const at = (e) => e.updatedAt ?? e.statusUpdatedAt ?? 0;
82
+ for (const e of entries) {
83
+ const cur = best.get(e.sessionId);
84
+ if (!cur || at(e) > at(cur) || (at(e) === at(cur) && e.pid > cur.pid))
85
+ best.set(e.sessionId, e);
86
+ }
87
+ return [...best.values()];
88
+ }
89
+ export function createClaudeSource(opts = {}) {
90
+ const claudeDir = opts.claudeDir ?? join(homedir(), ".claude");
91
+ const sessionsDir = join(claudeDir, "sessions");
92
+ const projectsDir = join(claudeDir, "projects");
93
+ const isAlive = opts.isAlive ?? defaultIsAlive;
94
+ const pollMs = opts.pollMs ?? 5000;
95
+ const debounceMs = opts.debounceMs ?? 200;
96
+ const maxDormant = opts.maxDormant ?? 200;
97
+ const warn = onceLogger(opts.log ?? console.warn);
98
+ const scanner = new TranscriptScanner({ warn });
99
+ const git = new GitReader({ ttlMs: opts.gitTtlMs ?? 2000 });
100
+ const pathById = new Map();
101
+ /** A cwd seen in any transcript of a project dir, for transcripts without one. */
102
+ const cwdBySlug = new Map();
103
+ async function scan(path, slug) {
104
+ const t = await scanner.scan(path);
105
+ if (t?.cwd)
106
+ cwdBySlug.set(slug, t.cwd);
107
+ return t;
108
+ }
109
+ async function slugs() {
110
+ try {
111
+ return (await readdir(projectsDir, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ }
117
+ async function findTranscript(id) {
118
+ const cached = pathById.get(id);
119
+ if (cached && (await stat(cached).catch(() => undefined)))
120
+ return cached;
121
+ pathById.delete(id);
122
+ for (const slug of await slugs()) {
123
+ const p = join(projectsDir, slug, `${id}.jsonl`);
124
+ if (await stat(p).catch(() => undefined)) {
125
+ pathById.set(id, p);
126
+ return p;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+ async function liveEntries() {
132
+ const all = await readRegistry(sessionsDir, warn);
133
+ return dedupe(all.filter((e) => isLive(e, isAlive)));
134
+ }
135
+ async function list() {
136
+ const out = [];
137
+ for (const e of await liveEntries()) {
138
+ const path = await findTranscript(e.sessionId);
139
+ const t = path ? await scan(path, basename(join(path, ".."))) : undefined;
140
+ const cwd = e.cwd || t?.cwd || "";
141
+ out.push(liveSession(e, t, await git.get(cwd)));
142
+ }
143
+ return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
144
+ }
145
+ async function transcriptFiles() {
146
+ const out = [];
147
+ for (const slug of await slugs()) {
148
+ let entries;
149
+ try {
150
+ entries = await readdir(join(projectsDir, slug), { withFileTypes: true });
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ for (const d of entries) {
156
+ if (!d.isFile() || !d.name.endsWith(".jsonl"))
157
+ continue;
158
+ const path = join(projectsDir, slug, d.name);
159
+ const st = await stat(path).catch(() => undefined);
160
+ if (!st)
161
+ continue;
162
+ const id = d.name.slice(0, -".jsonl".length);
163
+ pathById.set(id, path);
164
+ out.push({ id, slug, path, mtimeMs: st.mtimeMs });
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+ /** Scans and maps files in order; with `limit`, skips superseded transcripts and stops after `limit`. */
170
+ async function toDormant(files, limit) {
171
+ const scanned = [];
172
+ for (const f of files) {
173
+ if (limit !== undefined && scanned.length >= limit)
174
+ break;
175
+ const t = await scan(f.path, f.slug);
176
+ if (!t)
177
+ continue;
178
+ // A continued-in line means the conversation moved to a background
179
+ // session; Claude Code hides these from /resume, so the picker does too.
180
+ if (limit !== undefined && t.continuedIn)
181
+ continue;
182
+ scanned.push([f, t]);
183
+ }
184
+ // Map after scanning all, so sibling cwds are known.
185
+ const out = [];
186
+ for (const [f, t] of scanned) {
187
+ const cwd = t.cwd ?? cwdBySlug.get(f.slug);
188
+ out.push(dormantSession(f, t, cwdBySlug.get(f.slug), cwd ? await git.get(cwd) : undefined));
189
+ }
190
+ return out;
191
+ }
192
+ async function listDormant() {
193
+ const live = new Set((await liveEntries()).map((e) => e.sessionId));
194
+ const files = (await transcriptFiles())
195
+ .filter((f) => !live.has(f.id))
196
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
197
+ return toDormant(files, maxDormant);
198
+ }
199
+ async function get(id) {
200
+ const live = (await list()).find((s) => s.id === id);
201
+ if (live)
202
+ return live;
203
+ const path = await findTranscript(id);
204
+ if (!path)
205
+ return undefined;
206
+ const st = await stat(path).catch(() => undefined);
207
+ if (!st)
208
+ return undefined;
209
+ const slug = basename(join(path, ".."));
210
+ if (!cwdBySlug.has(slug)) {
211
+ // Learn a fallback cwd from a sibling transcript when this one has none.
212
+ const t = await scan(path, slug);
213
+ if (!t?.cwd)
214
+ await toDormant((await transcriptFiles()).filter((f) => f.slug === slug && f.id !== id).slice(0, 5));
215
+ }
216
+ const [s] = await toDormant([{ id, slug, path, mtimeMs: st.mtimeMs }]);
217
+ return s;
218
+ }
219
+ function watch(cb) {
220
+ let closed = false;
221
+ let running = false;
222
+ let again = false;
223
+ let last;
224
+ let debounce;
225
+ let watcher;
226
+ const refresh = async () => {
227
+ if (running) {
228
+ again = true;
229
+ return;
230
+ }
231
+ running = true;
232
+ try {
233
+ do {
234
+ again = false;
235
+ const sessions = await list();
236
+ if (closed)
237
+ return;
238
+ const key = JSON.stringify(sessions);
239
+ if (key !== last) {
240
+ last = key;
241
+ try {
242
+ cb(sessions);
243
+ }
244
+ catch (e) {
245
+ warn(`excaliagent: session watcher callback threw: ${e.message}`, "watch-callback");
246
+ }
247
+ }
248
+ } while (again && !closed);
249
+ }
250
+ catch (e) {
251
+ warn(`excaliagent: refreshing Claude sessions failed: ${e.message}`, "refresh");
252
+ }
253
+ finally {
254
+ running = false;
255
+ }
256
+ };
257
+ const kick = () => {
258
+ if (closed)
259
+ return;
260
+ clearTimeout(debounce);
261
+ debounce = setTimeout(() => void refresh(), debounceMs);
262
+ };
263
+ const ensureWatcher = () => {
264
+ if (watcher || closed)
265
+ return;
266
+ try {
267
+ watcher = fsWatch(sessionsDir, kick);
268
+ watcher.on("error", () => {
269
+ watcher?.close();
270
+ watcher = undefined;
271
+ });
272
+ }
273
+ catch {
274
+ watcher = undefined; // dir missing; the poll retries
275
+ }
276
+ };
277
+ ensureWatcher();
278
+ const poll = setInterval(() => {
279
+ ensureWatcher();
280
+ void refresh();
281
+ }, pollMs);
282
+ void refresh();
283
+ return () => {
284
+ closed = true;
285
+ clearInterval(poll);
286
+ clearTimeout(debounce);
287
+ watcher?.close();
288
+ watcher = undefined;
289
+ };
290
+ }
291
+ return { list, listDormant, get, watch };
292
+ }
@@ -0,0 +1,65 @@
1
+ // Reads the current branch straight from a repo's HEAD (no git subprocess).
2
+ // Claude Code stamps gitBranch once per session, so the transcript goes stale.
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ async function gitDirAt(dir) {
6
+ const dotGit = join(dir, ".git");
7
+ const st = await stat(dotGit).catch(() => undefined);
8
+ if (!st)
9
+ return undefined;
10
+ if (st.isDirectory())
11
+ return dotGit;
12
+ if (!st.isFile())
13
+ return undefined;
14
+ const text = await readFile(dotGit, "utf8").catch(() => "");
15
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(text);
16
+ if (!m)
17
+ return undefined;
18
+ return isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
19
+ }
20
+ export function parseHead(text) {
21
+ const t = text.trim();
22
+ const ref = /^ref:\s*(\S+)$/.exec(t);
23
+ if (ref)
24
+ return ref[1].replace(/^refs\/heads\//, "");
25
+ if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(t))
26
+ return t.slice(0, 7);
27
+ return undefined;
28
+ }
29
+ /** Walks up from cwd to the nearest `.git`; undefined if none or HEAD is unreadable. */
30
+ export async function readGitInfo(cwd) {
31
+ if (!cwd || !isAbsolute(cwd))
32
+ return undefined;
33
+ let dir = resolve(cwd);
34
+ for (;;) {
35
+ const gitDir = await gitDirAt(dir);
36
+ if (gitDir) {
37
+ const head = await readFile(join(gitDir, "HEAD"), "utf8").catch(() => undefined);
38
+ const branch = head === undefined ? undefined : parseHead(head);
39
+ return branch ? { root: dir, branch } : undefined;
40
+ }
41
+ const parent = dirname(dir);
42
+ if (parent === dir)
43
+ return undefined;
44
+ dir = parent;
45
+ }
46
+ }
47
+ /** Per-cwd cache: each cwd is re-read at most every ttlMs. */
48
+ export class GitReader {
49
+ cache = new Map();
50
+ ttlMs;
51
+ now;
52
+ constructor(opts = {}) {
53
+ this.ttlMs = opts.ttlMs ?? 2000;
54
+ this.now = opts.now ?? Date.now;
55
+ }
56
+ get(cwd) {
57
+ const hit = this.cache.get(cwd);
58
+ const now = this.now();
59
+ if (hit && now - hit.at < this.ttlMs)
60
+ return hit.value;
61
+ const value = readGitInfo(cwd).catch(() => undefined);
62
+ this.cache.set(cwd, { at: now, value });
63
+ return value;
64
+ }
65
+ }
@@ -0,0 +1,10 @@
1
+ /** Wraps a logger so each key is reported at most once. */
2
+ export function onceLogger(log) {
3
+ const seen = new Set();
4
+ return (message, key) => {
5
+ if (seen.has(key))
6
+ return;
7
+ seen.add(key);
8
+ log(message);
9
+ };
10
+ }