From 5548ed2319b1188ccadaa793fa61fb012f9e753e Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:16:48 -0700 Subject: [PATCH 1/6] feat(tui)!: launch and bridge native OpenCode v2 --- .gitignore | 1 + bun.lock | 599 ++++++++++++++++++++++++++++++++++++++ flake.nix | 3 + lua/opencode/bridge.lua | 22 +- lua/opencode/config.lua | 11 +- lua/opencode/session.lua | 6 + lua/opencode/terminal.lua | 18 +- package.json | 15 +- tests/helpers.lua | 15 + tests/run.lua | 8 + tests/terminal_spec.lua | 61 ++++ tui-plugin/bridge.test.ts | 127 ++++++++ tui-plugin/bridge.ts | 134 +++++++++ tui-plugin/tui.ts | 248 ++-------------- tui-plugin/types.d.ts | 102 ------- tui.ts | 2 + 16 files changed, 1019 insertions(+), 353 deletions(-) create mode 100644 bun.lock create mode 100644 tests/helpers.lua create mode 100644 tests/run.lua create mode 100644 tests/terminal_spec.lua create mode 100644 tui-plugin/bridge.test.ts create mode 100644 tui-plugin/bridge.ts delete mode 100644 tui-plugin/types.d.ts create mode 100644 tui.ts diff --git a/.gitignore b/.gitignore index 238d11c..4444c69 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ vendor/plenary.nvim +node_modules/ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3252c20 --- /dev/null +++ b/bun.lock @@ -0,0 +1,599 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-nvim", + "devDependencies": { + "@opencode/client": "2.0.1", + "@opencode/plugin": "2.0.1", + "@types/bun": "1.3.14", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.70", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-KlU89w6Hmb4oZB5zFz/MNIhPOBQGVE7KrDr3BTPCwC4W+q566YH8tGNsAML781LKATtqmNCGFry8XvsJ2XPusg=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], + + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="], + + "@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="], + + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="], + + "@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + + "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], + + "@opencode/ai": ["@opencode/ai@2.0.1", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.1", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-ifgAxBMunVfpN0CtZmO9j3CHSmbfGud9+pIO/zq4+QWy9ttOjAdyUCfkaNTD32uUfiGW+ZYOYN+dRPHm6uW3pA=="], + + "@opencode/client": ["@opencode/client@2.0.1", "", { "dependencies": { "@opencode/protocol": "2.0.1", "@opencode/schema": "2.0.1" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-7R1FyjqNaNNJvUi7LYtN2kOeCFvtwIQZXq64E6VEoZwqrfLVMNM4bJCrW4hXksil7F4mgalSmbdP+yzRvjcE3Q=="], + + "@opencode/plugin": ["@opencode/plugin@2.0.1", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.1", "@opencode/client": "2.0.1", "@opencode/protocol": "2.0.1", "@opencode/schema": "2.0.1", "@opencode/util": "2.0.1", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.1", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-8KKDbUVcHoAbNyz07wcH8nNOAXsR3a4VpUMtBX1lsSpfVtMZj1hjaWnWYTyVw6ahb5vjd/j4tdVJJ8bBK5hQvA=="], + + "@opencode/protocol": ["@opencode/protocol@2.0.1", "", { "dependencies": { "@opencode/schema": "2.0.1", "effect": "4.0.0-rc.112" } }, "sha512-bxyryexh10Lvpw09dPKxhRmgcR14fXeoJbmhXyjpD+Jb8VR0UKOB55ux+GU2kIlO3eoZ/Uh99IZ/5DN0npk4CA=="], + + "@opencode/schema": ["@opencode/schema@2.0.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-wsk2jNeBIgrkqTDidtsAcr2doR5xcRXgBvkusMEDbJr3L1z0x/VZe/xnOMlHSQZ6px0Tf/+Mz5N2fMYAo2NbIw=="], + + "@opencode/util": ["@opencode/util@2.0.1", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "pacote": "21.5.1" } }, "sha512-Bdjq0m3KbKvNz9F7n98RKJDvGottShColCy49GPi6Cr68Yf+34MUt96zrMpThgMPCKRqBMu7ZNy3pcMbFX240w=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="], + + "@redis/client": ["@redis/client@6.2.1", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q=="], + + "@redis/json": ["@redis/json@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ=="], + + "@redis/search": ["@redis/search@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ=="], + + "@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="], + + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + + "@smithy/core": ["@smithy/core@3.34.1", "", { "dependencies": { "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.10.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["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" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], + + "npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="], + + "npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + + "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], + + "redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], + + "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], + + "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/flake.nix b/flake.nix index 776767b..36bc9b6 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,9 @@ packages = with pkgs; [ stylua typescript + bun + neovim + curl ]; }; } diff --git a/lua/opencode/bridge.lua b/lua/opencode/bridge.lua index 4fc1fef..4995dc3 100644 --- a/lua/opencode/bridge.lua +++ b/lua/opencode/bridge.lua @@ -4,7 +4,7 @@ --- ---Flow overview: --- ---- Neovim Lua child `opencode attach` TUI +--- Neovim Lua child `opencode --server` TUI --- (this plugin) + bundled `tui-plugin/tui.ts` --- ^ | --- | localhost HTTP POST | @@ -13,12 +13,12 @@ --- Separate path: --- Neovim Lua <---- HTTP/SSE ----> OpenCode backend --- ----The embedded `opencode attach` terminal runs in a child process, so it cannot +---The embedded `opencode --server` terminal runs in a child process, so it cannot ---call Neovim Lua directly. Instead, the bundled TUI plugin ---(`tui-plugin/tui.ts`) reads bridge env vars, posts JSON payloads to a ---loopback HTTP server owned by this module, and reports: ---1. which TUI route/session is currently visible ----2. the TUI process cwd +---2. the active session location ---3. selected TUI events that Neovim may want to react to --- ---Active attached-session state is delegated to `opencode.session`. @@ -35,6 +35,7 @@ local BRIDGE_ENV = constants.BRIDGE_ENV ---@field instanceID string|nil ---@field route opencode.BridgeRoute|nil ---@field sessionID string|nil +---@field workspaceID? string|nil ---@field cwd string|nil ---@field kind? "event"|nil ---@field event? opencode.Event @@ -197,6 +198,7 @@ local function update_state(payload) route = payload.route, session_id = payload.sessionID, cwd = payload.cwd, + workspace_id = type(payload.workspaceID) == "string" and payload.workspaceID or nil, instance_id = state.instance_id, }) end @@ -216,16 +218,26 @@ local function emit_active_event(payload) local route = payload.route == "session" and "session" or "home" local session_id = type(payload.sessionID) == "string" and payload.sessionID ~= "" and payload.sessionID or nil local cwd = payload.cwd or current.cwd + local data = event.data + local owner = type(data) == "table" and (data.sessionID or (type(data.form) == "table" and data.form.sessionID)) + if route ~= "session" or not session_id or owner ~= session_id then + return + end + local instance_id = state.instance_id vim.schedule(function() + if instance_id ~= state.instance_id then + return + end vim.api.nvim_exec_autocmds("User", { pattern = "OpencodeActiveEvent:" .. event.type, data = { event = event, route = route, session_id = session_id, - instance_id = state.instance_id, + instance_id = instance_id, cwd = cwd, + workspace_id = payload.workspaceID, }, }) end) @@ -343,7 +355,7 @@ end ---Ensure the local bridge server exists and return env vars for the child TUI. --- ----`opencode.terminal` injects these values into the `opencode attach` process. +---`opencode.terminal` injects these values into the `opencode --server` process. ---The bundled TUI plugin reads them and POSTs state/event payloads back to this ---bridge server. ---@return table diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 5175480..f13eeff 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -14,8 +14,8 @@ local M = {} ---@field url? string Backend URL for API and attach mode ---@class opencode.TerminalConfig ----@field cmd? string Command to run (defaults to generated `opencode attach ...` command) ----@field dir? string Directory argument passed to `opencode attach` +---@field cmd? string Command to run (defaults to generated `opencode --server ...` command) +---@field dir? string Directory argument passed to the TUI ---@field continue? boolean Default `--continue` behavior for terminal launches (default: true) ---@field layout? "split"|"tab" Terminal placement for launches (default: "split") ---@field width? number Terminal window width passed to snacks.win.width @@ -91,10 +91,11 @@ end ---Read backend auth from the standard OpenCode environment variables visible ---to the Neovim process. --- ----`OPENCODE_SERVER_USERNAME` defaults to `opencode` upstream when omitted. +---V2 uses the fixed username `opencode`. OPENCODE_PASSWORD takes precedence +---over the legacy OPENCODE_SERVER_PASSWORD, as it does in the native CLI. ---@return { username: string, password: string }|nil function M.get_auth() - local password = vim.env.OPENCODE_SERVER_PASSWORD + local password = vim.env.OPENCODE_PASSWORD or vim.env.OPENCODE_SERVER_PASSWORD if password == "" then password = nil end @@ -103,7 +104,7 @@ function M.get_auth() end return { - username = vim.env.OPENCODE_SERVER_USERNAME or "opencode", + username = "opencode", password = password, } end diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index e2ca949..ad45022 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -30,6 +30,7 @@ local M = {} ---@class opencode.ActiveSessionUpdate ---@field route? opencode.SessionRoute ---@field session_id? string|nil OpenCode session id reported by the embedded TUI +---@field workspace_id? string|nil Native OpenCode workspace identity ---@field cwd? string|nil Current working directory reported by the embedded TUI ---@field instance_id? string|nil Bridge instance id for the publishing TUI process @@ -38,6 +39,7 @@ local M = {} ---@field session_id string|nil Active OpenCode session id currently visible in the embedded TUI ---@field target_session_id string|nil OpenCode session id the local terminal should attach/follow ---@field instance_id string|nil Bridge instance id for the active embedded TUI process +---@field workspace_id string|nil Native OpenCode workspace identity ---@field cwd string|nil Current working directory reported by the embedded TUI ---@field follow_active_session boolean Whether explicit attach mode should follow the active embedded TUI session id @@ -61,6 +63,7 @@ local function emit_changed() session_id = state.session_id, instance_id = state.instance_id, cwd = state.cwd, + workspace_id = state.workspace_id, }, }) end) @@ -79,11 +82,13 @@ function M.update_active(payload) local changed = state.route ~= next_route or state.session_id ~= next_session_id or state.cwd ~= next_cwd + or state.workspace_id ~= payload.workspace_id or state.instance_id ~= next_instance_id state.route = next_route state.session_id = next_session_id state.cwd = next_cwd + state.workspace_id = payload.workspace_id state.instance_id = next_instance_id if state.follow_active_session and next_session_id and next_session_id ~= "" then @@ -127,6 +132,7 @@ function M.get_state() target_session_id = state.target_session_id, instance_id = state.instance_id, cwd = state.cwd, + workspace_id = state.workspace_id, follow_active_session = state.follow_active_session, } end diff --git a/lua/opencode/terminal.lua b/lua/opencode/terminal.lua index 60f7c43..cf6252f 100644 --- a/lua/opencode/terminal.lua +++ b/lua/opencode/terminal.lua @@ -236,7 +236,7 @@ end --- ---OpenCode output is usually relative to the embedded TUI's cwd, not ---necessarily Neovim's current cwd. Prefer the bridged active cwd, then the ----configured terminal dir used to launch `opencode attach`, and only then fall +---configured terminal dir used to launch `opencode --server`, and only then fall ---back to Neovim's cwd/path behavior. ---@return string[] local function reference_dirs() @@ -515,20 +515,20 @@ local function normalize_env(env) return normalized end ----Shell-quote a single argument for the generated `opencode attach` command. +---Shell-quote a single argument for the generated `opencode --server` command. --- ---This is only used for the generated command path. A custom `terminal.cmd` ---bypasses this helper entirely. ---@param text string ---@return string local function quote(text) - return '"' .. text:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + return vim.fn.shellescape(text) end ---Build the environment for the embedded attach-mode process. --- ---This merges three sources: ----1. user-provided `terminal.env` for the child `opencode attach` process +---1. user-provided `terminal.env` for the child `opencode --server` process ---2. backend auth env inherited from Neovim config ---3. bridge env so the TUI can report active-session state back to Neovim --- @@ -544,7 +544,7 @@ local function get_env(opts) local bridge_env = bridge.ensure() if auth then - env.OPENCODE_SERVER_USERNAME = auth.username + env.OPENCODE_PASSWORD = auth.password env.OPENCODE_SERVER_PASSWORD = auth.password end @@ -576,7 +576,7 @@ end ---Build the command used to launch the embedded terminal. --- ---If `terminal.cmd` is configured, use it verbatim as the escape hatch. The ----generated path builds `opencode attach ...` from structured config. +---generated path builds `opencode --server ...` from structured config. ---@param target? opencode.TerminalTarget ---@param launch_opts? opencode.TerminalLaunchOpts ---@return string @@ -588,10 +588,10 @@ local function get_cmd(target, launch_opts) end local cmd = { - "opencode attach", + "opencode", + "--server", quote(config.get_url()), - "--dir", - quote(terminal.dir or "."), + quote(vim.fn.fnamemodify(terminal.dir or ".", ":p")), } if target.session_id and target.session_id ~= "" then table.insert(cmd, "--session") diff --git a/package.json b/package.json index a1f9978..c3e8c55 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,16 @@ { "name": "opencode-nvim", + "private": true, "type": "module", - "main": "./tui-plugin/tui.ts", - "exports": { - ".": "./tui-plugin/tui.ts", - "./tui": "./tui-plugin/tui.ts" + "main": "./tui.ts", + "exports": { ".": "./tui.ts", "./tui": "./tui.ts" }, + "scripts": { + "typecheck": "tsc -p tui-plugin/tsconfig.json", + "test": "bun test tui-plugin && nvim --headless -u NONE -l tests/run.lua" + }, + "devDependencies": { + "@opencode/plugin": "2.0.1", + "@opencode/client": "2.0.1", + "@types/bun": "1.3.14" } } diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..b707574 --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,15 @@ +local M = { passed = 0, failed = 0 } +function M.eq(expected, actual) + assert(vim.deep_equal(expected, actual), "expected " .. vim.inspect(expected) .. "\nactual " .. vim.inspect(actual)) +end +function M.test(name, fn) + local ok, err = xpcall(fn, debug.traceback) + if ok then + M.passed = M.passed + 1 + print("PASS " .. name) + else + M.failed = M.failed + 1 + print("FAIL " .. name .. "\n" .. err) + end +end +return M diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..d529600 --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,8 @@ +vim.opt.runtimepath:prepend(vim.fn.getcwd()) +package.path = "./?.lua;" .. package.path +local t = require("tests.helpers") +for _, path in ipairs(vim.fn.glob("tests/*_spec.lua", false, true)) do + dofile(path) +end +print(("Lua: %d passed, %d failed"):format(t.passed, t.failed)) +vim.cmd(t.failed == 0 and "qa!" or "cquit 1") diff --git a/tests/terminal_spec.lua b/tests/terminal_spec.lua new file mode 100644 index 0000000..cebfdb7 --- /dev/null +++ b/tests/terminal_spec.lua @@ -0,0 +1,61 @@ +local t = require("tests.helpers") +local config = require("opencode.config") +local session = require("opencode.session") +local previous = package.loaded["snacks.terminal"] +local captured +package.loaded["snacks.terminal"] = { + get = function(cmd, opts) + captured = { cmd, opts } + end, + open = function(cmd, opts) + captured = { cmd, opts } + end, +} +package.loaded["opencode.terminal"] = nil +local terminal = require("opencode.terminal") +local saved = { vim.env.OPENCODE_PASSWORD, vim.env.OPENCODE_SERVER_PASSWORD } +vim.env.OPENCODE_PASSWORD = "v2-test-password" +vim.env.OPENCODE_SERVER_PASSWORD = "legacy-test-password" + +t.test("v2 launch uses explicit server, positional absolute directory, and continue", function() + config.setup({ terminal = { dir = "/tmp/project $HOME `echo escaped`", env = { OPENCODE_PASSWORD = "wrong" } } }) + terminal.get() + local cmd, opts = unpack(captured) + assert(cmd:find("opencode --server ", 1, true)) + assert(not cmd:find("attach", 1, true)) + assert(not cmd:find("--dir", 1, true)) + assert(cmd:find("--continue", 1, true)) + -- Evaluate only the generated argument quoting; printf cannot execute a TUI. + local args = vim.system({ "sh", "-c", "set -- " .. cmd .. '; printf "%s\\n" "$@"' }, { text = true }):wait() + t.eq(0, args.code) + assert(args.stdout:find("/tmp/project $HOME `echo escaped`", 1, true)) + t.eq("v2-test-password", opts.env.OPENCODE_PASSWORD) + t.eq("opencode", config.get_auth().username) +end) + +t.test("explicit session takes precedence and new session omits continue", function() + terminal.get({ session_id = "ses_test" }) + assert(captured[1]:find("--session 'ses_test'", 1, true)) + assert(not captured[1]:find("--continue", 1, true)) + terminal.start({ continue = false }) + assert(not captured[1]:find("--continue", 1, true)) +end) + +t.test("custom terminal commands are preserved", function() + config.setup({ terminal = { cmd = "custom-tui --flag" } }) + terminal.get() + t.eq("custom-tui --flag", captured[1]) +end) + +t.test("workspace changes update session identity even with the same directory", function() + session.update_active({ route = "session", session_id = "ses_test", cwd = "/tmp/project", workspace_id = "wrk_one" }) + t.eq("wrk_one", session.get_state().workspace_id) + session.update_active({ route = "session", session_id = "ses_test", cwd = "/tmp/project" }) + t.eq(nil, session.get_state().workspace_id) +end) +vim.env.OPENCODE_PASSWORD, vim.env.OPENCODE_SERVER_PASSWORD = saved[1], saved[2] +config.setup() +session.update_active({ route = "home" }) +require("opencode.editor_context").stop() +package.loaded["snacks.terminal"] = previous +package.loaded["opencode.terminal"] = nil diff --git a/tui-plugin/bridge.test.ts b/tui-plugin/bridge.test.ts new file mode 100644 index 0000000..d90884e --- /dev/null +++ b/tui-plugin/bridge.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import type { OpenCodeEvent, SessionInfo } from "@opencode/client"; +import { createBridge, currentState } from "./bridge"; + +type Context = Parameters[0]; +const env = { + OPENCODE_NVIM_BRIDGE_URL: "http://127.0.0.1:12345/opencode/session", + OPENCODE_NVIM_BRIDGE_TOKEN: "test-token", + OPENCODE_NVIM_INSTANCE_ID: "test-instance", +}; +function fixture() { + let route: ReturnType = { type: "home" }; + const sessions = new Map(); + const toasts: unknown[] = []; + const context: Context = { + ui: { + router: { current: () => route, navigate: (next) => { route = next.type === "plugin" ? { ...next, id: "test-plugin" } : next; }, register: () => () => {} }, + toast: { show: (value) => { toasts.push(value); } }, + }, + data: { session: { get: (id) => sessions.get(id) }, location: { default: () => ({ directory: "/launch" }) } }, + }; + function select(id: string, directory = "/session", workspaceID?: string) { + // Only fields consumed by the bridge are needed in this cache fixture. + sessions.set(id, { id, location: { directory, workspaceID } } as SessionInfo); + route = { type: "session", sessionID: id }; + } + return { context, select, toasts }; +} +function event(type: string, sessionID: string): OpenCodeEvent { + return { type, data: { sessionID }, id: "evt_test" } as OpenCodeEvent; +} + +describe("native TUI bridge", () => { + test("uses route/cache location, including workspace, and clears it on home", () => { + const f = fixture(); + f.select("ses_one", "/other/project", "wrk_one"); + expect(currentState(f.context)).toEqual({ route: "session", sessionID: "ses_one", cwd: "/other/project", workspaceID: "wrk_one" }); + f.context.ui.router.navigate({ type: "home" }); + expect(currentState(f.context)).toEqual({ route: "home", sessionID: null, cwd: "/launch", workspaceID: null }); + f.context.ui.router.navigate({ type: "session", sessionID: "ses_loading" }); + expect(currentState(f.context).sessionID).toBeNull(); + }); + + test("inert outside Neovim; deduplicates only successful snapshots", async () => { + const f = fixture(); + expect(createBridge(f.context, {})).toBeUndefined(); + const calls: unknown[] = []; + const transport = (async (_url, init) => { + calls.push(JSON.parse(init!.body as string)); + return new Response(null, { status: calls.length === 1 ? 500 : 200 }); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + await bridge.sync(); + await bridge.sync(); + await bridge.sync(); + expect(calls).toHaveLength(2); + expect(f.toasts).toHaveLength(1); + bridge.dispose(); + await bridge.sync(); + expect(calls).toHaveLength(2); + }); + + test("filters unrelated and unowned events, including nested forms", async () => { + const f = fixture(); + f.select("ses_active"); + const calls: Record[] = []; + const transport = (async (_url, init) => { + calls.push(JSON.parse(init!.body as string)); + return new Response(null, { status: 200 }); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + bridge.event(event("session.execution.succeeded", "ses_other")); + bridge.event(event("permission.asked", "ses_active")); + bridge.event({ type: "form.created", data: { form: { sessionID: "global" } } } as OpenCodeEvent); + bridge.event({ type: "form.created", data: { form: { sessionID: "ses_active" } } } as OpenCodeEvent); + await bridge.sync(); + expect(calls.filter((x) => x.kind === "event").map((x) => x.event.type)).toEqual(["permission.asked", "form.created"]); + expect(calls.every((x) => x.sessionID === "ses_active")).toBe(true); + bridge.dispose(); + }); + + test("serializes route switches and resets deduplication on a new setup", async () => { + const f = fixture(); + f.select("ses_one"); + let release!: () => void; + const calls: Record[] = []; + const transport = (async (_url, init) => { + calls.push(JSON.parse(init!.body as string)); + if (calls.length === 1) await new Promise((resolve) => { release = resolve; }); + return new Response(null, { status: 200 }); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + const first = bridge.sync(); + await Promise.resolve(); + f.select("ses_two"); + bridge.event(event("session.execution.started", "ses_two")); + release(); + await first; + await bridge.sync(); + expect(calls.map((x) => x.sessionID)).toEqual(["ses_one", "ses_two", "ses_two"]); + bridge.dispose(); + // A fresh setup must publish even if its initial state matches an old setup. + const next = createBridge(f.context, env, transport)!; + await next.sync(); + expect(calls).toHaveLength(4); + next.dispose(); + }); + test("cleanup aborts an in-flight publish without reporting an error", async () => { + const f = fixture(); + let signal: AbortSignal | undefined; + const transport = (async (_url, init) => { + signal = init!.signal!; + await new Promise((_resolve, reject) => { + signal!.addEventListener("abort", () => reject(new Error("aborted"))); + }); + return new Response(); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + const pending = bridge.sync(); + await Promise.resolve(); + bridge.dispose(); + await pending; + expect(signal?.aborted).toBe(true); + expect(f.toasts).toHaveLength(0); + }); + +}); diff --git a/tui-plugin/bridge.ts b/tui-plugin/bridge.ts new file mode 100644 index 0000000..7ef1332 --- /dev/null +++ b/tui-plugin/bridge.ts @@ -0,0 +1,134 @@ +import type { OpenCodeEvent } from "@opencode/client"; +import type { Plugin } from "@opencode/plugin/tui"; + +type Context = { + ui: Pick; + data: { + session: Pick; + location: Pick; + }; +}; + +const forwarded = new Set([ + "session.execution.started", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.retry.scheduled", + "session.inbox.enqueued", + "session.inbox.delivered", + "session.inbox.cancelled", + "session.agent.selected", + "session.model.selected", + "permission.asked", + "permission.replied", + "form.created", + "form.replied", + "form.cancelled", +]); + +export function currentState(context: Context) { + const route = context.ui.router.current(); + const session = route.type === "session" ? context.data.session.get(route.sessionID) : undefined; + const location = session?.location ?? context.data.location.default(); + return { + // Wait for the session cache before exposing a target for direct reviews. + route: session ? "session" : "home", + sessionID: session?.id ?? null, + cwd: location.directory, + workspaceID: location.workspaceID ?? null, + }; +} + +export function eventSessionID(event: OpenCodeEvent): string | undefined { + if (event.type === "form.created") return event.data.form.sessionID; + if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID; +} + +// Each setup owns its transport state. Hot reloads cannot inherit an old +// deduplication snapshot or keep publishing after their cleanup runs. +export function createBridge( + context: Context, + env: Record, + transport: typeof fetch = fetch, +) { + const url = env.OPENCODE_NVIM_BRIDGE_URL; + const token = env.OPENCODE_NVIM_BRIDGE_TOKEN; + const instanceID = env.OPENCODE_NVIM_INSTANCE_ID; + if (!url || !token || !instanceID) return; + + let disposed = false; + let lastState = ""; + let syncing = false; + let notifiedError = false; + let pending = Promise.resolve(); + const controllers = new Set(); + + async function post(payload: object) { + if (disposed) return false; + const controller = new AbortController(); + controllers.add(controller); + const timeout = setTimeout(() => controller.abort(), 2000); + try { + const response = await transport(url!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token, instanceID, ...payload }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + notifiedError = false; + return true; + } catch { + if (!disposed && !notifiedError) { + notifiedError = true; + context.ui.toast.show({ + title: "nvim bridge", + message: "Could not reach Neovim; bridge updates will retry.", + variant: "error", + duration: 2500, + }); + } + return false; + } finally { + clearTimeout(timeout); + controllers.delete(controller); + } + } + + async function publishState(state = currentState(context)) { + const body = JSON.stringify(state); + if (body === lastState) return true; + if (!(await post(state))) return false; + lastState = body; + return true; + } + + return { + sync() { + if (disposed || syncing) return pending; + syncing = true; + pending = pending.then(async () => { + try { + if (!disposed) await publishState(); + } finally { + syncing = false; + } + }); + return pending; + }, + event(event: OpenCodeEvent) { + if (disposed || !forwarded.has(event.type)) return; + const state = currentState(context); + if (state.route !== "session" || eventSessionID(event) !== state.sessionID) return; + pending = pending.then(async () => { + if (disposed || !(await publishState(state))) return; + await post({ kind: "event", ...state, event }); + }); + }, + dispose() { + disposed = true; + for (const controller of controllers) controller.abort(); + }, + }; +} diff --git a/tui-plugin/tui.ts b/tui-plugin/tui.ts index e97205e..dbf4d75 100644 --- a/tui-plugin/tui.ts +++ b/tui-plugin/tui.ts @@ -1,228 +1,20 @@ -/** - * OpenCode TUI plugin that bridges embedded `opencode attach` state back to - * the current Neovim instance. - * - * This is not a regular server-side OpenCode plugin. - * - * It runs inside the child TUI process created by `opencode.nvim` when the - * plugin embeds `opencode attach` in a Snacks terminal. From that process, the - * TUI can see its own active route, session, cwd, and local UI events, but it - * cannot call Neovim Lua directly. - * - * This plugin exists to close that gap. It reads loopback bridge connection - * details from environment variables injected by Neovim, then POSTs small JSON - * payloads back to the local bridge server in `lua/opencode/bridge.lua`. - * Neovim uses those payloads to track: - * - * 1. which TUI route is currently visible (`home` vs `session`) - * 2. which OpenCode session/conversation the embedded TUI is showing - * 3. which cwd the child TUI process is using - * 4. a small set of TUI-local events that do not naturally flow through the - * backend HTTP/SSE client - * - * High-level flow: - * - * Neovim Lua child `opencode attach` process - * (`opencode.nvim`) + this TUI plugin module - * ^ | - * | localhost HTTP POST | - * +--------------------------------------+ - * - * This is intentionally narrow in scope: it mirrors local embedded-TUI state - * into Neovim. It is not trying to be a second backend API client. - */ -import type { - TuiPlugin, - TuiPluginApi, - TuiPluginModule, -} from "@opencode-ai/plugin/tui"; - -const id = "opencode-nvim-bridge"; - -// Keep these bridge protocol constants aligned with `lua/opencode/constants.lua`. -const BRIDGE_ENV = { - URL: "OPENCODE_NVIM_BRIDGE_URL", - TOKEN: "OPENCODE_NVIM_BRIDGE_TOKEN", - INSTANCE_ID: "OPENCODE_NVIM_INSTANCE_ID", -} as const; - -const bridgeUrl = process.env[BRIDGE_ENV.URL]; -const bridgeToken = process.env[BRIDGE_ENV.TOKEN]; -const instanceID = process.env[BRIDGE_ENV.INSTANCE_ID]; - -// Route polling runs on a short interval, so skip bridge writes when the -// visible TUI state is unchanged. -let lastPayload = ""; -let notifiedError = false; - -// Only forward local TUI events that Neovim is likely to care about. Backend -// session updates still come from the normal HTTP/SSE client on the Lua side. -const forwardedEventTypes = [ - "session.status", - "session.idle", - "session.error", - "message.updated", - "permission.asked", - "permission.replied", - "question.asked", - "question.replied", -] as const; - -function canBridge() { - return Boolean(bridgeUrl && bridgeToken && instanceID); -} - -function toast( - api: TuiPluginApi, - message: string, - variant: "info" | "success" | "warning" | "error" = "info", -) { - api.ui.toast({ - title: "nvim bridge", - message, - variant, - duration: 2500, - }); -} - -async function publish( - api: TuiPluginApi, - route: "home" | "session", - sessionID?: string, -) { - if (!canBridge()) return; - - const payload = { - token: bridgeToken, - instanceID, - route, - sessionID: route === "session" ? (sessionID ?? null) : null, - cwd: process.cwd(), - }; - const body = JSON.stringify(payload); - if (body === lastPayload) return; - - try { - const response = await fetch(bridgeUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - lastPayload = body; - notifiedError = false; - } catch (error) { - if (!notifiedError) { - notifiedError = true; - const message = error instanceof Error ? error.message : String(error); - toast(api, `publish failed: ${message}`, "error"); - } - } -} - -// Reduce the richer TUI router state down to the small bridge payload shape the -// Neovim side needs for attach-mode coordination. -function currentState(api: TuiPluginApi) { - const current = api.route.current; - return { - route: - current.name === "session" ? ("session" as const) : ("home" as const), - sessionID: current.name === "session" ? current.params.sessionID : null, - cwd: process.cwd(), - }; -} - -function eventSessionID(event: { properties?: { sessionID?: unknown } }) { - return typeof event.properties?.sessionID === "string" - ? event.properties.sessionID - : null; -} - -async function publishEvent( - api: TuiPluginApi, - event: { type: string; properties?: { sessionID?: unknown } }, -) { - if (!canBridge()) return; - - const current = currentState(api); - const payload = { - kind: "event", - token: bridgeToken, - instanceID, - ...current, - // Prefer the event's explicit session when present. That keeps Neovim's - // local event handling correctly scoped even if the visible route changed - // around the same time. - sessionID: eventSessionID(event) ?? current.sessionID, - event, - }; - - try { - const response = await fetch(bridgeUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - notifiedError = false; - } catch (error) { - if (!notifiedError) { - notifiedError = true; - const message = error instanceof Error ? error.message : String(error); - toast(api, `event publish failed: ${message}`, "error"); - } - } -} - -function syncRoute(api: TuiPluginApi) { - const current = api.route.current; - if (current.name === "session") { - void publish(api, "session", current.params.sessionID); - return; - } - - void publish(api, "home"); -} - -const tui: TuiPlugin = async (api) => { - syncRoute(api); - - const disposers = forwardedEventTypes.map((type) => - api.event.on(type, (event) => { - void publishEvent(api, event); - }), - ); - - // A lightweight polling loop is the simplest reliable way to notice route or - // session switches inside the TUI. `publish()` dedupes unchanged snapshots, - // so steady-state polling stays quiet. - const timer = setInterval(() => { - syncRoute(api); - }, 300); - - api.lifecycle.onDispose(() => { - clearInterval(timer); - for (const dispose of disposers) { - dispose(); - } - }); -}; - -const plugin: TuiPluginModule & { id: string } = { - id, - tui, -}; - -export default plugin; +import { Plugin } from "@opencode/plugin/tui"; +import { createBridge } from "./bridge"; + +export default Plugin.define({ + id: "opencode-nvim-bridge", + setup(context) { + const bridge = createBridge(context, process.env); + if (!bridge) return; + + void bridge.sync(); + const unsubscribe = context.data.listen(({ details }) => bridge.event(details)); + // Polling also catches cache/location changes without owning a Solid root. + const timer = setInterval(() => void bridge.sync(), 300); + return () => { + clearInterval(timer); + unsubscribe(); + bridge.dispose(); + }; + }, +}); diff --git a/tui-plugin/types.d.ts b/tui-plugin/types.d.ts deleted file mode 100644 index bdb2fab..0000000 --- a/tui-plugin/types.d.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Minimal local type shims for the bundled TUI plugin. - * - * This repository does not keep a full TypeScript toolchain or the OpenCode - * TUI plugin package's transitive type dependencies installed alongside - * `tui-plugin/tui.ts`, so editor tooling cannot resolve the real package - * types from here by default. - * - * Keep this file intentionally small and limited to the subset of the TUI API - * that `tui.ts` actually uses. - */ - -declare const process: { - env: Record; - cwd(): string; -}; - -declare module "@opencode-ai/plugin/tui" { - export type TuiToast = { - title?: string; - message: string; - variant?: "info" | "success" | "warning" | "error"; - duration?: number; - }; - - export type TuiRouteCurrent = - | { name: "home" } - | { - name: "session"; - params: { - sessionID: string; - initialPrompt?: unknown; - }; - }; - - type SessionEvent = { - type: "session.status" | "session.idle" | "session.error"; - properties?: { - sessionID?: unknown; - }; - }; - - type PermissionEvent = { - type: "permission.asked" | "permission.replied"; - properties?: { - sessionID?: unknown; - }; - }; - - type QuestionEvent = { - type: "question.asked" | "question.replied"; - properties?: { - sessionID?: unknown; - }; - }; - - type MessageEvent = { - type: "message.updated"; - properties?: { - sessionID?: unknown; - info?: { - role?: unknown; - }; - }; - }; - - export type TuiEvent = - | SessionEvent - | PermissionEvent - | QuestionEvent - | MessageEvent; - - export type TuiPluginApi = { - route: { - readonly current: TuiRouteCurrent; - }; - ui: { - toast(input: TuiToast): void; - }; - event: { - on( - type: Type, - handler: (event: Extract) => void, - ): () => void; - }; - lifecycle: { - onDispose(fn: () => void): () => void; - }; - }; - - export type TuiPlugin = ( - api: TuiPluginApi, - options: unknown, - meta: unknown, - ) => Promise; - - export type TuiPluginModule = { - id?: string; - tui: TuiPlugin; - server?: never; - }; -} diff --git a/tui.ts b/tui.ts new file mode 100644 index 0000000..efa0d07 --- /dev/null +++ b/tui.ts @@ -0,0 +1,2 @@ +// OpenCode v2 resolves local plugin directories through this entrypoint. +export { default } from "./tui-plugin/tui"; From bde19e74c3fa65dc24edd91a74e621fcd1f5e4bf Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:30:17 -0700 Subject: [PATCH 2/6] feat(client)!: use native v2 reviews and resilient event streams --- lua/opencode/client.lua | 864 ++++++++-------------------------- lua/opencode/config.lua | 2 + lua/opencode/http.lua | 59 +++ lua/opencode/protocol.lua | 85 ++++ lua/opencode/reload.lua | 59 +++ lua/opencode/review.lua | 58 +-- lua/opencode/review_queue.lua | 12 +- lua/opencode/sse.lua | 51 ++ plugin/opencode.lua | 147 ++---- tests/client_spec.lua | 96 ++++ tests/helpers.lua | 41 ++ tests/protocol_spec.lua | 70 +++ tests/reload_spec.lua | 34 ++ tests/review_spec.lua | 58 +++ tests/run.lua | 4 + tests/server.ts | 44 ++ tests/watchdog_spec.lua | 64 +++ 17 files changed, 928 insertions(+), 820 deletions(-) create mode 100644 lua/opencode/http.lua create mode 100644 lua/opencode/protocol.lua create mode 100644 lua/opencode/reload.lua create mode 100644 lua/opencode/sse.lua create mode 100644 tests/client_spec.lua create mode 100644 tests/protocol_spec.lua create mode 100644 tests/reload_spec.lua create mode 100644 tests/review_spec.lua create mode 100644 tests/server.ts create mode 100644 tests/watchdog_spec.lua diff --git a/lua/opencode/client.lua b/lua/opencode/client.lua index a221121..585fb6b 100644 --- a/lua/opencode/client.lua +++ b/lua/opencode/client.lua @@ -1,728 +1,280 @@ ----HTTP/SSE client for opencode server. ---- ----This module owns communication with the OpenCode backend server. ---- ----It has two closely related responsibilities: ----1. one-shot JSON requests to backend HTTP endpoints ----2. a long-lived SSE subscription for backend file/edit events ---- ----This is distinct from `opencode.bridge`, which is the localhost loopback ----bridge between Neovim and the child `opencode attach` TUI process. ----Attached-session state lives in `opencode.session`. +---OpenCode v2 HTTP client and one process-wide, volatile SSE subscription. local M = {} local config = require("opencode.config") local session = require("opencode.session") -local util = require("opencode.util") +local http = require("opencode.http") +local protocol = require("opencode.protocol") +local sse = require("opencode.sse") ----@class opencode.ClientAuth ----@field username string ----@field password string - ----@class opencode.SseState ----@field url string|nil ----@field directory string|nil Directory scope currently attached to the backend SSE subscription ----@field job_id integer|nil ----@field connected boolean - ----@class opencode.PathResponse ----@field directory string ----@field worktree string - ----@class opencode.ModelRef ----@field providerID string ----@field modelID string - ----@class opencode.PromptAsyncOpts ----@field directory? string Backend directory scope for the request ----@field agent? string ----@field model? opencode.ModelRef ----@field variant? string - ----@class opencode.SessionMessagesOpts ----@field directory? string Backend directory scope for the request ----@field limit? integer - ----@alias opencode.JsonRequestResponse table|string|nil ----@alias opencode.JsonRequestCallback fun(err: string|nil, response: opencode.JsonRequestResponse, status: integer|nil) - ----@return opencode.SseState -local function new_sse_state() - return { - url = nil, - directory = nil, - job_id = nil, - connected = false, - } -end - ----Tracks the current backend SSE connection state. Only one subscription at a time. ----@type opencode.SseState -local sse_state = new_sse_state() - ----Timer to detect connection loss. Reset on every received event. ----@type uv.uv_timer_t -local heartbeat_timer = assert(vim.uv.new_timer(), "Failed to create heartbeat timer") - ----Retry timer used while reconnecting after a disconnect or failed probe. ----@type uv.uv_timer_t -local reconnect_timer = assert(vim.uv.new_timer(), "Failed to create reconnect timer") - ----Timeout slightly longer than the server's 10s heartbeat interval. -local HEARTBEAT_TIMEOUT_MS = 15000 - ----Bounded reconnect backoff. Keep this short so server restarts recover quickly. -local RECONNECT_DELAYS_MS = { 250, 500, 1000, 2000, 3000 } - ----Incremented whenever the active SSE transport is replaced or stopped. ----Callbacks from stale transports must not schedule reconnects. -local sse_generation = 0 - ----Current reconnect attempt count, reset after the stream becomes healthy again. -local reconnect_attempt = 0 - ----Read backend auth from Neovim's process environment via `opencode.config`. ----@return opencode.ClientAuth|nil -local function auth() - return config.get_auth() -end - ----Add HTTP basic auth flags to a curl command when auth is configured. ----@param command string[] -local function add_auth(command) - local value = auth() - if not value then - return - end - - table.insert(command, "-u") - table.insert(command, value.username .. ":" .. value.password) -end +---@class opencode.Event +---@field type string +---@field data table +---@field location? { directory: string, workspaceID?: string } + +local state = { connected = false } +local generation, attempt = 0, 0 +local heartbeat = assert(vim.uv.new_timer()) +local retry = assert(vim.uv.new_timer()) +local HEARTBEAT_TIMEOUT_MS = 45000 -- upstream comments arrive every 15 seconds +local DELAYS_MS = { 250, 500, 1000, 2000, 3000 } +local wanted = false +local subscribe ----Append a path to a backend base URL without duplicating `/`. ----@param base string ----@param path string ----@return string local function endpoint(base, path) - return base:gsub("/$", "") .. path + return base:gsub("/+$", "") .. path end ----Resolve the directory scope the backend SSE stream should subscribe to. ---- ----Prefer the active embedded-TUI cwd reported through `opencode.session`. Fall ----back to the configured terminal directory before any session is active. ----@return string|nil -local function subscription_directory() - local active = session.get_state() - if active.cwd and active.cwd ~= "" then - return active.cwd - end - - local terminal = config.opts.terminal or {} - local dir = terminal.dir or "." - return vim.fs.normalize(vim.fn.fnamemodify(dir, ":p")) +local function scope() + local current = session.get_state() + current.cwd = current.cwd or vim.fn.fnamemodify((config.opts.terminal or {}).dir or ".", ":p") + return current end ----@param path string|nil ----@return string|nil -local function normalize_path(path) - if type(path) ~= "string" or path == "" then - return nil - end - - return vim.fs.normalize(vim.fn.fnamemodify(path, ":p")) -end - ----@param parent string ----@param child string ----@return boolean -local function path_contains(parent, child) - local normalized_parent = parent:gsub("/+$", "") - local normalized_child = child:gsub("/+$", "") - local prefix = normalized_parent .. "/" - return normalized_child == normalized_parent or normalized_child:sub(1, #prefix) == prefix -end - ----@param event_directory string|nil ----@param directory string|nil ----@return boolean -local function matches_directory_scope(event_directory, directory) - if not event_directory or event_directory == "" then - return true - end - - local event_path = normalize_path(event_directory) - local subscription_path = normalize_path(directory) - if not event_path or not subscription_path then - return false - end - - return path_contains(event_path, subscription_path) or path_contains(subscription_path, event_path) -end - ----@class opencode.NormalizedEvent ----@field event opencode.Event ----@field directory string|nil - ----Normalize instance and global SSE event wire formats. ---- ----OpenCode 1.14.42+ serves the old instance `/event` stream through the ----Effect HttpApi backend. That stream can end when its instance scope is ----disposed, producing reconnect loops. The global stream is process-scoped and ----wraps instance events as `{ directory, payload }`, so the plugin consumes it ----and filters events back to the active session directory here. ----@param response table ----@param directory string|nil ----@return opencode.NormalizedEvent|nil -local function normalize_event(response, directory) - if type(response) ~= "table" then - return nil - end - - local event = response - local event_directory = nil - if type(response.payload) == "table" then - event = response.payload - event_directory = response.directory - end - - if type(event.type) ~= "string" then - return nil - end - - if not matches_directory_scope(event_directory, directory) then - return nil - end - - return { - event = event, - directory = event_directory, - } -end - ----Build the process-scoped SSE endpoint URL. ----@param base string ----@return string -local function sse_endpoint(base) - return endpoint(base, "/global/event") -end - ----@return integer -local function reconnect_delay_ms() - local index = math.min(reconnect_attempt + 1, #RECONNECT_DELAYS_MS) - return RECONNECT_DELAYS_MS[index] -end - -local function clear_reconnect_state() - reconnect_timer:stop() - reconnect_attempt = 0 -end - ----@param url string|nil -local function emit_disconnected(url) +local function emit(event, url) + local current = scope() vim.api.nvim_exec_autocmds("User", { - pattern = "OpencodeEvent:server.disconnected", - data = { event = { type = "server.disconnected" }, url = url }, + pattern = "OpencodeEvent:" .. event.type, + data = { event = event, url = url, directory = current.cwd, workspace_id = current.workspace_id }, }) end ----Probe the configured backend with `/path` and return its URL when reachable. ---- ----This is used before establishing SSE so we can fail fast with a clearer ----message when the configured backend is unavailable. One-shot JSON request ----helpers use the configured URL directly and surface transport errors there. ----@return string|nil, string|nil -local function get_validated_url() - local url = config.get_url() - ---@type string[] - local command = { - "curl", - "-s", - "--connect-timeout", - "1", - } - add_auth(command) - table.insert(command, endpoint(url, "/path")) - - local ok, result = pcall(function() - local curl_result = vim.system(command):wait() - util.check_system_call(curl_result, "curl") - - local decoded_ok, data = pcall(vim.fn.json_decode, curl_result.stdout) - if decoded_ok and (data.directory or data.worktree) then - return data +---Fetch authoritative session state for notification/status integrations. +---The callback receives the unwrapped Session.Info (agent/model/location/outcome). +function M.get_session(session_id, callback) + return http.request( + endpoint(config.get_url(), "/api/session/" .. vim.uri_encode(session_id)), + "GET", + nil, + function(err, response, status) + local data = response and response.data + if not err and (type(data) ~= "table" or data.id ~= session_id) then + err = "Invalid OpenCode session response" + end + callback(err, not err and data or nil, status) end - - error("Failed to parse `opencode` path data: " .. curl_result.stdout, 0) - end) - - if ok and result then - return url, nil - end - - return nil, "No `opencode` responding at configured URL: " .. url + ) end ----Start a curl process that streams newline-delimited JSON/SSE data. ---- ----This helper exists specifically for the backend event stream path used by ----`M.sse_subscribe()`. ----@param url string ----@param method "GET"|"POST" ----@param body table|nil ----@param callback fun(response: opencode.Event)|nil ----@param on_exit? fun(code: integer, stderr_lines: string[]) ----@return integer job_id -local function curl(url, method, body, callback, on_exit) - ---@type string[] - local command = { - "curl", - "-s", - "--connect-timeout", - "1", - "-X", - method, - "-H", - "Content-Type: application/json", - "-H", - "Accept: application/json", - "-H", - "Accept: text/event-stream", - "-N", - } - - add_auth(command) - - if body then - table.insert(command, "-d") - table.insert(command, vim.fn.json_encode(body)) +---A completed connection is a resync boundary, not replay of missed events. +function M.resync() + local current, version = scope(), generation + vim.api.nvim_exec_autocmds("User", { pattern = "OpencodeResync", data = current }) + if not current.session_id then + return end - - table.insert(command, url) - - ---@type string[] - local response_buffer = {} - local function process_response_buffer() - if #response_buffer == 0 then + M.get_session(current.session_id, function(err, value) + if err or version ~= generation or session.get_state().session_id ~= current.session_id then return end + vim.api.nvim_exec_autocmds("User", { pattern = "OpencodeSessionRefreshed", data = { session = value } }) + end) +end - local full_event = table.concat(response_buffer) - response_buffer = {} +---Submit once. Success means durable inbox admission, not finished execution. +---A transport error or malformed acknowledgement is never retried automatically. +function M.prompt(session_id, parts, opts, callback) + opts = opts or {} + local ok, body = pcall(protocol.prompt, parts, opts.delivery or "queue") + if not ok then vim.schedule(function() - local ok, response = pcall(vim.fn.json_decode, full_event) - if ok then - if callback then - callback(response) - end - return - end - - vim.notify( - "Response decode error: " .. full_event .. "; " .. response, - vim.log.levels.ERROR, - { title = "opencode" } - ) + callback(body, nil, nil) end) + return end - - ---@type string[] - local stderr_lines = {} - return vim.fn.jobstart(command, { - on_stdout = function(_, data) - if not data then - return - end - for _, line in ipairs(data) do - if line == "" then - process_response_buffer() - else - table.insert(response_buffer, (line:gsub("^data: ?", ""))) - end - end - end, - on_stderr = function(_, data) - if not data then - return - end - for _, line in ipairs(data) do - if line ~= "" then - table.insert(stderr_lines, line) - end - end - end, - on_exit = function(_, code) - if code == 0 then - process_response_buffer() + return http.request( + endpoint(config.get_url(), "/api/session/" .. vim.uri_encode(session_id) .. "/prompt"), + "POST", + body, + function(err, response, status) + if not err and not protocol.admitted(response, session_id) then + err = "Invalid OpenCode prompt acknowledgement" end - - if on_exit then - on_exit(code, stderr_lines) + if err then + callback(err .. ". Check the TUI before sending again; receipt may be uncertain.", nil, status) return end - - if code ~= 18 and code ~= 143 then - local error_message = "curl command failed with exit code: " - .. code - .. "\nstderr:\n" - .. (#stderr_lines > 0 and table.concat(stderr_lines, "\n") or "") - vim.notify(error_message, vim.log.levels.ERROR, { title = "opencode" }) - end - end, - }) -end - ----Build the optional directory-scope header for backend JSON requests. ----@param directory string|nil ----@return table|nil -local function directory_headers(directory) - if not directory or directory == "" then - return nil - end - - return { - ["x-opencode-directory"] = vim.uri_encode(directory), - } -end - ----Issue a one-shot JSON request against the backend HTTP API. ----@param url string ----@param method "GET"|"POST" ----@param body table|nil ----@param headers? table ----@param callback opencode.JsonRequestCallback|nil ----@return vim.SystemObj -local function request_json(url, method, body, headers, callback) - ---@type string[] - local command = { - "curl", - "-sS", - "--connect-timeout", - "1", - "-X", - method, - "-H", - "Content-Type: application/json", - "-H", - "Accept: application/json", - "-w", - "\n%{http_code}", - } - - add_auth(command) - - if headers then - for key, value in pairs(headers) do - table.insert(command, "-H") - table.insert(command, key .. ": " .. value) + callback(nil, response.data, status) end - end - - if body then - table.insert(command, "-d") - table.insert(command, vim.fn.json_encode(body)) - end - - table.insert(command, url) - - return vim.system(command, { text = true }, function(result) - vim.schedule(function() - if result.code ~= 0 then - local err = "curl command failed with exit code: " - .. tostring(result.code) - .. "\nstderr:\n" - .. ((result.stderr and result.stderr ~= "") and result.stderr or "") - if callback then - callback(err, nil, nil) - end - return - end - - local stdout = result.stdout or "" - local response_text, status_text = stdout:match("^(.*)\n(%d%d%d)%s*$") - if not status_text then - response_text = stdout - end - - local status = tonumber(status_text) - local decoded = nil - if response_text and response_text ~= "" then - local ok, value = pcall(vim.fn.json_decode, response_text) - decoded = ok and value or response_text - end - - if status and status >= 200 and status < 300 then - if callback then - callback(nil, decoded, status) - end - return - end - - local message = "HTTP request failed" - if status then - message = message .. " with status " .. tostring(status) - end - if type(decoded) == "table" and decoded.error then - message = message .. ": " .. tostring(decoded.error) - elseif type(decoded) == "string" and decoded ~= "" then - message = message .. ": " .. decoded - end - - if callback then - callback(message, decoded, status) - end - end) - end) + ) end ----Send prompt parts directly to an OpenCode session over the backend API. ----@param session_id string ----@param parts table[] ----@param opts? opencode.PromptAsyncOpts ----@param callback? opencode.JsonRequestCallback ----@return vim.SystemObj|nil -function M.prompt_async(session_id, parts, opts, callback) - local url = config.get_url() - - local headers = directory_headers(opts and opts.directory or nil) - - local body = { parts = parts } - if opts and opts.agent then - body.agent = opts.agent +local function reset() + generation = generation + 1 + heartbeat:stop() + retry:stop() + local old = state + state = { connected = false } + if old.process then + pcall(old.process.kill, old.process, 15) end - if opts and opts.model then - body.model = opts.model + if old.probe then + pcall(old.probe.kill, old.probe, 15) end - if opts and opts.variant then - body.variant = opts.variant - end - - return request_json(endpoint(url, "/session/" .. session_id .. "/prompt_async"), "POST", body, headers, callback) -end - ----Fetch recent messages for an OpenCode session. ----@param session_id string ----@param opts? opencode.SessionMessagesOpts ----@param callback? opencode.JsonRequestCallback ----@return vim.SystemObj|nil -function M.session_messages(session_id, opts, callback) - local url = config.get_url() - - local headers = directory_headers(opts and opts.directory or nil) - - local limit = (opts and opts.limit) or 100 - local path = "/session/" .. session_id .. "/message?limit=" .. tostring(limit) - return request_json(endpoint(url, path), "GET", nil, headers, callback) + return old end ----@class opencode.Event ----@field type string ----@field properties table|nil - ----@return boolean was_connected ----@return string|nil previous_url -local function reset_sse_state() - heartbeat_timer:stop() - sse_generation = sse_generation + 1 - - if sse_state.job_id then - vim.fn.jobstop(sse_state.job_id) +local function schedule_retry() + if not wanted then + return end - - local was_connected = sse_state.connected - local previous_url = sse_state.url - sse_state = new_sse_state() - return was_connected, previous_url + local delay = DELAYS_MS[math.min(attempt + 1, #DELAYS_MS)] + attempt = attempt + 1 + retry:start( + delay, + 0, + vim.schedule_wrap(function() + subscribe(false) + end) + ) end ----@param code integer ----@param stderr_lines string[] ----@return string -local function exit_reason(code, stderr_lines) - if code == 0 then - return "stream ended" - end - - if code == 18 then - return "stream interrupted" - end - - if code == 28 then - return "connect timed out" - end - - if code == 7 then - return "connect failed" - end - - if #stderr_lines > 0 then - return stderr_lines[#stderr_lines] +function M.sse_reconnect(reason) + local old = reset() + if old.connected then + vim.notify( + "OpenCode event stream disconnected" .. (reason and (": " .. reason) or ""), + vim.log.levels.WARN, + { title = "opencode" } + ) + emit({ type = "server.disconnected", data = {} }, old.url) end - - return "curl exited with code " .. tostring(code) + schedule_retry() end -local attempt_subscribe - -local function schedule_reconnect() - reconnect_timer:stop() - local delay = reconnect_delay_ms() - reconnect_attempt = reconnect_attempt + 1 - reconnect_timer:start( - delay, +local function watch_heartbeat(version) + heartbeat:start( + HEARTBEAT_TIMEOUT_MS, 0, vim.schedule_wrap(function() - attempt_subscribe(false, false) + if version == generation then + M.sse_reconnect("heartbeat timeout") + end end) ) end ----Subscribe to backend SSE events for the current backend directory scope. ---- ----OpenCode's global stream is process-scoped; this subscription records the ----active embedded-TUI cwd so incoming global envelopes can be filtered back to ----the relevant session directory. ----@param url string ----@param callback? fun(event: opencode.Event) +---Subscribe after a successful v2 health probe. Scope is read for each event; +---switching sessions/locations does not needlessly replace the global stream. function M.sse_subscribe(url, callback) - local directory = subscription_directory() - if sse_state.url == url and sse_state.directory == directory and (sse_state.connected or sse_state.job_id) then + if state.process and state.url == url then return end - - if sse_state.job_id then - reset_sse_state() - end - - reconnect_timer:stop() - - local first_event = true - local generation = sse_generation + 1 - sse_generation = generation - sse_state = { - url = url, - directory = directory, - connected = false, - job_id = curl(sse_endpoint(url), "GET", nil, function(response) - if generation ~= sse_generation then - return + reset() + wanted = true + state.url = url + local version, warned = generation, false + local parser = sse.new(function(raw) + if version ~= generation then + return + end + local ok, value = pcall(vim.json.decode, raw) + if not ok then + if not warned then + warned = true + vim.notify("Ignoring malformed OpenCode event", vim.log.levels.WARN, { title = "opencode" }) end - - local normalized = normalize_event(response, directory) - if not normalized then + return + end + local event = protocol.event(value, scope()) + if not event then + return + end + emit(event, url) + if callback then + callback(event) + end + end, function() + if version ~= generation then + return + end + watch_heartbeat(version) + if state.connected then + return + end + state.connected = true + attempt = 0 + vim.notify("OpenCode event stream connected", vim.log.levels.INFO, { title = "opencode" }) + M.resync() + end) + local command, input = http.command(endpoint(url, "/api/event"), "GET") + vim.list_extend(command, { "--fail", "-N", "-H", "Accept: text/event-stream" }) + watch_heartbeat(version) -- also bounds a connected peer that never sends data + local ok, process = pcall(vim.system, command, { + stdin = input, + stdout = function(err, chunk) + if err or not chunk then return end - - local event = normalized.event - - if first_event then - first_event = false - clear_reconnect_state() - sse_state.connected = true - vim.notify("SSE connected: " .. url, vim.log.levels.INFO, { title = "opencode" }) - end - - heartbeat_timer:stop() - heartbeat_timer:start( - HEARTBEAT_TIMEOUT_MS, - 0, - vim.schedule_wrap(function() - M.sse_reconnect("heartbeat timeout") - end) - ) - - vim.api.nvim_exec_autocmds("User", { - pattern = "OpencodeEvent:" .. event.type, - data = { event = event, url = url, directory = normalized.directory }, - }) - - if callback then - callback(event) - end - end, function(code, stderr_lines) vim.schedule(function() - if generation ~= sse_generation then - return + if version == generation then + parser.feed(chunk) end - - sse_state.job_id = nil - M.sse_reconnect(exit_reason(code, stderr_lines)) end) - end), - } -end - ----Unsubscribe from SSE events (clean shutdown, no auto-reconnect). -function M.sse_unsubscribe() - local was_connected, previous_url = reset_sse_state() - clear_reconnect_state() - - if was_connected then - vim.notify("SSE disconnected", vim.log.levels.INFO, { title = "opencode" }) - emit_disconnected(previous_url) - end -end - ----Reconnect the backend SSE stream after a disconnect or heartbeat timeout. ----@param reason? string -function M.sse_reconnect(reason) - local was_connected, previous_url = reset_sse_state() - - if was_connected then - vim.notify( - "SSE connection lost" .. (reason and (": " .. reason) or ""), - vim.log.levels.WARN, - { title = "opencode" } - ) - emit_disconnected(previous_url) + end, + stderr = function() end, -- curl errors may contain request details + }, function(result) + vim.schedule(function() + if version == generation then + M.sse_reconnect("curl exit " .. result.code) + end + end) + end) + if not ok then + M.sse_reconnect("could not start curl") + return end - - schedule_reconnect() + state.process = process end ----@param notify_on_error? boolean ----@param reset_backoff boolean -attempt_subscribe = function(notify_on_error, reset_backoff) - reconnect_timer:stop() - if reset_backoff then - reconnect_attempt = 0 - end - - local url, err = get_validated_url() - if err or not url then - if notify_on_error and err then - vim.notify("SSE cannot connect: " .. err, vim.log.levels.WARN, { title = "opencode" }) - end - schedule_reconnect() +subscribe = function(notify_on_error) + local url = config.get_url() + if state.url == url and (state.process or state.probe) then return end - - M.sse_subscribe(url) + reset() + state.url = url + local version = generation + state.probe = http.request(endpoint(url, "/api/health"), "GET", nil, function(err, value) + if version ~= generation or not wanted then + return + end + state.probe = nil + if err or not protocol.health(value) then + if notify_on_error then + vim.notify( + "OpenCode v2 is required at " .. url .. (err and (": " .. err) or "; incompatible health response"), + vim.log.levels.WARN, + { title = "opencode" } + ) + end + schedule_retry() + return + end + M.sse_subscribe(url) + end, 5) end ----Ensure the backend SSE subscription is connected for the active directory scope. ----@param notify_on_error? boolean function M.ensure_subscribed(notify_on_error) - attempt_subscribe(notify_on_error, true) + wanted = true + subscribe(notify_on_error) end ----Return whether the backend SSE stream is currently marked connected. ----@return boolean -function M.is_connected() - return sse_state.connected +function M.sse_unsubscribe() + wanted = false + local old = reset() + attempt = 0 + if old.connected then + emit({ type = "server.disconnected", data = {} }, old.url) + end end ----@class opencode.SseStatus ----@field connected boolean ----@field url string|nil ----@field directory string|nil +function M.is_connected() + return state.connected +end ----@return opencode.SseStatus function M.get_status() - return { - connected = sse_state.connected, - url = sse_state.url, - directory = sse_state.directory, - } + local current = scope() + return { connected = state.connected, url = state.url, directory = current.cwd, workspace_id = current.workspace_id } end return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f13eeff..f46edf6 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -8,6 +8,7 @@ local M = {} ---@field auto_reload? boolean Reload buffers on file.edited (default: true) ---@field terminal? opencode.TerminalConfig ---@field editor_context? opencode.EditorContextConfig +---@field review? { delivery?: "queue"|"steer" } Native delivery while the session is busy (default: queue) ---@field review_queue? opencode.ReviewQueueConfig ---@class opencode.ServerConfig @@ -42,6 +43,7 @@ local defaults = { editor_context = { enabled = true, }, + review = { delivery = "queue" }, review_queue = { signs = { enabled = true, diff --git a/lua/opencode/http.lua b/lua/opencode/http.lua new file mode 100644 index 0000000..23a6321 --- /dev/null +++ b/lua/opencode/http.lua @@ -0,0 +1,59 @@ +---Bounded curl requests. Credentials and prompt bodies travel over stdin, not argv. +local M = {} +local config = require("opencode.config") + +local function quoted(value) + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") .. '"' +end + +function M.command(url, method, body) + local command = { "curl", "-q", "-sS", "--connect-timeout", "2", "--config", "-", "--request", method, "--url", url } + local input = { 'header = "Content-Type: application/json"' } + local auth = config.get_auth() + if auth then + table.insert(input, "user = " .. quoted(auth.username .. ":" .. auth.password)) + end + if body then + table.insert(input, "data-binary = " .. quoted(vim.json.encode(body))) + end + return command, table.concat(input, "\n") .. "\n" +end + +---@alias opencode.JsonRequestCallback fun(err: string|nil, response: table|nil, status: integer|nil) +---@param callback opencode.JsonRequestCallback +function M.request(url, method, body, callback, timeout) + local command, input = M.command(url, method, body) + vim.list_extend( + command, + { "--max-time", tostring(timeout or 30), "-H", "Accept: application/json", "-w", "\n%{http_code}" } + ) + local function complete(result) + vim.schedule(function() + if result.code ~= 0 then + callback("OpenCode request failed (curl exit " .. tostring(result.code) .. ")", nil, nil) + return + end + local raw, code = (result.stdout or ""):match("^(.*)\n(%d%d%d)%s*$") + local status = tonumber(code) + if not status or status < 200 or status >= 300 then + callback("OpenCode HTTP " .. (code or "response missing status"), nil, status) + return + end + local ok, value = pcall(vim.json.decode, raw or "") + if not ok or type(value) ~= "table" then + callback("Invalid OpenCode JSON response", nil, status) + return + end + callback(nil, value, status) + end) + end + local ok, process = pcall(vim.system, command, { text = true, stdin = input }, complete) + if ok then + return process + end + vim.schedule(function() + callback("Could not start curl for OpenCode", nil, nil) + end) +end + +return M diff --git a/lua/opencode/protocol.lua b/lua/opencode/protocol.lua new file mode 100644 index 0000000..b29e90a --- /dev/null +++ b/lua/opencode/protocol.lua @@ -0,0 +1,85 @@ +---Native OpenCode v2 wire contracts. Keep server data in its native shape. +local M = {} + +function M.health(value) + return type(value) == "table" + and value.healthy == true + and type(value.version) == "string" + and value.version:match("^2%.") ~= nil +end + +---Convert editor review parts to a native prompt. The session owns agent/model. +function M.prompt(parts, delivery) + local texts, files = {}, {} + for _, part in ipairs(parts) do + if part.type == "text" and type(part.text) == "string" then + table.insert(texts, part.text) + elseif part.type == "file" and type(part.url) == "string" then + table.insert(files, { uri = part.url, name = part.filename }) + else + error("Unsupported OpenCode review part", 0) + end + end + assert(delivery == "queue" or delivery == "steer", "review delivery must be queue or steer") + return { text = table.concat(texts, "\n\n"), files = files, delivery = delivery } +end + +function M.admitted(value, session_id) + local item = type(value) == "table" and value.data + return type(item) == "table" + and type(item.id) == "string" + and item.id:match("^msg_") ~= nil + and item.sessionID == session_id + and item.type == "user" + and (item.delivery == "queue" or item.delivery == "steer") +end + +function M.session_id(event) + local data = type(event) == "table" and event.data + if type(data) ~= "table" then + return nil + end + if event.type == "form.created" then + return type(data.form) == "table" and data.form.sessionID or nil + end + return type(data.sessionID) == "string" and data.sessionID or nil +end + +local function normalized(path) + if type(path) ~= "string" or path == "" then + return nil + end + local absolute = vim.fn.fnamemodify(path, ":p") + return (vim.uv.fs_realpath(absolute) or vim.fs.normalize(absolute)):gsub("/+$", "") +end + +local function workspace(value) + return type(value) == "string" and value or nil +end + +---The backend stream is global. Only server control events, the active session, +---or events with the same explicit location belong to this editor's scope. +function M.event(value, scope) + if type(value) ~= "table" or type(value.type) ~= "string" or type(value.data) ~= "table" then + return nil + end + if value.type == "server.connected" then + return value + end + if scope.session_id and M.session_id(value) == scope.session_id then + return value + end + local location = value.location + if type(location) ~= "table" then + return nil + end + if workspace(location.workspaceID) ~= workspace(scope.workspace_id) then + return nil + end + if normalized(location.directory) ~= normalized(scope.cwd) or not normalized(scope.cwd) then + return nil + end + return value +end + +return M diff --git a/lua/opencode/reload.lua b/lua/opencode/reload.lua new file mode 100644 index 0000000..9284343 --- /dev/null +++ b/lua/opencode/reload.lua @@ -0,0 +1,59 @@ +---Coalesced, location-scoped disk checks for v2's incomplete file event feed. +local M = {} +local config = require("opencode.config") +local pending, scheduled = {}, false + +local function canonical(path) + local absolute = vim.fn.fnamemodify(path, ":p") + return (vim.uv.fs_realpath(absolute) or vim.fs.normalize(absolute)):gsub("/+$", "") +end + +local function matches(name, request) + local path = canonical(name) + if request.file then + return path == canonical(request.file) + end + if not request.directory then + return false + end + local directory = canonical(request.directory) + return path == directory or path:sub(1, #directory + 1) == directory .. "/" +end + +function M.request(request) + if config.opts.auto_reload == false then + return + end + table.insert(pending, request) + if scheduled then + return + end + scheduled = true + vim.defer_fn(function() + local requests = pending + pending, scheduled = {}, false + if config.opts.auto_reload == false then + return + end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(buf) + and vim.bo[buf].buftype == "" + and not vim.bo[buf].modified + and vim.api.nvim_get_option_value("autoread", { buf = buf }) + then + local name = vim.api.nvim_buf_get_name(buf) + for _, item in ipairs(requests) do + if name ~= "" and matches(name, item) then + -- In Terminal mode, avoid unrelated autocmds stealing TUI input. + local prefix = vim.api.nvim_get_mode().mode:sub(1, 1) == "t" and "noautocmd " or "" + pcall(vim.cmd, prefix .. "checktime " .. buf) + break + end + end + end + end + end, 150) +end + +return M diff --git a/lua/opencode/review.lua b/lua/opencode/review.lua index 5bf9765..2cab12a 100644 --- a/lua/opencode/review.lua +++ b/lua/opencode/review.lua @@ -16,6 +16,7 @@ ---session. local M = {} local client = require("opencode.client") +local config = require("opencode.config") local input = require("opencode.input") local session = require("opencode.session") @@ -29,12 +30,6 @@ local session = require("opencode.session") ---@field route "session" ---@field session_id string ----@class opencode.RecentUserInfo ----@field role string ----@field agent? string ----@field model? opencode.ModelRef ----@field variant? string - ---@class opencode.review.SendOpts ---@field success_message? string ---@field on_success? fun() @@ -225,31 +220,13 @@ local function review_parts(selection, message) } end ----Return the most recent user message metadata from a session message list. ---- ----When present, the direct review send reuses the last user-facing agent/model/ ----variant so the review goes to the same target the user was already using. ----@param response table ----@return opencode.RecentUserInfo|nil -local function last_user_info(response) - for i = #response, 1, -1 do - local item = response[i] - if type(item) == "table" and type(item.info) == "table" and item.info.role == "user" then - return item.info - end - end - - return nil -end - ---Send the prepared review payload to the active backend session. ---@param current opencode.ActiveReviewSession ---@param parts table[] ----@param prompt_opts? opencode.PromptAsyncOpts ---@param send_opts? opencode.review.SendOpts -local function send_review(current, parts, prompt_opts, send_opts) +local function send_review(current, parts, send_opts) send_opts = send_opts or {} - client.prompt_async(current.session_id, parts, prompt_opts, function(err) + client.prompt(current.session_id, parts, { delivery = (config.opts.review or {}).delivery or "queue" }, function(err) if err then vim.notify(err, vim.log.levels.ERROR, { title = "opencode" }) if send_opts.on_error then @@ -259,7 +236,7 @@ local function send_review(current, parts, prompt_opts, send_opts) end vim.notify( - send_opts.success_message or "Sent review to active OpenCode session", + send_opts.success_message or "Review accepted by active OpenCode session", vim.log.levels.INFO, { title = "opencode" } ) @@ -270,31 +247,6 @@ local function send_review(current, parts, prompt_opts, send_opts) end) end ----Fetch recent conversation metadata, then send the review. ---- ----If recent messages are unavailable, still send the review scoped to the ----active session and directory. ----@param current opencode.ActiveReviewSession ----@param parts table[] ----@param send_opts? opencode.review.SendOpts -local function send_with_recent_context(current, parts, send_opts) - client.session_messages(current.session_id, { directory = current.cwd, limit = 100 }, function(err, response) - if err or type(response) ~= "table" then - send_review(current, parts, { directory = current.cwd }, send_opts) - return - end - - local last_user = last_user_info(response) - - send_review(current, parts, { - directory = current.cwd, - agent = last_user and last_user.agent or nil, - model = last_user and last_user.model or nil, - variant = last_user and last_user.variant or nil, - }, send_opts) - end) -end - M.current_selection_or_line = current_selection_or_line M.visual_selection = visual_selection M.file_url = file_url @@ -311,7 +263,7 @@ function M.send_parts(parts, send_opts) return false end - send_with_recent_context(current, parts, send_opts) + send_review(current, parts, send_opts) return true end diff --git a/lua/opencode/review_queue.lua b/lua/opencode/review_queue.lua index 69a1ab6..675ed8d 100644 --- a/lua/opencode/review_queue.lua +++ b/lua/opencode/review_queue.lua @@ -747,18 +747,18 @@ local function send_items(items, success_message) return false end - local sent_ids = {} - for _, item in ipairs(items) do - table.insert(sent_ids, item.id) - end + local sent = vim.deepcopy(items) sending = true local started = review.send_parts(build_parts(items), { success_message = success_message, on_success = function() sending = false - for _, id in ipairs(sent_ids) do - M.remove(id) + for _, item in ipairs(sent) do + local current = M.get(item.id) + if current and current.message == item.message then + M.remove(item.id) + end end M.refresh_quickfix() end, diff --git a/lua/opencode/sse.lua b/lua/opencode/sse.lua new file mode 100644 index 0000000..828d97c --- /dev/null +++ b/lua/opencode/sse.lua @@ -0,0 +1,51 @@ +---Incremental SSE framing, independent of curl/Neovim chunk boundaries. +local M = {} + +---@param on_data fun(data: string) +---@param on_activity fun() +function M.new(on_data, on_activity) + local pending, lines = "", {} + local function line(value) + if value == "" then + if #lines > 0 then + on_activity() + local data = table.concat(lines, "\n") + lines = {} + on_data(data) + end + elseif value:sub(1, 1) == ":" then + on_activity() + else + local field, content = value:match("^([^:]+): ?(.*)$") + if value == "data" then + field, content = "data", "" + end + if field == "data" then + table.insert(lines, content) + end + -- id/event/retry fields do not contain JSON. This feed has no replay. + end + end + return { + feed = function(chunk) + pending = pending .. chunk + local start = 1 + while true do + local stop = pending:find("[\r\n]", start) + if not stop then + break + end + local cr = pending:sub(stop, stop) == "\r" + if cr and stop == #pending then + break + end -- CRLF may cross chunks + line(pending:sub(start, stop - 1)) + start = stop + ((cr and pending:sub(stop + 1, stop + 1) == "\n") and 2 or 1) + end + pending = pending:sub(start) + end, + -- An incomplete frame at EOF is intentionally discarded, never delivered. + } +end + +return M diff --git a/plugin/opencode.lua b/plugin/opencode.lua index aed9f72..8656ac1 100644 --- a/plugin/opencode.lua +++ b/plugin/opencode.lua @@ -6,75 +6,13 @@ -- loaded. local client = require("opencode.client") -local config = require("opencode.config") local opencode = require("opencode") local review_queue = require("opencode.review_queue") local terminal = require("opencode.terminal") +local reload = require("opencode.reload") local augroup = vim.api.nvim_create_augroup("Opencode", { clear = true }) ----Normalize a path to its absolute canonical form when possible. ---- ----`fs_realpath()` resolves symlinks for existing paths. When that fails (for ----example, a file was deleted between the event and the reload), fall back to a ----normalized absolute path so buffer-name comparisons still work. ----@param path string|nil ----@return string|nil -local function normalize_path(path) - if type(path) ~= "string" or path == "" then - return nil - end - - local absolute = vim.fn.fnamemodify(path, ":p") - return vim.uv.fs_realpath(absolute) or vim.fs.normalize(absolute) -end - ----Find loaded buffers whose resolved path matches the given path. ----@param path string|nil ----@return integer[] -local function matching_buffers(path) - local target = normalize_path(path) - if not target then - return {} - end - - ---@type integer[] - local matches = {} - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) then - local name = vim.api.nvim_buf_get_name(buf) - if name ~= "" and normalize_path(name) == target then - table.insert(matches, buf) - end - end - end - - return matches -end - ----Run `:checktime` for a specific buffer. ---- ----OpenCode's SSE event tells us that a file changed, but that event alone does ----not reload any Neovim buffer. `autoread` only allows reloads when Neovim runs ----a file-change check; `:checktime` is the explicit "check now" step that makes ----Neovim re-stat the file and reload the buffer when it is safe to do so. ---- ----When Neovim is currently in Terminal mode, use `:noautocmd checktime` to ----avoid unrelated scheduled autocmd work re-entering Normal mode while the ----embedded TUI owns the terminal. ----@param buf integer -local function checktime_buffer(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - - local mode = vim.api.nvim_get_mode().mode - local command = (mode:sub(1, 1) == "t" and "noautocmd checktime " or "checktime ") .. tostring(buf) - pcall(function() - vim.cmd(command) - end) -end - -- Stop the embedded attach-mode terminal before Neovim teardown starts. vim.api.nvim_create_autocmd("VimLeavePre", { group = augroup, @@ -131,61 +69,60 @@ vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter" }, { desc = "Restore Terminal mode when opencode terminal gains focus", }) --- Reload matching buffers when the backend reports a file edit. --- --- `OpencodeEvent:file.edited` and `OpencodeEvent:file.watcher.updated` only tell --- us that something changed on disk. The actual buffer refresh still depends on --- Neovim's normal external-file flow: --- --- - `vim.o.autoread = true` permits reloading an unmodified buffer from disk --- - `:checktime` performs the file-change check that notices the new mtime --- --- Without `autoread`, Neovim may detect the change but will not transparently --- refresh the buffer. Without `:checktime`, Neovim may not notice the change --- until a later built-in checkpoint like focus or buffer switches. --- --- Prefer a targeted `checktime {buf}` when the event payload names a file. --- Fall back to plain `:checktime` when no matching loaded buffer is found. +-- V2 does not emit every file edit yet. Execution boundaries and reconnects +-- also check project buffers; modified buffers always remain untouched. vim.api.nvim_create_autocmd("User", { group = augroup, - pattern = { "OpencodeEvent:file.edited", "OpencodeEvent:file.watcher.updated" }, - ---@param args vim.api.keyset.create_autocmd.callback_args - callback = function(args) - local opts = config.opts - if opts.auto_reload ~= false then - if not vim.o.autoread then - vim.notify( - "Set `vim.o.autoread = true` to enable opencode auto-reload", - vim.log.levels.WARN, - { title = "opencode" } - ) - else - vim.schedule(function() - local event = args.data and args.data.event or nil - local properties = type(event) == "table" and event.properties or nil - local buffers = matching_buffers(type(properties) == "table" and properties.file or nil) + pattern = { + "OpencodeEvent:filesystem.changed", + "OpencodeEvent:session.tool.success", + "OpencodeEvent:session.tool.failed", + "OpencodeEvent:session.shell.ended", + "OpencodeEvent:session.execution.succeeded", + "OpencodeEvent:session.execution.failed", + "OpencodeEvent:session.execution.interrupted", + "OpencodeEvent:session.revert.committed", + }, + callback = function(ev) + local data = ev.data + local event = data.event + local file = event.type == "filesystem.changed" and event.data.file or nil + if type(file) ~= "string" then + file = nil + end + reload.request({ file = file, directory = data.directory }) + end, + desc = "Check buffers after OpenCode file changes or execution settles", +}) - if #buffers > 0 then - for _, buf in ipairs(buffers) do - checktime_buffer(buf) - end - return - end +vim.api.nvim_create_autocmd("User", { + group = augroup, + pattern = "OpencodeResync", + callback = function(ev) + reload.request({ directory = ev.data.cwd }) + end, + desc = "Check disk state after OpenCode reconnects", +}) - vim.cmd("checktime") - end) - end +vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter" }, { + group = augroup, + callback = function() + if client.is_connected() then + reload.request({ directory = client.get_status().directory }) end end, - desc = "Reload buffers edited by opencode", + desc = "Check OpenCode project buffers when returning to the editor", }) --- Re-scope the backend SSE subscription when the attached TUI changes cwd. +-- The stream stays global; each event uses the current TUI location. vim.api.nvim_create_autocmd("User", { group = augroup, pattern = "OpencodeSessionChanged", callback = function() client.ensure_subscribed() + if client.is_connected() then + client.resync() + end end, desc = "Keep opencode SSE subscription aligned with active session directory", }) diff --git a/tests/client_spec.lua b/tests/client_spec.lua new file mode 100644 index 0000000..bc87ce9 --- /dev/null +++ b/tests/client_spec.lua @@ -0,0 +1,96 @@ +local t = require("tests.helpers") +local client = require("opencode.client") +local session = require("opencode.session") +local http = require("opencode.http") + +t.test("curl transports auth and prompt bytes over stdin and unwraps current session state", function() + t.with_server(function(base) + local command, input = http.command(base, "POST", { text = 'quotes " and \\ and\nnewlines' }) + assert(not table.concat(command, " "):find("fixture-password", 1, true)) + assert(input:find("fixture-password", 1, true)) + local done + client.get_session("ses_active", function(err, value) + assert(not err, err) + t.eq("current-model", value.model.id) + done = true + end) + t.wait(function() + return done + end) + local text = 'Please explain "this" \\ path.\nKeep both lines.' + done = false + client.prompt("ses_active", { { type = "text", text = text } }, {}, function(err, value) + assert(not err, err) + t.eq("msg_admitted", value.id) + t.eq(text, value.payload.text) + done = true + end) + t.wait(function() + return done + end) + end) +end) + +t.test("native SSE filters locations and reconnects with a fresh session snapshot", function() + t.with_server(function(base) + session.update_active({ route = "session", session_id = "ses_active", cwd = "/fixture", workspace_id = "wrk_one" }) + local events, resyncs, refreshed = {}, 0, 0 + local group = vim.api.nvim_create_augroup("OpenCodeClientTest", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "OpencodeEvent:*", + callback = function(ev) + table.insert(events, ev.data.event.type) + end, + }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "OpencodeResync", + callback = function() + resyncs = resyncs + 1 + end, + }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "OpencodeSessionRefreshed", + callback = function() + refreshed = refreshed + 1 + end, + }) + client.ensure_subscribed(true) + t.wait(client.is_connected) + t.wait(function() + return refreshed == 1 + end) + t.request(base, "/emit", "POST", { raw = ": heartbeat\r\n\r\ndata: not json\n\n" }) + t.request(base, "/emit", "POST", { + type = "permission.asked", + data = { sessionID = "ses_other" }, + location = { directory = "/fixture", workspaceID = "wrk_two" }, + }) + t.request(base, "/emit", "POST", { type = "session.execution.succeeded", data = { sessionID = "ses_active" } }) + t.wait(function() + return vim.tbl_contains(events, "session.execution.succeeded") + end) + assert(not vim.tbl_contains(events, "permission.asked")) + t.eq(1, resyncs) + -- A scope switch does not open a second global connection. + session.update_active({ route = "session", session_id = "ses_next", cwd = "/next" }) + client.ensure_subscribed() + t.eq(1, #vim.tbl_filter(function(r) + return r.path == "/api/event" + end, t.request(base, "/requests").data)) + t.request(base, "/disconnect", "POST", {}) + t.wait(function() + return resyncs == 2 and refreshed == 2 + end) + t.eq("/next", client.get_status().directory) + client.sse_unsubscribe() + local count = resyncs + vim.wait(350, function() + return false + end) + t.eq(count, resyncs) + vim.api.nvim_del_augroup_by_id(group) + end) +end) diff --git a/tests/helpers.lua b/tests/helpers.lua index b707574..d1febbc 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -12,4 +12,45 @@ function M.test(name, fn) print("FAIL " .. name .. "\n" .. err) end end +function M.wait(predicate, message) + assert(vim.wait(5000, predicate, 10), message or "timed out") +end +function M.request(base, path, method, body) + local done, result + require("opencode.http").request(base .. path, method or "GET", body, function(err, value, status) + result, done = { err, value, status }, true + end) + M.wait(function() + return done + end) + assert(not result[1], result[1]) + return result[2] +end +function M.with_server(fn) + local config = require("opencode.config") + local old = config.opts + local password, legacy = vim.env.OPENCODE_PASSWORD, vim.env.OPENCODE_SERVER_PASSWORD + vim.env.OPENCODE_PASSWORD, vim.env.OPENCODE_SERVER_PASSWORD = "fixture-password", "ignored-password" + local base + local server = vim.system({ "bun", "tests/server.ts" }, { + stdout = function(_, data) + if data then + base = data:match("http://127%.0%.0%.1:%d+") or base + end + end, + }) + local ok, err = xpcall(function() + M.wait(function() + return base + end, "fixture did not start") + config.setup({ server = { url = base } }) + fn(base) + end, debug.traceback) + require("opencode.client").sse_unsubscribe() + server:kill(15) + server:wait() + config.opts = old + vim.env.OPENCODE_PASSWORD, vim.env.OPENCODE_SERVER_PASSWORD = password, legacy + assert(ok, err) +end return M diff --git a/tests/protocol_spec.lua b/tests/protocol_spec.lua new file mode 100644 index 0000000..d2a43a6 --- /dev/null +++ b/tests/protocol_spec.lua @@ -0,0 +1,70 @@ +local t = require("tests.helpers") +local protocol = require("opencode.protocol") +local parser = require("opencode.sse") + +t.test("SSE handles every byte boundary, CRLF, comments and multiline data", function() + local frames, activity = {}, 0 + local p = parser.new(function(data) + table.insert(frames, data) + end, function() + activity = activity + 1 + end) + local wire = + ': heartbeat\r\n\r\nid: evt_one\r\nevent: message\r\ndata: {"type":\r\ndata: "server.connected","data":{}}\r\n\r\n' + for i = 1, #wire do + p.feed(wire:sub(i, i)) + end + t.eq({ '{"type":\n"server.connected","data":{}}' }, frames) + t.eq(2, activity) + p.feed('data: {"incomplete":true}') + t.eq(1, #frames) +end) + +t.test("SSE separates multiple frames in a chunk and ignores non-data fields", function() + local frames = {} + local p = parser.new(function(value) + frames[#frames + 1] = value + end, function() end) + p.feed("retry: 1000\n\ndata: first\n\ndata: second\n\n") + t.eq({ "first", "second" }, frames) +end) + +t.test("native health and event ownership reject legacy and unrelated workspaces", function() + assert(protocol.health({ healthy = true, version = "2.0.1" })) + assert(not protocol.health({ healthy = true, version = "1.18.30" })) + assert(not protocol.health({ directory = "/fixture" })) + local scope = { cwd = "/fixture", workspace_id = "wrk_one", session_id = "ses_active" } + local event = { + type = "session.execution.succeeded", + data = { sessionID = "ses_other" }, + location = { directory = "/fixture", workspaceID = "wrk_two" }, + } + t.eq(nil, protocol.event(event, scope)) + event.location.workspaceID = "wrk_one" + t.eq(event, protocol.event(event, scope)) + event.location = nil + t.eq(nil, protocol.event(event, scope)) + event.data.sessionID = "ses_active" + t.eq(event, protocol.event(event, scope)) + t.eq(nil, protocol.event({ type = "message.updated", properties = {} }, scope)) + t.eq(nil, protocol.session_id({ type = "form.created", data = { form = {} } })) + t.eq("ses_active", protocol.session_id({ type = "form.created", data = { form = { sessionID = "ses_active" } } })) +end) + +t.test("review attachments preserve URI escaping and one-based line ranges", function() + local review = require("opencode.review") + local parts = + review.review_parts({ path = "/fixture/a #b.txt", start_line = 2, end_line = 4 }, "Please explain this.") + local prompt = protocol.prompt(parts, "queue") + t.eq("Please explain this.", prompt.text) + t.eq("file:///fixture/a%20%23b.txt?start=2&end=4", prompt.files[1].uri) + t.eq("queue", prompt.delivery) + t.eq(nil, prompt.agent) + t.eq(nil, prompt.model) + assert( + not protocol.admitted( + { data = { id = "msg_one", sessionID = "ses_other", type = "user", delivery = "queue" } }, + "ses_active" + ) + ) +end) diff --git a/tests/reload_spec.lua b/tests/reload_spec.lua new file mode 100644 index 0000000..973b576 --- /dev/null +++ b/tests/reload_spec.lua @@ -0,0 +1,34 @@ +local t = require("tests.helpers") +local reload = require("opencode.reload") + +t.test("reload coalesces settlement events and preserves dirty/unrelated buffers", function() + local root = assert(vim.uv.fs_mkdtemp((vim.env.TMPDIR or "/tmp") .. "/opencode-reload.XXXXXX")) + local files = { root .. "/clean.txt", root .. "/dirty.txt", root .. "/other/outside.txt" } + vim.fn.mkdir(root .. "/other") + for _, path in ipairs(files) do + vim.fn.writefile({ "original" }, path) + end + local buffers = {} + for _, path in ipairs(files) do + local buf = vim.fn.bufadd(path) + vim.fn.bufload(buf) + vim.bo[buf].autoread = true + table.insert(buffers, buf) + end + vim.api.nvim_buf_set_lines(buffers[2], 0, -1, false, { "unsaved editor change" }) + for _, path in ipairs(files) do + vim.fn.writefile({ "longer disk replacement" }, path) + end + reload.request({ file = files[1] }) + reload.request({ file = files[1] }) + reload.request({ file = files[2] }) + t.wait(function() + return vim.api.nvim_buf_get_lines(buffers[1], 0, -1, false)[1] == "longer disk replacement" + end) + t.eq({ "unsaved editor change" }, vim.api.nvim_buf_get_lines(buffers[2], 0, -1, false)) + t.eq({ "original" }, vim.api.nvim_buf_get_lines(buffers[3], 0, -1, false)) + for _, buf in ipairs(buffers) do + vim.api.nvim_buf_delete(buf, { force = true }) + end + vim.fn.delete(root, "rf") +end) diff --git a/tests/review_spec.lua b/tests/review_spec.lua new file mode 100644 index 0000000..7caccbb --- /dev/null +++ b/tests/review_spec.lua @@ -0,0 +1,58 @@ +local t = require("tests.helpers") +local queue = require("opencode.review_queue") +local session = require("opencode.session") +local client = require("opencode.client") + +t.test("review queue retains rejected and malformed sends and clears only admitted unchanged comments", function() + t.with_server(function(base) + local notifications = {} + local notify = vim.notify + vim.notify = function(message) + table.insert(notifications, message) + end + local ok, err = xpcall(function() + local selection = { path = "/fixture/example.txt", start_line = 2, end_line = 3 } + queue.clear() + assert(queue.add(selection, "First comment")) + for _, id in ipairs({ "ses_rejected", "ses_malformed" }) do + session.update_active({ route = "session", session_id = id, cwd = "/fixture" }) + queue.send() + t.wait(function() + return #notifications > 0 + end) + t.eq(1, queue.count()) + notifications = {} + end + session.update_active({ route = "session", session_id = "ses_delayed", cwd = "/fixture" }) + local first = queue.items()[1].id + queue.send() + queue.update(first, "Edited while sending") + assert(queue.add(selection, "Added while sending")) + t.wait(function() + return #notifications > 0 + end) + t.eq(2, queue.count()) + notifications = {} + queue.send() + t.wait(function() + return queue.count() == 0 + end) + local requests = t.request(base, "/requests").data + local prompts = vim.tbl_filter(function(r) + return r.path:match("/prompt$") + end, requests) + t.eq(4, #prompts) + t.eq(2, #prompts[4].body.files) + assert(prompts[4].body.text:find("Edited while sending", 1, true)) + for _, r in ipairs(prompts) do + t.eq("queue", r.body.delivery) + t.eq(nil, r.body.agent) + t.eq(nil, r.body.model) + end + end, debug.traceback) + vim.notify = notify + queue.clear() + client.sse_unsubscribe() + assert(ok, err) + end) +end) diff --git a/tests/run.lua b/tests/run.lua index d529600..e69b9be 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1,5 +1,9 @@ vim.opt.runtimepath:prepend(vim.fn.getcwd()) package.path = "./?.lua;" .. package.path +-- Composer UI is covered by the optional real Snacks smoke test. +package.preload["snacks"] = function() + return {} +end local t = require("tests.helpers") for _, path in ipairs(vim.fn.glob("tests/*_spec.lua", false, true)) do dofile(path) diff --git a/tests/server.ts b/tests/server.ts new file mode 100644 index 0000000..9c055da --- /dev/null +++ b/tests/server.ts @@ -0,0 +1,44 @@ +// A local native-v2 contract fixture; no model or user data is involved. +const streams = new Set>(); +const requests: { path: string; body: unknown }[] = []; +const encode = new TextEncoder(); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(req) { + const path = new URL(req.url).pathname; + const body = req.method === "POST" ? await req.json() : undefined; + if (path === "/requests") return Response.json({ data: requests }); + if (path === "/emit") { + for (const stream of streams) stream.enqueue(encode.encode(body.raw ?? `data: ${JSON.stringify(body)}\n\n`)); + return Response.json({ ok: true }); + } + if (path === "/disconnect") { + for (const stream of streams) stream.close(); + streams.clear(); + return Response.json({ ok: true }); + } + if (req.headers.get("authorization") !== `Basic ${btoa("opencode:fixture-password")}`) return new Response(null, { status: 401 }); + requests.push({ path, body }); + if (path === "/api/health") return Response.json({ healthy: true, version: "2.0.1", pid: 1 }); + if (path === "/api/event") { + return new Response(new ReadableStream({ + start(controller) { + streams.add(controller); + controller.enqueue(encode.encode('data: {"type":"server.connected","data":{}}\n\n')); + }, + cancel() { /* disposed with the fixture process */ }, + }), { headers: { "Content-Type": "text/event-stream" } }); + } + const match = path.match(/^\/api\/session\/(ses_\w+)(\/prompt)?$/); + if (!match) return new Response(null, { status: 404 }); + const sessionID = match[1]; + if (!match[2]) return Response.json({ data: { id: sessionID, agent: "reviewer", model: { providerID: "example", id: "current-model", variant: "medium" }, location: { directory: "/fixture" } } }); + if (sessionID === "ses_rejected") return new Response(null, { status: 409 }); + if (sessionID === "ses_malformed") return Response.json({ data: { id: "msg_wrong", sessionID: "ses_other", type: "user", delivery: "queue" } }); + if (sessionID === "ses_delayed") await Bun.sleep(100); + return Response.json({ data: { id: "msg_admitted", sessionID, type: "user", payload: body, delivery: body.delivery, timeCreated: Date.now() } }); + }, +}); +console.log(server.url.href); diff --git a/tests/watchdog_spec.lua b/tests/watchdog_spec.lua new file mode 100644 index 0000000..af4913d --- /dev/null +++ b/tests/watchdog_spec.lua @@ -0,0 +1,64 @@ +local t = require("tests.helpers") + +t.test("watchdog tolerates v2 heartbeats and ignores stale stream/probe callbacks", function() + require("opencode.client").sse_unsubscribe() + local system, new_timer = vim.system, vim.uv.new_timer + local timers, jobs = {}, {} + vim.uv.new_timer = function() + local timer = { stop = function() end } + timer.start = function(_, delay, _, cb) + timer.delay, timer.callback = delay, cb + end + table.insert(timers, timer) + return timer + end + vim.system = function(command, opts, complete) + local job = { command = command, opts = opts, complete = complete, kill = function() end } + table.insert(jobs, job) + return job + end + package.loaded["opencode.client"] = nil + local client = require("opencode.client") + local function flush() + vim.wait(10, function() + return false + end) + end + local ok, err = xpcall(function() + require("opencode.session").update_active({ route = "home" }) + client.ensure_subscribed() + jobs[1].complete({ code = 0, stdout = '{"healthy":true,"version":"2.0.1"}\n200' }) + flush() + local stream = jobs[2] + t.eq(45000, timers[1].delay) + stream.opts.stdout(nil, ": heartbeat\n\n") + flush() + assert(client.is_connected()) + -- The full grace period restarts on comments and unrelated event frames. + stream.opts.stdout(nil, 'data: {"type":"permission.asked","data":{"sessionID":"ses_other"}}\n\n') + flush() + t.eq(45000, timers[1].delay) + timers[1].callback() + flush() + assert(not client.is_connected()) + t.eq(250, timers[2].delay) + timers[2].callback() + flush() + local probe = jobs[3] + client.sse_unsubscribe() + stream.opts.stdout(nil, ": heartbeat\n\n") + stream.complete({ code = 0 }) + probe.complete({ code = 0, stdout = '{"healthy":true,"version":"2.0.1"}\n200' }) + flush() + t.eq(3, #jobs) + assert(not client.is_connected()) + client.ensure_subscribed() + jobs[4].complete({ code = 0, stdout = '{"healthy":true,"version":"1.18.30"}\n200' }) + flush() + t.eq(4, #jobs) -- a v1 daemon never opens an event stream + client.sse_unsubscribe() + end, debug.traceback) + vim.system, vim.uv.new_timer = system, new_timer + package.loaded["opencode.client"] = nil + assert(ok, err) +end) From a99791face48e99b04d018963c9390dd6a8c28ba Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:46:30 -0700 Subject: [PATCH 3/6] fix(events): discard stale retries and queued session events Guard retry callbacks across unsubscribe and resubscribe, and recheck the TUI route while draining queued events. Add regressions for both scheduler races identified during review. --- lua/opencode/client.lua | 14 ++++++++-- tests/watchdog_spec.lua | 53 ++++++++++++++++++++++++++++++++++++ tui-plugin/bridge.test.ts | 56 +++++++++++++++++++++++++++++++++++++++ tui-plugin/bridge.ts | 4 +++ 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/lua/opencode/client.lua b/lua/opencode/client.lua index 585fb6b..dd04d27 100644 --- a/lua/opencode/client.lua +++ b/lua/opencode/client.lua @@ -117,13 +117,17 @@ local function schedule_retry() if not wanted then return end + local version = generation local delay = DELAYS_MS[math.min(attempt + 1, #DELAYS_MS)] attempt = attempt + 1 retry:start( delay, 0, vim.schedule_wrap(function() - subscribe(false) + -- A fired timer can already be queued on the main loop when reset stops it. + if wanted and version == generation then + subscribe(false) + end end) ) end @@ -227,6 +231,9 @@ function M.sse_subscribe(url, callback) end subscribe = function(notify_on_error) + if not wanted then + return + end local url = config.get_url() if state.url == url and (state.process or state.probe) then return @@ -235,10 +242,13 @@ subscribe = function(notify_on_error) state.url = url local version = generation state.probe = http.request(endpoint(url, "/api/health"), "GET", nil, function(err, value) - if version ~= generation or not wanted then + if version ~= generation then return end state.probe = nil + if not wanted then + return + end if err or not protocol.health(value) then if notify_on_error then vim.notify( diff --git a/tests/watchdog_spec.lua b/tests/watchdog_spec.lua index af4913d..58dbd9c 100644 --- a/tests/watchdog_spec.lua +++ b/tests/watchdog_spec.lua @@ -62,3 +62,56 @@ t.test("watchdog tolerates v2 heartbeats and ignores stale stream/probe callback package.loaded["opencode.client"] = nil assert(ok, err) end) + +t.test("a retry already queued on the main loop cannot survive unsubscribe", function() + local system, new_timer = vim.system, vim.uv.new_timer + local timers, jobs = {}, {} + vim.uv.new_timer = function() + local timer = { stop = function() end } + timer.start = function(_, _, _, callback) + timer.callback = callback + end + table.insert(timers, timer) + return timer + end + vim.system = function(command, opts, complete) + local job = { command = command, opts = opts, complete = complete, kill = function() end } + table.insert(jobs, job) + return job + end + package.loaded["opencode.client"] = nil + local client = require("opencode.client") + local function flush() + vim.wait(10, function() + return false + end) + end + local ok, err = xpcall(function() + client.ensure_subscribed() + jobs[1].complete({ code = 7 }) + flush() + timers[2].callback() -- schedule_wrap has queued work that timer:stop cannot cancel + client.sse_unsubscribe() + flush() + t.eq(1, #jobs) + client.ensure_subscribed() + t.eq(2, #jobs) + jobs[2].complete({ code = 7 }) + flush() + timers[2].callback() + client.sse_unsubscribe() + client.ensure_subscribed() -- a newer subscription must also survive the old retry + local probe = jobs[3] + flush() + t.eq(3, #jobs) + probe.complete({ code = 0, stdout = '{"healthy":true,"version":"2.0.1"}\n200' }) + flush() + jobs[4].opts.stdout(nil, ": heartbeat\n\n") + flush() + assert(client.is_connected()) + end, debug.traceback) + client.sse_unsubscribe() + vim.system, vim.uv.new_timer = system, new_timer + package.loaded["opencode.client"] = nil + assert(ok, err) +end) diff --git a/tui-plugin/bridge.test.ts b/tui-plugin/bridge.test.ts index d90884e..fcacc23 100644 --- a/tui-plugin/bridge.test.ts +++ b/tui-plugin/bridge.test.ts @@ -105,6 +105,62 @@ describe("native TUI bridge", () => { expect(calls).toHaveLength(4); next.dispose(); }); + test("a queued event cannot restore a session after a newer route sync", async () => { + const f = fixture(); + f.select("ses_one"); + let release!: () => void; + let blocked!: () => void; + const waiting = new Promise((resolve) => { blocked = resolve; }); + const calls: Record[] = []; + const transport = (async (_url, init) => { + calls.push(JSON.parse(init!.body as string)); + if (calls.length === 2) { + await new Promise((resolve) => { release = resolve; blocked(); }); + } + return new Response(null, { status: 200 }); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + await bridge.sync(); + bridge.event(event("session.execution.started", "ses_one")); + await waiting; + const sync = bridge.sync(); + bridge.event(event("permission.asked", "ses_one")); + f.select("ses_two"); + release(); + await sync; + await bridge.sync(); + expect(calls.map((x) => [x.kind ?? "state", x.sessionID])).toEqual([ + ["state", "ses_one"], ["event", "ses_one"], ["state", "ses_two"], + ]); + bridge.dispose(); + }); + + test("rechecks event ownership after an in-flight state publish", async () => { + const f = fixture(); + f.select("ses_one"); + let release!: () => void; + let blocked!: () => void; + const waiting = new Promise((resolve) => { blocked = resolve; }); + const calls: Record[] = []; + const transport = (async (_url, init) => { + calls.push(JSON.parse(init!.body as string)); + if (calls.length === 1) { + await new Promise((resolve) => { release = resolve; blocked(); }); + } + return new Response(null, { status: 200 }); + }) as typeof fetch; + const bridge = createBridge(f.context, env, transport)!; + bridge.event(event("permission.asked", "ses_one")); + await waiting; + f.select("ses_two"); + release(); + await bridge.sync(); + expect(calls.map((x) => [x.kind ?? "state", x.sessionID])).toEqual([ + ["state", "ses_one"], ["state", "ses_two"], + ]); + bridge.dispose(); + }); + test("cleanup aborts an in-flight publish without reporting an error", async () => { const f = fixture(); let signal: AbortSignal | undefined; diff --git a/tui-plugin/bridge.ts b/tui-plugin/bridge.ts index 7ef1332..726d293 100644 --- a/tui-plugin/bridge.ts +++ b/tui-plugin/bridge.ts @@ -122,7 +122,11 @@ export function createBridge( const state = currentState(context); if (state.route !== "session" || eventSessionID(event) !== state.sessionID) return; pending = pending.then(async () => { + // Ownership can change while an earlier POST is holding up the queue. + const state = currentState(context); + if (state.route !== "session" || eventSessionID(event) !== state.sessionID) return; if (disposed || !(await publishState(state))) return; + if (JSON.stringify(currentState(context)) !== JSON.stringify(state)) return; await post({ kind: "event", ...state, event }); }); }, From fa62c30161db20735863464fbd6af721e1d81657 Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:47:28 -0700 Subject: [PATCH 4/6] fix(tui): load the bridge without development dependencies Keep the native Plugin.Definition contract as a type-only import so a plugin-manager checkout can load directly in OpenCode. Verify the entrypoint in a clean temporary directory with package installation disabled. --- tui-plugin/entrypoint.test.ts | 25 +++++++++++++++++++++++++ tui-plugin/tui.ts | 6 +++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 tui-plugin/entrypoint.test.ts diff --git a/tui-plugin/entrypoint.test.ts b/tui-plugin/entrypoint.test.ts new file mode 100644 index 0000000..bc6201d --- /dev/null +++ b/tui-plugin/entrypoint.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("the local TUI entrypoint loads without installing development dependencies", () => { + const directory = mkdtempSync(join(tmpdir(), "opencode-nvim-entry-")); + try { + mkdirSync(join(directory, "tui-plugin")); + for (const file of ["package.json", "tui.ts", "tui-plugin/tui.ts", "tui-plugin/bridge.ts"]) { + copyFileSync(new URL(`../${file}`, import.meta.url), join(directory, file)); + } + writeFileSync(join(directory, "check.ts"), ` + import plugin from "./tui.ts"; + if (plugin.id !== "opencode-nvim-bridge" || typeof plugin.setup !== "function") { + throw new Error("Invalid native TUI plugin definition"); + } + `); + const result = Bun.spawnSync([process.execPath, "--no-install", "check.ts"], { cwd: directory }); + expect(result.stderr.toString()).toBe(""); + expect(result.exitCode).toBe(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tui-plugin/tui.ts b/tui-plugin/tui.ts index dbf4d75..7fae624 100644 --- a/tui-plugin/tui.ts +++ b/tui-plugin/tui.ts @@ -1,7 +1,7 @@ -import { Plugin } from "@opencode/plugin/tui"; +import type { Plugin } from "@opencode/plugin/tui"; import { createBridge } from "./bridge"; -export default Plugin.define({ +export default { id: "opencode-nvim-bridge", setup(context) { const bridge = createBridge(context, process.env); @@ -17,4 +17,4 @@ export default Plugin.define({ bridge.dispose(); }; }, -}); +} satisfies Plugin.Definition; From be0d2d0352e65c5878327fc657564b6c6c4f1545 Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:51:07 -0700 Subject: [PATCH 5/6] fix(reload): honor inherited autoread settings Resolve an unset buffer-local autoread option through its global value while preserving explicit buffer overrides. Cover both inheritance directions and verify settlement and reconnect reloads inside the native TUI. --- lua/opencode/reload.lua | 11 ++++++++++- tests/reload_spec.lua | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lua/opencode/reload.lua b/lua/opencode/reload.lua index 9284343..c6221f1 100644 --- a/lua/opencode/reload.lua +++ b/lua/opencode/reload.lua @@ -20,6 +20,15 @@ local function matches(name, request) return path == directory or path:sub(1, #directory + 1) == directory .. "/" end +local function autoread(buf) + local value = vim.api.nvim_get_option_value("autoread", { buf = buf }) + -- An unset global-local boolean is nil; honor the inherited global value. + if value == nil then + return vim.go.autoread + end + return value +end + function M.request(request) if config.opts.auto_reload == false then return @@ -40,7 +49,7 @@ function M.request(request) vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "" and not vim.bo[buf].modified - and vim.api.nvim_get_option_value("autoread", { buf = buf }) + and autoread(buf) then local name = vim.api.nvim_buf_get_name(buf) for _, item in ipairs(requests) do diff --git a/tests/reload_spec.lua b/tests/reload_spec.lua index 973b576..c5be569 100644 --- a/tests/reload_spec.lua +++ b/tests/reload_spec.lua @@ -32,3 +32,43 @@ t.test("reload coalesces settlement events and preserves dirty/unrelated buffers end vim.fn.delete(root, "rf") end) + +t.test("reload honors inherited autoread and explicit buffer overrides", function() + local root = assert(vim.uv.fs_mkdtemp((vim.env.TMPDIR or "/tmp") .. "/opencode-autoread.XXXXXX")) + local buffers, global = {}, vim.go.autoread + local ok, err = xpcall(function() + vim.go.autoread = true + for _, name in ipairs({ "inherited.txt", "override.txt" }) do + local path = root .. "/" .. name + vim.fn.writefile({ "original" }, path) + local buf = vim.fn.bufadd(path) + vim.fn.bufload(buf) + table.insert(buffers, buf) + end + vim.bo[buffers[2]].autoread = false + local function replace(text) + for _, buf in ipairs(buffers) do + vim.fn.writefile({ text }, vim.api.nvim_buf_get_name(buf)) + end + reload.request({ directory = root }) + end + replace("first disk replacement") + t.wait(function() + return vim.api.nvim_buf_get_lines(buffers[1], 0, -1, false)[1] == "first disk replacement" + end) + t.eq({ "original" }, vim.api.nvim_buf_get_lines(buffers[2], 0, -1, false)) + vim.go.autoread = false + vim.bo[buffers[2]].autoread = true + replace("second longer disk replacement") + t.wait(function() + return vim.api.nvim_buf_get_lines(buffers[2], 0, -1, false)[1] == "second longer disk replacement" + end) + t.eq({ "first disk replacement" }, vim.api.nvim_buf_get_lines(buffers[1], 0, -1, false)) + end, debug.traceback) + for _, buf in ipairs(buffers) do + vim.api.nvim_buf_delete(buf, { force = true }) + end + vim.go.autoread = global + vim.fn.delete(root, "rf") + assert(ok, err) +end) From 90d1436f607d21fa9852dde8e73d486eaaab39a2 Mon Sep 17 00:00:00 2001 From: Kevin McBride <681929+krmcbride@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:45:49 -0700 Subject: [PATCH 6/6] docs: document native OpenCode v2 setup and migration Document external server connections, writable CLI bridge registration, native event hooks, and review delivery. Include migration steps and validation coverage for the v2 port. --- README.md | 148 +++++++++++++++-------- docs/opencode-v2-compatibility.md | 188 ++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 docs/opencode-v2-compatibility.md diff --git a/README.md b/README.md index 3844a57..63a03a7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # opencode.nvim +**Requires OpenCode v2 (tested with 2.0.1).** This is a breaking migration from +v1; existing Neovim commands remain, but the CLI, TUI registration, and event +contracts change. Read the [migration guide](docs/opencode-v2-compatibility.md) +before updating. Pin `aa2a146` if you still need v1. + A Neovim plugin for running a local [opencode](https://github.com/anomalyco/opencode) attach-mode TUI inside a [snacks.nvim](https://github.com/folke/snacks.nvim) terminal, with Neovim-side session bridging and local integrations. ![opencode.nvim showing the attach-mode UI inside Snacks terminal](assets/screenshot.png) @@ -8,7 +13,7 @@ A Neovim plugin for running a local [opencode](https://github.com/anomalyco/open ## Features -- Launch a local `opencode attach` TUI against a configured backend server +- Launch a local `opencode --server` TUI against a configured backend server - Bridge the active attached TUI session back into Neovim - Share the active Neovim file or visual selection with OpenCode's native editor-context integration - Snacks terminal integration for an embedded OpenCode TUI pane @@ -36,7 +41,7 @@ A Neovim plugin for running a local [opencode](https://github.com/anomalyco/open layout = "split", width = 0.43, env = { - -- Extra environment for the child `opencode attach` process + -- Extra environment for the child `opencode --server` process SOME_CHILD_PROCESS_FLAG = "1", -- Disables OpenTUI's Kitty graphics probe so it does not leak raw -- probe text into the embedded Snacks terminal buffer. @@ -79,7 +84,10 @@ require("opencode").setup({ server = { url = "http://127.0.0.1:4096", -- Backend server URL }, - auto_reload = true, -- Reload matching buffers on OpenCode edit events + auto_reload = true, -- Check unmodified project buffers after edits/runs/reconnects + review = { + delivery = "queue", -- While busy: "queue" for the next run, "steer" for the current run + }, editor_context = { enabled = true, -- Share active Neovim file/selection with the embedded OpenCode TUI }, @@ -93,7 +101,7 @@ require("opencode").setup({ }, terminal = { cmd = nil, -- Optional custom attach command - dir = ".", -- Directory passed to `opencode attach` + dir = ".", -- Positional TUI directory (made absolute at launch) continue = true, -- Default launch behavior; `start` can override per call layout = "split", -- "split" for a right pane, "tab" for a dedicated Neovim tab width = 0.35, @@ -102,43 +110,36 @@ require("opencode").setup({ }) ``` -> **`terminal.env` note:** `opts.terminal.env` is only passed to the child `opencode attach` process. Backend/server feature flags usually need to be configured on the backend server process itself, not here. +> **`terminal.env` note:** `opts.terminal.env` is only passed to the child `opencode --server` process. Backend/server feature flags usually need to be configured on the backend server process itself, not here. > **Editor context note:** `editor_context.enabled = true` starts a localhost WebSocket server lazily when the embedded TUI starts and injects `OPENCODE_EDITOR_SSE_PORT` into that TUI process. The long-running OpenCode backend server is not involved. If `terminal.env.OPENCODE_EDITOR_SSE_PORT` is already set, that explicit value is left untouched. To keep OpenCode's native integration from accidentally using an inherited Claude Code bridge, `CLAUDE_CODE_SSE_PORT` is cleared in the child TUI env and a warning is shown if a non-empty value is detected. > **Embedded terminal note:** `opencode.nvim` runs the OpenCode TUI in a Snacks terminal. Neovim's embedded terminal does not support Kitty graphics, so setting `OPENTUI_GRAPHICS = "0"` under `opts.terminal.env` is recommended to avoid stray raw text like `Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA` appearing in the terminal buffer. -> **Auto-reload note:** `auto_reload = true` still depends on Neovim `autoread`; set `vim.o.autoread = true` in your config. External non-OpenCode edits only surface through `OpencodeEvent:file.watcher.updated` when the backend server file watcher is enabled. -> **Auth note:** backend auth is read from Neovim's `OPENCODE_SERVER_PASSWORD` and optional `OPENCODE_SERVER_USERNAME` environment variables. If you source credentials from a file or secret manager, populate `vim.env` before calling `require("opencode").setup(...)`. +> **Auto-reload note:** set `vim.o.autoread = true`. V2 does not emit every file edit yet, so the plugin coalesces disk checks after file, tool, shell, execution, and reconnect events, and when returning to the editor. Only loaded, unmodified buffers in the active location are reloaded. +> **Auth note:** backend auth is read from Neovim's `OPENCODE_PASSWORD`, with `OPENCODE_SERVER_PASSWORD` as a fallback. V2 uses the fixed username `opencode`. The same password is passed to the TUI; credentials belong in Neovim's environment, not only in `terminal.env`. If you source credentials from a file or secret manager, populate `vim.env` before calling `require("opencode").setup(...)`. > **Width:** Set terminal width with `opts.terminal.width`. > Other terminal behavior uses plugin defaults. ### OpenCode TUI Plugin -To track the active attached TUI session, OpenCode also needs the bundled TUI bridge plugin. -Add it to your OpenCode `tui.json` plugin list, not `opencode.json`: +To track the visible TUI session, register this repository in OpenCode v2's +`cli.json` (normally `~/.config/opencode/cli.json`): ```json { - "$schema": "https://opencode.ai/tui.json", - "plugin": [ - "/path/to/opencode.nvim" - ] + "$schema": "https://opencode.ai/v2/cli.json", + "plugins": ["/absolute/path/to/opencode.nvim"], + "prompt": { "editor": true } } ``` -For a typical `lazy.nvim` install, that path is usually: +For `lazy.nvim`, use the **absolute** path to its `opencode.nvim` directory. +V2 does not interpolate `{env:HOME}` or expand `~` in this list. Keep `cli.json` +writable: OpenCode also saves UI preferences there. If you manage configuration +declaratively, seed the file once or manage a writable copy. -```json -{ - "$schema": "https://opencode.ai/tui.json", - "plugin": [ - "{env:HOME}/.local/share/nvim/lazy/opencode.nvim" - ] -} -``` - -OpenCode does not expand `~` in plugin specs; use an absolute path or `{env:HOME}`. -The bundled TUI plugin lives in `tui-plugin/` and exports the OpenCode plugin id `opencode-nvim-bridge`. - -The bridge plugin is inert unless `opencode.nvim` launches the TUI with its bridge environment variables. +The root `tui.ts` entrypoint loads the native TUI bridge definition. +The plugin id remains `opencode-nvim-bridge`. It is inert when launched outside +Neovim. End users do not need to run `bun install`; the SDK dependencies are only +used for development typechecking. Restart the embedded TUI after registering the bridge. ## API @@ -201,7 +202,7 @@ require("opencode").prompt("Explain this", { clear = true, submit = true }) `mention_selection()` uses OpenCode's native editor-context WebSocket and sends an `at_mentioned` notification directly to the embedded TUI. This is the preferred path for current-line and visual-range mentions because it creates the TUI file mention without writing text through the terminal PTY or relying on autocomplete focus behavior. -`prompt("@this")`, `prompt("@buffer")`, and `prompt("@diagnostics")` use opencode.nvim's older prompt-append path. Keep this path for whole-buffer mentions, diagnostics, directory/file refs from pickers, and compatibility with older OpenCode versions. `mention_selection()` falls back to `prompt("@this")` if the embedded TUI has not connected to the editor-context WebSocket yet, unless called with `{ fallback = false }`. +`prompt("@this")`, `prompt("@buffer")`, and `prompt("@diagnostics")` use opencode.nvim's older prompt-append path. Keep this path for whole-buffer mentions, diagnostics, directory/file refs from pickers, and other text-based prompt composition. `mention_selection()` falls back to `prompt("@this")` if the embedded TUI has not connected to the editor-context WebSocket yet, unless called with `{ fallback = false }`. **Context Placeholders:** @@ -239,10 +240,8 @@ require("opencode").clear_review_queue() require("opencode").review_queue_count() ``` -Reviews are sent directly through `POST /session//prompt_async` using: - -- one text part for your comment -- one ranged file attachment using `file://...?...start=&end=` +Reviews use `POST /api/session//prompt` with native `text`, `files`, +and `delivery`. File URIs preserve one-based inclusive `?start=N&end=M` ranges. The review popup is a small cursor-anchored editor float: @@ -252,13 +251,23 @@ The review popup is a small cursor-anchored editor float: - `q` cancels in normal mode - `Enter` inserts a newline -Direct review sends reuse the last persisted user message's `agent`, `model`, and `variant` when available, so they generally match the active session's existing model choice without requiring OpenCode core changes. +The server uses the session's current agent/model/variant; reviews do not infer +these from old messages or change them. `review.delivery = "queue"` (the default) +admits feedback for the next run when busy. Set it to `"steer"` to deliver feedback +to the running agent. Neither option automatically submits comments from the +local Neovim review queue. + +A successful send means the server durably accepted the input, **not** that the +agent finished. Queued comments clear only after a valid admission acknowledgement; +comments added or edited during the request remain. Failed or uncertain requests +are never retried automatically. If receipt is uncertain, inspect the TUI before +manually sending again to avoid duplicate feedback. Queued reviews use the same popup and selection behavior. The queue is process-local and is not persisted across Neovim restarts. Quickfix is only a projection for navigation and source previews; the plugin keeps the actual queue state internally. bqf is optional, but it makes the quickfix queue easier to browse. Loaded buffers also show a sign-column marker on lines with queued comments. For loaded buffers, queued ranges are tracked with Neovim extmarks so quickfix, signs, and sends follow line shifts from edits above the queued range; if those marks are unavailable, the plugin falls back to the originally queued line numbers. The queue sign defaults to `󰅺` using the `OpencodeReviewQueueSign` highlight group, which links to `DiagnosticWarn` by default. The default marker is a Nerd Font glyph; if your font does not render it, override `review_queue.signs.text` with `O`, `◉`, or another sign-column glyph. -Opening the queue with `open_review_queue()` or `:Opencode review-queue-open` refreshes quickfix with one item per queued comment and a flattened one-line summary for multiline comments. Press `e` in that quickfix list to reopen the selected queued comment in the editor popup, anchored on the queued source location when that file is visible. From a source buffer, `edit_review_queue_comment()` edits the queued comment whose sign is on the current cursor line, and `delete_review_queue_comment()` prompts before deleting that current-line comment. In the edit popup, `Ctrl-S` saves the comment back to the queue and `Ctrl-Enter` sends only that queued comment immediately. Sending one comment uses the same direct one-text-part/one-file-part shape as immediate reviews; sending multiple queued comments uses first-person grouping so OpenCode treats each comment as feedback from you, the user. Sent comments are cleared only after the backend send succeeds; failed sends leave queued comments intact. +Opening the queue with `open_review_queue()` or `:Opencode review-queue-open` refreshes quickfix with one item per queued comment and a flattened one-line summary for multiline comments. Press `e` in that quickfix list to reopen the selected queued comment in the editor popup, anchored on the queued source location when that file is visible. From a source buffer, `edit_review_queue_comment()` edits the queued comment whose sign is on the current cursor line, and `delete_review_queue_comment()` prompts before deleting that current-line comment. In the edit popup, `Ctrl-S` saves the comment back to the queue and `Ctrl-Enter` sends only that queued comment immediately. Sending one comment uses the same native text and ranged attachment as immediate reviews; sending multiple queued comments uses first-person grouping so OpenCode treats each comment as feedback from you, the user. Sent comments clear only after confirmed admission; failed sends leave them intact. ## User Commands @@ -277,7 +286,7 @@ Opening the queue with `open_review_queue()` or `:Opencode review-queue-open` re `opencode.nvim` exposes three useful integration surfaces: -- `OpencodeEvent:*` for backend SSE events in the currently subscribed backend directory +- `OpencodeEvent:*` for backend SSE events in the active backend location (directory and workspace) - `OpencodeActiveEvent:*` for local embedded-TUI events scoped to the currently attached session - `OpencodeSessionChanged` for coarse route/session/cwd changes reported by the embedded TUI @@ -294,7 +303,7 @@ vim.api.nvim_create_autocmd("User", { pattern = "OpencodeEvent:*", callback = function(args) local event = args.data.event - if event.type == "session.idle" then + if event.type == "session.execution.succeeded" then vim.notify("opencode finished responding") end end, @@ -305,6 +314,7 @@ For `OpencodeEvent:*`, `args.data` includes: - `event`: the backend SSE event object - `url`: the backend base URL that produced the event +- `directory`, `workspace_id`: the active location used for filtering ### `OpencodeActiveEvent:*` @@ -314,14 +324,14 @@ This is the most useful surface for integrations that care about the embedded TU - desktop notifications when the active session goes idle or errors - tmux/workmux or window-title status updates while the agent is busy or waiting on a question/permission prompt -- local UI reactions to `question.asked` / `permission.asked` without watching every backend event globally +- local UI reactions to `form.created` / `permission.asked` without watching every backend event globally ```lua vim.api.nvim_create_autocmd("User", { pattern = "OpencodeActiveEvent:*", callback = function(args) local event = args.data.event - if event.type == "session.idle" then + if event.type == "session.execution.succeeded" then vim.notify("active embedded OpenCode session is idle") end end, @@ -330,16 +340,19 @@ vim.api.nvim_create_autocmd("User", { `OpencodeActiveEvent:*` comes from the embedded TUI bridge plugin and is scoped to the currently attached session, which makes it suitable for local integrations like notifications, statusline widgets, or tmux/workmux hooks. -Currently forwarded event types: +Currently forwarded native event types: + +- `session.execution.started`, `.succeeded`, `.failed`, `.interrupted` +- `session.retry.scheduled` +- `session.inbox.enqueued`, `.delivered`, `.cancelled` +- `session.agent.selected`, `session.model.selected` +- `permission.asked`, `permission.replied` +- `form.created`, `form.replied`, `form.cancelled` -- `session.status` -- `session.idle` -- `session.error` -- `message.updated` -- `permission.asked` -- `permission.replied` -- `question.asked` -- `question.replied` +Payloads use `event.data`, not v1's `event.properties`. Ownership is normally +`event.data.sessionID`; for `form.created` it is `event.data.form.sessionID`. +Events from other sessions (including child sessions) and global forms are not +forwarded as active events. V1 event names are not synthesized. For `OpencodeActiveEvent:*`, `args.data` includes: @@ -347,11 +360,12 @@ For `OpencodeActiveEvent:*`, `args.data` includes: - `route`: the local TUI route when the event was observed - `session_id`: the local attached session id when available - `instance_id`: the Neovim bridge instance id -- `cwd`: the TUI working directory snapshot +- `cwd`: the native active session directory (or default location on the home route) +- `workspace_id`: the native workspace id, when present ### `OpencodeSessionChanged` -`OpencodeSessionChanged` fires when the local bridge reports that the active embedded TUI route, session id, or cwd changed. +`OpencodeSessionChanged` fires when the local bridge reports that the active embedded TUI route, session id, cwd, or workspace changed. This is the right surface for integrations that want coarse session-aware state rather than every individual lifecycle event, for example: @@ -374,7 +388,41 @@ For `OpencodeSessionChanged`, `args.data` includes: - `route`: the local TUI route when the event was observed - `session_id`: the active embedded OpenCode session id when available - `instance_id`: the Neovim bridge instance id -- `cwd`: the TUI working directory snapshot +- `cwd`: the native active session directory (or default location on the home route) +- `workspace_id`: the native workspace id, when present + +### Reconnects and session metadata + +The native SSE feed is volatile: disconnected clients miss events. A 45-second +watchdog allows room for its 15-second comment heartbeats. Location filtering +does not affect connection health. After connecting, `OpencodeResync` requests +fresh disk checks, and `OpencodeSessionRefreshed` supplies the active session's +current `Session.Info` as `args.data.session`. These are snapshots, not replayed +completion/permission notifications. + +Notification hooks can use the native helper instead of maintaining their own +HTTP requests and authentication: + +```lua +require("opencode.client").get_session(session_id, function(err, info) + if err then return end + -- info.title, info.parentID, info.agent, info.model, info.location, info.outcome +end) +``` + +## Development + +```sh +nix develop --command bun install --frozen-lockfile +nix develop --command bun run typecheck +nix develop --command bun run test +nix develop --command stylua --check lua plugin tests +``` + +The tests use a local native HTTP/SSE fixture and headless Neovim. They do not +contact a model or your OpenCode server. The bridge is typechecked against the +published v2 SDK. See the [migration and validation guide](docs/opencode-v2-compatibility.md) +for the separate live-server checks and remaining integration work. ## Acknowledgments diff --git a/docs/opencode-v2-compatibility.md b/docs/opencode-v2-compatibility.md new file mode 100644 index 0000000..49051fb --- /dev/null +++ b/docs/opencode-v2-compatibility.md @@ -0,0 +1,188 @@ +# OpenCode v2 migration + +This plugin now targets the native v2 CLI, TUI plugin SDK, and server API. +The Neovim commands, Snacks layouts, editor context, prompt expansion, and review +queue remain. OpenCode v1 users should pin plugin commit `aa2a146`. + +The implementation was developed against OpenCode **2.0.1**, source +[`41e5d1b6`](https://github.com/anomalyco/opencode/tree/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3). +The relevant contracts were also checked against the current v2 source on +2026-09-13, +[`195158c3`](https://github.com/anomalyco/opencode/tree/195158c34c4300d38b861fc4398de5f8cdb51d58). +The published SDK dependency is pinned for development typechecking. The bridge +has no runtime package dependencies; a normal plugin checkout is sufficient. + +## Keep the external server workflow + +The plugin connects to an existing server. It does not install OpenCode, start a +daemon, or own server lifetime. A supervisor can run the foreground server, +optionally inside a third-party sandbox: + +```text +Supervisor -> optional sandbox -> opencode serve --hostname=127.0.0.1 --port=4096 +Neovim -> Snacks terminal -> opencode --server http://127.0.0.1:4096 /project +``` + +Do not add `--service` inside that supervisor: it changes OpenCode's service +management rather than running the foreground process being supervised. The TUI +is a separate client process; tool execution runs in the server's environment. +Closing Neovim or its terminal does not stop the server. + +V2 uses the primary `opencode` executable and existing configuration locations. +Follow the [official migration guide](https://opencode.ai/v2/docs/migrate-v1/) +and preserve a v1 backup before migrating. An adjacent executable alone does not +isolate state. Do not run v1 against already migrated data. + +The generated TUI command uses a positional absolute directory, `--server`, and +optional `--continue` or `--session`. It no longer invokes `attach` or `--dir`. +A custom `terminal.cmd` is still an escape hatch and must itself launch a v2 TUI +against the intended server. + +## Register the bridge in the client + +Merge this into the writable global `~/.config/opencode/cli.json`: + +```json +{ + "$schema": "https://opencode.ai/v2/cli.json", + "plugins": ["/absolute/path/to/opencode.nvim"], + "prompt": { "editor": true } +} +``` + +Use the absolute installation path, or the worktree path while testing. The +loader finds the root `tui.ts`; package exports alone are insufficient for a +local directory. V2 uses plural `plugins`, does not expand `{env:HOME}` or `~` +here, and does not use v1's `tui.json(c)` plugin list. Keep unrelated UI preferences. + +OpenCode saves preferences by replacing `cli.json`. If you manage configuration +declaratively, seed it once or manage a writable copy rather than assume an +immutable symlink remains in place. Updating a seed alone does not update an +already-created live file. + +Populate Neovim's `OPENCODE_PASSWORD` before setup. The legacy +`OPENCODE_SERVER_PASSWORD` is accepted when the new variable is absent. V2 uses +Basic username `opencode`; `OPENCODE_SERVER_USERNAME` is no longer a username +configuration option. Both Lua requests and the child TUI use the same resolved +password. Provider credentials belong with the backend, not in the TUI wrapper. + +## Review delivery + +The direct API sends native `text`, `files = [{uri, name}]`, and `delivery`. +Ranged URIs retain one-based inclusive `?start=N&end=M` selections. The server +materializes those attachments in the session's location and selects the +session's current agent/model/variant. The plugin no longer reads old user +messages to infer a model. + +`review.delivery = "queue"` is the default: feedback waits for the next run if +the session is busy. `"steer"` makes it available to the running agent. This is +separate from collecting comments in Neovim's local review queue; the local queue +still sends only when explicitly requested. + +A valid native inbox acknowledgement confirms durable admission, not completed +execution. Only acknowledged, unchanged queued comments are removed. Failed or +uncertain requests keep their comments and are never resubmitted automatically. +After a timeout or malformed acknowledgement, inspect the TUI before manually +sending again. The local review queue is still lost when Neovim exits. + +## Update notification hooks + +Native events retain their `type`, `data`, and optional `location`. There is no +v1 `properties` adapter and no synthesized v1 lifecycle event stream. + +| V1 integration | V2 integration | +| --- | --- | +| `session.status` / `session.idle` | `session.execution.started` / `.succeeded`, with `.failed` and `.interrupted` handled separately | +| `session.error` | `session.execution.failed`, error under `event.data.error` | +| `question.asked` / `.replied` | `form.created` / `.replied` / `.cancelled` | +| `permission.asked` / `.replied` | Same names, payload under `event.data` | +| `message.updated` | Native typed message/inbox events; no blanket alias | +| `GET /session/:id` returning the object | `GET /api/session/:id` returning `{data: Session.Info}` | + +`OpencodeActiveEvent:*` is filtered to the session visible in this embedded TUI. +Most events own `data.sessionID`; `form.created` owns `data.form.sessionID`. +Child-session events and global forms are not relabeled as active-session events. +Use `require("opencode.client").get_session(id, callback)` for native session +metadata; it unwraps `data` and handles authentication. Custom notification hooks +remain responsible for their own caching and deduplication. + +`OpencodeEvent:*` receives server control events, the active session's events, +and events explicitly scoped to the same directory/workspace. Connection health +is independent of filtering. Switching sessions does not replace the global +stream. + +The SSE feed is volatile. The parser handles fragmented frames, CRLF, multiline +data, and comment heartbeats. Its watchdog allows 45 seconds for a server that +normally sends a heartbeat every 15 seconds. On reconnect, `OpencodeResync` +triggers disk checks and `OpencodeSessionRefreshed` supplies the current session +snapshot. Missed lifecycle events are not replayed as new notifications. + +## Reload behavior + +V2 does not consistently publish file edits yet. The plugin handles +`filesystem.changed` and coalesces disk checks after tool/shell/execution +settlement, reverts, reconnects, and returning to the editor. Set +`vim.o.autoread = true`. Only loaded unmodified buffers are eligible; dirty buffers +keep their unsaved text. Without a concrete filename, checks are limited to the +active location's directory. There is no remote-to-local filesystem mapping. + +## Migration checklist + +Install OpenCode v2 and start or select a compatible backend before updating the +plugin. Neovim selects the server explicitly and supplies its password; ordinary +terminal use of `opencode` can retain upstream behavior. + +1. Update the installed plugin to a v2-compatible commit. Keep your server URL, + mappings, terminal layout, `autoread`, and `OPENTUI_GRAPHICS=0` settings. +2. Register the installed plugin's absolute directory in writable `cli.json`, + keep `prompt.editor = true`, and restart the embedded TUI. If you generate + the configuration from a template, update both the template and live file. +3. Provide the backend password in Neovim's environment before plugin setup. +4. Update custom notification hooks for native lifecycle/form events and + `event.data` fields. Use the plugin's `get_session` helper for session + metadata, and recheck notification deduplication. +5. Check any other OpenCode plugins for v2 compatibility before enabling them. + +## Validation + +The deterministic suite runs with `nix develop --command bun run test` after +`bun install --frozen-lockfile`. It covers native request/acknowledgement shapes, +credential precedence and transport, URI ranges, queue failure/concurrency, +stream framing, workspace scope, heartbeat/retry cleanup, route-switch races, +loading without installed packages, and safe buffer reload with inherited or +explicit `autoread` settings. +Typechecking uses the real published SDK, not local ambient declarations. + +For a real-server check, use an isolated Neovim configuration and a writable CLI +config copy containing this checkout's bridge. Use a disposable project the +server is allowed to write to and sessions created only for the test: + +- Launch new/continue/explicit-session modes in Snacks split and tab layouts. +- Confirm `:Opencode status` reports the expected session, location, and connected + editor WebSocket; send a native visual-range mention and a normal prompt. +- Submit a direct review and a multi-comment queue. Inspect persisted native + user messages for ranged attachments and verify the selected agent/model. +- Remain idle beyond 15 seconds, reconnect after a missed disk change, and check + clean-buffer reload while a dirty buffer retains its unsaved text. +- Exercise permission/form attention and session switching without forwarding + unrelated or child-session events. Close the TUI and confirm server lifetime + is unchanged. Delete only the test sessions/files afterward. + +Live checks passed with OpenCode 2.0.1, Neovim 0.12.4, Snacks terminal, and a +server running in the Fence sandbox: new/continue/explicit-session launch, +split and tab layouts, native ranged mentions, prompt submission, direct and batched reviews, +idle heartbeat, clean-buffer reload, dirty-buffer preservation, and reconnect +after a missed disk edit. The bridge also loaded without `node_modules`. The +deterministic Lua suite passed on Neovim 0.11.5 and 0.12.4. Permission/form UI +flows and remote workspace runtimes have fixture/source coverage only. + +## Source references + +These implementation contracts were checked in the pinned upstream source: + +- [CLI launch and explicit server connection](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/cli/src/commands/commands.ts) +- [Native TUI context](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/plugin/src/tui/context.ts) +- [Native session HTTP contracts](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/protocol/src/groups/session.ts) +- [Attachment input](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/schema/src/prompt-input.ts) +- [SSE handler and heartbeat](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/server/src/handlers/event.ts) +- [Native session events](https://github.com/anomalyco/opencode/blob/41e5d1b6b69b768a1d79c1c012a2bc5de1dc9bf3/packages/schema/src/session-event.ts)