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.
- package/README.md +113 -29
- package/dist/server/args.js +14 -1
- package/dist/server/characters.js +540 -0
- package/dist/server/cli.js +16 -1
- package/dist/server/http.js +95 -6
- package/dist/server/hub.js +17 -3
- package/dist/server/panes/herdr.js +29 -3
- package/dist/server/sources/claude.js +36 -1
- package/dist/server/sources/codex-live.js +279 -0
- package/dist/server/sources/codex-rollout.js +154 -0
- package/dist/server/sources/codex.js +363 -0
- package/dist/server/sources/jsonl.js +142 -0
- package/dist/server/sources/multi.js +53 -0
- package/dist/server/sources/opencode-live.js +144 -0
- package/dist/server/sources/opencode.js +265 -0
- package/dist/server/sources/proc.js +34 -0
- package/dist/server/sources/transcript.js +9 -103
- package/dist/shared/packs.js +62 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/{abnfDiagram-VCTEODGH-DV-5IMmB.js → abnfDiagram-VCTEODGH-BxrKsDoB.js} +1 -1
- package/dist/web/assets/{ar-SA-G6X2FPQ2-n3XdOipE.js → ar-SA-G6X2FPQ2-Cc6gbg88.js} +1 -1
- package/dist/web/assets/{architecture-7GRP2DOG-C497sWO2.js → architecture-7GRP2DOG-CIqN5I9B.js} +1 -1
- package/dist/web/assets/{architectureDiagram-5GKGNRK7-DuCJZflG.js → architectureDiagram-5GKGNRK7-DsXC8AqK.js} +1 -1
- package/dist/web/assets/{az-AZ-76LH7QW2-DSYFdGPZ.js → az-AZ-76LH7QW2-BH8VzNec.js} +1 -1
- package/dist/web/assets/{bg-BG-XCXSNQG7-aq-bPVBx.js → bg-BG-XCXSNQG7-ClFUbics.js} +1 -1
- package/dist/web/assets/{blockDiagram-I7D4REHJ-D6rbmy9X.js → blockDiagram-I7D4REHJ-CltS-Zt2.js} +1 -1
- package/dist/web/assets/{bn-BD-2XOGV67Q-BsnX_gLN.js → bn-BD-2XOGV67Q-BVUiOO4B.js} +1 -1
- package/dist/web/assets/{c4Diagram-7LVT6UL2-bkZh0r6f.js → c4Diagram-7LVT6UL2-C-C_SvWm.js} +1 -1
- package/dist/web/assets/{ca-ES-6MX7JW3Y-CvtjFAUU.js → ca-ES-6MX7JW3Y-CEK8mB8J.js} +1 -1
- package/dist/web/assets/channel-Dom8D_0o.js +1 -0
- package/dist/web/assets/{chunk-4HAMMTFA-DZJ_s-sA.js → chunk-4HAMMTFA-Bfdmb0Hg.js} +1 -1
- package/dist/web/assets/{chunk-75Z2AOVW-DU74RPBJ.js → chunk-75Z2AOVW-NYwkutzS.js} +1 -1
- package/dist/web/assets/{chunk-DU6HZSFF-CChYnq-U.js → chunk-DU6HZSFF-SqE0xdu5.js} +1 -1
- package/dist/web/assets/{chunk-EIO257PC-DIGaCD3q.js → chunk-EIO257PC-DpOeKaLq.js} +1 -1
- package/dist/web/assets/{chunk-F27PBJKO-C65b5F8o.js → chunk-F27PBJKO-g9bJrSwZ.js} +1 -1
- package/dist/web/assets/{chunk-GMAD6QVW-CJy4srfe.js → chunk-GMAD6QVW-B1y2ol5w.js} +1 -1
- package/dist/web/assets/{chunk-GVQU2GXP-CedQyfJw.js → chunk-GVQU2GXP-BUjRNRXo.js} +1 -1
- package/dist/web/assets/{chunk-IMKFNOWR-DPwApfTy.js → chunk-IMKFNOWR-DyeQep8U.js} +1 -1
- package/dist/web/assets/{chunk-L3NEJ4N5-DXku7BIn.js → chunk-L3NEJ4N5-BcTVhsWp.js} +1 -1
- package/dist/web/assets/{chunk-OSK3NFVY-BstcDrHK.js → chunk-OSK3NFVY-Db7B8jPm.js} +1 -1
- package/dist/web/assets/{chunk-P2QGCYS3-BHerElni.js → chunk-P2QGCYS3-Cx5iBC5z.js} +1 -1
- package/dist/web/assets/{chunk-POPQ4Y6H-ZS2hGTJJ.js → chunk-POPQ4Y6H-CqVRZDA8.js} +1 -1
- package/dist/web/assets/{chunk-PWAF6VOD-BUUBZvSR.js → chunk-PWAF6VOD-Cf2j9yew.js} +1 -1
- package/dist/web/assets/{chunk-SHT3W25Y-CMdySuQW.js → chunk-SHT3W25Y-0_N13xgE.js} +1 -1
- package/dist/web/assets/{chunk-SVP7TREG-E_6g4L6l.js → chunk-SVP7TREG-DkRJF9jr.js} +1 -1
- package/dist/web/assets/{chunk-TICWLB2K-CBTJSW3m.js → chunk-TICWLB2K-D6OC50xH.js} +1 -1
- package/dist/web/assets/classDiagram-ZZMXUADV-BMEkgkEJ.js +1 -0
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-BMEkgkEJ.js +1 -0
- package/dist/web/assets/{cs-CZ-2BRQDIVT-DLt9XJMu.js → cs-CZ-2BRQDIVT-DWt1RUsP.js} +1 -1
- package/dist/web/assets/{cynefin-OW5HDTMX-BYqJDvxe.js → cynefin-OW5HDTMX-Dx0HzRCl.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-5FMLGOSQ-iwuMBkRk.js → cynefinDiagram-5FMLGOSQ-C8AMQNhe.js} +1 -1
- package/dist/web/assets/{da-DK-5WZEPLOC-BnzwheRI.js → da-DK-5WZEPLOC-BPggh0j6.js} +1 -1
- package/dist/web/assets/{dagre-GXQ25YYZ-DGskQLMq.js → dagre-GXQ25YYZ-f9qU6BFq.js} +1 -1
- package/dist/web/assets/{de-DE-XR44H4JA-B05-7snG.js → de-DE-XR44H4JA-B1q1pJrQ.js} +1 -1
- package/dist/web/assets/{diagram-S7CK7UJ4-Sclpr_lb.js → diagram-S7CK7UJ4-Bcq77Nl3.js} +1 -1
- package/dist/web/assets/{diagram-UQ7AKVKN-CkRKjDjq.js → diagram-UQ7AKVKN-DXzlgVTk.js} +1 -1
- package/dist/web/assets/{diagram-VSXAHHWV-DKAzSk6r.js → diagram-VSXAHHWV-C1vz2FvT.js} +1 -1
- package/dist/web/assets/{diagram-VX7I27RA-Bjdeg2uG.js → diagram-VX7I27RA-_c4ucp3r.js} +1 -1
- package/dist/web/assets/{diagram-Z3DM3KII-4jN9UD5P.js → diagram-Z3DM3KII-cUg91K-F.js} +1 -1
- package/dist/web/assets/{dist-BQE0NGB-.js → dist-DcwmvBdV.js} +4 -4
- package/dist/web/assets/{ebnfDiagram-PWID7BFC-BSFSSJ77.js → ebnfDiagram-PWID7BFC-9iWfohHg.js} +1 -1
- package/dist/web/assets/{el-GR-BZB4AONW-ShhNmsmN.js → el-GR-BZB4AONW-CrhwvNDD.js} +1 -1
- package/dist/web/assets/{en-B4ZKOASM-BNw5UiKn.js → en-B4ZKOASM-DCjnkz5L.js} +1 -1
- package/dist/web/assets/{erDiagram-RLTQ6QDP-CXr09bBy.js → erDiagram-RLTQ6QDP-A72uVlS3.js} +1 -1
- package/dist/web/assets/{es-ES-U4NZUMDT-2jCdm2lH.js → es-ES-U4NZUMDT-D0sRCoDL.js} +1 -1
- package/dist/web/assets/{eu-ES-A7QVB2H4-BhBUGvkL.js → eu-ES-A7QVB2H4-DLG-liPb.js} +1 -1
- package/dist/web/assets/{eventmodeling-NTZA5JFV-DLX5id4n.js → eventmodeling-NTZA5JFV-CIKZvDAj.js} +1 -1
- package/dist/web/assets/{fa-IR-HGAKTJCU-B-Z0NFoQ.js → fa-IR-HGAKTJCU-BW3pPOVT.js} +1 -1
- package/dist/web/assets/{fi-FI-Z5N7JZ37-C3a93Lzr.js → fi-FI-Z5N7JZ37-D2XaqwPp.js} +1 -1
- package/dist/web/assets/flowDiagram-HODETNUW-DhQSc3KP.js +1 -0
- package/dist/web/assets/{fr-FR-RHASNOE6-BfJc7jwO.js → fr-FR-RHASNOE6-B-BjRA7r.js} +1 -1
- package/dist/web/assets/{ganttDiagram-EL5Y4UJY-V2sHQ0x1.js → ganttDiagram-EL5Y4UJY-cgwUfr0G.js} +1 -1
- package/dist/web/assets/{gitGraph-4MIJSDKK-BGpxF8pu.js → gitGraph-4MIJSDKK-CeKopPn3.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-WWUBYQGX-asoaR4Hx.js → gitGraphDiagram-WWUBYQGX-BWjAy7ei.js} +1 -1
- package/dist/web/assets/{gl-ES-HMX3MZ6V-C8eeUPra.js → gl-ES-HMX3MZ6V-DYUAqB0N.js} +1 -1
- package/dist/web/assets/{he-IL-6SHJWFNN-DB0HanYn.js → he-IL-6SHJWFNN-CXkYqbuu.js} +1 -1
- package/dist/web/assets/{hi-IN-IWLTKZ5I-B3Gf328f.js → hi-IN-IWLTKZ5I-BwVBsHWG.js} +1 -1
- package/dist/web/assets/{hu-HU-A5ZG7DT2-7jNfqM7f.js → hu-HU-A5ZG7DT2-DkmlE7Aa.js} +1 -1
- package/dist/web/assets/{id-ID-SAP4L64H-DZd1jLNQ.js → id-ID-SAP4L64H-CwkytHX_.js} +1 -1
- package/dist/web/assets/{image-GAAHSSAO-DKJMzS7d.js → image-GAAHSSAO-u-hBxkaK.js} +1 -1
- package/dist/web/assets/index-D43I17T9.js +212 -0
- package/dist/web/assets/{index-DtRVLazd.css → index-DxXUknKX.css} +1 -1
- package/dist/web/assets/{info-A6RAGUB7-Dw0JaEzD.js → info-A6RAGUB7-DmadBmci.js} +1 -1
- package/dist/web/assets/{infoDiagram-27XIBGKW-DhivBaFe.js → infoDiagram-27XIBGKW-D93CKwhy.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-5VMMS53U-DHIyFGlH.js → ishikawaDiagram-5VMMS53U-BQ-x5YXp.js} +1 -1
- package/dist/web/assets/{it-IT-JPQ66NNP-BF7oahO7.js → it-IT-JPQ66NNP-Bc-Qa6KI.js} +1 -1
- package/dist/web/assets/{ja-JP-DBVTYXUO-CtGU47sH.js → ja-JP-DBVTYXUO-BPmQCryQ.js} +1 -1
- package/dist/web/assets/{journeyDiagram-3NMN7TZE-wwS9CODt.js → journeyDiagram-3NMN7TZE-CO_LXx1E.js} +1 -1
- package/dist/web/assets/{kaa-6HZHGXH3-CyohUVIM.js → kaa-6HZHGXH3-DuBFVAjv.js} +1 -1
- package/dist/web/assets/{kab-KAB-ZGHBKWFO-2acNr90z.js → kab-KAB-ZGHBKWFO-DhEFzSTh.js} +1 -1
- package/dist/web/assets/{kanban-definition-UXKFOSKX-vqJ11RID.js → kanban-definition-UXKFOSKX-B72Jztau.js} +1 -1
- package/dist/web/assets/{kk-KZ-P5N5QNE5-C56zTJ0v.js → kk-KZ-P5N5QNE5-CW7PxU2h.js} +1 -1
- package/dist/web/assets/{km-KH-HSX4SM5Z-Bg7FoxwJ.js → km-KH-HSX4SM5Z-CYy7yySD.js} +1 -1
- package/dist/web/assets/{ko-KR-MTYHY66A-wlZa8Fb7.js → ko-KR-MTYHY66A-C5O645Qe.js} +1 -1
- package/dist/web/assets/{ku-TR-6OUDTVRD-DpMT6JcJ.js → ku-TR-6OUDTVRD-CKcd_iWr.js} +1 -1
- package/dist/web/assets/{line-Cy2R8zKE.js → line-Ckd3sAmP.js} +1 -1
- package/dist/web/assets/{lt-LT-XHIRWOB4-0nDOQqUM.js → lt-LT-XHIRWOB4-ya2_xWiy.js} +1 -1
- package/dist/web/assets/{lv-LV-5QDEKY6T-DGNAIt-v.js → lv-LV-5QDEKY6T-cYYwyzmz.js} +1 -1
- package/dist/web/assets/{mermaid-parser.core-C-IWeUrh.js → mermaid-parser.core-Dbqkvrw-.js} +3 -3
- package/dist/web/assets/{mindmap-definition-YA3MSWOX-DDX8XN8u.js → mindmap-definition-YA3MSWOX-B4onxNgk.js} +1 -1
- package/dist/web/assets/{mr-IN-CRQNXWMA-CHXpujpK.js → mr-IN-CRQNXWMA-CfaHpVWe.js} +1 -1
- package/dist/web/assets/{my-MM-5M5IBNSE-Bg0H7WJb.js → my-MM-5M5IBNSE-BfKiTATq.js} +1 -1
- package/dist/web/assets/{nb-NO-T6EIAALU-BE_hPlhF.js → nb-NO-T6EIAALU-Cz3yDFYg.js} +1 -1
- package/dist/web/assets/{nl-NL-IS3SIHDZ-fdiiEQqB.js → nl-NL-IS3SIHDZ-DDX026n5.js} +1 -1
- package/dist/web/assets/{nn-NO-6E72VCQL-C7j1gMVo.js → nn-NO-6E72VCQL-BcokL_SN.js} +1 -1
- package/dist/web/assets/{oc-FR-POXYY2M6-Q5CdkT-D.js → oc-FR-POXYY2M6-bNFsGpEp.js} +1 -1
- package/dist/web/assets/{pa-IN-N4M65BXN-DJyQmbX0.js → pa-IN-N4M65BXN-iIHN9Z8W.js} +1 -1
- package/dist/web/assets/{packet-AYTQ26CC-D4ouOVez.js → packet-AYTQ26CC-BzBpP9hg.js} +1 -1
- package/dist/web/assets/{pegDiagram-XKGWAZYB-DAWOkGLy.js → pegDiagram-XKGWAZYB-BPScRPVZ.js} +1 -1
- package/dist/web/assets/percentages-BXMCSKIN-EDfR-TFr.js +1 -0
- package/dist/web/assets/{pie-WAS4IAKB-MCWueKc-.js → pie-WAS4IAKB-N-9RHtKL.js} +1 -1
- package/dist/web/assets/{pieDiagram-E7YTZNPT-BLlQT9t_.js → pieDiagram-E7YTZNPT-Bpd_yCG-.js} +1 -1
- package/dist/web/assets/{pl-PL-T2D74RX3-CcWFE9AN.js → pl-PL-T2D74RX3-DeqOx-a4.js} +1 -1
- package/dist/web/assets/{pt-BR-5N22H2LF-D6QCOM38.js → pt-BR-5N22H2LF-DG1PCIkb.js} +1 -1
- package/dist/web/assets/{pt-PT-UZXXM6DQ-ByJh7ZNa.js → pt-PT-UZXXM6DQ-BsUJk2fq.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-AXDQQJYC-CIinawCS.js → quadrantDiagram-AXDQQJYC-CSLCp0xS.js} +1 -1
- package/dist/web/assets/{radar-RG4KPBEZ-Cm5qT3Hl.js → radar-RG4KPBEZ-rKULJroO.js} +1 -1
- package/dist/web/assets/{railroad-74A4TZTK-DO3FGMCu.js → railroad-74A4TZTK-hDn0WPg3.js} +1 -1
- package/dist/web/assets/{railroad-abnf-HS5TGJTU-DqC68RR5.js → railroad-abnf-HS5TGJTU-DfI0hiCR.js} +1 -1
- package/dist/web/assets/{railroad-ebnf-LZEXJU2U-Cby7H3Dj.js → railroad-ebnf-LZEXJU2U-paFQAnqR.js} +1 -1
- package/dist/web/assets/{railroad-peg-WCYAUIDC-CJpoUW06.js → railroad-peg-WCYAUIDC-B-2Um3QI.js} +1 -1
- package/dist/web/assets/{railroadDiagram-O6MQD6OU-CIJ1E8KE.js → railroadDiagram-O6MQD6OU-WkZPN5MA.js} +1 -1
- package/dist/web/assets/{requirementDiagram-BXWQKSXE-BTDHXcSN.js → requirementDiagram-BXWQKSXE-BJULFhuR.js} +1 -1
- package/dist/web/assets/{ro-RO-JPDTUUEW-BlhbQCSw.js → ro-RO-JPDTUUEW-yKjTMizq.js} +1 -1
- package/dist/web/assets/{ru-RU-B4JR7IUQ-Cd8b9tGe.js → ru-RU-B4JR7IUQ-DITGdMQL.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-P5KCCOFB-DFx8uxV2.js → sankeyDiagram-P5KCCOFB-b_vfju3s.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-WJ2MYXX4-Dr8vTa3e.js → sequenceDiagram-WJ2MYXX4-D5n2aEN6.js} +1 -1
- package/dist/web/assets/{si-LK-N5RQ5JYF-Y7h5xFBD.js → si-LK-N5RQ5JYF-DaFciStR.js} +1 -1
- package/dist/web/assets/{sk-SK-C5VTKIMK-DU5G4wEe.js → sk-SK-C5VTKIMK-CpyCIFtD.js} +1 -1
- package/dist/web/assets/{sl-SI-NN7IZMDC-4olCD7lc.js → sl-SI-NN7IZMDC-cnwvs7FN.js} +1 -1
- package/dist/web/assets/{stateDiagram-D77RDMKH-CRqebLR8.js → stateDiagram-D77RDMKH-CbFjpKDm.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-MOkTt8Mv.js +1 -0
- package/dist/web/assets/subset-shared.chunk-CWAaXrHb.js +1 -0
- package/dist/web/assets/{subset-worker.chunk-Bqgy-Y1W.js → subset-worker.chunk-Cebu2JDe.js} +1 -1
- package/dist/web/assets/{sv-SE-XGPEYMSR-Cp0rVzLX.js → sv-SE-XGPEYMSR-dOntI1Fk.js} +1 -1
- package/dist/web/assets/{swimlanes-42K2YHIH-BYd8YoU8.js → swimlanes-42K2YHIH-DtmsuzRR.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-v2ue4raa.js +8 -0
- package/dist/web/assets/{ta-IN-2NMHFXQM-DPDOL5cy.js → ta-IN-2NMHFXQM-Comju6i2.js} +1 -1
- package/dist/web/assets/{th-TH-HPSO5L25-CN82X-G0.js → th-TH-HPSO5L25-uPCBvbue.js} +1 -1
- package/dist/web/assets/{timeline-definition-24CTP7MA-DeQFCevv.js → timeline-definition-24CTP7MA-DKZOmZFD.js} +1 -1
- package/dist/web/assets/{tr-TR-DEFEU3FU-DZkONFun.js → tr-TR-DEFEU3FU-LaP2C4go.js} +1 -1
- package/dist/web/assets/{treeView-Q6P3EWNA-C4dlZskR.js → treeView-Q6P3EWNA-DJii9IFr.js} +1 -1
- package/dist/web/assets/{treemap-WGGIJYW6-oSQNu9HW.js → treemap-WGGIJYW6-U42pUP2Q.js} +1 -1
- package/dist/web/assets/{uk-UA-QMV73CPH-da8znbfC.js → uk-UA-QMV73CPH-u-WBulor.js} +1 -1
- package/dist/web/assets/{vennDiagram-4TSXK5OY-e4T6zK4z.js → vennDiagram-4TSXK5OY-C8_zJPdp.js} +1 -1
- package/dist/web/assets/{vi-VN-M7AON7JQ-QWCjquXm.js → vi-VN-M7AON7JQ-DOSv3aq5.js} +1 -1
- package/dist/web/assets/{wardley-WFR3VGLG-BzQw11Sd.js → wardley-WFR3VGLG-DU89dbbh.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-VM6X3IG4-DyPpKV2H.js → wardleyDiagram-VM6X3IG4-BGnASelu.js} +1 -1
- package/dist/web/assets/{xychartDiagram-S5SC5T6Z-DKjIes2P.js → xychartDiagram-S5SC5T6Z-js8-abv7.js} +1 -1
- package/dist/web/assets/{zh-CN-LNUGB5OW-BbT-kvRj.js → zh-CN-LNUGB5OW-B8soJHf3.js} +1 -1
- package/dist/web/assets/{zh-HK-E62DVLB3-BPw2AWZD.js → zh-HK-E62DVLB3-DKByBmSJ.js} +1 -1
- package/dist/web/assets/{zh-TW-RAJ6MFWO-BE9F3OdK.js → zh-TW-RAJ6MFWO-CYBStGL6.js} +1 -1
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon.svg +12 -0
- package/dist/web/index.html +6 -3
- package/package.json +4 -2
- package/dist/web/assets/channel-B_LXUtkZ.js +0 -1
- package/dist/web/assets/classDiagram-ZZMXUADV-zYihe46b.js +0 -1
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-zYihe46b.js +0 -1
- package/dist/web/assets/flowDiagram-HODETNUW-CnV5VPNb.js +0 -1
- package/dist/web/assets/index-BLscnX3-.js +0 -212
- package/dist/web/assets/percentages-BXMCSKIN-B3ZxlSu3.js +0 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DWV16uKx.js +0 -1
- package/dist/web/assets/subset-shared.chunk-1t88pPR-.js +0 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-CACbHpLI.js +0 -8
package/dist/server/http.js
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
|
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)
|
package/dist/server/hub.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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:
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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
|
+
}
|