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
@@ -4,6 +4,7 @@ import { extname, join, normalize, sep } from "node:path";
4
4
  import { timingSafeEqual } from "node:crypto";
5
5
  import { SESSION_ID } from "./hub.js";
6
6
  import { validFile } from "./scene-store.js";
7
+ import { StatusError } from "./characters.js";
7
8
  /** Cookies ignore the port, so the name carries it: two boards never clobber each other. */
8
9
  export const cookieName = (port) => `excaliagent_token_${port}`;
9
10
  /** The token is stable per state dir (token.ts), so the cookie may outlive a restart. */
@@ -53,15 +54,21 @@ function text(res, status, body) {
53
54
  res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
54
55
  res.end(body);
55
56
  }
56
- async function readJson(req) {
57
+ /** Body limits of the character and pack routes: an image upload, everything else. */
58
+ export const IMAGE_BODY_LIMIT = 1536 * 1024;
59
+ export const SMALL_BODY_LIMIT = 64 * 1024;
60
+ async function readJson(req, limit = MAX_BODY) {
57
61
  if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
58
62
  throw new HttpError(415, "expected application/json");
59
63
  }
64
+ // Refuse a declared oversize body before reading any of it.
65
+ if (Number(req.headers["content-length"] ?? 0) > limit)
66
+ throw new HttpError(413, "body too large");
60
67
  const chunks = [];
61
68
  let size = 0;
62
69
  for await (const chunk of req) {
63
70
  size += chunk.length;
64
- if (size > MAX_BODY)
71
+ if (size > limit)
65
72
  throw new HttpError(413, "body too large");
66
73
  chunks.push(chunk);
67
74
  }
@@ -122,9 +129,77 @@ export function createAppServer(opts) {
122
129
  unsubscribe();
123
130
  });
124
131
  }
