excaliagent 0.3.0 → 0.4.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 (165) hide show
  1. package/README.md +113 -29
  2. package/dist/server/args.js +14 -1
  3. package/dist/server/characters.js +540 -0
  4. package/dist/server/cli.js +16 -1
  5. package/dist/server/http.js +95 -6
  6. package/dist/server/hub.js +17 -3
  7. package/dist/server/panes/herdr.js +29 -3
  8. package/dist/server/sources/claude.js +36 -1
  9. package/dist/server/sources/codex-live.js +279 -0
  10. package/dist/server/sources/codex-rollout.js +154 -0
  11. package/dist/server/sources/codex.js +363 -0
  12. package/dist/server/sources/jsonl.js +142 -0
  13. package/dist/server/sources/multi.js +53 -0
  14. package/dist/server/sources/opencode-live.js +144 -0
  15. package/dist/server/sources/opencode.js +265 -0
  16. package/dist/server/sources/proc.js +34 -0
  17. package/dist/server/sources/transcript.js +9 -103
  18. package/dist/shared/packs.js +62 -0
  19. package/dist/web/apple-touch-icon.png +0 -0
  20. package/dist/web/assets/{abnfDiagram-VCTEODGH-DV-5IMmB.js → abnfDiagram-VCTEODGH-BxrKsDoB.js} +1 -1
  21. package/dist/web/assets/{ar-SA-G6X2FPQ2-n3XdOipE.js → ar-SA-G6X2FPQ2-Cc6gbg88.js} +1 -1
  22. package/dist/web/assets/{architecture-7GRP2DOG-C497sWO2.js → architecture-7GRP2DOG-CIqN5I9B.js} +1 -1
  23. package/dist/web/assets/{architectureDiagram-5GKGNRK7-DuCJZflG.js → architectureDiagram-5GKGNRK7-DsXC8AqK.js} +1 -1
  24. package/dist/web/assets/{az-AZ-76LH7QW2-DSYFdGPZ.js → az-AZ-76LH7QW2-BH8VzNec.js} +1 -1
  25. package/dist/web/assets/{bg-BG-XCXSNQG7-aq-bPVBx.js → bg-BG-XCXSNQG7-ClFUbics.js} +1 -1
  26. package/dist/web/assets/{blockDiagram-I7D4REHJ-D6rbmy9X.js → blockDiagram-I7D4REHJ-CltS-Zt2.js} +1 -1
  27. package/dist/web/assets/{bn-BD-2XOGV67Q-BsnX_gLN.js → bn-BD-2XOGV67Q-BVUiOO4B.js} +1 -1
  28. package/dist/web/assets/{c4Diagram-7LVT6UL2-bkZh0r6f.js → c4Diagram-7LVT6UL2-C-C_SvWm.js} +1 -1
  29. package/dist/web/assets/{ca-ES-6MX7JW3Y-CvtjFAUU.js → ca-ES-6MX7JW3Y-CEK8mB8J.js} +1 -1
  30. package/dist/web/assets/channel-Dom8D_0o.js +1 -0
  31. package/dist/web/assets/{chunk-4HAMMTFA-DZJ_s-sA.js → chunk-4HAMMTFA-Bfdmb0Hg.js} +1 -1
  32. package/dist/web/assets/{chunk-75Z2AOVW-DU74RPBJ.js → chunk-75Z2AOVW-NYwkutzS.js} +1 -1
  33. package/dist/web/assets/{chunk-DU6HZSFF-CChYnq-U.js → chunk-DU6HZSFF-SqE0xdu5.js} +1 -1
  34. package/dist/web/assets/{chunk-EIO257PC-DIGaCD3q.js → chunk-EIO257PC-DpOeKaLq.js} +1 -1
  35. package/dist/web/assets/{chunk-F27PBJKO-C65b5F8o.js → chunk-F27PBJKO-g9bJrSwZ.js} +1 -1
  36. package/dist/web/assets/{chunk-GMAD6QVW-CJy4srfe.js → chunk-GMAD6QVW-B1y2ol5w.js} +1 -1
  37. package/dist/web/assets/{chunk-GVQU2GXP-CedQyfJw.js → chunk-GVQU2GXP-BUjRNRXo.js} +1 -1
  38. package/dist/web/assets/{chunk-IMKFNOWR-DPwApfTy.js → chunk-IMKFNOWR-DyeQep8U.js} +1 -1
  39. package/dist/web/assets/{chunk-L3NEJ4N5-DXku7BIn.js → chunk-L3NEJ4N5-BcTVhsWp.js} +1 -1
  40. package/dist/web/assets/{chunk-OSK3NFVY-BstcDrHK.js → chunk-OSK3NFVY-Db7B8jPm.js} +1 -1
  41. package/dist/web/assets/{chunk-P2QGCYS3-BHerElni.js → chunk-P2QGCYS3-Cx5iBC5z.js} +1 -1
  42. package/dist/web/assets/{chunk-POPQ4Y6H-ZS2hGTJJ.js → chunk-POPQ4Y6H-CqVRZDA8.js} +1 -1
  43. package/dist/web/assets/{chunk-PWAF6VOD-BUUBZvSR.js → chunk-PWAF6VOD-Cf2j9yew.js} +1 -1
  44. package/dist/web/assets/{chunk-SHT3W25Y-CMdySuQW.js → chunk-SHT3W25Y-0_N13xgE.js} +1 -1
  45. package/dist/web/assets/{chunk-SVP7TREG-E_6g4L6l.js → chunk-SVP7TREG-DkRJF9jr.js} +1 -1
  46. package/dist/web/assets/{chunk-TICWLB2K-CBTJSW3m.js → chunk-TICWLB2K-D6OC50xH.js} +1 -1
  47. package/dist/web/assets/classDiagram-ZZMXUADV-BMEkgkEJ.js +1 -0
  48. package/dist/web/assets/classDiagram-v2-VYDZK3BY-BMEkgkEJ.js +1 -0
  49. package/dist/web/assets/{cs-CZ-2BRQDIVT-DLt9XJMu.js → cs-CZ-2BRQDIVT-DWt1RUsP.js} +1 -1
  50. package/dist/web/assets/{cynefin-OW5HDTMX-BYqJDvxe.js → cynefin-OW5HDTMX-Dx0HzRCl.js} +1 -1
  51. package/dist/web/assets/{cynefinDiagram-5FMLGOSQ-iwuMBkRk.js → cynefinDiagram-5FMLGOSQ-C8AMQNhe.js} +1 -1
  52. package/dist/web/assets/{da-DK-5WZEPLOC-BnzwheRI.js → da-DK-5WZEPLOC-BPggh0j6.js} +1 -1
  53. package/dist/web/assets/{dagre-GXQ25YYZ-DGskQLMq.js → dagre-GXQ25YYZ-f9qU6BFq.js} +1 -1
  54. package/dist/web/assets/{de-DE-XR44H4JA-B05-7snG.js → de-DE-XR44H4JA-B1q1pJrQ.js} +1 -1
  55. package/dist/web/assets/{diagram-S7CK7UJ4-Sclpr_lb.js → diagram-S7CK7UJ4-Bcq77Nl3.js} +1 -1
  56. package/dist/web/assets/{diagram-UQ7AKVKN-CkRKjDjq.js → diagram-UQ7AKVKN-DXzlgVTk.js} +1 -1
  57. package/dist/web/assets/{diagram-VSXAHHWV-DKAzSk6r.js → diagram-VSXAHHWV-C1vz2FvT.js} +1 -1
  58. package/dist/web/assets/{diagram-VX7I27RA-Bjdeg2uG.js → diagram-VX7I27RA-_c4ucp3r.js} +1 -1
  59. package/dist/web/assets/{diagram-Z3DM3KII-4jN9UD5P.js → diagram-Z3DM3KII-cUg91K-F.js} +1 -1
  60. package/dist/web/assets/{dist-BQE0NGB-.js → dist-DcwmvBdV.js} +4 -4
  61. package/dist/web/assets/{ebnfDiagram-PWID7BFC-BSFSSJ77.js → ebnfDiagram-PWID7BFC-9iWfohHg.js} +1 -1
  62. package/dist/web/assets/{el-GR-BZB4AONW-ShhNmsmN.js → el-GR-BZB4AONW-CrhwvNDD.js} +1 -1
  63. package/dist/web/assets/{en-B4ZKOASM-BNw5UiKn.js → en-B4ZKOASM-DCjnkz5L.js} +1 -1
  64. package/dist/web/assets/{erDiagram-RLTQ6QDP-CXr09bBy.js → erDiagram-RLTQ6QDP-A72uVlS3.js} +1 -1
  65. package/dist/web/assets/{es-ES-U4NZUMDT-2jCdm2lH.js → es-ES-U4NZUMDT-D0sRCoDL.js} +1 -1
  66. package/dist/web/assets/{eu-ES-A7QVB2H4-BhBUGvkL.js → eu-ES-A7QVB2H4-DLG-liPb.js} +1 -1
  67. package/dist/web/assets/{eventmodeling-NTZA5JFV-DLX5id4n.js → eventmodeling-NTZA5JFV-CIKZvDAj.js} +1 -1
  68. package/dist/web/assets/{fa-IR-HGAKTJCU-B-Z0NFoQ.js → fa-IR-HGAKTJCU-BW3pPOVT.js} +1 -1
  69. package/dist/web/assets/{fi-FI-Z5N7JZ37-C3a93Lzr.js → fi-FI-Z5N7JZ37-D2XaqwPp.js} +1 -1
  70. package/dist/web/assets/flowDiagram-HODETNUW-DhQSc3KP.js +1 -0
  71. package/dist/web/assets/{fr-FR-RHASNOE6-BfJc7jwO.js → fr-FR-RHASNOE6-B-BjRA7r.js} +1 -1
  72. package/dist/web/assets/{ganttDiagram-EL5Y4UJY-V2sHQ0x1.js → ganttDiagram-EL5Y4UJY-cgwUfr0G.js} +1 -1
  73. package/dist/web/assets/{gitGraph-4MIJSDKK-BGpxF8pu.js → gitGraph-4MIJSDKK-CeKopPn3.js} +1 -1
  74. package/dist/web/assets/{gitGraphDiagram-WWUBYQGX-asoaR4Hx.js → gitGraphDiagram-WWUBYQGX-BWjAy7ei.js} +1 -1
  75. package/dist/web/assets/{gl-ES-HMX3MZ6V-C8eeUPra.js → gl-ES-HMX3MZ6V-DYUAqB0N.js} +1 -1
  76. package/dist/web/assets/{he-IL-6SHJWFNN-DB0HanYn.js → he-IL-6SHJWFNN-CXkYqbuu.js} +1 -1
  77. package/dist/web/assets/{hi-IN-IWLTKZ5I-B3Gf328f.js → hi-IN-IWLTKZ5I-BwVBsHWG.js} +1 -1
  78. package/dist/web/assets/{hu-HU-A5ZG7DT2-7jNfqM7f.js → hu-HU-A5ZG7DT2-DkmlE7Aa.js} +1 -1
  79. package/dist/web/assets/{id-ID-SAP4L64H-DZd1jLNQ.js → id-ID-SAP4L64H-CwkytHX_.js} +1 -1
  80. package/dist/web/assets/{image-GAAHSSAO-DKJMzS7d.js → image-GAAHSSAO-u-hBxkaK.js} +1 -1
  81. package/dist/web/assets/index-D43I17T9.js +212 -0
  82. package/dist/web/assets/{index-DtRVLazd.css → index-DxXUknKX.css} +1 -1
  83. package/dist/web/assets/{info-A6RAGUB7-Dw0JaEzD.js → info-A6RAGUB7-DmadBmci.js} +1 -1
  84. package/dist/web/assets/{infoDiagram-27XIBGKW-DhivBaFe.js → infoDiagram-27XIBGKW-D93CKwhy.js} +1 -1
  85. package/dist/web/assets/{ishikawaDiagram-5VMMS53U-DHIyFGlH.js → ishikawaDiagram-5VMMS53U-BQ-x5YXp.js} +1 -1
  86. package/dist/web/assets/{it-IT-JPQ66NNP-BF7oahO7.js → it-IT-JPQ66NNP-Bc-Qa6KI.js} +1 -1
  87. package/dist/web/assets/{ja-JP-DBVTYXUO-CtGU47sH.js → ja-JP-DBVTYXUO-BPmQCryQ.js} +1 -1
  88. package/dist/web/assets/{journeyDiagram-3NMN7TZE-wwS9CODt.js → journeyDiagram-3NMN7TZE-CO_LXx1E.js} +1 -1
  89. package/dist/web/assets/{kaa-6HZHGXH3-CyohUVIM.js → kaa-6HZHGXH3-DuBFVAjv.js} +1 -1
  90. package/dist/web/assets/{kab-KAB-ZGHBKWFO-2acNr90z.js → kab-KAB-ZGHBKWFO-DhEFzSTh.js} +1 -1
  91. package/dist/web/assets/{kanban-definition-UXKFOSKX-vqJ11RID.js → kanban-definition-UXKFOSKX-B72Jztau.js} +1 -1
  92. package/dist/web/assets/{kk-KZ-P5N5QNE5-C56zTJ0v.js → kk-KZ-P5N5QNE5-CW7PxU2h.js} +1 -1
  93. package/dist/web/assets/{km-KH-HSX4SM5Z-Bg7FoxwJ.js → km-KH-HSX4SM5Z-CYy7yySD.js} +1 -1
  94. package/dist/web/assets/{ko-KR-MTYHY66A-wlZa8Fb7.js → ko-KR-MTYHY66A-C5O645Qe.js} +1 -1
  95. package/dist/web/assets/{ku-TR-6OUDTVRD-DpMT6JcJ.js → ku-TR-6OUDTVRD-CKcd_iWr.js} +1 -1
  96. package/dist/web/assets/{line-Cy2R8zKE.js → line-Ckd3sAmP.js} +1 -1
  97. package/dist/web/assets/{lt-LT-XHIRWOB4-0nDOQqUM.js → lt-LT-XHIRWOB4-ya2_xWiy.js} +1 -1
  98. package/dist/web/assets/{lv-LV-5QDEKY6T-DGNAIt-v.js → lv-LV-5QDEKY6T-cYYwyzmz.js} +1 -1
  99. package/dist/web/assets/{mermaid-parser.core-C-IWeUrh.js → mermaid-parser.core-Dbqkvrw-.js} +3 -3
  100. package/dist/web/assets/{mindmap-definition-YA3MSWOX-DDX8XN8u.js → mindmap-definition-YA3MSWOX-B4onxNgk.js} +1 -1
  101. package/dist/web/assets/{mr-IN-CRQNXWMA-CHXpujpK.js → mr-IN-CRQNXWMA-CfaHpVWe.js} +1 -1
  102. package/dist/web/assets/{my-MM-5M5IBNSE-Bg0H7WJb.js → my-MM-5M5IBNSE-BfKiTATq.js} +1 -1
  103. package/dist/web/assets/{nb-NO-T6EIAALU-BE_hPlhF.js → nb-NO-T6EIAALU-Cz3yDFYg.js} +1 -1
  104. package/dist/web/assets/{nl-NL-IS3SIHDZ-fdiiEQqB.js → nl-NL-IS3SIHDZ-DDX026n5.js} +1 -1
  105. package/dist/web/assets/{nn-NO-6E72VCQL-C7j1gMVo.js → nn-NO-6E72VCQL-BcokL_SN.js} +1 -1
  106. package/dist/web/assets/{oc-FR-POXYY2M6-Q5CdkT-D.js → oc-FR-POXYY2M6-bNFsGpEp.js} +1 -1
  107. package/dist/web/assets/{pa-IN-N4M65BXN-DJyQmbX0.js → pa-IN-N4M65BXN-iIHN9Z8W.js} +1 -1
  108. package/dist/web/assets/{packet-AYTQ26CC-D4ouOVez.js → packet-AYTQ26CC-BzBpP9hg.js} +1 -1
  109. package/dist/web/assets/{pegDiagram-XKGWAZYB-DAWOkGLy.js → pegDiagram-XKGWAZYB-BPScRPVZ.js} +1 -1
  110. package/dist/web/assets/percentages-BXMCSKIN-EDfR-TFr.js +1 -0
  111. package/dist/web/assets/{pie-WAS4IAKB-MCWueKc-.js → pie-WAS4IAKB-N-9RHtKL.js} +1 -1
  112. package/dist/web/assets/{pieDiagram-E7YTZNPT-BLlQT9t_.js → pieDiagram-E7YTZNPT-Bpd_yCG-.js} +1 -1
  113. package/dist/web/assets/{pl-PL-T2D74RX3-CcWFE9AN.js → pl-PL-T2D74RX3-DeqOx-a4.js} +1 -1
  114. package/dist/web/assets/{pt-BR-5N22H2LF-D6QCOM38.js → pt-BR-5N22H2LF-DG1PCIkb.js} +1 -1
  115. package/dist/web/assets/{pt-PT-UZXXM6DQ-ByJh7ZNa.js → pt-PT-UZXXM6DQ-BsUJk2fq.js} +1 -1
  116. package/dist/web/assets/{quadrantDiagram-AXDQQJYC-CIinawCS.js → quadrantDiagram-AXDQQJYC-CSLCp0xS.js} +1 -1
  117. package/dist/web/assets/{radar-RG4KPBEZ-Cm5qT3Hl.js → radar-RG4KPBEZ-rKULJroO.js} +1 -1
  118. package/dist/web/assets/{railroad-74A4TZTK-DO3FGMCu.js → railroad-74A4TZTK-hDn0WPg3.js} +1 -1
  119. package/dist/web/assets/{railroad-abnf-HS5TGJTU-DqC68RR5.js → railroad-abnf-HS5TGJTU-DfI0hiCR.js} +1 -1
  120. package/dist/web/assets/{railroad-ebnf-LZEXJU2U-Cby7H3Dj.js → railroad-ebnf-LZEXJU2U-paFQAnqR.js} +1 -1
  121. package/dist/web/assets/{railroad-peg-WCYAUIDC-CJpoUW06.js → railroad-peg-WCYAUIDC-B-2Um3QI.js} +1 -1
  122. package/dist/web/assets/{railroadDiagram-O6MQD6OU-CIJ1E8KE.js → railroadDiagram-O6MQD6OU-WkZPN5MA.js} +1 -1
  123. package/dist/web/assets/{requirementDiagram-BXWQKSXE-BTDHXcSN.js → requirementDiagram-BXWQKSXE-BJULFhuR.js} +1 -1
  124. package/dist/web/assets/{ro-RO-JPDTUUEW-BlhbQCSw.js → ro-RO-JPDTUUEW-yKjTMizq.js} +1 -1
  125. package/dist/web/assets/{ru-RU-B4JR7IUQ-Cd8b9tGe.js → ru-RU-B4JR7IUQ-DITGdMQL.js} +1 -1
  126. package/dist/web/assets/{sankeyDiagram-P5KCCOFB-DFx8uxV2.js → sankeyDiagram-P5KCCOFB-b_vfju3s.js} +1 -1
  127. package/dist/web/assets/{sequenceDiagram-WJ2MYXX4-Dr8vTa3e.js → sequenceDiagram-WJ2MYXX4-D5n2aEN6.js} +1 -1
  128. package/dist/web/assets/{si-LK-N5RQ5JYF-Y7h5xFBD.js → si-LK-N5RQ5JYF-DaFciStR.js} +1 -1
  129. package/dist/web/assets/{sk-SK-C5VTKIMK-DU5G4wEe.js → sk-SK-C5VTKIMK-CpyCIFtD.js} +1 -1
  130. package/dist/web/assets/{sl-SI-NN7IZMDC-4olCD7lc.js → sl-SI-NN7IZMDC-cnwvs7FN.js} +1 -1
  131. package/dist/web/assets/{stateDiagram-D77RDMKH-CRqebLR8.js → stateDiagram-D77RDMKH-CbFjpKDm.js} +1 -1
  132. package/dist/web/assets/stateDiagram-v2-MP3YSRHH-MOkTt8Mv.js +1 -0
  133. package/dist/web/assets/subset-shared.chunk-CWAaXrHb.js +1 -0
  134. package/dist/web/assets/{subset-worker.chunk-Bqgy-Y1W.js → subset-worker.chunk-Cebu2JDe.js} +1 -1
  135. package/dist/web/assets/{sv-SE-XGPEYMSR-Cp0rVzLX.js → sv-SE-XGPEYMSR-dOntI1Fk.js} +1 -1
  136. package/dist/web/assets/{swimlanes-42K2YHIH-BYd8YoU8.js → swimlanes-42K2YHIH-DtmsuzRR.js} +1 -1
  137. package/dist/web/assets/swimlanesDiagram-VR7AAH4N-v2ue4raa.js +8 -0
  138. package/dist/web/assets/{ta-IN-2NMHFXQM-DPDOL5cy.js → ta-IN-2NMHFXQM-Comju6i2.js} +1 -1
  139. package/dist/web/assets/{th-TH-HPSO5L25-CN82X-G0.js → th-TH-HPSO5L25-uPCBvbue.js} +1 -1
  140. package/dist/web/assets/{timeline-definition-24CTP7MA-DeQFCevv.js → timeline-definition-24CTP7MA-DKZOmZFD.js} +1 -1
  141. package/dist/web/assets/{tr-TR-DEFEU3FU-DZkONFun.js → tr-TR-DEFEU3FU-LaP2C4go.js} +1 -1
  142. package/dist/web/assets/{treeView-Q6P3EWNA-C4dlZskR.js → treeView-Q6P3EWNA-DJii9IFr.js} +1 -1
  143. package/dist/web/assets/{treemap-WGGIJYW6-oSQNu9HW.js → treemap-WGGIJYW6-U42pUP2Q.js} +1 -1
  144. package/dist/web/assets/{uk-UA-QMV73CPH-da8znbfC.js → uk-UA-QMV73CPH-u-WBulor.js} +1 -1
  145. package/dist/web/assets/{vennDiagram-4TSXK5OY-e4T6zK4z.js → vennDiagram-4TSXK5OY-C8_zJPdp.js} +1 -1
  146. package/dist/web/assets/{vi-VN-M7AON7JQ-QWCjquXm.js → vi-VN-M7AON7JQ-DOSv3aq5.js} +1 -1
  147. package/dist/web/assets/{wardley-WFR3VGLG-BzQw11Sd.js → wardley-WFR3VGLG-DU89dbbh.js} +1 -1
  148. package/dist/web/assets/{wardleyDiagram-VM6X3IG4-DyPpKV2H.js → wardleyDiagram-VM6X3IG4-BGnASelu.js} +1 -1
  149. package/dist/web/assets/{xychartDiagram-S5SC5T6Z-DKjIes2P.js → xychartDiagram-S5SC5T6Z-js8-abv7.js} +1 -1
  150. package/dist/web/assets/{zh-CN-LNUGB5OW-BbT-kvRj.js → zh-CN-LNUGB5OW-B8soJHf3.js} +1 -1
  151. package/dist/web/assets/{zh-HK-E62DVLB3-BPw2AWZD.js → zh-HK-E62DVLB3-DKByBmSJ.js} +1 -1
  152. package/dist/web/assets/{zh-TW-RAJ6MFWO-BE9F3OdK.js → zh-TW-RAJ6MFWO-CYBStGL6.js} +1 -1
  153. package/dist/web/favicon-32.png +0 -0
  154. package/dist/web/favicon.svg +12 -0
  155. package/dist/web/index.html +6 -3
  156. package/package.json +4 -2
  157. package/dist/web/assets/channel-B_LXUtkZ.js +0 -1
  158. package/dist/web/assets/classDiagram-ZZMXUADV-zYihe46b.js +0 -1
  159. package/dist/web/assets/classDiagram-v2-VYDZK3BY-zYihe46b.js +0 -1
  160. package/dist/web/assets/flowDiagram-HODETNUW-CnV5VPNb.js +0 -1
  161. package/dist/web/assets/index-BLscnX3-.js +0 -212
  162. package/dist/web/assets/percentages-BXMCSKIN-B3ZxlSu3.js +0 -1
  163. package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DWV16uKx.js +0 -1
  164. package/dist/web/assets/subset-shared.chunk-1t88pPR-.js +0 -1
  165. package/dist/web/assets/swimlanesDiagram-VR7AAH4N-CACbHpLI.js +0 -8
