From 00694acc8ffb55c0918fc8b7ee5b6652961c0380 Mon Sep 17 00:00:00 2001 From: Sylvain DUARTE Date: Fri, 31 Jul 2026 16:43:38 +0200 Subject: [PATCH 1/5] chore: add OpenTUI and Solid dev dependencies Types for the TUI plugin added in a follow-up commit. `@opencode-ai/plugin` moves off the `latest` tag because that tag currently resolves to 1.2.27, which predates the `./tui` export the plugin types live behind. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- package.json | 6 +- pnpm-lock.yaml | 1220 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1216 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 97bb40e..31f2e6e 100644 --- a/package.json +++ b/package.json @@ -64,10 +64,14 @@ "prepublishOnly": "pnpm run build && pnpm test" }, "devDependencies": { - "@opencode-ai/plugin": "latest", + "@opencode-ai/plugin": "1.15.13", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5", "@types/node": "^25.5.0", "oxfmt": "0.41.0", "oxlint": "1.56.0", + "solid-js": "^1.9.14", "typescript": "^5.0.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccd2115..b0bc943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,17 @@ importers: .: devDependencies: '@opencode-ai/plugin': - specifier: latest - version: 1.2.27 + specifier: 1.15.13 + version: 1.15.13(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/core': + specifier: ^0.4.5 + version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: ^0.4.5 + version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: ^0.4.5 + version: 0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -20,17 +29,280 @@ importers: oxlint: specifier: 1.56.0 version: 1.56.0 + solid-js: + specifier: ^1.9.14 + version: 1.9.14 typescript: specifier: ^5.0.0 version: 5.9.3 packages: - '@opencode-ai/plugin@1.2.27': - resolution: {integrity: sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} - '@opencode-ai/sdk@1.2.27': - resolution: {integrity: sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@opencode-ai/plugin@1.15.13': + resolution: {integrity: sha512-NFwZGhmxIPijtfz9swPJXDmhOpq4UWP8WjEE7GEMr7FwtJrK/hv6v36nFimed5+OKk+pQCrTJn/vhRW7Io72IA==} + peerDependencies: + '@opentui/core': '>=0.2.16' + '@opentui/keymap': '>=0.2.16' + '@opentui/solid': '>=0.2.16' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.15.13': + resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/keymap@0.4.5': + resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} + peerDependencies: + '@opentui/react': 0.4.5 + '@opentui/solid': 0.4.5 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.4.5': + resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==} + peerDependencies: + solid-js: 1.9.12 '@oxfmt/binding-android-arm-eabi@0.41.0': resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} @@ -276,9 +548,224 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.11.8: + resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + effect@4.0.0-beta.66: + resolution: {integrity: sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==} + + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@1.12.1: + resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + oxfmt@0.41.0: resolution: {integrity: sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -294,10 +781,102 @@ packages: oxlint-tsgolint: optional: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + solid-js@1.9.14: + resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -306,17 +885,351 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} + hasBin: true + + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} snapshots: - '@opencode-ai/plugin@1.2.27': + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': dependencies: - '@opencode-ai/sdk': 1.2.27 + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@opencode-ai/plugin@1.15.13(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@opencode-ai/sdk': 1.15.13 + effect: 4.0.0-beta.66 zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@opencode-ai/sdk@1.15.13': + dependencies: + cross-spawn: 7.0.6 - '@opencode-ai/sdk@1.2.27': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10))(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10) + solid-js: 1.9.14 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.14)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.14) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.14 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter '@oxfmt/binding-android-arm-eabi@0.41.0': optional: true @@ -432,10 +1345,203 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.56.0': optional: true + '@standard-schema/spec@1.1.0': {} + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + ansi-regex@6.2.2: {} + + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.14): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.14 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.11.8: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.8 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.399 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + caniuse-lite@1.0.30001806: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: + optional: true + + diff@9.0.0: {} + + effect@4.0.0-beta.66: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 6.0.0 + kubernetes-types: 1.30.0 + msgpackr: 1.12.1 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 13.0.2 + yaml: 2.9.0 + + electron-to-chromium@1.5.399: {} + + emoji-regex@10.6.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + es-errors@1.3.0: {} + + escalade@3.2.0: {} + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-my-way-ts@0.1.6: {} + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.6.0: {} + + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-entities@2.3.3: {} + + ini@6.0.0: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + kubernetes-types@1.30.0: {} + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + marked@17.0.1: {} + + minimatch@8.0.7: + dependencies: + brace-expansion: 2.1.4 + + minipass@4.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@1.12.1: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-releases@2.0.51: {} + oxfmt@0.41.0: dependencies: tinypool: 2.1.0 @@ -482,10 +1588,106 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.56.0 '@oxlint/binding-win32-x64-msvc': 1.56.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@3.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + pure-rand@8.4.2: {} + + reselect@4.1.8: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + s-js@0.4.9: {} + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + solid-js@1.9.14: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + tinypool@2.1.0: {} + toml@4.3.0: {} + typescript@5.9.3: {} undici-types@7.18.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uuid@13.0.2: {} + + web-tree-sitter@0.25.10: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + yallist@3.1.1: {} + + yaml@2.9.0: {} + zod@4.1.8: {} From 3fe8917bc5254b1bfe913a076caa7e6ea5e1a92e Mon Sep 17 00:00:00 2001 From: Sylvain DUARTE Date: Fri, 31 Jul 2026 16:43:46 +0200 Subject: [PATCH 2/5] feat: record Claude subscription quota from response headers Anthropic returns the `anthropic-ratelimit-unified-*` family on every /v1/messages response under subscription auth, carrying 5h and 7d utilization and their reset times. The fetch interceptor already holds those responses, so recording the quota costs no extra API call. Read from the response that is actually returned, after the 401, rotation and long-context retries, so the numbers match the call that counted rather than an attempt that was discarded. A response without the family yields null instead of a blank snapshot, since count_tokens calls and validation errors carry no quota headers and would otherwise erase a good reading. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/index.test.ts | 1 + src/index.ts | 17 ++++ src/usage.test.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++++ src/usage.ts | 175 ++++++++++++++++++++++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 src/usage.test.ts create mode 100644 src/usage.ts diff --git a/src/index.test.ts b/src/index.test.ts index 9c356a3..757c7a8 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -127,6 +127,7 @@ const SOURCE_FILES = [ "credentials.ts", "logger.ts", "http.ts", + "usage.ts", ] as const async function copySourceFiles( diff --git a/src/index.ts b/src/index.ts index fff9c6c..4f8d0d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { transformBody, transformResponseStream, } from "./transforms.ts" +import { recordUsageFromHeaders } from "./usage.ts" import { getCachedCredentials, reloadCredentialsFromSource, @@ -60,6 +61,15 @@ export { computeVersionSuffix, extractFirstUserMessageText, } from "./signing.ts" +export { + getUsageStatePaths, + parseUsageHeaders, + recordUsageFromHeaders, + resetUsageWriteCache, + writeUsageSnapshot, + type UsageSnapshot, + type UsageWindow, +} from "./usage.ts" function getCliVersion(): string { return process.env.ANTHROPIC_CLI_VERSION ?? config.ccVersion @@ -600,6 +610,13 @@ const plugin: Plugin = async () => { .catch(() => {}) } + // Quota headers ride on every /v1/messages response, including the + // 429 that says the quota is spent. Read from the response that is + // actually being returned — after the 401, rotation and long-context + // retries above — so the recorded numbers match the call that + // counted, not one of the attempts that was thrown away. + recordUsageFromHeaders(response.headers) + // A 401 that survived recovery carries an error body, not an SSE // stream. Deciding here rather than from a flag set mid-flight // makes the retried and non-retried paths behave identically. diff --git a/src/usage.test.ts b/src/usage.test.ts new file mode 100644 index 0000000..3f9dfde --- /dev/null +++ b/src/usage.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict" +import { describe, it, beforeEach, afterEach } from "node:test" +import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir, homedir } from "node:os" +import { + getUsageStatePaths, + parseUsageHeaders, + recordUsageFromHeaders, + resetUsageWriteCache, + writeUsageSnapshot, + type UsageSnapshot, +} from "./usage.ts" + +function restore(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key] + else process.env[key] = value +} + +/** A response carrying the full unified rate-limit family. */ +function quotaHeaders(overrides: Record = {}): Headers { + return new Headers({ + "anthropic-ratelimit-unified-status": "allowed", + "anthropic-ratelimit-unified-representative-claim": "five_hour", + "anthropic-ratelimit-unified-5h-utilization": "0.26", + "anthropic-ratelimit-unified-5h-reset": "1775455200", + "anthropic-ratelimit-unified-7d-utilization": "0.19", + "anthropic-ratelimit-unified-7d-reset": "1775973600", + ...overrides, + }) +} + +describe("parseUsageHeaders", () => { + it("reads both windows as fractions with epoch-second resets", () => { + const snapshot = parseUsageHeaders(quotaHeaders()) + + assert.ok(snapshot) + assert.equal(snapshot.status, "allowed") + assert.equal(snapshot.representativeClaim, "five_hour") + assert.deepEqual(snapshot.fiveHour, { + utilization: 0.26, + resetsAt: 1775455200, + }) + assert.deepEqual(snapshot.sevenDay, { + utilization: 0.19, + resetsAt: 1775973600, + }) + }) + + it("returns null when the response carries no quota headers", () => { + // count_tokens calls and request-validation errors come back without the + // family. Returning a blank snapshot would blank out a good reading. + const snapshot = parseUsageHeaders( + new Headers({ "content-type": "application/json" }), + ) + + assert.equal(snapshot, null) + }) + + it("keeps a window whose reset header is absent", () => { + const headers = quotaHeaders() + headers.delete("anthropic-ratelimit-unified-5h-reset") + + const snapshot = parseUsageHeaders(headers) + + assert.deepEqual(snapshot?.fiveHour, { utilization: 0.26, resetsAt: null }) + }) + + it("reports a rejection, which is what a spent quota looks like", () => { + const snapshot = parseUsageHeaders( + quotaHeaders({ + "anthropic-ratelimit-unified-status": "rejected", + "anthropic-ratelimit-unified-5h-utilization": "1", + }), + ) + + assert.equal(snapshot?.status, "rejected") + assert.equal(snapshot?.fiveHour?.utilization, 1) + }) + + it("survives a status header with no windows at all", () => { + const snapshot = parseUsageHeaders( + new Headers({ "anthropic-ratelimit-unified-status": "allowed" }), + ) + + assert.ok(snapshot) + assert.equal(snapshot.fiveHour, null) + assert.equal(snapshot.sevenDay, null) + }) + + it("ignores a non-numeric utilization rather than emitting NaN", () => { + const snapshot = parseUsageHeaders( + quotaHeaders({ "anthropic-ratelimit-unified-5h-utilization": "n/a" }), + ) + + assert.equal(snapshot?.fiveHour, null) + assert.ok(snapshot?.sevenDay) + }) + + it("captures the overage pool when present", () => { + const snapshot = parseUsageHeaders( + quotaHeaders({ + "anthropic-ratelimit-unified-overage-status": "rejected", + "anthropic-ratelimit-unified-overage-disabled-reason": + "org_level_disabled", + }), + ) + + assert.equal(snapshot?.overage?.status, "rejected") + assert.equal(snapshot?.overage?.disabledReason, "org_level_disabled") + }) + + it("omits the overage pool when the account has none", () => { + assert.equal(parseUsageHeaders(quotaHeaders())?.overage, null) + }) +}) + +describe("getUsageStatePaths", () => { + it("covers every root OpenCode may have been installed under", () => { + const paths = getUsageStatePaths() + + assert.ok(paths.length >= 1) + assert.ok(paths.every((path) => path.endsWith("claude-usage.json"))) + assert.ok( + paths.includes( + join(homedir(), ".local", "share", "opencode", "claude-usage.json"), + ), + ) + if (process.platform === "win32") { + // Different Windows install methods read different roots, and the TUI + // that consumes this has no say in which one it got. + assert.equal(paths.length, 2) + } + }) +}) + +describe("writeUsageSnapshot", () => { + let tmpDir: string + let originalUserProfile: string | undefined + let originalHome: string | undefined + let originalLocalAppData: string | undefined + + const snapshot: UsageSnapshot = { + updatedAt: 1, + status: "allowed", + representativeClaim: "five_hour", + fiveHour: { utilization: 0.26, resetsAt: 1775455200 }, + sevenDay: { utilization: 0.19, resetsAt: 1775973600 }, + overage: null, + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "claude-auth-usage-test-")) + originalUserProfile = process.env.USERPROFILE + originalHome = process.env.HOME + originalLocalAppData = process.env.LOCALAPPDATA + // homedir() reads USERPROFILE on Windows and HOME elsewhere, so both have + // to move or this suite writes to the developer's real state file. + process.env.USERPROFILE = tmpDir + process.env.HOME = tmpDir + process.env.LOCALAPPDATA = join(tmpDir, "AppData", "Local") + resetUsageWriteCache() + }) + + afterEach(() => { + restore("USERPROFILE", originalUserProfile) + restore("HOME", originalHome) + restore("LOCALAPPDATA", originalLocalAppData) + rmSync(tmpDir, { recursive: true, force: true }) + resetUsageWriteCache() + }) + + it("creates the directory and writes readable JSON", () => { + writeUsageSnapshot(snapshot) + + const written = getUsageStatePaths().filter((path) => existsSync(path)) + assert.ok(written.length > 0, "expected at least one snapshot on disk") + for (const path of written) { + assert.deepEqual(JSON.parse(readFileSync(path, "utf-8")), snapshot) + } + }) + + it("leaves no .tmp file behind, so a reader never sees a torn write", () => { + writeUsageSnapshot(snapshot) + + for (const path of getUsageStatePaths()) { + assert.ok(!existsSync(`${path}.tmp`)) + } + }) + + it("skips a write when only updatedAt differs", () => { + writeUsageSnapshot(snapshot) + const path = getUsageStatePaths().find((candidate) => existsSync(candidate)) + assert.ok(path) + const first = readFileSync(path, "utf-8") + + // updatedAt changes on every single response; deduplicating on it would + // defeat the check entirely and rewrite both files continuously. + writeUsageSnapshot({ ...snapshot, updatedAt: 999 }) + + assert.equal(readFileSync(path, "utf-8"), first) + }) + + it("writes again once a reading actually changes", () => { + writeUsageSnapshot(snapshot) + writeUsageSnapshot({ + ...snapshot, + updatedAt: 999, + fiveHour: { utilization: 0.5, resetsAt: 1775455200 }, + }) + + const path = getUsageStatePaths().find((candidate) => existsSync(candidate)) + assert.ok(path) + const stored = JSON.parse(readFileSync(path, "utf-8")) as UsageSnapshot + assert.equal(stored.fiveHour?.utilization, 0.5) + }) + + it("recordUsageFromHeaders persists a parsed response", () => { + recordUsageFromHeaders(quotaHeaders()) + + const path = getUsageStatePaths().find((candidate) => existsSync(candidate)) + assert.ok(path) + const stored = JSON.parse(readFileSync(path, "utf-8")) as UsageSnapshot + assert.equal(stored.fiveHour?.utilization, 0.26) + }) + + it("recordUsageFromHeaders writes nothing for a quota-less response", () => { + recordUsageFromHeaders(new Headers({ "content-type": "application/json" })) + + assert.ok(getUsageStatePaths().every((path) => !existsSync(path))) + }) +}) diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..3879ee2 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,175 @@ +import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { log } from "./logger.ts" + +/** + * Claude subscription quota, read off the `anthropic-ratelimit-unified-*` + * headers Anthropic returns on every /v1/messages response. + * + * Deliberately the only source. Anthropic also exposes the numbers at + * GET /api/oauth/usage, but polling that spends a request per refresh on an + * undocumented endpoint to learn what the responses already carry for free. + * A window whose reset time has passed is re-derived as empty by the reader + * instead, which is what that poll was really for. + */ +export type UsageWindow = { + /** 0..1 */ + utilization: number + /** Unix epoch SECONDS, or null when the window has never been entered. */ + resetsAt: number | null +} + +export type UsageSnapshot = { + /** Unix epoch MILLISECONDS this snapshot was produced. */ + updatedAt: number + /** allowed | allowed_warning | rejected */ + status: string | null + /** Which window is currently the binding constraint. */ + representativeClaim: string | null + fiveHour: UsageWindow | null + sevenDay: UsageWindow | null + overage: { + status: string | null + utilization: number | null + resetsAt: number | null + disabledReason: string | null + } | null +} + +const USAGE_FILENAME = "claude-usage.json" + +/** + * Mirrors getAuthJsonPaths() in credentials.ts. The same Windows split applies: + * different OpenCode installation methods read from different roots, and the + * TUI process that renders this has no say in which one it got. + */ +export function getUsageStatePaths(): string[] { + const xdgPath = join(homedir(), ".local", "share", "opencode", USAGE_FILENAME) + if (process.platform !== "win32") return [xdgPath] + const appData = + process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") + return [xdgPath, join(appData, "opencode", USAGE_FILENAME)] +} + +function num(raw: string | null): number | null { + if (raw === null) return null + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : null +} + +function readWindow(headers: Headers, abbrev: "5h" | "7d"): UsageWindow | null { + const utilization = num( + headers.get(`anthropic-ratelimit-unified-${abbrev}-utilization`), + ) + if (utilization === null) return null + return { + utilization, + resetsAt: num(headers.get(`anthropic-ratelimit-unified-${abbrev}-reset`)), + } +} + +/** + * Reads the `anthropic-ratelimit-unified-*` family off a live response. + * + * Returns null when the family is absent rather than an empty snapshot — + * count_tokens calls and request-validation errors carry no quota headers, + * and overwriting a good snapshot with blanks would make the display flicker + * to nothing on every such call. + */ +export function parseUsageHeaders(headers: Headers): UsageSnapshot | null { + const fiveHour = readWindow(headers, "5h") + const sevenDay = readWindow(headers, "7d") + const status = headers.get("anthropic-ratelimit-unified-status") + if (!fiveHour && !sevenDay && !status) return null + + const overageStatus = headers.get( + "anthropic-ratelimit-unified-overage-status", + ) + const overageUtilization = num( + headers.get("anthropic-ratelimit-unified-overage-utilization"), + ) + + return { + updatedAt: Date.now(), + status, + representativeClaim: headers.get( + "anthropic-ratelimit-unified-representative-claim", + ), + fiveHour, + sevenDay, + overage: + overageStatus || overageUtilization !== null + ? { + status: overageStatus, + utilization: overageUtilization, + resetsAt: num( + headers.get("anthropic-ratelimit-unified-overage-reset"), + ), + disabledReason: headers.get( + "anthropic-ratelimit-unified-overage-disabled-reason", + ), + } + : null, + } +} + +/** + * Serialised form of the last write, used to skip no-op writes. Compared + * without `updatedAt`, since that changes on every single response and would + * defeat the check entirely. + */ +let lastWritten: string | null = null + +function comparable(snapshot: UsageSnapshot): string { + const { updatedAt: _ignored, ...rest } = snapshot + return JSON.stringify(rest) +} + +export function writeUsageSnapshot(snapshot: UsageSnapshot): void { + const key = comparable(snapshot) + if (key === lastWritten) return + + const serialized = JSON.stringify(snapshot, null, 2) + for (const path of getUsageStatePaths()) { + try { + const dir = dirname(path) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + // Written aside then renamed: the TUI polls this file on a timer and a + // torn read would surface as a parse error exactly when usage is highest. + const tmp = `${path}.tmp` + writeFileSync(tmp, serialized, "utf-8") + renameSync(tmp, path) + } catch (err) { + log("usage_write_error", { + path, + error: err instanceof Error ? err.message : String(err), + }) + } + } + lastWritten = key +} + +/** Parse-and-persist in one call, for the fetch path. Never throws. */ +export function recordUsageFromHeaders(headers: Headers): void { + try { + const snapshot = parseUsageHeaders(headers) + if (!snapshot) return + log("usage_recorded", { + status: snapshot.status, + fiveHour: snapshot.fiveHour?.utilization ?? null, + sevenDay: snapshot.sevenDay?.utilization ?? null, + representativeClaim: snapshot.representativeClaim, + }) + writeUsageSnapshot(snapshot) + } catch (err) { + log("usage_record_threw", { + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** Test seam: clears the write-deduplication cache. */ +export function resetUsageWriteCache(): void { + lastWritten = null +} From b0b17d7690bc6d81112cd3b633deba6382057f24 Mon Sep 17 00:00:00 2001 From: Sylvain DUARTE Date: Fri, 31 Jul 2026 16:43:55 +0200 Subject: [PATCH 3/5] feat: display Claude quota in the OpenCode TUI A TUI plugin reads the snapshot the server half writes and renders it as `5h 46% 2h29m - 7d 15%`. The two halves are separate modules in separate processes and cannot share state directly, so they meet at a JSON file. Placement is configurable because the trade-offs are visual rather than technical; it defaults to `sidebar_title`, the only slot above the Context section. That slot is single_winner, so the plugin re-renders the session title it displaces. A window whose reset time has passed is re-derived as empty, which is what lets the recording side get away with reading only from responses: an idle session sends none, but its window still empties. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tui/claude-usage.tsx | 358 +++++++++++++++++++++++++++++++++++++++++++ tui/smoke.ts | 113 ++++++++++++++ tui/tsconfig.json | 11 ++ 3 files changed, 482 insertions(+) create mode 100644 tui/claude-usage.tsx create mode 100644 tui/smoke.ts create mode 100644 tui/tsconfig.json diff --git a/tui/claude-usage.tsx b/tui/claude-usage.tsx new file mode 100644 index 0000000..b669d75 --- /dev/null +++ b/tui/claude-usage.tsx @@ -0,0 +1,358 @@ +/** @jsxImportSource @opentui/solid */ +import type { + TuiPlugin, + TuiPluginModule, + TuiThemeCurrent, +} from "@opencode-ai/plugin/tui" +import type { JSX } from "@opentui/solid" +import { appendFileSync, existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { createSignal, onCleanup } from "solid-js" +import type { UsageSnapshot, UsageWindow } from "../src/usage.ts" + +/** + * The TUI runs in its own process and OpenCode swallows a plugin's console + * output, so a failure here is otherwise completely silent. + * + * Tracing turns on by the presence of the log file itself, not just an env var: + * OpenCode's TUI is launched by the user's terminal, so there is no reliable + * moment to export one. Create the file to enable, delete it to disable. + */ +const DEBUG_LOG_PATH = join( + homedir(), + ".local", + "share", + "opencode", + "claude-usage-tui.log", +) +const DEBUG_LOG = + process.env.CLAUDE_AUTH_DEBUG || existsSync(DEBUG_LOG_PATH) + ? DEBUG_LOG_PATH + : null + +function debug(event: string, detail?: unknown): void { + if (!DEBUG_LOG) return + try { + const payload = + detail instanceof Error + ? `${detail.message}\n${detail.stack ?? ""}` + : detail === undefined + ? "" + : JSON.stringify(detail) + appendFileSync( + DEBUG_LOG, + `${new Date().toISOString()} ${event} ${payload}\n`, + "utf-8", + ) + } catch { + // Tracing must never be the reason the plugin fails. + } +} + +debug("module_evaluated", { platform: process.platform }) + +/** + * Renders the Claude subscription quota the server half of this plugin records + * off every Anthropic response. + * + * The two halves cannot talk directly — a server plugin and a TUI plugin are + * separate modules loaded into separate processes — so they meet at the JSON + * file written by src/usage.ts. This side only ever reads it. + */ + +const USAGE_FILENAME = "claude-usage.json" +const POLL_INTERVAL_MS = 5_000 + +/** + * Beyond this the snapshot predates the current 7-day window, so even the + * reset-derived zero below would be a guess. Show nothing instead. + */ +const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000 + +/** Must stay in step with getUsageStatePaths() in src/usage.ts. */ +function usageStatePaths(): string[] { + const xdg = join(homedir(), ".local", "share", "opencode", USAGE_FILENAME) + if (process.platform !== "win32") return [xdg] + const appData = + process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") + return [xdg, join(appData, "opencode", USAGE_FILENAME)] +} + +/** + * Both candidate paths are read and the newer wins. Which one the server half + * wrote depends on how OpenCode was installed, and this side has no way to + * know — reading both is cheaper than being wrong. + */ +function readSnapshot(): UsageSnapshot | null { + let newest: UsageSnapshot | null = null + for (const path of usageStatePaths()) { + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as UsageSnapshot + if (typeof parsed?.updatedAt !== "number") continue + if (!newest || parsed.updatedAt > newest.updatedAt) newest = parsed + } catch { + // Missing until the first request of the first ever session, and + // momentarily unreadable if caught mid-write. Both are normal. + } + } + if (!newest) return null + return Date.now() - newest.updatedAt > STALE_AFTER_MS ? null : newest +} + +/** + * A window whose reset time has passed is empty, whatever the last response + * said it was. Deriving that here is what lets the server half get away with + * recording quota only from responses: an idle session stops sending requests, + * but its window still empties, and the display follows without a single extra + * API call. + */ +function currentWindow(window: UsageWindow | null): UsageWindow | null { + if (!window) return null + if (window.resetsAt !== null && window.resetsAt * 1000 <= Date.now()) { + return { utilization: 0, resetsAt: null } + } + return window +} + +function percent(utilization: number): number { + return Math.round(utilization * 100) +} + +/** "2h13m", "47m", "<1m" — narrow enough to sit in the prompt bar. */ +function countdown(resetsAtSeconds: number | null): string | null { + if (resetsAtSeconds === null) return null + const remainingMs = resetsAtSeconds * 1000 - Date.now() + if (remainingMs <= 0) return null + const minutes = Math.floor(remainingMs / 60_000) + if (minutes < 1) return "<1m" + const hours = Math.floor(minutes / 60) + return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m` +} + +function severityColor(theme: TuiThemeCurrent, utilization: number) { + const pct = percent(utilization) + if (pct >= 90) return theme.error + if (pct >= 70) return theme.warning + return theme.text +} + +/** + * Where the readout is rendered. Configurable because none of these is + * obviously right and the trade-offs are visual, not technical: + * + * - `sidebar_title` top of the sidebar, directly above Context. Replaces the + * host title block, so this plugin re-renders the title. + * - `sidebar_content` bottom of the sidebar, below Models. + * - `sidebar_footer` the sidebar's last line — note this is `single_winner`, + * so choosing it hides OpenCode's own "Open Code" footer. + * - `session_prompt_right` / `home_prompt_right` + * right edge of the prompt bar; can be clipped on a narrow + * terminal, since it shares that edge with other readouts. + */ +type TuiSlotCtx = { theme: { current: TuiThemeCurrent } } +type SidebarTitleProps = { title: string; share_url?: string } + +const SLOT_NAMES = [ + "sidebar_title", + "sidebar_content", + "sidebar_footer", + "session_prompt_right", + "home_prompt_right", +] as const +type SlotName = (typeof SLOT_NAMES)[number] + +const DEFAULT_SLOTS: SlotName[] = ["sidebar_title"] +const DEFAULT_LABEL = "Claude quota" + +/** + * The sidebar placements sit among the host's own labelled sections, so they + * get a heading to match. The prompt-bar placements are a single line with no + * room for one. + */ +const LABELLED_SLOTS: ReadonlySet = new Set([ + "sidebar_title", + "sidebar_content", +]) + +function resolveLabel(options: unknown): string { + const requested = (options as { label?: unknown } | undefined)?.label + return typeof requested === "string" && requested.trim().length > 0 + ? requested + : DEFAULT_LABEL +} + +function resolveSlots(options: unknown): SlotName[] { + const requested = (options as { slots?: unknown } | undefined)?.slots + if (!Array.isArray(requested)) return DEFAULT_SLOTS + const valid = requested.filter((name): name is SlotName => + SLOT_NAMES.includes(name as SlotName), + ) + const rejected = requested.filter((name) => !valid.includes(name as SlotName)) + if (rejected.length > 0) debug("unknown_slots_ignored", { rejected }) + return valid.length > 0 ? valid : DEFAULT_SLOTS +} + +const plugin: TuiPluginModule & { id: string } = { + id: "claude-auth-usage", + tui: (async (api, options) => { + debug("tui_setup_start") + const initial = readSnapshot() + debug("initial_snapshot", { found: initial !== null }) + const [snapshot, setSnapshot] = createSignal(initial) + + // One poller for the whole plugin rather than one per slot: the prompt-right + // slot remounts on every session switch, and a per-slot interval would make + // the read rate a function of how much the user navigates. + let lastSeen = "" + const poll = () => { + const next = readSnapshot() + const key = JSON.stringify(next) + // Signals compare by identity, so re-setting an equal-but-new object every + // 5s would re-render the prompt bar forever on an idle session. + if (key === lastSeen) return + lastSeen = key + setSnapshot(next) + } + const timer = setInterval(poll, POLL_INTERVAL_MS) + timer.unref?.() + api.lifecycle.onDispose(() => clearInterval(timer)) + + const label = resolveLabel(options) + const rendersTraced = new Map() + const Usage = (props: { theme: TuiThemeCurrent; slot: SlotName }) => { + const seen = rendersTraced.get(props.slot) ?? 0 + if (seen < 2) { + rendersTraced.set(props.slot, seen + 1) + debug("slot_render", { + slot: props.slot, + hasSnapshot: snapshot() !== null, + }) + } + // The countdown has to move without the file changing, so it gets its own + // clock rather than riding on the poll signal. + const [, setTick] = createSignal(0) + const clock = setInterval(() => setTick((n) => n + 1), 30_000) + clock.unref?.() + onCleanup(() => clearInterval(clock)) + + const current = () => snapshot() + const fiveHour = () => currentWindow(current()?.fiveHour ?? null) + const sevenDay = () => currentWindow(current()?.sevenDay ?? null) + + // A recorded rejection expires with the window that caused it, so it is + // read off the raw reset time rather than the derived window — which has + // already been zeroed by then and could no longer tell us. + const rejected = () => { + const snap = current() + if (snap?.status !== "rejected") return false + const raw = snap.fiveHour + return !raw?.resetsAt || raw.resetsAt * 1000 > Date.now() + } + + const readout = () => ( + + {rejected() ? ( + limit + ) : ( + "" + )} + {fiveHour() ? ( + <> + 5h{" "} + + {percent(fiveHour()!.utilization)}% + + {countdown(fiveHour()!.resetsAt) + ? ` ${countdown(fiveHour()!.resetsAt)}` + : ""} + + ) : ( + "" + )} + {fiveHour() && sevenDay() ? " · " : ""} + {sevenDay() ? ( + <> + 7d{" "} + + {percent(sevenDay()!.utilization)}% + + + ) : ( + "" + )} + + ) + + if (!LABELLED_SLOTS.has(props.slot)) return readout() + + // paddingTop keeps this off the line above it, matching the gap the host + // puts between its own sidebar sections. + return ( + + {label} + {readout()} + + ) + } + + const enabled = resolveSlots(options) + + const renderers: Record< + SlotName, + (ctx: TuiSlotCtx, props: never) => JSX.Element + > = { + /** + * `sidebar_title` is `single_winner`, so winning it means the host's + * title block stops rendering and this has to stand in for it — hence + * the title and share URL below. That is the price of the only slot + * that sits above Context. + */ + sidebar_title: (ctx, props) => { + const title = props as SidebarTitleProps + return ( + + + {title.title} + + {/* null, not "": a bare string child of a is an orphan text + node in OpenTUI and throws. Inside the empty-string + fallbacks are fine, which is why only this one differs. */} + {title.share_url ? ( + {title.share_url} + ) : null} + + + ) + }, + sidebar_content: (ctx) => ( + + ), + sidebar_footer: (ctx) => ( + + ), + session_prompt_right: (ctx) => ( + + ), + home_prompt_right: (ctx) => ( + + ), + } + + const slots = Object.fromEntries( + enabled.map((name) => [name, renderers[name]]), + ) + const registeredAs = api.slots.register({ slots }) + debug("slots_registered", { registeredAs, slots: enabled }) + }) satisfies TuiPlugin, +} + +export default plugin diff --git a/tui/smoke.ts b/tui/smoke.ts new file mode 100644 index 0000000..58b41c9 --- /dev/null +++ b/tui/smoke.ts @@ -0,0 +1,113 @@ +/** + * Load-and-render smoke test for the TUI plugin. + * + * OpenCode transpiles plugin `.tsx` at import time with OpenTUI's Solid + * transform; this reproduces that, then actually renders the registered slot + * into a test terminal, so a JSX or reactivity error surfaces here rather than + * on the user's next restart. + * + * bun tui/smoke.ts + */ +import "@opentui/solid/runtime-plugin-support" + +const { testRender } = (await import("@opentui/solid")) as { + testRender: ( + node: () => unknown, + config?: { width?: number; height?: number }, + ) => Promise<{ + renderOnce: () => Promise + captureCharFrame: () => string + }> +} + +const mod = (await import("./claude-usage.tsx")) as { + default?: { id?: string; tui?: unknown } +} +const plugin = mod.default + +if (!plugin) throw new Error("no default export") +if (typeof plugin.id !== "string") throw new Error("missing string id") +if (typeof plugin.tui !== "function") throw new Error("tui is not a function") + +type SlotFn = (ctx: unknown, props: unknown) => unknown +let registered: Record = {} + +// Exercise every placement, not just the default, so a broken renderer for an +// opt-in slot is caught here rather than by whoever opts into it. +const requestedSlots = [ + "sidebar_title", + "sidebar_content", + "sidebar_footer", + "session_prompt_right", + "home_prompt_right", +] + +await (plugin.tui as (api: unknown, options: unknown) => Promise)( + { + lifecycle: { onDispose: () => () => {} }, + slots: { + register: (input: { slots: Record }) => { + registered = input.slots + return plugin.id as string + }, + }, + }, + { slots: requestedSlots }, +) + +const slots = Object.keys(registered) +console.log("id:", plugin.id) +console.log("slots registered:", slots.join(", ")) +if (slots.length === 0) throw new Error("registered no slots") + +const theme = { + current: { + text: "#e6e6e6", + textMuted: "#8a8a8a", + warning: "#e5c07b", + error: "#e06c75", + }, +} + +if (slots.length !== requestedSlots.length) { + throw new Error( + `expected ${requestedSlots.length} slots, registered ${slots.join(", ")}`, + ) +} + +for (const name of slots) { + const setup = await testRender( + () => + registered[name]!( + { theme }, + { + session_id: "ses_abcdef123456", + title: "Display session status in opencode", + }, + ), + { width: 60, height: 4 }, + ) + await setup.renderOnce() + const frame = setup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + console.log(`[${name}] ${JSON.stringify(frame)}`) + if (frame.length === 0) throw new Error(`${name} rendered nothing`) + // Every placement must show the readout itself, not just its surroundings. + if (!frame.some((line) => line.includes("5h"))) { + throw new Error(`${name} rendered no usage readout`) + } + // The sidebar placements sit among the host's labelled sections and must + // carry a heading; the prompt-bar ones have no room and must not. + const labelled = name === "sidebar_title" || name === "sidebar_content" + const hasLabel = frame.some((line) => line.includes("Claude quota")) + if (labelled !== hasLabel) { + throw new Error( + `${name}: expected label ${labelled}, got ${hasLabel} — ${JSON.stringify(frame)}`, + ) + } +} + +console.log("OK") diff --git a/tui/tsconfig.json b/tui/tsconfig.json new file mode 100644 index 0000000..20a30df --- /dev/null +++ b/tui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["./**/*.tsx", "../src/usage.ts"] +} From d39be858295a70084ae4d1ec85497dbd536575da Mon Sep 17 00:00:00 2001 From: Sylvain DUARTE Date: Fri, 31 Jul 2026 16:56:37 +0200 Subject: [PATCH 4/5] fix: advance the quota countdown on an idle session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock signal's getter was discarded, so nothing subscribed to it and setting it scheduled no re-render. The countdown therefore only moved when a response happened to rewrite the snapshot — never on the idle session the clock exists for. The countdown is now a memo that reads the tick, which also gives it a single evaluation per render. It was previously computed twice in one JSX expression, once to test for a value and once to display it, so a minute boundary falling between the two calls would render the literal text `null`. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tui/claude-usage.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tui/claude-usage.tsx b/tui/claude-usage.tsx index b669d75..2b667d6 100644 --- a/tui/claude-usage.tsx +++ b/tui/claude-usage.tsx @@ -8,7 +8,7 @@ import type { JSX } from "@opentui/solid" import { appendFileSync, existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" -import { createSignal, onCleanup } from "solid-js" +import { createMemo, createSignal, onCleanup } from "solid-js" import type { UsageSnapshot, UsageWindow } from "../src/usage.ts" /** @@ -231,7 +231,7 @@ const plugin: TuiPluginModule & { id: string } = { } // The countdown has to move without the file changing, so it gets its own // clock rather than riding on the poll signal. - const [, setTick] = createSignal(0) + const [tick, setTick] = createSignal(0) const clock = setInterval(() => setTick((n) => n + 1), 30_000) clock.unref?.() onCleanup(() => clearInterval(clock)) @@ -240,6 +240,22 @@ const plugin: TuiPluginModule & { id: string } = { const fiveHour = () => currentWindow(current()?.fiveHour ?? null) const sevenDay = () => currentWindow(current()?.sevenDay ?? null) + /** + * Reading tick() is what makes the clock above do anything: setting a + * signal nothing reads schedules no re-render, so without this the + * countdown would only advance when a response happened to rewrite the + * snapshot — that is, never on the idle session it exists for. + * + * A memo rather than a plain accessor so the two reads below cannot + * straddle a minute boundary and disagree about whether there is any + * time left to show. + */ + const fiveHourCountdown = createMemo(() => { + tick() + const window = fiveHour() + return window ? countdown(window.resetsAt) : null + }) + // A recorded rejection expires with the window that caused it, so it is // read off the raw reset time rather than the derived window — which has // already been zeroed by then and could no longer tell us. @@ -267,9 +283,7 @@ const plugin: TuiPluginModule & { id: string } = { > {percent(fiveHour()!.utilization)}% - {countdown(fiveHour()!.resetsAt) - ? ` ${countdown(fiveHour()!.resetsAt)}` - : ""} + {fiveHourCountdown() ? ` ${fiveHourCountdown()}` : ""} ) : ( "" From abf633a1862385b16c56c686654073d84dd5aeb1 Mon Sep 17 00:00:00 2001 From: Sylvain DUARTE Date: Fri, 31 Jul 2026 16:56:50 +0200 Subject: [PATCH 5/5] test: make the TUI smoke test self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It rendered against the real snapshot file, so it asserted on whatever quota the developer's machine happened to hold and failed outright on a machine that had never run the plugin. It now seeds a synthetic snapshot in a temporary HOME and asserts against that. It also could not fail: a rejection from the top-level await left the exit code at zero, so every assertion in it was decorative. Failures now set a non-zero exit code, which is what makes the two added checks worth anything — that no slot renders a quota when no snapshot exists, and that the countdown never renders as the literal text `null`. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tui/smoke.ts | 190 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 55 deletions(-) diff --git a/tui/smoke.ts b/tui/smoke.ts index 58b41c9..c9855b2 100644 --- a/tui/smoke.ts +++ b/tui/smoke.ts @@ -2,13 +2,54 @@ * Load-and-render smoke test for the TUI plugin. * * OpenCode transpiles plugin `.tsx` at import time with OpenTUI's Solid - * transform; this reproduces that, then actually renders the registered slot + * transform; this reproduces that, then actually renders the registered slots * into a test terminal, so a JSX or reactivity error surfaces here rather than * on the user's next restart. * * bun tui/smoke.ts + * + * Runs against a synthetic snapshot in a temporary HOME rather than the real + * one, so it neither depends on this machine having used the plugin before nor + * reports whatever the developer's live quota happens to be. */ import "@opentui/solid/runtime-plugin-support" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const home = mkdtempSync(join(tmpdir(), "claude-usage-smoke-")) +// homedir() reads USERPROFILE on Windows and HOME elsewhere. Set before the +// plugin is imported: it resolves its state and log paths from them. +process.env.HOME = home +process.env.USERPROFILE = home +process.env.LOCALAPPDATA = join(home, "AppData", "Local") + +const FIVE_HOUR_UTILIZATION = 0.26 +const SEVEN_DAY_UTILIZATION = 0.19 + +function seedSnapshot(): void { + const dir = join(home, ".local", "share", "opencode") + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, "claude-usage.json"), + JSON.stringify({ + updatedAt: Date.now(), + status: "allowed", + representativeClaim: "five_hour", + fiveHour: { + utilization: FIVE_HOUR_UTILIZATION, + // Far enough out that the countdown cannot expire mid-run. + resetsAt: Math.floor(Date.now() / 1000) + 2 * 3600 + 13 * 60, + }, + sevenDay: { + utilization: SEVEN_DAY_UTILIZATION, + resetsAt: Math.floor(Date.now() / 1000) + 5 * 24 * 3600, + }, + overage: null, + }), + "utf-8", + ) +} const { testRender } = (await import("@opentui/solid")) as { testRender: ( @@ -30,7 +71,6 @@ if (typeof plugin.id !== "string") throw new Error("missing string id") if (typeof plugin.tui !== "function") throw new Error("tui is not a function") type SlotFn = (ctx: unknown, props: unknown) => unknown -let registered: Record = {} // Exercise every placement, not just the default, so a broken renderer for an // opt-in slot is caught here rather than by whoever opts into it. @@ -42,24 +82,6 @@ const requestedSlots = [ "home_prompt_right", ] -await (plugin.tui as (api: unknown, options: unknown) => Promise)( - { - lifecycle: { onDispose: () => () => {} }, - slots: { - register: (input: { slots: Record }) => { - registered = input.slots - return plugin.id as string - }, - }, - }, - { slots: requestedSlots }, -) - -const slots = Object.keys(registered) -console.log("id:", plugin.id) -console.log("slots registered:", slots.join(", ")) -if (slots.length === 0) throw new Error("registered no slots") - const theme = { current: { text: "#e6e6e6", @@ -69,45 +91,103 @@ const theme = { }, } -if (slots.length !== requestedSlots.length) { - throw new Error( - `expected ${requestedSlots.length} slots, registered ${slots.join(", ")}`, - ) -} - -for (const name of slots) { - const setup = await testRender( - () => - registered[name]!( - { theme }, - { - session_id: "ses_abcdef123456", - title: "Display session status in opencode", +async function renderSlots(): Promise> { + let registered: Record = {} + await (plugin!.tui as (api: unknown, options: unknown) => Promise)( + { + lifecycle: { onDispose: () => () => {} }, + slots: { + register: (input: { slots: Record }) => { + registered = input.slots + return plugin!.id as string }, - ), - { width: 60, height: 4 }, + }, + }, + { slots: requestedSlots }, ) - await setup.renderOnce() - const frame = setup - .captureCharFrame() - .split("\n") - .map((line) => line.trimEnd()) - .filter((line) => line.length > 0) - console.log(`[${name}] ${JSON.stringify(frame)}`) - if (frame.length === 0) throw new Error(`${name} rendered nothing`) - // Every placement must show the readout itself, not just its surroundings. - if (!frame.some((line) => line.includes("5h"))) { - throw new Error(`${name} rendered no usage readout`) - } - // The sidebar placements sit among the host's labelled sections and must - // carry a heading; the prompt-bar ones have no room and must not. - const labelled = name === "sidebar_title" || name === "sidebar_content" - const hasLabel = frame.some((line) => line.includes("Claude quota")) - if (labelled !== hasLabel) { + + const names = Object.keys(registered) + if (names.length !== requestedSlots.length) { throw new Error( - `${name}: expected label ${labelled}, got ${hasLabel} — ${JSON.stringify(frame)}`, + `expected ${requestedSlots.length} slots, registered ${names.join(", ")}`, ) } + + const frames: Record = {} + for (const name of names) { + const setup = await testRender( + () => + registered[name]!( + { theme }, + { + session_id: "ses_abcdef123456", + title: "Display session status in opencode", + }, + ), + { width: 60, height: 4 }, + ) + await setup.renderOnce() + frames[name] = setup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + } + return frames } -console.log("OK") +try { + console.log("id:", plugin.id) + + // Phase 1: no snapshot on disk — the readout must stay out of the way rather + // than render a half-empty row of labels. + const empty = await renderSlots() + for (const [name, frame] of Object.entries(empty)) { + const showsQuota = frame.some((line) => line.includes("5h")) + if (showsQuota) { + throw new Error(`${name} rendered a quota with no snapshot on disk`) + } + } + console.log("no-snapshot: every slot renders no readout (ok)") + + // Phase 2: with a snapshot, every placement must show the readout, and only + // the sidebar ones carry the heading. + seedSnapshot() + const seeded = await renderSlots() + for (const [name, frame] of Object.entries(seeded)) { + console.log(`[${name}] ${JSON.stringify(frame)}`) + if (frame.length === 0) throw new Error(`${name} rendered nothing`) + + const expected = `5h ${Math.round(FIVE_HOUR_UTILIZATION * 100)}%` + if (!frame.some((line) => line.includes(expected))) { + throw new Error(`${name} is missing "${expected}"`) + } + // Shape, not an exact value: the countdown floors to the minute, so the + // second that passes between seeding and rendering turns 2h13m into 2h12m. + if (!frame.some((line) => /\b\d+h\d+m\b/.test(line))) { + throw new Error(`${name} is missing the reset countdown`) + } + // A countdown that lost a race against the minute boundary renders as the + // string "null" rather than disappearing, so assert it never appears. + if (frame.some((line) => line.includes("null"))) { + throw new Error(`${name} rendered a null countdown`) + } + + const labelled = name === "sidebar_title" || name === "sidebar_content" + const hasLabel = frame.some((line) => line.includes("Claude quota")) + if (labelled !== hasLabel) { + throw new Error( + `${name}: expected label ${labelled}, got ${hasLabel} — ${JSON.stringify(frame)}`, + ) + } + } + + console.log("OK") +} catch (error) { + // An unhandled top-level rejection does not set a non-zero exit code here, + // which would leave this passing however badly it failed. + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} finally { + rmSync(home, { recursive: true, force: true }) +}