125
- async function api(req, res, pathname) {
132
+ /** GET /api/packs/<pack>/<file>: a custom pack's image, only if that pack lists it. */
133
+ async function packImage(res, rest, version) {
134
+ const parts = rest.split("/");
135
+ if (parts.length !== 2)
136
+ return json(res, 404, { error: "not found" });
137
+ let pack, file;
138
+ try {
139
+ [pack, file] = parts.map((p) => decodeURIComponent(p));
140
+ }
141
+ catch {
142
+ throw new HttpError(400, "bad path");
143
+ }
144
+ const img = await opts.characters.readImage(pack, file);
145
+ if (!img)
146
+ return json(res, 404, { error: "not found" });
147
+ res.writeHead(200, {
148
+ "content-type": img.mimeType,
149
+ // `?v=<content hash>` (from the pack listing) names these exact bytes.
150
+ "cache-control": version === img.hash ? "private, max-age=31536000, immutable" : "no-store",
151
+ "x-content-type-options": "nosniff",
152
+ // An SVG opened directly must not run scripts on our origin.
153
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
154
+ });
155
+ res.end(img.bytes);
156
+ }
157
+ async function api(req, res, pathname, search) {
126
158
  const route = `${req.method} ${pathname}`;
159
+ if (req.method === "GET" && pathname.startsWith("/api/packs/"))
160
+ return packImage(res, pathname.slice("/api/packs/".length), search.get("v"));
161
+ const chars = opts.characters;
162
+ const body = async () => {
163
+ const b = await readJson(req, pathname === "/api/packs/image" ? IMAGE_BODY_LIMIT : SMALL_BODY_LIMIT);
164
+ if (!b || typeof b !== "object")
165
+ throw new HttpError(400, "expected a JSON object");
166
+ return b;
167
+ };
127
168
  switch (route) {
169
+ case "GET /api/characters":
170
+ return json(res, 200, await chars.load());
171
+ case "POST /api/characters/assign": {
172
+ const b = await body();
173
+ return json(res, 200, await chars.assign(b.pack, b.assign));
174
+ }
175
+ case "POST /api/characters/override": {
176
+ const b = await body();
177
+ return json(res, 200, await chars.override(b.sessionId, (b.character ?? null)));
178
+ }
179
+ case "POST /api/characters/active":
180
+ return json(res, 200, await chars.setActive((await body()).pack));
181
+ case "POST /api/packs": {
182
+ const b = await body();
183
+ return json(res, 200, await chars.createPack(b.name, (b.from ?? undefined)));
184
+ }
185
+ case "POST /api/packs/rename": {
186
+ const b = await body();
187
+ return json(res, 200, await chars.renamePack(b.pack, b.name));
188
+ }
189
+ case "POST /api/packs/delete":
190
+ return json(res, 200, await chars.deletePack((await body()).pack));
191
+ case "POST /api/packs/emoji": {
192
+ const b = await body();
193
+ return json(res, 200, await chars.addEmoji(b.pack, b.emoji));
194
+ }
195
+ case "POST /api/packs/image": {
196
+ const b = await body();
197
+ return json(res, 200, await chars.addImage(b.pack, b.name, b.dataURL));
198
+ }
199
+ case "POST /api/packs/remove": {
200
+ const b = await body();
201
+ return json(res, 200, await chars.removeCharacter(b.pack, b.id));
202
+ }
128
203
  case "GET /api/sessions":
129
204
  return json(res, 200, opts.hub.sessions());
130
205
  case "GET /api/events":
@@ -167,6 +242,20 @@ export function createAppServer(opts) {
167
242
  }
168
243
  return json(res, 200, await opts.scenes.loadFiles(body.ids));
169
244
  }
245
+ case "POST /api/end": {
246
+ const body = (await readJson(req));
247
+ if (typeof body?.sessionId !== "string" || !SESSION_ID.test(body.sessionId)) {
248
+ throw new HttpError(400, "sessionId must be a session id");
249
+ }
250
+ const result = await opts.hub.end(body.sessionId);
251
+ if (result === "ended") {
252
+ res.writeHead(204).end();
253
+ return;
254
+ }
255
+ if (result === "unsupported")
256
+ return json(res, 501, { error: "unsupported" });
257
+ return json(res, 409, { error: "not running" });
258
+ }
170
259
  case "POST /api/focus": {
171
260
  const body = (await readJson(req));
172
261
  if (typeof body?.sessionId !== "string")
@@ -180,7 +269,7 @@ export function createAppServer(opts) {
180
269
  case "POST /api/resume": {
181
270
  const body = (await readJson(req));
182
271
  if (typeof body?.sessionId !== "string" || !SESSION_ID.test(body.sessionId)) {
183
- throw new HttpError(400, "sessionId must be a session UUID");
272
+ throw new HttpError(400, "sessionId must be a session id");
184
273
  }
185
274
  const result = await opts.hub.resume(body.sessionId);
186
275
  if (result === "resumed") {
@@ -220,14 +309,14 @@ export function createAppServer(opts) {
220
309
  return text(res, 401, "excaliagent: open the URL printed in the terminal (it carries the access token).");
221
310
  }
222
311
  if (url.pathname.startsWith("/api/"))
223
- return api(req, res, url.pathname);
312
+ return api(req, res, url.pathname, url.searchParams);
224
313
  if (opts.middleware)
225
314
  return opts.middleware(req, res, () => text(res, 404, "not found"));
226
315
  return serveStatic(req, res, url.pathname);
227
316
  }
228
317
  const server = createServer((req, res) => {
229
318
  handle(req, res).catch((err) => {
230
- const status = err instanceof HttpError ? err.status : 500;
319
+ const status = err instanceof HttpError || err instanceof StatusError ? err.status : 500;
231
320
  if (status === 500)
232
321
  console.error("excaliagent:", err);
233
322
  if (!res.headersSent)
@@ -1,5 +1,10 @@
1
- /** Claude session ids are UUIDs; anything else is never put into a command. */
2
- export const SESSION_ID = /^[0-9a-f-]{36}$/i;
1
+ /**
2
+ * The id formats of every supported harness (Claude and Codex: UUIDs;
3
+ * OpenCode: `ses_` + 26 alphanumerics).
4
+ * Anything else is refused before it reaches a source or the characters store;
5
+ * each source also checks its own exact format before an id goes into a command.
6
+ */
7
+ export const SESSION_ID = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|ses_[0-9a-zA-Z]{26})$/i;
3
8
  const LABEL_MAX = 40;
4
9
  function resumeLabel(s) {
5
10
  const title = s.title?.trim();
@@ -80,16 +85,25 @@ export function createHub({ source, panes }) {
80
85
  return "unknown";
81
86
  if (s.live)
82
87
  return "live";
88
+ if (!s.resumeCommand)
89
+ return "unknown";
83
90
  if (!panes.resume || !panes.available())
84
91
  return "no-herdr";
85
92
  await panes.resume({
86
93
  cwd: s.cwd,
87
94
  ...(s.repoRoot !== undefined ? { repoRoot: s.repoRoot } : {}),
88
- command: `claude --resume ${s.id}`,
95
+ command: s.resumeCommand,
89
96
  label: resumeLabel(s),
90
97
  });
91
98
  return "resumed";
92
99
  },
100
+ async end(sessionId) {
101
+ if (!SESSION_ID.test(sessionId))
102
+ return "unknown";
103
+ if (!source.end)
104
+ return "unsupported";
105
+ return source.end(sessionId);
106
+ },
93
107
  async focus(sessionId) {
94
108
  const pane = current.find((s) => s.id === sessionId)?.pane;
95
109
  if (!pane)
@@ -50,15 +50,40 @@ export function defaultHerdrSocketPath() {
50
50
  return herdrSocketPath(process.env, homedir()) ?? join(homedir(), ".config", "herdr", "herdr.sock");
51
51
  }
52
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()) {
53
+ /**
54
+ * pid → pane for every foreground process of every pane; `panes` supplies ids
55
+ * and labels. With `procs` (the process table), every descendant of a pane's
56
+ * shell maps to that pane too: a session is not always in the foreground (a
57
+ * command it runs can take the terminal, or it is suspended). Foreground wins.
58
+ */
59
+ export function parsePaneMap(infos, panes = new Map(), procs = []) {
55
60
  const map = new Map();
61
+ const byShell = new Map();
56
62
  for (const info of infos) {
57
63
  const pane = panes.get(info.pane_id) ?? { paneId: info.pane_id, workspaceId: workspaceOf(info.pane_id) };
58
64
  for (const p of info.foreground_processes ?? []) {
59
65
  if (typeof p.pid === "number")
60
66
  map.set(p.pid, pane);
61
67
  }
68
+ if (typeof info.shell_pid === "number")
69
+ byShell.set(info.shell_pid, pane);
70
+ }
71
+ if (!byShell.size)
72
+ return map;
73
+ const children = new Map();
74
+ for (const p of procs)
75
+ children.set(p.ppid, [...(children.get(p.ppid) ?? []), p.pid]);
76
+ const seen = new Set(); // a pid is walked once, even if the table has a cycle
77
+ for (const [shell, pane] of byShell) {
78
+ const todo = [...(children.get(shell) ?? [])];
79
+ for (let pid = todo.pop(); pid !== undefined; pid = todo.pop()) {
80
+ if (seen.has(pid))
81
+ continue;
82
+ seen.add(pid);
83
+ if (!map.has(pid))
84
+ map.set(pid, pane);
85
+ todo.push(...(children.get(pid) ?? []));
86
+ }
62
87
  }
63
88
  return map;
64
89
  }
@@ -150,6 +175,7 @@ export function createHerdrLink(opts = {}) {
150
175
  panes.set(p.pane_id, pane);
151
176
  }
152
177
  const panesList = list;
178
+ const procs = table.list().catch(() => []);
153
179
  const infos = await Promise.all(panesList.map(async (p) => {
154
180
  try {
155
181
  const res = (await client.request("pane.process_info", { pane_id: p.pane_id }));
@@ -161,7 +187,7 @@ export function createHerdrLink(opts = {}) {
161
187
  }));
162
188
  if (closed || !client.connected())
163
189
  return;
164
- setMap(parsePaneMap(infos.filter((i) => !!i), panes));
190
+ setMap(parsePaneMap(infos.filter((i) => !!i), panes, await procs));
165
191
  }
166
192
  // Coalesce: at most one build running, plus one queued behind it.
167
193
  let running;
@@ -5,10 +5,23 @@ import { watch as fsWatch } from "node:fs";
5
5
  import { readdir, stat } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, join } from "node:path";
8
+ import { createProcessTable } from "../desktop/process-table.js";
8
9
  import { GitReader } from "./git.js";
9
10
  import { onceLogger } from "./log.js";
10
11
  import { defaultIsAlive, isLive, readRegistry } from "./registry.js";
11
12
  import { TranscriptScanner } from "./transcript.js";
13
+ /** A Claude Code process: its executable, argv[0], or script (node cli.js) names claude. */
14
+ export function looksLikeClaude(argv, exe) {
15
+ const names = [argv?.[0] && basename(argv[0]), exe && basename(exe)];
16
+ return names.some((n) => !!n && /claude/i.test(n)) || /claude/i.test(argv?.[1] ?? "");
17
+ }
18
+ function defaultIsClaudeProcess() {
19
+ const table = createProcessTable();
20
+ return async (pid) => looksLikeClaude(await table.argv(pid), await table.exe(pid));
21
+ }
22
+ /** Claude session ids are UUIDs; anything else is never put into a command. */
23
+ export const CLAUDE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ export const claudeResumeCommand = (id) => `claude --resume ${id}`;
12
25
  const LIVE_STATUSES = new Set([
13
26
  "busy", "idle", "waiting", "needs_user", "needs_trust", "blocked",
14
27
  ]);
@@ -26,6 +39,8 @@ export function liveSession(e, t, git) {
26
39
  live: true,
27
40
  pid: e.pid,
28
41
  prLinks: t?.prLinks ?? [],
42
+ resumeCommand: claudeResumeCommand(e.sessionId),
43
+ canEnd: true,
29
44
  };
30
45
  return withGit(withTranscriptExtras(s, t, e.name, e.waitingFor), git);
31
46
  }
@@ -42,6 +57,7 @@ export function dormantSession(f, t, fallbackCwd, git) {
42
57
  cacheExpiresAt: t.cacheExpiresAt,
43
58
  live: false,
44
59
  prLinks: t.prLinks,
60
+ resumeCommand: claudeResumeCommand(f.id),
45
61
  };
46
62
  // A parked session keeps the branch it was on; only the useless "HEAD" stamp
47
63
  // is replaced by the repo's current branch.
@@ -97,6 +113,8 @@ export function createClaudeSource(opts = {}) {
97
113
  const warn = onceLogger(opts.log ?? console.warn);
98
114
  const scanner = new TranscriptScanner({ warn });
99
115
  const git = new GitReader({ ttlMs: opts.gitTtlMs ?? 2000 });
116
+ const isClaudeProcess = opts.isClaudeProcess ?? defaultIsClaudeProcess();
117
+ const kill = opts.kill ?? ((pid, signal) => process.kill(pid, signal));
100
118
  const pathById = new Map();
101
119
  /** A cwd seen in any transcript of a project dir, for transcripts without one. */
102
120
  const cwdBySlug = new Map();
@@ -197,6 +215,8 @@ export function createClaudeSource(opts = {}) {
197
215
  return toDormant(files, maxDormant);
198
216
  }
199
217
  async function get(id) {
218
+ if (!CLAUDE_ID.test(id))
219
+ return undefined;
200
220
  const live = (await list()).find((s) => s.id === id);
201
221
  if (live)
202
222
  return live;
@@ -288,5 +308,20 @@ export function createClaudeSource(opts = {}) {
288
308
  watcher = undefined;
289
309
  };
290
310
  }
291
- return { list, listDormant, get, watch };
311
+ /** SIGTERM, as when its terminal closes: Claude Code exits and keeps the transcript. */
312
+ async function end(id) {
313
+ if (!CLAUDE_ID.test(id))
314
+ return "not-live";
315
+ const e = (await liveEntries()).find((x) => x.sessionId === id);
316
+ if (!e || !(await isClaudeProcess(e.pid).catch(() => false)))
317
+ return "not-live";
318
+ try {
319
+ kill(e.pid, "SIGTERM");
320
+ }
321
+ catch {
322
+ return "not-live"; // exited in the meantime
323
+ }
324
+ return "ended";
325
+ }
326
+ return { list, listDormant, get, watch, end };
292
327
  }
@@ -0,0 +1,279 @@
1
+ // Which Codex threads are running, and in which process. Codex has no session
2
+ // registry like Claude's; what it has (verified on 0.163.0, Linux):
3
+ //
4
+ // - `<codexHome>/thread-writer-locks/<thread id>.lock`: created and flock()ed
5
+ // by whoever has the thread loaded, unlocked and deleted when it is unloaded.
6
+ // Crashes leave the file but release the lock, so only a *held* lock counts:
7
+ // the holder is found in /proc/locks by inode and confirmed through
8
+ // /proc/<pid>/fd.
9
+ // - The interactive TUI (`codex`, `codex resume <id>`) does not hold it: it
10
+ // talks to a shared app-server daemon (`codex app-server ... --managed-daemon`,
11
+ // one per Codex home) that loads the thread. The daemon keeps a thread loaded
12
+ // for `thread_unload_delay_secs` (default 30 min) after its last client left,
13
+ // so a held lock alone does not mean someone is looking at it.
14
+ // - `codex exec` loads its thread in-process and holds the lock itself.
15
+ //
16
+ // So a thread held by a non-daemon codex process is live in that process. A
17
+ // thread held by the daemon is live only when a TUI process claims it: by its
18
+ // argv (`resume <id>`), else by working directory, among threads loaded after
19
+ // that TUI started (a thread loaded earlier belongs to another, maybe closed,
20
+ // TUI; a fresh TUI's own thread appears ~1.4 s after it): one with a rollout
21
+ // if any, else its earliest new thread (with auto-review on, the daemon loads
22
+ // a second, reviewer thread right after the conversation, which never gets a
23
+ // rollout unless it reviews something).
24
+ // Daemon threads no TUI claims are lingering, i.e. dormant. Only the first two
25
+ // (in-process holder, `resume <id>`) are exact; a working-directory claim is a
26
+ // guess, so such a session cannot be stopped from the board. Pure matching
27
+ // lives in `assignLive`; the /proc reading around it is Linux-only and takes a
28
+ // proc root, so tests read a synthetic tree.
29
+ import { readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
30
+ import { basename, join } from "node:path";
31
+ import { createProcessTable } from "../desktop/process-table.js";
32
+ import { CODEX_ID, uuidV7Ms } from "./codex-rollout.js";
33
+ import { bootTime, startedAt } from "./proc.js";
34
+ /** A Codex executable: argv[0] or exe named `codex`, or the npm shim (`node …/codex.js`). */
35
+ export function looksLikeCodex(argv, exe) {
36
+ const names = [argv?.[0], exe].filter((n) => !!n).map((n) => basename(n));
37
+ if (names.some((n) => n === "codex" || n === "codex.exe"))
38
+ return true;
39
+ const script = argv?.[1] ? basename(argv[1]) : "";
40
+ return names.some((n) => /^node(\.exe)?$/.test(n)) && (script === "codex" || script === "codex.js");
41
+ }
42
+ // Options that take a value, so the next word is not the subcommand.
43
+ const VALUE_FLAGS = new Set([
44
+ "-c", "--config", "-m", "--model", "-p", "--profile", "-s", "--sandbox", "-a", "--ask-for-approval",
45
+ "-C", "--cd", "-i", "--image", "--add-dir", "--enable", "--disable", "--local-provider", "--remote",
46
+ "--remote-auth-token-env",
47
+ ]);
48
+ // Subcommands that are not an interactive session (the TUI is `codex [PROMPT]`, `resume`, `fork`).
49
+ const NOT_TUI = new Set([
50
+ "agents", "exec", "e", "review", "login", "logout", "mcp", "plugin", "app-server", "remote-control",
51
+ "completion", "update", "doctor", "sandbox", "debug", "apply", "a", "queue", "archive", "delete",
52
+ "migrate-rollouts", "unarchive", "cloud", "exec-server", "features", "help", "mcp-server",
53
+ ]);
54
+ /** The words after the program (and after the script, for the npm shim). */
55
+ function args(argv) {
56
+ const shim = /^node(\.exe)?$/.test(basename(argv[0] ?? ""));
57
+ return argv.slice(shim ? 2 : 1);
58
+ }
59
+ /** First positional word: the subcommand, a prompt, or undefined. */
60
+ export function codexSubcommand(argv) {
61
+ const a = args(argv);
62
+ for (let i = 0; i < a.length; i++) {
63
+ const w = a[i];
64
+ if (w === "--")
65
+ return a[i + 1];
66
+ if (w.startsWith("-")) {
67
+ if (VALUE_FLAGS.has(w))
68
+ i++;
69
+ continue;
70
+ }
71
+ return w;
72
+ }
73
+ return undefined;
74
+ }
75
+ export const isDaemon = (argv) => codexSubcommand(argv) === "app-server";
76
+ /**
77
+ * An interactive Codex TUI process. Codex also runs its own binary as helpers
78
+ * (verified on 0.163.0: `codex-linux-sandbox ...` around every sandboxed
79
+ * command, once under bwrap, with the repo as cwd); those have another
80
+ * argv[0] (`codex-linux-sandbox`, `apply_patch`) or a `--codex-run-as-*`
81
+ * argv[1], and are not TUIs.
82
+ */
83
+ export const isTui = (argv) => {
84
+ const shim = /^node(\.exe)?$/.test(basename(argv[0] ?? ""));
85
+ const name = basename((shim ? argv[1] : argv[0]) ?? "");
86
+ if (!(shim ? name === "codex" || name === "codex.js" : name === "codex" || name === "codex.exe"))
87
+ return false;
88
+ if (args(argv)[0]?.startsWith("--codex-run-as-"))
89
+ return false;
90
+ const sub = codexSubcommand(argv);
91
+ return sub === undefined || !NOT_TUI.has(sub);
92
+ };
93
+ /** `codex resume <id>` / `codex fork <id>`: the thread named on the command line. */
94
+ export function argvThreadId(argv) {
95
+ const a = args(argv);
96
+ const sub = codexSubcommand(argv);
97
+ if (sub !== "resume")
98
+ return undefined; // a fork gets a new id
99
+ const rest = a.slice(a.indexOf(sub) + 1);
100
+ return rest.find((w) => CODEX_ID.test(w))?.toLowerCase();
101
+ }
102
+ /**
103
+ * Pure: which held threads are live, and where. `threadCwd` gives a thread's
104
+ * start cwd (from its rollout; undefined before the first message is written).
105
+ * `isUser` says whether a thread may be claimed by a TUI (not a sub-agent etc.).
106
+ * `threadActivity` gives the epoch ms of a thread's last rollout activity, and
107
+ * is undefined exactly when it has no rollout (default: when it has no cwd).
108
+ * Deterministic: the same locks and processes give the same answer in any order.
109
+ */
110
+ export function assignLive(locks, procs, threadCwd, isUser = () => true, threadActivity = (id) => (threadCwd(id) === undefined ? undefined : 0)) {
111
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
112
+ const out = [];
113
+ const daemonHeld = [];
114
+ for (const l of locks) {
115
+ const holder = byPid.get(l.pid);
116
+ if (!holder)
117
+ continue;
118
+ if (isDaemon(holder.argv))
119
+ daemonHeld.push(l);
120
+ else
121
+ out.push({ threadId: l.threadId, pid: l.pid, exact: true });
122
+ }
123
+ // TUIs; the npm shim and the native binary below it count once (the outer one).
124
+ const codexPids = new Set(procs.map((p) => p.pid));
125
+ const tuis = procs
126
+ .filter((p) => !isDaemon(p.argv) && isTui(p.argv) && !codexPids.has(p.ppid))
127
+ .filter((p) => !out.some((t) => t.pid === p.pid))
128
+ // Newest first: a later TUI is more likely on the newest thread of a directory,
129
+ // and what it leaves (its reviewer, say) is reserved before older TUIs choose.
130
+ .sort((a, b) => b.startedAt - a.startedAt || b.pid - a.pid);
131
+ const free = new Set(daemonHeld.filter((l) => isUser(l.threadId)).map((l) => l.threadId));
132
+ const loaded = new Map(daemonHeld.map((l) => [l.threadId, l.loadedAt]));
133
+ const created = (id) => uuidV7Ms(id) ?? loaded.get(id);
134
+ // Earliest created first; a tie goes to the smaller id, never to scan order.
135
+ const earlier = (a, b) => created(a) - created(b) || (a < b ? -1 : a > b ? 1 : 0);
136
+ /** Free threads `t` can have opened: from its directory (or no rollout yet), loaded after it started. */
137
+ const candidates = (t) => [...free]
138
+ .filter((id) => {
139
+ const cwd = threadCwd(id);
140
+ if (cwd !== undefined && cwd !== t.cwd)
141
+ return false;
142
+ const rollout = threadActivity(id) !== undefined;
143
+ // `codex resume` (its picker) only ever opens a thread with a rollout.
144
+ if (!rollout && codexSubcommand(t.argv) === "resume")
145
+ return false;
146
+ // Loaded after it started (else another TUI's, lingering), and, with no
147
+ // rollout yet (nobody can resume such a thread), created after it
148
+ // started. startedAt is never late (btime is whole seconds, rounded
149
+ // down), so its own thread always passes.
150
+ if (t.startedAt > 0 && (loaded.get(id) < t.startedAt || (!rollout && created(id) < t.startedAt)))
151
+ return false;
152
+ return true;
153
+ })
154
+ .sort(earlier);
155
+ // Threads loaded after a TUI started that it did not take (its reviewer, say):
156
+ // no other TUI may take them either, unless one is that TUI's own earliest.
157
+ const reserved = new Set();
158
+ const claimed = new Set();
159
+ for (const t of tuis) {
160
+ const id = argvThreadId(t.argv);
161
+ // A TUI that names its thread never guesses another one, even if that one is taken.
162
+ if (id)
163
+ claimed.add(t.pid);
164
+ if (id && free.has(id)) {
165
+ free.delete(id);
166
+ out.push({ threadId: id, pid: t.pid, exact: true, ...(t.cwd ? { cwd: t.cwd } : {}) });
167
+ }
168
+ }
169
+ for (const t of tuis)
170
+ if (claimed.has(t.pid) && t.cwd)
171
+ for (const id of candidates(t))
172
+ reserved.add(id);
173
+ for (const t of tuis) {
174
+ if (claimed.has(t.pid) || !t.cwd)
175
+ continue;
176
+ const mine = candidates(t);
177
+ const allowed = mine.filter((id, i) => i === 0 || !reserved.has(id));
178
+ for (const id of mine)
179
+ reserved.add(id);
180
+ // A thread with a rollout wins (the newest activity, then the newest load,
181
+ // then the larger id); else the earliest new thread: the conversation, not
182
+ // the auto-review thread the daemon loads just after it.
183
+ const withRollout = allowed.filter((id) => threadActivity(id) !== undefined);
184
+ const best = withRollout.length
185
+ ? withRollout.reduce((a, b) => {
186
+ const d = threadActivity(b) - threadActivity(a) || loaded.get(b) - loaded.get(a);
187
+ return d > 0 || (d === 0 && b > a) ? b : a;
188
+ })
189
+ : allowed[0];
190
+ if (best === undefined)
191
+ continue;
192
+ free.delete(best);
193
+ out.push({ threadId: best, pid: t.pid, cwd: t.cwd, exact: false });
194
+ }
195
+ return out;
196
+ }
197
+ /** `/proc/locks` → inode → pids holding (not waiting for) a lock on it. */
198
+ export function parseProcLocks(text) {
199
+ const out = new Map();
200
+ for (const line of text.split("\n")) {
201
+ const f = line.trim().split(/\s+/);
202
+ if (f.length < 6 || f[1] === "->")
203
+ continue;
204
+ const pid = Number(f[4]);
205
+ const inode = Number(f[5].split(":")[2]);
206
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(inode))
207
+ continue;
208
+ out.set(inode, [...(out.get(inode) ?? []), pid]);
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Reads the held thread locks under `lockDir`, their holders and, only when a
214
+ * daemon holds one, the Codex TUI processes that may be showing it. No lock
215
+ * file → nothing else is read. Undefined when /proc/locks is not readable (not
216
+ * Linux): liveness unknown.
217
+ */
218
+ export async function scanCodexProcesses(lockDir, procRoot = "/proc") {
219
+ const locksText = await readFile(join(procRoot, "locks"), "utf8").catch(() => undefined);
220
+ if (locksText === undefined)
221
+ return undefined;
222
+ lockDir = await realpath(lockDir).catch(() => lockDir);
223
+ const files = (await readdir(lockDir).catch(() => [])).filter((f) => f.endsWith(".lock") && CODEX_ID.test(f.slice(0, -5)));
224
+ if (!files.length)
225
+ return { locks: [], procs: [] };
226
+ const table = createProcessTable({ platform: "linux", procRoot });
227
+ const btime = await bootTime(procRoot);
228
+ const procs = new Map();
229
+ const proc = async (pid, ppid) => {
230
+ const known = procs.get(pid);
231
+ if (known !== undefined)
232
+ return known ?? undefined;
233
+ const [argv, exe] = await Promise.all([table.argv(pid), table.exe(pid)]);
234
+ if (!argv || !looksLikeCodex(argv, exe)) {
235
+ procs.set(pid, null);
236
+ return undefined;
237
+ }
238
+ const [cwd, parent, started] = await Promise.all([
239
+ readlink(join(procRoot, String(pid), "cwd")).catch(() => undefined),
240
+ ppid ?? table.parent(pid),
241
+ startedAt(procRoot, pid, btime),
242
+ ]);
243
+ const p = { pid, ppid: parent ?? 0, argv, startedAt: started, ...(cwd ? { cwd } : {}) };
244
+ procs.set(pid, p);
245
+ return p;
246
+ };
247
+ const fds = async (pid) => {
248
+ const dir = join(procRoot, String(pid), "fd");
249
+ const names = await readdir(dir).catch(() => []);
250
+ return new Set((await Promise.all(names.map((n) => readlink(join(dir, n)).catch(() => "")))).filter(Boolean));
251
+ };
252
+ const holders = parseProcLocks(locksText);
253
+ const fdCache = new Map();
254
+ const locks = [];
255
+ for (const f of files) {
256
+ const path = join(lockDir, f);
257
+ const st = await stat(path).catch(() => undefined);
258
+ if (!st)
259
+ continue;
260
+ for (const pid of holders.get(st.ino) ?? []) {
261
+ if (!(await proc(pid)))
262
+ continue;
263
+ if (!fdCache.has(pid))
264
+ fdCache.set(pid, await fds(pid));
265
+ if (!fdCache.get(pid).has(path))
266
+ continue;
267
+ locks.push({ threadId: f.slice(0, -5).toLowerCase(), pid, loadedAt: st.birthtimeMs || st.ctimeMs });
268
+ break;
269
+ }
270
+ }
271
+ if (locks.some((l) => isDaemon(procs.get(l.pid).argv))) {
272
+ for (const p of await table.list()) {
273
+ // Cheap filter before reading argv: the native binary, or a node shim.
274
+ if (/codex|node|MainThread/i.test(p.comm))
275
+ await proc(p.pid, p.ppid);
276
+ }
277
+ }
278
+ return { locks, procs: [...procs.values()].filter((p) => p !== null) };
279
+ }