@@ -0,0 +1,540 @@
1
+ // Characters and packs on disk (docs/design.md "Characters"):
2
+ //
3
+ // <stateDir>/characters.json active pack, remembered assignments, overrides
4
+ // <stateDir>/packs/<name>/ a custom pack: image files + optional pack.json
5
+ // <stateDir>/packs/<name>/pack.json {"emoji": ["🧟", ...]}
6
+ //
7
+ // Hand-dropped folders and files work. Everything is validated: pack names come
8
+ // from a safe charset (never a path), image files are served only if the pack's
9
+ // own listing has them (so no name can escape the folder), image types are
10
+ // allowlisted and sniffed, sizes are capped, writes are atomic. A broken pack
11
+ // folder or file degrades only that pack.
12
+ import { createHash } from "node:crypto";
13
+ import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
14
+ import { extname, join } from "node:path";
15
+ import { BUILT_IN_PACKS, DEFAULT_PACK, MAX_PACK_CHARACTERS, PACK_NAME, emojiFileId, validEmoji, } from "../shared/packs.js";
16
+ import { SESSION_ID } from "./hub.js";
17
+ /** Largest image a pack may hold (uploaded or hand-dropped). */
18
+ export const MAX_IMAGE_BYTES = 1024 * 1024;
19
+ /** An error carrying the HTTP status the route should answer with. */
20
+ export class StatusError extends Error {
21
+ status;
22
+ constructor(status, message) {
23
+ super(message);
24
+ this.status = status;
25
+ }
26
+ }
27
+ const MIME_BY_EXT = {
28
+ ".png": "image/png",
29
+ ".gif": "image/gif",
30
+ ".webp": "image/webp",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".svg": "image/svg+xml",
34
+ };
35
+ const EXT_BY_MIME = {
36
+ "image/png": ".png",
37
+ "image/gif": ".gif",
38
+ "image/webp": ".webp",
39
+ "image/jpeg": ".jpg",
40
+ "image/svg+xml": ".svg",
41
+ };
42
+ const SVG_START = /^\s*(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*|<!DOCTYPE[^>]*>\s*)*<svg[\s>]/i;
43
+ /** Do the bytes look like `mimeType`? Keeps HTML or scripts out of a pack posing as images. */
44
+ export function sniffImage(bytes, mimeType) {
45
+ const ascii = (from, to) => bytes.subarray(from, to).toString("latin1");
46
+ switch (mimeType) {
47
+ case "image/png":
48
+ return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
49
+ case "image/gif":
50
+ return ascii(0, 6) === "GIF87a" || ascii(0, 6) === "GIF89a";
51
+ case "image/webp":
52
+ return ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP";
53
+ case "image/jpeg":
54
+ return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
55
+ case "image/svg+xml":
56
+ return SVG_START.test(bytes.subarray(0, 4096).toString("utf8").replace(/^/, ""));
57
+ default:
58
+ return false;
59
+ }
60
+ }
61
+ /** A hand-dropped file name we list: no path parts, not hidden, printable, an allowed extension. */
62
+ function listableName(name) {
63
+ return (name.length <= 200 &&
64
+ !name.startsWith(".") &&
65
+ !/[/\\\p{Cc}]/u.test(name) &&
66
+ Object.hasOwn(MIME_BY_EXT, extname(name).toLowerCase()));
67
+ }
68
+ /** An uploaded file's name, made safe: `[A-Za-z0-9_-]` runs, at most 60, plus the extension of its type. */
69
+ function uploadName(raw, mimeType) {
70
+ let base = typeof raw === "string" ? raw : "";
71
+ const ext = extname(base).toLowerCase();
72
+ if (Object.hasOwn(MIME_BY_EXT, ext))
73
+ base = base.slice(0, -ext.length);
74
+ base = base.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 60);
75
+ return `${base || "image"}${EXT_BY_MIME[mimeType]}`;
76
+ }
77
+ const emptyState = () => ({ activePack: DEFAULT_PACK, assigned: {}, overrides: {} });
78
+ const isObject = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
79
+ const validPackId = (id) => typeof id === "string" && (PACK_NAME.test(id) || BUILT_IN_PACKS.some((p) => p.id === id));
80
+ /** Keep only well-formed entries of a (possibly hand-edited) characters.json. */
81
+ function cleanState(raw) {
82
+ const out = emptyState();
83
+ if (!isObject(raw))
84
+ return out;
85
+ if (validPackId(raw.activePack))
86
+ out.activePack = raw.activePack;
87
+ if (isObject(raw.assigned)) {
88
+ for (const [pack, picks] of Object.entries(raw.assigned)) {
89
+ if (!validPackId(pack) || !isObject(picks))
90
+ continue;
91
+ const clean = {};
92
+ for (const [sid, id] of Object.entries(picks))
93
+ if (SESSION_ID.test(sid) && typeof id === "string")
94
+ clean[sid] = id;
95
+ if (Object.keys(clean).length)
96
+ out.assigned[pack] = clean;
97
+ }
98
+ }
99
+ if (isObject(raw.overrides)) {
100
+ for (const [sid, ref] of Object.entries(raw.overrides)) {
101
+ if (SESSION_ID.test(sid) && isObject(ref) && validPackId(ref.pack) && typeof ref.id === "string")
102
+ out.overrides[sid] = { pack: ref.pack, id: ref.id };
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ export function createCharacterStore(stateDir, log = console) {
108
+ const statePath = join(stateDir, "characters.json");
109
+ const packsDir = join(stateDir, "packs");
110
+ let queue = Promise.resolve();
111
+ let seq = 0;
112
+ /** path → content hash, valid while mtime and size match. */
113
+ const hashes = new Map();
114
+ /** Mutations run one at a time. */
115
+ function serial(fn) {
116
+ const run = queue.then(fn);
117
+ queue = run.catch(() => { });
118
+ return run;
119
+ }
120
+ async function writeAtomic(target, data) {
121
+ const tmp = `${target}.tmp-${process.pid}-${seq++}`;
122
+ try {
123
+ const fh = await open(tmp, "w", 0o600);
124
+ try {
125
+ await fh.writeFile(data);
126
+ await fh.sync();
127
+ }
128
+ finally {
129
+ await fh.close();
130
+ }
131
+ await rename(tmp, target);
132
+ }
133
+ catch (err) {
134
+ await unlink(tmp).catch(() => { });
135
+ throw err;
136
+ }
137
+ }
138
+ async function readState() {
139
+ let raw;
140
+ try {
141
+ raw = await readFile(statePath, "utf8");
142
+ }
143
+ catch (err) {
144
+ if (err.code === "ENOENT")
145
+ return emptyState();
146
+ throw err;
147
+ }
148
+ try {
149
+ return cleanState(JSON.parse(raw));
150
+ }
151
+ catch (err) {
152
+ const aside = `${statePath}.corrupt-${Date.now()}`;
153
+ await rename(statePath, aside).catch(() => { });
154
+ log.warn(`excaliagent: ${statePath} was unreadable (${err.message}); moved to ${aside}`);
155
+ return emptyState();
156
+ }
157
+ }
158
+ async function writeState(state) {
159
+ await mkdir(stateDir, { recursive: true });
160
+ await writeAtomic(statePath, JSON.stringify({ version: 1, ...state }, null, 1));
161
+ }
162
+ async function contentHash(path, mtimeMs, size, bytes) {
163
+ const hit = hashes.get(path);
164
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === size)
165
+ return hit.hash;
166
+ const hash = createHash("sha256").update(bytes ?? (await readFile(path))).digest("hex").slice(0, 24);
167
+ hashes.set(path, { mtimeMs, size, hash });
168
+ return hash;
169
+ }
170
+ /**
171
+ * One image file of a pack folder, if it qualifies: a listable name, a
172
+ * regular file (not a symlink), within the size cap, bytes matching its type.
173
+ * `why` says why not (undefined: not an image file at all).
174
+ */
175
+ async function checkImage(dir, file, readAll) {
176
+ if (!listableName(file))
177
+ return { ok: false };
178
+ const path = join(dir, file);
179
+ const mimeType = MIME_BY_EXT[extname(file).toLowerCase()];
180
+ try {
181
+ const st = await lstat(path);
182
+ if (!st.isFile())
183
+ return { ok: false };
184
+ if (st.size > MAX_IMAGE_BYTES)
185
+ return { ok: false, why: `${file}: over ${MAX_IMAGE_BYTES / 1024 / 1024} MB` };
186
+ let bytes;
187
+ let head;
188
+ if (readAll)
189
+ head = bytes = await readFile(path);
190
+ else {
191
+ const fh = await open(path, "r");
192
+ try {
193
+ head = Buffer.alloc(Math.min(st.size, 4096));
194
+ await fh.read(head, 0, head.length, 0);
195
+ }
196
+ finally {
197
+ await fh.close();
198
+ }
199
+ }
200
+ if (!sniffImage(head, mimeType))
201
+ return { ok: false, why: `${file}: not a ${mimeType} image` };
202
+ return { ok: true, mimeType, hash: await contentHash(path, st.mtimeMs, st.size, bytes), bytes };
203
+ }
204
+ catch (err) {
205
+ return { ok: false, why: `${file}: ${err.message}` };
206
+ }
207
+ }
208
+ async function readEmojiList(dir) {
209
+ let raw;
210
+ try {
211
+ raw = await readFile(join(dir, "pack.json"), "utf8");
212
+ }
213
+ catch (err) {
214
+ if (err.code === "ENOENT")
215
+ return { emoji: [] };
216
+ return { emoji: [], error: `pack.json: ${err.message}` };
217
+ }
218
+ try {
219
+ const parsed = JSON.parse(raw);
220
+ const list = isObject(parsed) && Array.isArray(parsed.emoji) ? parsed.emoji : undefined;
221
+ if (!list)
222
+ return { emoji: [], error: 'pack.json: expected {"emoji": [...]}' };
223
+ return { emoji: [...new Set(list.filter(validEmoji))] };
224
+ }
225
+ catch (err) {
226
+ return { emoji: [], error: `pack.json: ${err.message}` };
227
+ }
228
+ }
229
+ /** A custom pack's listing; never throws (a broken pack lists what it can, plus an error). */
230
+ async function readPack(name) {
231
+ const dir = join(packsDir, name);
232
+ const errors = [];
233
+ const characters = [];
234
+ const list = await readEmojiList(dir);
235
+ if (list.error)
236
+ errors.push(list.error);
237
+ for (const e of list.emoji)
238
+ characters.push({ id: e, kind: "emoji", fileId: emojiFileId(e) });
239
+ let entries = [];
240
+ try {
241
+ entries = await readdir(dir, { withFileTypes: true });
242
+ }
243
+ catch (err) {
244
+ errors.push(err.message);
245
+ }
246
+ const files = entries
247
+ .filter((e) => e.isFile() && listableName(e.name))
248
+ .map((e) => e.name)
249
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
250
+ let capped = characters.length > MAX_PACK_CHARACTERS;
251
+ characters.length = Math.min(characters.length, MAX_PACK_CHARACTERS);
252
+ for (const file of files) {
253
+ // Stop at the cap: no hashing of images that would be ignored anyway.
254
+ if (characters.length >= MAX_PACK_CHARACTERS) {
255
+ capped = true;
256
+ break;
257
+ }
258
+ const img = await checkImage(dir, file, false);
259
+ if (!img.ok) {
260
+ if (img.why)
261
+ errors.push(img.why);
262
+ continue;
263
+ }
264
+ characters.push({
265
+ id: file,
266
+ kind: "image",
267
+ fileId: `excaliagent-img-${img.hash}`,
268
+ url: `/api/packs/${encodeURIComponent(name)}/${encodeURIComponent(file)}?v=${img.hash}`,
269
+ });
270
+ }
271
+ if (capped)
272
+ errors.push(`more than ${MAX_PACK_CHARACTERS} characters; the rest are ignored`);
273
+ return { id: name, name, builtIn: false, characters, ...(errors.length ? { error: errors.join("; ") } : {}) };
274
+ }
275
+ /**
276
+ * The pack folders (real directories with a valid name, as the directory
277
+ * lists them: on a case-insensitive file system "FOO" must not alias "foo"),
278
+ * plus the entries that are not packs, as problems.
279
+ */
280
+ async function scanPacks() {
281
+ let entries;
282
+ try {
283
+ entries = await readdir(packsDir, { withFileTypes: true });
284
+ }
285
+ catch {
286
+ return { names: [], problems: [] };
287
+ }
288
+ const names = [];
289
+ const problems = [];
290
+ for (const e of entries) {
291
+ if (e.name.startsWith("."))
292
+ continue;
293
+ if (!e.isDirectory()) {
294
+ if (e.isSymbolicLink())
295
+ problems.push(`packs/${e.name}: a link, not a folder; ignored`);
296
+ continue;
297
+ }
298
+ if (PACK_NAME.test(e.name))
299
+ names.push(e.name);
300
+ else
301
+ problems.push(`packs/${e.name}: not a valid pack name (letters, digits, - and _, up to 40); ignored`);
302
+ }
303
+ return { names: names.sort((a, b) => a.localeCompare(b)), problems };
304
+ }
305
+ async function data(state) {
306
+ const s = state ?? (await readState());
307
+ const { names, problems } = await scanPacks();
308
+ const packs = [...BUILT_IN_PACKS, ...(await Promise.all(names.map(readPack)))];
309
+ const activePack = packs.some((p) => p.id === s.activePack) ? s.activePack : DEFAULT_PACK;
310
+ return { ...s, activePack, packs, ...(problems.length ? { problems } : {}) };
311
+ }
312
+ /** Is `id` a custom pack folder, exactly as listed? */
313
+ async function isPackFolder(id) {
314
+ return PACK_NAME.test(id) && (await scanPacks()).names.includes(id);
315
+ }
316
+ /** A pack by id, or a 400 (bad name) / 404 (no such pack). */
317
+ async function findPack(id) {
318
+ const builtIn = BUILT_IN_PACKS.find((p) => p.id === id);
319
+ if (builtIn)
320
+ return builtIn;
321
+ if (typeof id !== "string" || !PACK_NAME.test(id))
322
+ throw new StatusError(400, "invalid pack name");
323
+ if (!(await isPackFolder(id)))
324
+ throw new StatusError(404, "no such pack");
325
+ return readPack(id);
326
+ }
327
+ async function customPack(id) {
328
+ if (BUILT_IN_PACKS.some((p) => p.id === id))
329
+ throw new StatusError(403, "built-in packs are read-only");
330
+ return findPack(id);
331
+ }
332
+ function newName(name) {
333
+ if (typeof name !== "string" || !PACK_NAME.test(name)) {
334
+ throw new StatusError(400, `pack names must match ${PACK_NAME}`);
335
+ }
336
+ return name;
337
+ }
338
+ /** No folder (in any letter case: file systems may ignore it) or file takes `name`, except `self`. */
339
+ async function mustNotExist(name, self) {
340
+ const lower = name.toLowerCase();
341
+ const taken = (await readdir(packsDir).catch(() => [])).some((n) => n.toLowerCase() === lower && n !== self);
342
+ if (taken)
343
+ throw new StatusError(409, "a pack with that name exists");
344
+ }
345
+ async function writeEmojiList(pack, emoji) {
346
+ await writeAtomic(join(packsDir, pack, "pack.json"), `${JSON.stringify({ emoji }, null, 1)}\n`);
347
+ }
348
+ /** Mutate characters.json under the lock and return the new data. */
349
+ async function update(fn) {
350
+ const s = await readState();
351
+ await fn(s);
352
+ await writeState(s);
353
+ return data(s);
354
+ }
355
+ function checkSession(sid) {
356
+ if (typeof sid !== "string" || !SESSION_ID.test(sid))
357
+ throw new StatusError(400, "sessionId must be a session UUID");
358
+ }
359
+ /** Forget assignments and overrides that point at characters `gone` of `pack`. */
360
+ function forget(s, pack, gone) {
361
+ const picks = s.assigned[pack];
362
+ if (picks)
363
+ for (const sid of Object.keys(picks))
364
+ if (gone(picks[sid]))
365
+ delete picks[sid];
366
+ if (picks && !Object.keys(picks).length)
367
+ delete s.assigned[pack];
368
+ for (const [sid, ref] of Object.entries(s.overrides))
369
+ if (ref.pack === pack && gone(ref.id))
370
+ delete s.overrides[sid];
371
+ }
372
+ return {
373
+ load: () => serial(() => data()),
374
+ // Picks are computed by the board from the packs it last saw; a pick for a
375
+ // pack, character or session that is gone (or malformed) is dropped, not
376
+ // the whole batch. The answer shows what was kept.
377
+ assign: (pack, picks) => serial(async () => {
378
+ if (!isObject(picks))
379
+ throw new StatusError(400, "assign must be an object");
380
+ const entries = Object.entries(picks);
381
+ if (entries.length > 1000)
382
+ throw new StatusError(400, "too many assignments");
383
+ const p = await findPack(pack).catch((err) => {
384
+ if (err instanceof StatusError && err.status === 404)
385
+ return undefined;
386
+ throw err;
387
+ });
388
+ const ids = new Set(p?.characters.map((c) => c.id));
389
+ const keep = entries.filter(([sid, id]) => SESSION_ID.test(sid) && typeof id === "string" && ids.has(id));
390
+ if (!p || !keep.length)
391
+ return data();
392
+ return update((s) => {
393
+ s.assigned[p.id] = { ...s.assigned[p.id], ...Object.fromEntries(keep) };
394
+ });
395
+ }),
396
+ override: (sessionId, ref) => serial(async () => {
397
+ checkSession(sessionId);
398
+ if (ref !== null) {
399
+ if (!isObject(ref))
400
+ throw new StatusError(400, "character must be {pack, id}");
401
+ const p = await findPack(ref.pack).catch(() => undefined);
402
+ if (!p || !p.characters.some((c) => c.id === ref.id))
403
+ throw new StatusError(400, "no such character");
404
+ return update((s) => {
405
+ s.overrides[sessionId] = { pack: p.id, id: ref.id };
406
+ });
407
+ }
408
+ return update((s) => {
409
+ delete s.overrides[sessionId];
410
+ });
411
+ }),
412
+ setActive: (pack) => serial(async () => {
413
+ const p = await findPack(pack).catch(() => undefined);
414
+ if (!p)
415
+ throw new StatusError(400, "no such pack");
416
+ return update((s) => {
417
+ s.activePack = p.id;
418
+ });
419
+ }),
420
+ createPack: (name, from) => serial(async () => {
421
+ const n = newName(name);
422
+ const source = from === undefined ? undefined : await findPack(from);
423
+ await mkdir(packsDir, { recursive: true });
424
+ await mustNotExist(n);
425
+ const dir = join(packsDir, n);
426
+ if (source && !source.builtIn) {
427
+ // Copy the folder (pack.json and images), not following symlinks.
428
+ await cp(join(packsDir, source.id), dir, { recursive: true, verbatimSymlinks: true, errorOnExist: true });
429
+ }
430
+ else {
431
+ await mkdir(dir);
432
+ if (source)
433
+ await writeEmojiList(n, source.characters.map((c) => c.id));
434
+ }
435
+ return data();
436
+ }),
437
+ renamePack: (pack, name) => serial(async () => {
438
+ const p = await customPack(pack);
439
+ const n = newName(name);
440
+ if (n === p.id)
441
+ return data();
442
+ await mustNotExist(n, p.id);
443
+ // State first, then the folder; if the folder cannot move, put the state back.
444
+ const before = await readState();
445
+ const after = structuredClone(before);
446
+ if (after.activePack === p.id)
447
+ after.activePack = n;
448
+ if (after.assigned[p.id]) {
449
+ after.assigned[n] = after.assigned[p.id];
450
+ delete after.assigned[p.id];
451
+ }
452
+ for (const ref of Object.values(after.overrides))
453
+ if (ref.pack === p.id)
454
+ ref.pack = n;
455
+ await writeState(after);
456
+ try {
457
+ await rename(join(packsDir, p.id), join(packsDir, n));
458
+ }
459
+ catch (err) {
460
+ await writeState(before);
461
+ throw err;
462
+ }
463
+ return data(after);
464
+ }),
465
+ deletePack: (pack) => serial(async () => {
466
+ const p = await customPack(pack);
467
+ // A validated name directly under packs/, checked to be a real directory by findPack.
468
+ await rm(join(packsDir, p.id), { recursive: true, force: true });
469
+ return update((s) => {
470
+ if (s.activePack === p.id)
471
+ s.activePack = DEFAULT_PACK;
472
+ delete s.assigned[p.id];
473
+ forget(s, p.id, () => true);
474
+ });
475
+ }),
476
+ addEmoji: (pack, emoji) => serial(async () => {
477
+ const p = await customPack(pack);
478
+ if (!validEmoji(emoji))
479
+ throw new StatusError(400, "not a single emoji");
480
+ const list = await readEmojiList(join(packsDir, p.id));
481
+ if (list.error)
482
+ throw new StatusError(409, `cannot edit this pack: ${list.error}`);
483
+ if (list.emoji.includes(emoji))
484
+ return data();
485
+ if (p.characters.length >= MAX_PACK_CHARACTERS)
486
+ throw new StatusError(400, `a pack holds at most ${MAX_PACK_CHARACTERS} characters`);
487
+ await writeEmojiList(p.id, [...list.emoji, emoji]);
488
+ return data();
489
+ }),
490
+ addImage: (pack, name, dataURL) => serial(async () => {
491
+ const p = await customPack(pack);
492
+ const m = typeof dataURL === "string" ? /^data:([a-z0-9.+/-]+);base64,([A-Za-z0-9+/=\s]*)$/.exec(dataURL) : null;
493
+ if (!m)
494
+ throw new StatusError(400, "image must be a base64 data: URL");
495
+ const mimeType = m[1];
496
+ if (!Object.hasOwn(EXT_BY_MIME, mimeType))
497
+ throw new StatusError(400, "images must be png, gif, webp, jpeg or svg");
498
+ if (m[2].length > Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 8)
499
+ throw new StatusError(400, "image too large (max 1 MB)");
500
+ const bytes = Buffer.from(m[2], "base64");
501
+ if (bytes.length > MAX_IMAGE_BYTES)
502
+ throw new StatusError(400, "image too large (max 1 MB)");
503
+ if (!sniffImage(bytes, mimeType))
504
+ throw new StatusError(400, `not a ${mimeType} image`);
505
+ if (p.characters.length >= MAX_PACK_CHARACTERS)
506
+ throw new StatusError(400, `a pack holds at most ${MAX_PACK_CHARACTERS} characters`);
507
+ const base = uploadName(name, mimeType);
508
+ const ext = extname(base);
509
+ const stem = base.slice(0, -ext.length);
510
+ const taken = new Set(await readdir(join(packsDir, p.id)));
511
+ let file = base;
512
+ for (let i = 2; taken.has(file); i++)
513
+ file = `${stem}-${i}${ext}`;
514
+ await writeAtomic(join(packsDir, p.id, file), bytes);
515
+ return data();
516
+ }),
517
+ removeCharacter: (pack, id) => serial(async () => {
518
+ const p = await customPack(pack);
519
+ const c = p.characters.find((c) => c.id === id);
520
+ if (!c)
521
+ throw new StatusError(404, "no such character");
522
+ if (c.kind === "emoji") {
523
+ const list = await readEmojiList(join(packsDir, p.id));
524
+ await writeEmojiList(p.id, list.emoji.filter((e) => e !== id));
525
+ }
526
+ else {
527
+ // `id` is a name from the pack's own listing, so it is a plain file in its folder.
528
+ await unlink(join(packsDir, p.id, c.id));
529
+ }
530
+ return update((s) => forget(s, p.id, (x) => x === id));
531
+ }),
532
+ async readImage(pack, file) {
533
+ if (typeof pack !== "string" || typeof file !== "string" || !(await isPackFolder(pack)))
534
+ return undefined;
535
+ // `file` passed listableName (no separators, not hidden), so it names an entry of this folder.
536
+ const img = await checkImage(join(packsDir, pack), file, true);
537
+ return img.ok ? { bytes: img.bytes, mimeType: img.mimeType, hash: img.hash } : undefined;
538
+ },
539
+ };
540
+ }
@@ -8,7 +8,12 @@ import { createAppServer } from "./http.js";
8
8
  import { createDesktop } from "./desktop/desktop.js";
9
9
  import { createHerdrLink, createNoPaneLink } from "./panes/herdr.js";
10
10
  import { createSceneStore, defaultStateDir } from "./scene-store.js";
11
+ import { createCharacterStore } from "./characters.js";
11
12
  import { createClaudeSource } from "./sources/claude.js";
13
+ import { createCodexSource } from "./sources/codex.js";
14
+ import { combineSources } from "./sources/multi.js";
15
+ import { procRootFromEnv } from "./sources/proc.js";
16
+ import { createOpenCodeSource } from "./sources/opencode.js";
12
17
  import { loadOrCreateToken } from "./token.js";
13
18
  import { clearRunning, findRunning, recordRunning } from "./instance.js";
14
19
  const here = dirname(fileURLToPath(import.meta.url));
@@ -39,7 +44,13 @@ async function main(args) {
39
44
  openBrowser(url);
40
45
  return;
41
46
  }
42
- const source = createClaudeSource({ claudeDir: args.claudeDir });
47
+ // Tests and the demo point process scans at a synthetic tree (never documented as a flag).
48
+ const procRoot = procRootFromEnv();
49
+ const source = combineSources([
50
+ createClaudeSource({ claudeDir: args.claudeDir }),
51
+ ...(args.codex ? [createCodexSource({ codexDir: args.codexDir, ...(procRoot ? { procRoot } : {}) })] : []),
52
+ ...(args.opencode ? [createOpenCodeSource({ ...(args.opencodeDir ? { dataDir: args.opencodeDir } : {}), ...(procRoot ? { procRoot } : {}) })] : []),
53
+ ]);
43
54
  const desktop = args.herdr && args.raise ? createDesktop({ env: process.env }) : undefined;
44
55
  const panes = args.herdr ? createHerdrLink({ desktop }) : createNoPaneLink();
45
56
  const hub = createHub({ source, panes });
@@ -50,6 +61,7 @@ async function main(args) {
50
61
  const server = createAppServer({
51
62
  hub,
52
63
  scenes,
64
+ characters: createCharacterStore(stateDir),
53
65
  token,
54
66
  webRoot: join(root, "dist", "web"),
55
67
  get middleware() {
@@ -82,6 +94,9 @@ async function main(args) {
82
94
  const url = `http://127.0.0.1:${server.port}/?token=${token}`;
83
95
  console.log(`excaliagent ${args.dev ? "(dev) " : ""}listening on http://127.0.0.1:${server.port}`);
84
96
  console.log(`scene: ${scenes.path}`);
97
+ console.log(`sources: ${["claude", ...(args.codex ? ["codex"] : []), ...(args.opencode ? ["opencode"] : [])].join(", ")}`);
98
+ if (procRoot)
99
+ console.log(`processes: ${procRoot} (EXCALIAGENT_PROC_ROOT)`);
85
100
  console.log(`herdr: ${panes.available() ? "connected" : args.herdr ? "not running (will retry)" : "disabled"}`);
86
101
  if (args.herdr)
87
102
  console.log(`raise window on focus: ${!desktop ? "off (--no-raise)" : desktop.kind === "none" ? "unsupported here" : desktop.kind}`);