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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
2
|
+
/** Fail soft: one broken adapter must not take the others down. */
|
|
3
|
+
async function settle(work, warn, what) {
|
|
4
|
+
try {
|
|
5
|
+
return await work;
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
warn(`excaliagent: ${what} failed: ${e.message}`);
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function combineSources(sources, warn = console.warn) {
|
|
13
|
+
if (sources.length === 1)
|
|
14
|
+
return sources[0];
|
|
15
|
+
return {
|
|
16
|
+
async list() {
|
|
17
|
+
const lists = await Promise.all(sources.map((s) => settle(s.list(), warn, "listing sessions")));
|
|
18
|
+
return lists.flat().sort(byId);
|
|
19
|
+
},
|
|
20
|
+
watch(cb) {
|
|
21
|
+
const latest = sources.map(() => undefined);
|
|
22
|
+
const emit = () => cb(latest.flatMap((l) => l ?? []).sort(byId));
|
|
23
|
+
const offs = sources.map((s, i) => s.watch((sessions) => {
|
|
24
|
+
latest[i] = sessions;
|
|
25
|
+
emit();
|
|
26
|
+
}));
|
|
27
|
+
return () => {
|
|
28
|
+
for (const off of offs)
|
|
29
|
+
off();
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
async listDormant() {
|
|
33
|
+
const lists = await Promise.all(sources.map((s) => settle(s.listDormant(), warn, "listing dormant sessions")));
|
|
34
|
+
return lists.flat().sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
35
|
+
},
|
|
36
|
+
async get(id) {
|
|
37
|
+
for (const s of sources) {
|
|
38
|
+
const found = await s.get(id).catch(() => undefined);
|
|
39
|
+
if (found)
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
},
|
|
44
|
+
async end(id) {
|
|
45
|
+
for (const s of sources) {
|
|
46
|
+
const found = await s.get(id).catch(() => undefined);
|
|
47
|
+
if (found)
|
|
48
|
+
return s.end ? s.end(id) : "unsupported";
|
|
49
|
+
}
|
|
50
|
+
return "not-live";
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Which OpenCode sessions are running, and in which process. OpenCode
|
|
2
|
+
// (verified on 1.19.3, Linux) keeps no session registry, lock or port file:
|
|
3
|
+
// the TUI is one process (the server runs in a worker thread, no listener
|
|
4
|
+
// unless --port is given) and the session it shows lives only in its memory.
|
|
5
|
+
// What is visible from outside: the TUI's argv (`-s/--session <id>` when it was
|
|
6
|
+
// started on a session) and its working directory (= the session's
|
|
7
|
+
// `directory` for sessions it creates). So:
|
|
8
|
+
//
|
|
9
|
+
// 1. a TUI started with `-s <id>` (not `--fork`) shows that session;
|
|
10
|
+
// 2. any other TUI shows the most recently updated root session of its
|
|
11
|
+
// directory, if that session was updated after the TUI started (a TUI on
|
|
12
|
+
// its home screen, or idle on an older session, claims nothing).
|
|
13
|
+
//
|
|
14
|
+
// This is a heuristic; docs/design.md "OpenCode data" lists where it is wrong.
|
|
15
|
+
// Only rule 1 is exact; only exact matches may be stopped from the board.
|
|
16
|
+
// Pure matching lives in `assignOpenCodeLive`; the /proc reading is Linux-only
|
|
17
|
+
// and takes a proc root, so tests read a synthetic tree.
|
|
18
|
+
import { readlink } from "node:fs/promises";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
20
|
+
import { createProcessTable } from "../desktop/process-table.js";
|
|
21
|
+
import { bootTime, startedAt } from "./proc.js";
|
|
22
|
+
/** OpenCode session ids: `ses_` + 12 hex (time) + 14 base62. */
|
|
23
|
+
export const OPENCODE_ID = /^ses_[0-9a-zA-Z]{26}$/;
|
|
24
|
+
/** The native binary (`opencode`, nix `.opencode-wrapped`), or the npm shim (`node …/bin/opencode`). */
|
|
25
|
+
export function looksLikeOpenCode(argv, exe) {
|
|
26
|
+
const names = [argv?.[0], exe].filter((n) => !!n).map((n) => basename(n));
|
|
27
|
+
if (names.some((n) => /^\.?opencode(-wrapped)?(\.exe)?$/.test(n)))
|
|
28
|
+
return true;
|
|
29
|
+
const script = argv?.[1] ? basename(argv[1]) : "";
|
|
30
|
+
return names.some((n) => /^(node|bun)(\.exe)?$/.test(n)) && (script === "opencode" || script === "opencode.js");
|
|
31
|
+
}
|
|
32
|
+
const VALUE_FLAGS = new Set([
|
|
33
|
+
"--log-level", "--port", "--hostname", "--mdns-domain", "--cors", "-m", "--model", "-s", "--session", "--prompt",
|
|
34
|
+
"--agent", "--replay-limit",
|
|
35
|
+
]);
|
|
36
|
+
// Subcommands that are not the interactive TUI (`opencode [project]`, `opencode pr <n>`).
|
|
37
|
+
const NOT_TUI = new Set([
|
|
38
|
+
"completion", "acp", "mcp", "attach", "run", "debug", "providers", "auth", "agent", "upgrade", "uninstall", "serve",
|
|
39
|
+
"web", "models", "stats", "export", "import", "github", "session", "plugin", "plug", "db",
|
|
40
|
+
]);
|
|
41
|
+
function args(argv) {
|
|
42
|
+
const shim = /^(node|bun)(\.exe)?$/.test(basename(argv[0] ?? ""));
|
|
43
|
+
return argv.slice(shim ? 2 : 1);
|
|
44
|
+
}
|
|
45
|
+
/** First positional word: a subcommand, a project dir, or undefined. */
|
|
46
|
+
export function openCodeSubcommand(argv) {
|
|
47
|
+
const a = args(argv);
|
|
48
|
+
for (let i = 0; i < a.length; i++) {
|
|
49
|
+
const w = a[i];
|
|
50
|
+
if (w === "--")
|
|
51
|
+
return a[i + 1];
|
|
52
|
+
if (w.startsWith("-")) {
|
|
53
|
+
if (VALUE_FLAGS.has(w))
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
return w;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
export const isOpenCodeTui = (argv) => {
|
|
62
|
+
const sub = openCodeSubcommand(argv);
|
|
63
|
+
return sub === undefined || !NOT_TUI.has(sub);
|
|
64
|
+
};
|
|
65
|
+
/** `-s <id>`, `--session <id>`, `--session=<id>`; undefined with `--fork` (that makes a new session). */
|
|
66
|
+
export function argvSessionId(argv) {
|
|
67
|
+
const a = args(argv);
|
|
68
|
+
if (a.includes("--fork"))
|
|
69
|
+
return undefined;
|
|
70
|
+
for (let i = 0; i < a.length; i++) {
|
|
71
|
+
const w = a[i];
|
|
72
|
+
const v = w === "-s" || w === "--session" ? a[i + 1] : w.startsWith("--session=") ? w.slice(10) : undefined;
|
|
73
|
+
if (v && OPENCODE_ID.test(v))
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pure. `byId` finds a root, unarchived session by id; `inDir` lists the root,
|
|
80
|
+
* unarchived sessions whose directory is `dir`.
|
|
81
|
+
*/
|
|
82
|
+
export function assignOpenCodeLive(procs, byId, inDir) {
|
|
83
|
+
const pids = new Set(procs.map((p) => p.pid));
|
|
84
|
+
// The npm shim and its native child count once, as the shim (the pane's foreground).
|
|
85
|
+
// Newest first: a later TUI is more likely on the newest session of a directory.
|
|
86
|
+
const tuis = procs
|
|
87
|
+
.filter((p) => isOpenCodeTui(p.argv) && !pids.has(p.ppid))
|
|
88
|
+
.sort((a, b) => b.startedAt - a.startedAt || b.pid - a.pid);
|
|
89
|
+
const out = [];
|
|
90
|
+
const taken = new Set();
|
|
91
|
+
const rest = [];
|
|
92
|
+
for (const t of tuis) {
|
|
93
|
+
const id = argvSessionId(t.argv);
|
|
94
|
+
// A TUI that names its session never guesses another one.
|
|
95
|
+
if (!id) {
|
|
96
|
+
rest.push(t);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const s = byId(id);
|
|
100
|
+
if (s && !taken.has(s.id)) {
|
|
101
|
+
taken.add(s.id);
|
|
102
|
+
out.push({ id: s.id, pid: t.pid, since: t.startedAt, exact: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const t of rest) {
|
|
106
|
+
if (!t.cwd)
|
|
107
|
+
continue;
|
|
108
|
+
const best = inDir(t.cwd)
|
|
109
|
+
.filter((s) => !taken.has(s.id) && s.updatedAt >= t.startedAt)
|
|
110
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
111
|
+
if (!best)
|
|
112
|
+
continue;
|
|
113
|
+
taken.add(best.id);
|
|
114
|
+
out.push({ id: best.id, pid: t.pid, since: t.startedAt, exact: false });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Running OpenCode processes with argv, cwd and start time, read from
|
|
120
|
+
* `procRoot`. Undefined when it has no boot time (not Linux): liveness unknown.
|
|
121
|
+
*/
|
|
122
|
+
export async function scanOpenCodeProcesses(procRoot = "/proc") {
|
|
123
|
+
const btime = await bootTime(procRoot);
|
|
124
|
+
if (btime === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
const table = createProcessTable({ platform: "linux", procRoot });
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const p of await table.list()) {
|
|
129
|
+
// Cheap filter before reading argv: the binary, the nix wrapper, or a shim.
|
|
130
|
+
if (!/opencode|node|bun|MainThread/i.test(p.comm))
|
|
131
|
+
continue;
|
|
132
|
+
const [argv, exe] = await Promise.all([table.argv(p.pid), table.exe(p.pid)]);
|
|
133
|
+
if (!argv || !looksLikeOpenCode(argv, exe))
|
|
134
|
+
continue;
|
|
135
|
+
const [cwd, started] = await Promise.all([
|
|
136
|
+
readlink(join(procRoot, String(p.pid), "cwd")).catch(() => undefined),
|
|
137
|
+
startedAt(procRoot, p.pid, btime),
|
|
138
|
+
]);
|
|
139
|
+
if (!started)
|
|
140
|
+
continue;
|
|
141
|
+
out.push({ pid: p.pid, ppid: p.ppid, argv, ...(cwd ? { cwd } : {}), startedAt: started });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// OpenCode source: sessions from OpenCode's SQLite database
|
|
2
|
+
// ($XDG_DATA_HOME/opencode/opencode.db), read-only through `node:sqlite`
|
|
3
|
+
// (Node 22.13+; without it the source logs once and shows nothing). Live
|
|
4
|
+
// sessions come from the running TUI processes (opencode-live.ts); status from
|
|
5
|
+
// the newest message since the TUI started: busy while a reply is unfinished.
|
|
6
|
+
// Permission prompts are not stored, so they show as busy.
|
|
7
|
+
// Formats: docs/design.md "OpenCode data".
|
|
8
|
+
import { stat } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { createProcessTable } from "../desktop/process-table.js";
|
|
12
|
+
import { GitReader } from "./git.js";
|
|
13
|
+
import { onceLogger } from "./log.js";
|
|
14
|
+
import { assignOpenCodeLive, isOpenCodeTui, looksLikeOpenCode, OPENCODE_ID, scanOpenCodeProcesses, } from "./opencode-live.js";
|
|
15
|
+
export const openCodeResumeCommand = (id) => `opencode -s ${id}`;
|
|
16
|
+
export function defaultOpenCodeDataDir(env = process.env) {
|
|
17
|
+
return join(env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode");
|
|
18
|
+
}
|
|
19
|
+
export function defaultOpenCodeDbPath(dataDir, env = process.env) {
|
|
20
|
+
const override = env.OPENCODE_DB;
|
|
21
|
+
if (override && override !== ":memory:")
|
|
22
|
+
return override.startsWith("/") ? override : join(dataDir, override);
|
|
23
|
+
return join(dataDir, "opencode.db");
|
|
24
|
+
}
|
|
25
|
+
/** Default titles OpenCode gives before it has generated one. */
|
|
26
|
+
export function isDefaultTitle(title) {
|
|
27
|
+
return /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(title);
|
|
28
|
+
}
|
|
29
|
+
const COLS = "id, directory, title, time_updated, time_created, parent_id, time_archived";
|
|
30
|
+
const ROOT = "parent_id IS NULL AND time_archived IS NULL";
|
|
31
|
+
/** node:sqlite, read-only. */
|
|
32
|
+
export async function openOpenCodeDb(path) {
|
|
33
|
+
if (!(await stat(path).catch(() => undefined)))
|
|
34
|
+
return undefined;
|
|
35
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
36
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
37
|
+
// OpenCode writes all the time (WAL); wait briefly instead of failing a poll.
|
|
38
|
+
db.exec("PRAGMA busy_timeout = 250");
|
|
39
|
+
const placeholders = (n) => Array(n).fill("?").join(",");
|
|
40
|
+
return {
|
|
41
|
+
recent: (limit) => db.prepare(`SELECT ${COLS} FROM session WHERE ${ROOT} ORDER BY time_updated DESC LIMIT ?`).all(limit),
|
|
42
|
+
byIds: (ids) => ids.length ? db.prepare(`SELECT ${COLS} FROM session WHERE id IN (${placeholders(ids.length)})`).all(...ids) : [],
|
|
43
|
+
inDirs: (dirs) => dirs.length
|
|
44
|
+
? db.prepare(`SELECT ${COLS} FROM session WHERE ${ROOT} AND directory IN (${placeholders(dirs.length)})`).all(...dirs)
|
|
45
|
+
: [],
|
|
46
|
+
lastMessage: (id) => db
|
|
47
|
+
.prepare(`SELECT json_extract(data, '$.role') AS role, json_extract(data, '$.time.completed') AS completed, time_created AS created
|
|
48
|
+
FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT 1`)
|
|
49
|
+
.get(id),
|
|
50
|
+
close: () => db.close(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function defaultIsOpenCodeTui(procRoot) {
|
|
54
|
+
const table = procRoot ? createProcessTable({ platform: "linux", procRoot }) : createProcessTable();
|
|
55
|
+
return async (pid) => {
|
|
56
|
+
const argv = await table.argv(pid);
|
|
57
|
+
return !!argv && looksLikeOpenCode(argv, await table.exe(pid)) && isOpenCodeTui(argv);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Busy while the newest message since the TUI started is a prompt or an unfinished reply. */
|
|
61
|
+
export function openCodeStatus(last, since) {
|
|
62
|
+
if (!last || last.created < since)
|
|
63
|
+
return "idle";
|
|
64
|
+
if (last.role === "user")
|
|
65
|
+
return "busy";
|
|
66
|
+
return last.role === "assistant" && last.completed == null ? "busy" : "idle";
|
|
67
|
+
}
|
|
68
|
+
const epochMs = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
|
|
69
|
+
export function openCodeSession(row, opts) {
|
|
70
|
+
const s = {
|
|
71
|
+
id: row.id,
|
|
72
|
+
source: "opencode",
|
|
73
|
+
cwd: row.directory,
|
|
74
|
+
repo: basename(row.directory),
|
|
75
|
+
status: opts.live ? (opts.status ?? "idle") : "dormant",
|
|
76
|
+
// A missing or odd value never becomes a number the board would date from 1970 (it shows no age for 0).
|
|
77
|
+
lastActivityAt: epochMs(row.time_updated) || epochMs(row.time_created),
|
|
78
|
+
cacheTtlMs: 0,
|
|
79
|
+
cacheExpiresAt: 0,
|
|
80
|
+
live: !!opts.live,
|
|
81
|
+
prLinks: [],
|
|
82
|
+
resumeCommand: openCodeResumeCommand(row.id),
|
|
83
|
+
};
|
|
84
|
+
if (opts.live)
|
|
85
|
+
s.pid = opts.live.pid;
|
|
86
|
+
// Stop only for a TUI started on exactly this session, never a directory guess.
|
|
87
|
+
if (opts.live?.exact)
|
|
88
|
+
s.canEnd = true;
|
|
89
|
+
if (row.title && !isDefaultTitle(row.title))
|
|
90
|
+
s.title = row.title;
|
|
91
|
+
if (opts.git) {
|
|
92
|
+
s.repo = basename(opts.git.root);
|
|
93
|
+
s.repoRoot = opts.git.root;
|
|
94
|
+
s.branch = opts.git.branch;
|
|
95
|
+
}
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
const candidate = (r) => ({ id: r.id, directory: r.directory, updatedAt: r.time_updated });
|
|
99
|
+
/** Rows whose id is not a valid OpenCode id never become a session (or a command). */
|
|
100
|
+
const valid = (rows) => rows.filter((r) => typeof r.id === "string" && OPENCODE_ID.test(r.id));
|
|
101
|
+
export function createOpenCodeSource(opts = {}) {
|
|
102
|
+
const dataDir = opts.dataDir ?? defaultOpenCodeDataDir();
|
|
103
|
+
const dbPath = opts.dbPath ?? defaultOpenCodeDbPath(dataDir);
|
|
104
|
+
const openDb = opts.openDb ?? openOpenCodeDb;
|
|
105
|
+
const scan = opts.scan ?? (() => scanOpenCodeProcesses(opts.procRoot));
|
|
106
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
107
|
+
const cacheMs = opts.cacheMs ?? pollMs;
|
|
108
|
+
const maxDormant = opts.maxDormant ?? 200;
|
|
109
|
+
const warn = onceLogger(opts.log ?? console.warn);
|
|
110
|
+
const git = new GitReader({ ttlMs: opts.gitTtlMs ?? 2000 });
|
|
111
|
+
const isTui = opts.isOpenCodeTui ?? defaultIsOpenCodeTui(opts.procRoot);
|
|
112
|
+
const kill = opts.kill ?? ((pid, signal) => process.kill(pid, signal));
|
|
113
|
+
let db;
|
|
114
|
+
let opening;
|
|
115
|
+
async function database() {
|
|
116
|
+
if (db)
|
|
117
|
+
return db;
|
|
118
|
+
opening ??= openDb(dbPath)
|
|
119
|
+
.catch((e) => {
|
|
120
|
+
warn(`excaliagent: cannot open the OpenCode database ${dbPath}: ${e.message}`, "opencode-db");
|
|
121
|
+
return undefined;
|
|
122
|
+
})
|
|
123
|
+
.finally(() => (opening = undefined));
|
|
124
|
+
db = await opening;
|
|
125
|
+
return db;
|
|
126
|
+
}
|
|
127
|
+
/** Runs a query; on failure (schema change, file replaced) reopens next time. */
|
|
128
|
+
async function query(fn, fallback) {
|
|
129
|
+
const d = await database();
|
|
130
|
+
if (!d)
|
|
131
|
+
return fallback;
|
|
132
|
+
try {
|
|
133
|
+
return fn(d);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
warn(`excaliagent: reading the OpenCode database failed: ${e.message}`, "opencode-query");
|
|
137
|
+
db = undefined;
|
|
138
|
+
try {
|
|
139
|
+
d.close();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// already closed
|
|
143
|
+
}
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function live() {
|
|
148
|
+
const procs = await scan().catch(() => undefined);
|
|
149
|
+
if (!procs?.length)
|
|
150
|
+
return [];
|
|
151
|
+
const ids = procs.map((p) => p.argv).flatMap((a) => a.filter((w) => OPENCODE_ID.test(w) || w.startsWith("--session=")).map((w) => w.replace(/^--session=/, "")));
|
|
152
|
+
const dirs = [...new Set(procs.map((p) => p.cwd).filter((c) => !!c))];
|
|
153
|
+
const rows = new Map();
|
|
154
|
+
for (const r of valid(await query((d) => [...d.byIds(ids), ...d.inDirs(dirs)], [])))
|
|
155
|
+
rows.set(r.id, r);
|
|
156
|
+
const usable = [...rows.values()].filter((r) => r.parent_id == null && r.time_archived == null);
|
|
157
|
+
const assigned = assignOpenCodeLive(procs, (id) => {
|
|
158
|
+
const r = rows.get(id);
|
|
159
|
+
return r && usable.includes(r) ? candidate(r) : undefined;
|
|
160
|
+
}, (dir) => usable.filter((r) => r.directory === dir).map(candidate));
|
|
161
|
+
return assigned.map((l) => ({ row: rows.get(l.id), live: l }));
|
|
162
|
+
}
|
|
163
|
+
let look;
|
|
164
|
+
let looking;
|
|
165
|
+
async function current(fresh = false) {
|
|
166
|
+
if (!fresh && look && Date.now() - look.at < cacheMs)
|
|
167
|
+
return look;
|
|
168
|
+
looking ??= (async () => {
|
|
169
|
+
const found = await live();
|
|
170
|
+
look = { at: Date.now(), live: found, sessions: await build(found) };
|
|
171
|
+
return look;
|
|
172
|
+
})().finally(() => (looking = undefined));
|
|
173
|
+
return looking;
|
|
174
|
+
}
|
|
175
|
+
async function list() {
|
|
176
|
+
return (await current()).sessions;
|
|
177
|
+
}
|
|
178
|
+
async function build(found) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const { row, live: l } of found) {
|
|
181
|
+
const last = await query((d) => d.lastMessage(row.id), undefined);
|
|
182
|
+
out.push(openCodeSession(row, { live: l, status: openCodeStatus(last, l.since), git: await git.get(row.directory) }));
|
|
183
|
+
}
|
|
184
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
185
|
+
}
|
|
186
|
+
async function listDormant() {
|
|
187
|
+
const liveIds = new Set((await current()).live.map((l) => l.row.id));
|
|
188
|
+
const rows = valid(await query((d) => d.recent(maxDormant + liveIds.size), [])).filter((r) => !liveIds.has(r.id));
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const r of rows.slice(0, maxDormant))
|
|
191
|
+
out.push(openCodeSession(r, { git: await git.get(r.directory) }));
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
async function get(id) {
|
|
195
|
+
if (!OPENCODE_ID.test(id))
|
|
196
|
+
return undefined;
|
|
197
|
+
const running = (await list()).find((s) => s.id === id);
|
|
198
|
+
if (running)
|
|
199
|
+
return running;
|
|
200
|
+
const [row] = valid(await query((d) => d.byIds([id]), []));
|
|
201
|
+
return row ? openCodeSession(row, { git: await git.get(row.directory) }) : undefined;
|
|
202
|
+
}
|
|
203
|
+
function watch(cb) {
|
|
204
|
+
let closed = false;
|
|
205
|
+
let running = false;
|
|
206
|
+
let last;
|
|
207
|
+
// No file to watch tells more than a poll: the db changes on every token.
|
|
208
|
+
const refresh = async () => {
|
|
209
|
+
if (running || closed)
|
|
210
|
+
return;
|
|
211
|
+
running = true;
|
|
212
|
+
try {
|
|
213
|
+
const sessions = (await current(true)).sessions;
|
|
214
|
+
const key = JSON.stringify(sessions);
|
|
215
|
+
if (!closed && key !== last) {
|
|
216
|
+
last = key;
|
|
217
|
+
try {
|
|
218
|
+
cb(sessions);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
warn(`excaliagent: session watcher callback threw: ${e.message}`, "opencode-watch-callback");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
warn(`excaliagent: refreshing OpenCode sessions failed: ${e.message}`, "opencode-refresh");
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
running = false;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const poll = setInterval(() => void refresh(), pollMs);
|
|
233
|
+
void refresh();
|
|
234
|
+
return () => {
|
|
235
|
+
closed = true;
|
|
236
|
+
clearInterval(poll);
|
|
237
|
+
db?.close();
|
|
238
|
+
db = undefined;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* SIGTERM to the TUI: OpenCode closes the reply, restores the terminal and
|
|
243
|
+
* exits. Only for a TUI started with `-s <id>`: a session matched by
|
|
244
|
+
* directory is "unsupported", so a guess never ends a process.
|
|
245
|
+
*/
|
|
246
|
+
async function end(id) {
|
|
247
|
+
if (!OPENCODE_ID.test(id))
|
|
248
|
+
return "not-live";
|
|
249
|
+
const l = (await current()).live.find((x) => x.row.id === id);
|
|
250
|
+
if (!l)
|
|
251
|
+
return "not-live";
|
|
252
|
+
if (!l.live.exact)
|
|
253
|
+
return "unsupported";
|
|
254
|
+
if (!(await isTui(l.live.pid).catch(() => false)))
|
|
255
|
+
return "not-live";
|
|
256
|
+
try {
|
|
257
|
+
kill(l.live.pid, "SIGTERM");
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return "not-live";
|
|
261
|
+
}
|
|
262
|
+
return "ended";
|
|
263
|
+
}
|
|
264
|
+
return { list, listDormant, get, watch, end };
|
|
265
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Small /proc helpers shared by the adapters that find their sessions among
|
|
2
|
+
// running processes (Codex, OpenCode). Every reader takes the proc root, so
|
|
3
|
+
// tests and the demo point it at a synthetic tree (EXCALIAGENT_PROC_ROOT) and
|
|
4
|
+
// never read the machine's real processes.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/** Where the server reads processes from: the real /proc unless overridden (tests, demo). */
|
|
8
|
+
export function procRootFromEnv(env = process.env) {
|
|
9
|
+
return env.EXCALIAGENT_PROC_ROOT || undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Field 22 of /proc/<pid>/stat: start time in clock ticks since boot. */
|
|
12
|
+
export function parseStartTicks(stat) {
|
|
13
|
+
const close = stat.lastIndexOf(")");
|
|
14
|
+
const ticks = Number(stat.slice(close + 1).trim().split(/\s+/)[19]);
|
|
15
|
+
return close > 0 && Number.isFinite(ticks) ? ticks : undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Linux clock ticks per second (USER_HZ); 100 on every mainstream architecture. */
|
|
18
|
+
export const USER_HZ = 100;
|
|
19
|
+
/** Boot time (epoch s) from <procRoot>/stat, or undefined (not Linux). */
|
|
20
|
+
export async function bootTime(procRoot) {
|
|
21
|
+
const stat = await readFile(join(procRoot, "stat"), "utf8").catch(() => undefined);
|
|
22
|
+
const btime = stat ? Number(/^btime (\d+)$/m.exec(stat)?.[1]) : NaN;
|
|
23
|
+
return Number.isFinite(btime) && btime > 0 ? btime : undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Epoch ms a process started (btime is whole seconds, so this can be up to 1 s
|
|
27
|
+
* early), or 0 when unknown.
|
|
28
|
+
*/
|
|
29
|
+
export async function startedAt(procRoot, pid, btime) {
|
|
30
|
+
if (btime === undefined)
|
|
31
|
+
return 0;
|
|
32
|
+
const ticks = parseStartTicks(await readFile(join(procRoot, String(pid), "stat"), "utf8").catch(() => ""));
|
|
33
|
+
return ticks === undefined ? 0 : Math.floor((btime + ticks / USER_HZ) * 1000);
|
|
34
|
+
}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// Incremental scanner for Claude Code transcripts
|
|
2
2
|
// (<claudeDir>/projects/<slug>/<sessionId>.jsonl). Keeps a byte offset per file
|
|
3
3
|
// and parses only appended bytes. Never keeps message content.
|
|
4
|
-
import {
|
|
5
|
-
import { onceLogger } from "./log.js";
|
|
4
|
+
import { JsonlScanner } from "./jsonl.js";
|
|
6
5
|
export const TTL_1H = 3600_000;
|
|
7
6
|
export const TTL_5M = 300_000;
|
|
8
|
-
const CHUNK = 1 << 20;
|
|
9
|
-
const NL = 0x0a;
|
|
10
7
|
// Cheap prefilter; the parsed top-level `type` is still checked.
|
|
11
8
|
const INTERESTING = /"type":"(?:ai-title|agent-name|pr-link|continued-in|user|assistant|api-request)"/;
|
|
12
9
|
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
@@ -100,105 +97,14 @@ function snapshot(acc) {
|
|
|
100
97
|
out.gitBranch = acc.gitBranch;
|
|
101
98
|
return out;
|
|
102
99
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
const fold = {
|
|
101
|
+
empty: emptyAcc,
|
|
102
|
+
interesting: (line) => INTERESTING.test(line),
|
|
103
|
+
apply: applyLine,
|
|
104
|
+
snapshot,
|
|
105
|
+
};
|
|
106
|
+
export class TranscriptScanner extends JsonlScanner {
|
|
107
107
|
constructor(opts = {}) {
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
/** Scans new bytes of `path` and returns the folded info, or undefined if the file is gone. */
|
|
111
|
-
scan(path) {
|
|
112
|
-
const running = this.inflight.get(path);
|
|
113
|
-
if (running)
|
|
114
|
-
return running;
|
|
115
|
-
const p = this.doScan(path).finally(() => this.inflight.delete(path));
|
|
116
|
-
this.inflight.set(path, p);
|
|
117
|
-
return p;
|
|
118
|
-
}
|
|
119
|
-
/** Read bookkeeping, for tests. */
|
|
120
|
-
stats(path) {
|
|
121
|
-
const s = this.files.get(path);
|
|
122
|
-
return s && { offset: s.offset, bytesRead: s.bytesRead };
|
|
123
|
-
}
|
|
124
|
-
forget(path) {
|
|
125
|
-
this.files.delete(path);
|
|
126
|
-
}
|
|
127
|
-
async doScan(path) {
|
|
128
|
-
let st;
|
|
129
|
-
try {
|
|
130
|
-
st = await stat(path);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
this.files.delete(path);
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
let s = this.files.get(path);
|
|
137
|
-
const replaced = !s || s.ino !== st.ino || s.dev !== st.dev;
|
|
138
|
-
const shrunk = !!s && st.size < s.offset;
|
|
139
|
-
// Same size but touched: rewritten in place (appends always grow the file).
|
|
140
|
-
const rewritten = !!s && st.size === s.offset && st.mtimeMs !== s.mtimeMs;
|
|
141
|
-
if (!s || replaced || shrunk || rewritten) {
|
|
142
|
-
s = { ino: st.ino, dev: st.dev, mtimeMs: st.mtimeMs, offset: 0, bytesRead: s?.bytesRead ?? 0, partial: Buffer.alloc(0), acc: emptyAcc() };
|
|
143
|
-
this.files.set(path, s);
|
|
144
|
-
}
|
|
145
|
-
if (st.size > s.offset) {
|
|
146
|
-
try {
|
|
147
|
-
await this.readFrom(path, s, st.size);
|
|
148
|
-
}
|
|
149
|
-
catch (e) {
|
|
150
|
-
this.warn(`excaliagent: cannot read transcript ${path}: ${e.message}`, path);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
s.mtimeMs = st.mtimeMs;
|
|
154
|
-
return snapshot(s.acc);
|
|
155
|
-
}
|
|
156
|
-
async readFrom(path, s, end) {
|
|
157
|
-
const fh = await open(path, "r");
|
|
158
|
-
try {
|
|
159
|
-
const buf = Buffer.allocUnsafe(Math.min(CHUNK, end - s.offset));
|
|
160
|
-
while (s.offset < end) {
|
|
161
|
-
const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, end - s.offset), s.offset);
|
|
162
|
-
if (bytesRead === 0)
|
|
163
|
-
break;
|
|
164
|
-
s.offset += bytesRead;
|
|
165
|
-
s.bytesRead += bytesRead;
|
|
166
|
-
this.consume(path, s, buf.subarray(0, bytesRead));
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
await fh.close();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
consume(path, s, chunk) {
|
|
174
|
-
let start = 0;
|
|
175
|
-
let nl = chunk.indexOf(NL, start);
|
|
176
|
-
while (nl !== -1) {
|
|
177
|
-
const piece = chunk.subarray(start, nl);
|
|
178
|
-
const line = s.partial.length ? Buffer.concat([s.partial, piece]) : piece;
|
|
179
|
-
s.partial = Buffer.alloc(0);
|
|
180
|
-
this.parseLine(path, s.acc, line);
|
|
181
|
-
start = nl + 1;
|
|
182
|
-
nl = chunk.indexOf(NL, start);
|
|
183
|
-
}
|
|
184
|
-
if (start < chunk.length)
|
|
185
|
-
s.partial = Buffer.concat([s.partial, chunk.subarray(start)]);
|
|
186
|
-
}
|
|
187
|
-
parseLine(path, acc, line) {
|
|
188
|
-
if (line.length === 0)
|
|
189
|
-
return;
|
|
190
|
-
const text = line.toString("utf8");
|
|
191
|
-
if (!INTERESTING.test(text))
|
|
192
|
-
return;
|
|
193
|
-
let o;
|
|
194
|
-
try {
|
|
195
|
-
o = JSON.parse(text);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
this.warn(`excaliagent: skipping malformed line(s) in ${path}`, path);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (o && typeof o === "object" && !Array.isArray(o))
|
|
202
|
-
applyLine(acc, o);
|
|
108
|
+
super(fold, { ...opts, noun: "transcript" });
|
|
203
109
|
}
|
|
204
110
|
}
|