Compare commits

..

13 Commits

Author SHA1 Message Date
Aiden Cline 70b493af15 refactor(core): make Code Mode a service 2026-07-07 14:31:38 -05:00
Kit Langton 81f6e06681 feat: enable background subagents by default 2026-07-07 15:18:46 -04:00
James Long da68e2865e feat(tui): wire move session command (#35203) 2026-07-07 14:49:11 -04:00
Aiden Cline f86e1cc1ff fix(codemode): preserve collection value identity (#35776) 2026-07-07 13:40:55 -05:00
Aiden Cline 947bbf9490 feat(mcp): expose resource catalog API (#35773) 2026-07-07 13:34:29 -05:00
James Long 3e57b43a4a feat(session): support admit-only synthetic messages (#35774) 2026-07-07 14:17:20 -04:00
opencode-agent[bot] cb18a42a47 docs(codemode): clarify implicit return guidance (#35763)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 12:48:05 -05:00
Aiden Cline 1fb2837fd3 feat(codemode): expand numeric standard library (#35749) 2026-07-07 12:11:57 -05:00
Aiden Cline 66a8d830aa feat(core): support MCP resources (#35656) 2026-07-07 11:55:06 -05:00
opencode-agent[bot] d0733f83bf fix(codemode): return final top-level expression (#35759)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 11:44:50 -05:00
Kit Langton b2ce195bda fix(core): normalize Location.Ref keys in LocationServiceMap (#35757) 2026-07-07 12:25:29 -04:00
James Long 6c065ca6a5 feat(simulation): render driven UI screenshots and videos (#35739) 2026-07-07 12:15:01 -04:00
Dax Raad dd1c007877 docs 2026-07-07 12:08:45 -04:00
113 changed files with 2319 additions and 1116 deletions
+116 -5
View File
@@ -873,6 +873,7 @@
"name": "@opencode-ai/simulation", "name": "@opencode-ai/simulation",
"version": "1.17.13", "version": "1.17.13",
"dependencies": { "dependencies": {
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1611,11 +1612,11 @@
"@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="], "@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="],
@@ -1983,6 +1984,30 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.2", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.2", "@napi-rs/canvas-darwin-arm64": "1.0.2", "@napi-rs/canvas-darwin-x64": "1.0.2", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", "@napi-rs/canvas-linux-arm64-musl": "1.0.2", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-musl": "1.0.2", "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
@@ -2277,7 +2302,7 @@
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="],
@@ -2569,7 +2594,37 @@
"@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
@@ -3111,6 +3166,8 @@
"@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
@@ -3183,6 +3240,12 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/cli-config": ["@vercel/cli-config@0.2.0", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ=="],
"@vercel/cli-exec": ["@vercel/cli-exec@1.0.0", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug=="],
"@vercel/functions": ["@vercel/functions@3.7.5", "", { "dependencies": { "@vercel/oidc": "3.8.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-ESf8BbeDebqRUyMi09JwRbQqpLn4g6fjcVVHPsHB56j2dSqRrSHO4h3X4aaxJf6iQQjzhAtDGI2xCWQ27JE8PA=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -4987,6 +5050,8 @@
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -5401,6 +5466,8 @@
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="],
"rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
@@ -6021,8 +6088,12 @@
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -6619,6 +6690,10 @@
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -6639,6 +6714,8 @@
"@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
@@ -6747,6 +6824,14 @@
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.0", "", { "dependencies": { "@vercel/cli-config": "0.2.0", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A=="],
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
@@ -7043,6 +7128,8 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"p-any/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], "p-any/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -7707,6 +7794,10 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -7715,6 +7806,8 @@
"@puppeteer/browsers/yargs/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=="], "@puppeteer/browsers/yargs/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=="],
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@@ -7785,6 +7878,22 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
@@ -8351,6 +8460,8 @@
"@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+1 -1
View File
@@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true }) if (path === "/global/health") return json(route, { healthy: true })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission") if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question") if (path === "/question")
Regular → Executable
View File
-2
View File
@@ -84,7 +84,6 @@ type RunFooterOptions = {
theme: RunTheme theme: RunTheme
keymap: Keymap<Renderable, KeyEvent> keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig tuiConfig: RunTuiConfig
backgroundSubagents: boolean
diffStyle: RunDiffStyle diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void> onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -326,7 +325,6 @@ export class RunFooter implements FooterApi {
theme: footer.theme, theme: footer.theme,
diffStyle: options.diffStyle, diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig, tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
history: footer.history, history: footer.history,
agent: options.agentLabel, agent: options.agentLabel,
onSubmit: footer.handlePrompt, onSubmit: footer.handlePrompt,
+1 -4
View File
@@ -89,7 +89,6 @@ type RunFooterViewProps = {
theme: () => RunTheme theme: () => RunTheme
diffStyle?: RunDiffStyle diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig tuiConfig: RunTuiConfig
backgroundSubagents: boolean
history?: () => RunPrompt[] history?: () => RunPrompt[]
agent: string agent: string
onSubmit: (input: RunPrompt) => boolean onSubmit: (input: RunPrompt) => boolean
@@ -169,9 +168,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return tabs().findIndex((item) => item.sessionID === sessionID) + 1 return tabs().findIndex((item) => item.sessionID === sessionID) + 1
}) })
const foregroundSubagents = createMemo( const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
() => props.backgroundSubagents && activeTabs().some((item) => !item.background),
)
const model = createMemo(() => { const model = createMemo(() => {
const current = props.currentModel() const current = props.currentModel()
return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined } return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined }
-4
View File
@@ -1,6 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { truthy } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect" import { Effect } from "effect"
import path from "node:path" import path from "node:path"
@@ -84,9 +83,6 @@ export async function runMini(input: MiniCommandInput) {
files: [], files: [],
initialInput, initialInput,
thinking: true, thinking: true,
backgroundSubagents:
truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ||
(process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")),
replay: input.replay ?? true, replay: input.replay ?? true,
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, demo: input.demo,
@@ -64,7 +64,6 @@ export type LifecycleInput = {
model: RunInput["model"] model: RunInput["model"]
variant: string | undefined variant: string | undefined
tuiConfig: RunTuiConfig | Promise<RunTuiConfig> tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
backgroundSubagents: boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void> onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void> onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -236,7 +235,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
wrote, wrote,
keymap, keymap,
tuiConfig, tuiConfig,
backgroundSubagents: input.backgroundSubagents,
diffStyle: tuiConfig.diff_style ?? "auto", diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply, onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply, onQuestionReply: input.onQuestionReply,
-5
View File
@@ -56,7 +56,6 @@ type RunRuntimeInput = {
files: RunInput["files"] files: RunInput["files"]
initialInput?: string initialInput?: string
thinking: boolean thinking: boolean
backgroundSubagents: boolean
replay?: boolean replay?: boolean
replayLimit?: number replayLimit?: number
demo?: RunInput["demo"] demo?: RunInput["demo"]
@@ -75,7 +74,6 @@ type RunDeferredInput = {
files: RunInput["files"] files: RunInput["files"]
initialInput?: string initialInput?: string
thinking: boolean thinking: boolean
backgroundSubagents: boolean
replay?: boolean replay?: boolean
replayLimit?: number replayLimit?: number
demo?: RunInput["demo"] demo?: RunInput["demo"]
@@ -270,7 +268,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
model: state.model, model: state.model,
variant: state.activeVariant, variant: state.activeVariant,
tuiConfig: tuiConfigTask, tuiConfig: tuiConfigTask,
backgroundSubagents: input.backgroundSubagents,
onPermissionReply: async (next) => { onPermissionReply: async (next) => {
if (state.demo?.permission(next)) { if (state.demo?.permission(next)) {
return return
@@ -876,7 +873,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
files: input.files, files: input.files,
initialInput: input.initialInput, initialInput: input.initialInput,
thinking: input.thinking, thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay, replay: input.replay,
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, demo: input.demo,
@@ -928,7 +924,6 @@ export async function runInteractiveMode(
files: input.files, files: input.files,
initialInput: input.initialInput, initialInput: input.initialInput,
thinking: input.thinking, thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay, replay: input.replay,
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, demo: input.demo,
-1
View File
@@ -135,7 +135,6 @@ export type RunInput = {
files: RunFilePart[] files: RunFilePart[]
initialInput?: string initialInput?: string
thinking: boolean thinking: boolean
backgroundSubagents: boolean
demo?: boolean demo?: boolean
} }
-9
View File
@@ -7,7 +7,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Flock } from "@opencode-ai/core/util/flock"
import { start } from "@opencode-ai/server/process" import { start } from "@opencode-ai/server/process"
import { randomBytes, randomUUID } from "node:crypto" import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path" import path from "node:path"
@@ -37,14 +36,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
if (options.mode === "service") {
const service = yield* ServiceConfig.options()
yield* Flock.effect(path.basename(service.file, ".json") + "-process", {
dir: path.dirname(service.file),
staleMs: 3_000,
timeoutMs: 15_000,
})
}
const environmentPassword = yield* Env.password const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools. // Keep the lease credential out of the environment inherited by tools.
if (options.mode === "stdio") { if (options.mode === "stdio") {
+109 -92
View File
@@ -111,157 +111,167 @@ export type Endpoint4_8Input = {
export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>> export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>>
export type SessionRenameOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E> export type SessionRenameOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E>
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
export type Endpoint4_9Input = { export type Endpoint4_9Input = {
readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
readonly id?: Endpoint4_9Request["payload"]["id"] readonly destination: Endpoint4_9Request["payload"]["destination"]
readonly prompt: Endpoint4_9Request["payload"]["prompt"] readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"]
readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
readonly resume?: Endpoint4_9Request["payload"]["resume"]
} }
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"] export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
export type SessionPromptOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E> export type SessionMoveOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0] type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
export type Endpoint4_10Input = { export type Endpoint4_10Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"] readonly id?: Endpoint4_10Request["payload"]["id"]
readonly command: Endpoint4_10Request["payload"]["command"] readonly prompt: Endpoint4_10Request["payload"]["prompt"]
readonly arguments?: Endpoint4_10Request["payload"]["arguments"]
readonly agent?: Endpoint4_10Request["payload"]["agent"]
readonly model?: Endpoint4_10Request["payload"]["model"]
readonly files?: Endpoint4_10Request["payload"]["files"]
readonly agents?: Endpoint4_10Request["payload"]["agents"]
readonly delivery?: Endpoint4_10Request["payload"]["delivery"] readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
readonly resume?: Endpoint4_10Request["payload"]["resume"] readonly resume?: Endpoint4_10Request["payload"]["resume"]
} }
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"] export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
export type SessionCommandOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E> export type SessionPromptOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0] type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.command"]>[0]
export type Endpoint4_11Input = { export type Endpoint4_11Input = {
readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
readonly id?: Endpoint4_11Request["payload"]["id"] readonly id?: Endpoint4_11Request["payload"]["id"]
readonly skill: Endpoint4_11Request["payload"]["skill"] readonly command: Endpoint4_11Request["payload"]["command"]
readonly arguments?: Endpoint4_11Request["payload"]["arguments"]
readonly agent?: Endpoint4_11Request["payload"]["agent"]
readonly model?: Endpoint4_11Request["payload"]["model"]
readonly files?: Endpoint4_11Request["payload"]["files"]
readonly agents?: Endpoint4_11Request["payload"]["agents"]
readonly delivery?: Endpoint4_11Request["payload"]["delivery"]
readonly resume?: Endpoint4_11Request["payload"]["resume"] readonly resume?: Endpoint4_11Request["payload"]["resume"]
} }
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>> export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
export type SessionSkillOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E> export type SessionCommandOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
export type Endpoint4_12Input = { export type Endpoint4_12Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"] readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly text: Endpoint4_12Request["payload"]["text"] readonly id?: Endpoint4_12Request["payload"]["id"]
readonly description?: Endpoint4_12Request["payload"]["description"] readonly skill: Endpoint4_12Request["payload"]["skill"]
readonly metadata?: Endpoint4_12Request["payload"]["metadata"] readonly resume?: Endpoint4_12Request["payload"]["resume"]
} }
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>> export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E> export type SessionSkillOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
export type Endpoint4_13Input = { export type Endpoint4_13Input = {
readonly sessionID: Endpoint4_13Request["params"]["sessionID"] readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
readonly id?: Endpoint4_13Request["payload"]["id"] readonly text: Endpoint4_13Request["payload"]["text"]
readonly command: Endpoint4_13Request["payload"]["command"] readonly description?: Endpoint4_13Request["payload"]["description"]
readonly metadata?: Endpoint4_13Request["payload"]["metadata"]
readonly resume?: Endpoint4_13Request["payload"]["resume"]
} }
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>> export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
export type SessionShellOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E> export type SessionSyntheticOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
export type Endpoint4_14Input = { export type Endpoint4_14Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly id?: Endpoint4_14Request["payload"]["id"] readonly id?: Endpoint4_14Request["payload"]["id"]
readonly command: Endpoint4_14Request["payload"]["command"]
} }
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"] export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
export type SessionCompactOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E> export type SessionShellOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } export type Endpoint4_15Input = {
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>> readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
export type SessionWaitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E> readonly id?: Endpoint4_15Request["payload"]["id"]
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_16Input = {
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
readonly messageID: Endpoint4_16Request["payload"]["messageID"]
readonly files?: Endpoint4_16Request["payload"]["files"]
} }
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"] export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E> export type SessionCompactOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>> export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E> export type SessionWaitOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly messageID: Endpoint4_17Request["payload"]["messageID"]
readonly files?: Endpoint4_17Request["payload"]["files"]
}
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>> export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E> export type SessionRevertClearOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"] export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionContextOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E> export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0] type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context"]>[0]
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
export type Endpoint4_20Output = EffectValue< export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] }
export type Endpoint4_21Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]> ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
>["data"] >["data"]
export type SessionInstructionsEntryListOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint4_20Input,
) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"]
readonly value: Endpoint4_21Request["payload"]["value"]
}
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint4_21Input, input: Endpoint4_21Input,
) => Effect.Effect<Endpoint4_21Output, E> ) => Effect.Effect<Endpoint4_21Output, E>
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint4_22Input = { export type Endpoint4_22Input = {
readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly key: Endpoint4_22Request["params"]["key"] readonly key: Endpoint4_22Request["params"]["key"]
readonly value: Endpoint4_22Request["payload"]["value"]
} }
export type Endpoint4_22Output = EffectValue< export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]> export type SessionInstructionsEntryPutOperation<E = never> = (
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint4_22Input, input: Endpoint4_22Input,
) => Effect.Effect<Endpoint4_22Output, E> ) => Effect.Effect<Endpoint4_22Output, E>
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
export type Endpoint4_23Input = { export type Endpoint4_23Input = {
readonly sessionID: Endpoint4_23Request["params"]["sessionID"] readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
readonly after?: Endpoint4_23Request["query"]["after"] readonly key: Endpoint4_23Request["params"]["key"]
readonly follow?: Endpoint4_23Request["query"]["follow"]
} }
export type Endpoint4_23Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>> export type Endpoint4_23Output = EffectValue<
export type SessionLogOperation<E = never> = (input: Endpoint4_23Input) => Stream.Stream<Endpoint4_23Output, E> ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint4_23Input,
) => Effect.Effect<Endpoint4_23Output, E>
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } export type Endpoint4_24Input = {
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>> readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
export type SessionInterruptOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E> readonly after?: Endpoint4_24Request["query"]["after"]
readonly follow?: Endpoint4_24Request["query"]["follow"]
}
export type Endpoint4_24Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint4_24Input) => Stream.Stream<Endpoint4_24Output, E>
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0] type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>> export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_26Input = { export type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_26Request["params"]["sessionID"] export type Endpoint4_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
readonly messageID: Endpoint4_26Request["params"]["messageID"] export type SessionBackgroundOperation<E = never> = (input: Endpoint4_26Input) => Effect.Effect<Endpoint4_26Output, E>
type Endpoint4_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_27Input = {
readonly sessionID: Endpoint4_27Request["params"]["sessionID"]
readonly messageID: Endpoint4_27Request["params"]["messageID"]
} }
export type Endpoint4_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"] export type Endpoint4_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_26Input) => Effect.Effect<Endpoint4_26Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint4_27Input) => Effect.Effect<Endpoint4_27Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
@@ -273,6 +283,7 @@ export interface SessionApi<E = never> {
readonly switchAgent: SessionSwitchAgentOperation<E> readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E> readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E> readonly rename: SessionRenameOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E> readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E> readonly command: SessionCommandOperation<E>
readonly skill: SessionSkillOperation<E> readonly skill: SessionSkillOperation<E>
@@ -439,8 +450,14 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query
export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>> export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E> export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type ServerMcpCatalogOperation<E = never> = (input?: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export interface ServerMcpApi<E = never> { export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E> readonly list: ServerMcpListOperation<E>
readonly catalog: ServerMcpCatalogOperation<E>
} }
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
+135 -111
View File
@@ -141,15 +141,27 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
type Endpoint4_9Input = { type Endpoint4_9Input = {
readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
readonly id?: Endpoint4_9Request["payload"]["id"] readonly destination: Endpoint4_9Request["payload"]["destination"]
readonly prompt: Endpoint4_9Request["payload"]["prompt"] readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"]
readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
readonly resume?: Endpoint4_9Request["payload"]["resume"]
} }
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { destination: input["destination"], moveChanges: input["moveChanges"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint4_10Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"]
readonly prompt: Endpoint4_10Request["payload"]["prompt"]
readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
readonly resume?: Endpoint4_10Request["payload"]["resume"]
}
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
@@ -158,20 +170,20 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0] type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.command"]>[0]
type Endpoint4_10Input = { type Endpoint4_11Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"] readonly id?: Endpoint4_11Request["payload"]["id"]
readonly command: Endpoint4_10Request["payload"]["command"] readonly command: Endpoint4_11Request["payload"]["command"]
readonly arguments?: Endpoint4_10Request["payload"]["arguments"] readonly arguments?: Endpoint4_11Request["payload"]["arguments"]
readonly agent?: Endpoint4_10Request["payload"]["agent"] readonly agent?: Endpoint4_11Request["payload"]["agent"]
readonly model?: Endpoint4_10Request["payload"]["model"] readonly model?: Endpoint4_11Request["payload"]["model"]
readonly files?: Endpoint4_10Request["payload"]["files"] readonly files?: Endpoint4_11Request["payload"]["files"]
readonly agents?: Endpoint4_10Request["payload"]["agents"] readonly agents?: Endpoint4_11Request["payload"]["agents"]
readonly delivery?: Endpoint4_10Request["payload"]["delivery"] readonly delivery?: Endpoint4_11Request["payload"]["delivery"]
readonly resume?: Endpoint4_10Request["payload"]["resume"] readonly resume?: Endpoint4_11Request["payload"]["resume"]
} }
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
raw["session.command"]({ raw["session.command"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@@ -190,67 +202,73 @@ const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10I
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint4_11Input = { type Endpoint4_12Input = {
readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly id?: Endpoint4_11Request["payload"]["id"] readonly id?: Endpoint4_12Request["payload"]["id"]
readonly skill: Endpoint4_11Request["payload"]["skill"] readonly skill: Endpoint4_12Request["payload"]["skill"]
readonly resume?: Endpoint4_11Request["payload"]["resume"] readonly resume?: Endpoint4_12Request["payload"]["resume"]
} }
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.skill"]({ raw["session.skill"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
type Endpoint4_12Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly text: Endpoint4_12Request["payload"]["text"]
readonly description?: Endpoint4_12Request["payload"]["description"]
readonly metadata?: Endpoint4_12Request["payload"]["metadata"]
}
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint4_13Input = { type Endpoint4_13Input = {
readonly sessionID: Endpoint4_13Request["params"]["sessionID"] readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
readonly id?: Endpoint4_13Request["payload"]["id"] readonly text: Endpoint4_13Request["payload"]["text"]
readonly command: Endpoint4_13Request["payload"]["command"] readonly description?: Endpoint4_13Request["payload"]["description"]
readonly metadata?: Endpoint4_13Request["payload"]["metadata"]
readonly resume?: Endpoint4_13Request["payload"]["resume"]
} }
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
text: input["text"],
description: input["description"],
metadata: input["metadata"],
resume: input["resume"],
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint4_14Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly id?: Endpoint4_14Request["payload"]["id"]
readonly command: Endpoint4_14Request["payload"]["command"]
}
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.shell"]({ raw["session.shell"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] }, payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_14Input = { type Endpoint4_15Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
readonly id?: Endpoint4_14Request["payload"]["id"] readonly id?: Endpoint4_15Request["payload"]["id"]
} }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_16Input = { type Endpoint4_17Input = {
readonly sessionID: Endpoint4_16Request["params"]["sessionID"] readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly messageID: Endpoint4_16Request["payload"]["messageID"] readonly messageID: Endpoint4_17Request["payload"]["messageID"]
readonly files?: Endpoint4_16Request["payload"]["files"] readonly files?: Endpoint4_17Request["payload"]["files"]
} }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input["messageID"], files: input["files"] },
@@ -259,61 +277,61 @@ const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16I
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0] type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint4_21Input = { type Endpoint4_22Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"] readonly key: Endpoint4_22Request["params"]["key"]
readonly value: Endpoint4_21Request["payload"]["value"] readonly value: Endpoint4_22Request["payload"]["value"]
} }
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint4_22Input = { type Endpoint4_23Input = {
readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
readonly key: Endpoint4_22Request["params"]["key"] readonly key: Endpoint4_23Request["params"]["key"]
} }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_23Input = { type Endpoint4_24Input = {
readonly sessionID: Endpoint4_23Request["params"]["sessionID"] readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
readonly after?: Endpoint4_23Request["query"]["after"] readonly after?: Endpoint4_24Request["query"]["after"]
readonly follow?: Endpoint4_23Request["query"]["follow"] readonly follow?: Endpoint4_24Request["query"]["follow"]
} }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -324,22 +342,22 @@ const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23I
), ),
) )
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] }
const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_26Input = { type Endpoint4_27Input = {
readonly sessionID: Endpoint4_26Request["params"]["sessionID"] readonly sessionID: Endpoint4_27Request["params"]["sessionID"]
readonly messageID: Endpoint4_26Request["params"]["messageID"] readonly messageID: Endpoint4_27Request["params"]["messageID"]
} }
const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => const Endpoint4_27 = (raw: RawClient["server.session"]) => (input: Endpoint4_27Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -355,22 +373,23 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
switchAgent: Endpoint4_6(raw), switchAgent: Endpoint4_6(raw),
switchModel: Endpoint4_7(raw), switchModel: Endpoint4_7(raw),
rename: Endpoint4_8(raw), rename: Endpoint4_8(raw),
prompt: Endpoint4_9(raw), move: Endpoint4_9(raw),
command: Endpoint4_10(raw), prompt: Endpoint4_10(raw),
skill: Endpoint4_11(raw), command: Endpoint4_11(raw),
synthetic: Endpoint4_12(raw), skill: Endpoint4_12(raw),
shell: Endpoint4_13(raw), synthetic: Endpoint4_13(raw),
compact: Endpoint4_14(raw), shell: Endpoint4_14(raw),
wait: Endpoint4_15(raw), compact: Endpoint4_15(raw),
revertStage: Endpoint4_16(raw), wait: Endpoint4_16(raw),
revertClear: Endpoint4_17(raw), revertStage: Endpoint4_17(raw),
revertCommit: Endpoint4_18(raw), revertClear: Endpoint4_18(raw),
context: Endpoint4_19(raw), revertCommit: Endpoint4_19(raw),
instructions: { entry: { list: Endpoint4_20(raw), put: Endpoint4_21(raw), remove: Endpoint4_22(raw) } }, context: Endpoint4_20(raw),
log: Endpoint4_23(raw), instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } },
interrupt: Endpoint4_24(raw), log: Endpoint4_24(raw),
background: Endpoint4_25(raw), interrupt: Endpoint4_25(raw),
message: Endpoint4_26(raw), background: Endpoint4_26(raw),
message: Endpoint4_27(raw),
}) })
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0] type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -529,7 +548,12 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc
const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) => const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) }) type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) })
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = { type Endpoint11_0Input = {
@@ -23,6 +23,8 @@ import type {
SessionSwitchModelOutput, SessionSwitchModelOutput,
SessionRenameInput, SessionRenameInput,
SessionRenameOutput, SessionRenameOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput, SessionPromptInput,
SessionPromptOutput, SessionPromptOutput,
SessionCommandInput, SessionCommandInput,
@@ -87,6 +89,8 @@ import type {
IntegrationAttemptCancelOutput, IntegrationAttemptCancelOutput,
ServerMcpListInput, ServerMcpListInput,
ServerMcpListOutput, ServerMcpListOutput,
ServerMcpCatalogInput,
ServerMcpCatalogOutput,
CredentialUpdateInput, CredentialUpdateInput,
CredentialUpdateOutput, CredentialUpdateOutput,
CredentialRemoveInput, CredentialRemoveInput,
@@ -487,6 +491,18 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
request<SessionMoveOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
body: { destination: input["destination"], moveChanges: input["moveChanges"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) => prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPromptOutput }>( request<{ readonly data: SessionPromptOutput }>(
{ {
@@ -538,7 +554,12 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
body: { text: input["text"], description: input["description"], metadata: input["metadata"] }, body: {
text: input["text"],
description: input["description"],
metadata: input["metadata"],
resume: input["resume"],
},
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -892,6 +913,18 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) =>
request<ServerMcpCatalogOutput>(
{
method: "GET",
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
}, },
credential: { credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -526,6 +526,20 @@ export type SessionRenameInput = {
export type SessionRenameOutput = void export type SessionRenameOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly destination: {
readonly destination: { readonly directory: string }
readonly moveChanges?: boolean | undefined
}["destination"]
readonly moveChanges?: {
readonly destination: { readonly directory: string }
readonly moveChanges?: boolean | undefined
}["moveChanges"]
}
export type SessionMoveOutput = void
export type SessionPromptInput = { export type SessionPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: {
@@ -856,17 +870,26 @@ export type SessionSyntheticInput = {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly resume?: boolean | null
}["text"] }["text"]
readonly description?: { readonly description?: {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly resume?: boolean | null
}["description"] }["description"]
readonly metadata?: { readonly metadata?: {
readonly text: string readonly text: string
readonly description?: string | null readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly resume?: boolean | null
}["metadata"] }["metadata"]
readonly resume?: {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly resume?: boolean | null
}["resume"]
} }
export type SessionSyntheticOutput = void export type SessionSyntheticOutput = void
@@ -2478,6 +2501,36 @@ export type ServerMcpListOutput = {
}> }>
} }
export type ServerMcpCatalogInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerMcpCatalogOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly resources: ReadonlyArray<{
readonly server: string
readonly name: string
readonly uri: string
readonly description?: string
readonly mimeType?: string
}>
readonly templates: ReadonlyArray<{
readonly server: string
readonly name: string
readonly uriTemplate: string
readonly description?: string
readonly mimeType?: string
}>
}
}
export type CredentialUpdateInput = { export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: {
@@ -5508,6 +5561,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly server: string } readonly data: { readonly server: string }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "mcp.resources.changed"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly server: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
+23
View File
@@ -50,6 +50,29 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
}) })
test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: {
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
},
})
},
})
const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } })
expect(result.data.resources[0]?.uri).toBe("docs://readme")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("file.read returns binary content from the public HTTP contract", async () => { test("file.read returns binary content from the public HTTP contract", async () => {
let request: Request | undefined let request: Request | undefined
const client = OpenCode.make({ const client = OpenCode.make({
+2 -2
View File
@@ -62,7 +62,7 @@ const result =
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. `result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well.
## API ## API
@@ -241,7 +241,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
+3 -2
View File
@@ -153,11 +153,12 @@ current omissions to implement, not intentional product boundaries.
shims. Consider `Promise.any` in the same pass. shims. Consider `Promise.any` in the same pass.
- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
collection values, then extend it to bounded host streams when a stream boundary exists. collection values, then extend it to bounded host streams when a stream boundary exists.
- [ ] Support the mapper argument to `Array.from(...)`, including Effect-aware callbacks. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add `Object.is` after runtime method and tool references have stable identity semantics.
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
composition methods, and `Array.prototype.toSpliced`. composition methods, and `Array.prototype.toSpliced`.
- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime. - [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime.
- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
defects are distinguishable without leaking private causes. defects are distinguishable without leaking private causes.
@@ -30,7 +30,6 @@ export type Binding = {
export type StatementResult = export type StatementResult =
| { kind: "none" } | { kind: "none" }
| { kind: "value"; value: unknown }
| { kind: "return"; value: unknown } | { kind: "return"; value: unknown }
| { kind: "break" } | { kind: "break" }
| { kind: "continue" } | { kind: "continue" }
+24 -177
View File
@@ -5,7 +5,6 @@ import {
copyIn, copyIn,
copyOut, copyOut,
isBlockedMember, isBlockedMember,
MAX_VALUE_DEPTH,
ToolReference, ToolReference,
ToolRuntime, ToolRuntime,
ToolRuntimeError, ToolRuntimeError,
@@ -531,17 +530,29 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
if (args[0] instanceof SandboxURLSearchParams) { if (args[0] instanceof SandboxURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
} }
const source = boundedData(args[0], "Array.from input") const source = args[0]
if (source instanceof SandboxPromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (typeof source === "string") return Array.from(source) if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source] if (Array.isArray(source)) return [...source]
if ( if (
source !== null && source !== null &&
typeof source === "object" && typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number" typeof (source as { length?: unknown }).length === "number"
) { ) {
return Array.from(source as ArrayLike<unknown>) return Array.from(source as ArrayLike<unknown>)
} }
throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
} }
default: default:
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
@@ -607,7 +618,6 @@ class Interpreter<R> {
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string> private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string> private readonly logs: Array<string>
private lastValue: unknown
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
private readonly callPermits: Semaphore.Semaphore private readonly callPermits: Semaphore.Semaphore
// Fiber-backed promises whose settlement no program construct has observed yet. Successful // Fiber-backed promises whose settlement no program construct has observed yet. Successful
@@ -626,7 +636,6 @@ class Interpreter<R> {
this.invokeTool = invokeTool this.invokeTool = invokeTool
this.toolKeys = toolKeys this.toolKeys = toolKeys
this.logs = logs this.logs = logs
this.lastValue = undefined
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>() this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
@@ -672,13 +681,15 @@ class Interpreter<R> {
return Effect.gen(function* () { return Effect.gen(function* () {
self.hoistFunctions(program.body) self.hoistFunctions(program.body)
let value: unknown = undefined let value: unknown = undefined
let returned = false for (const [index, statement] of program.body.entries()) {
for (const statement of program.body) { if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
value = yield* self.evaluateExpression(getNode(statement, "expression"))
break
}
const result = yield* self.evaluateStatement(statement) const result = yield* self.evaluateStatement(statement)
if (result.kind === "return") { if (result.kind === "return") {
value = result.value value = result.value
returned = true
break break
} }
@@ -686,11 +697,7 @@ class Interpreter<R> {
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
} }
if (result.kind === "value") {
self.lastValue = result.value
} }
}
if (!returned) value = self.lastValue
// The program body runs inside an implicit async function, so a returned promise // The program body runs inside an implicit async function, so a returned promise
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works // resolves before crossing the data boundary - `return tools.ns.tool(...)` works
@@ -781,7 +788,7 @@ class Interpreter<R> {
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> { private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
switch (node.type) { switch (node.type) {
case "ExpressionStatement": case "ExpressionStatement":
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) return Effect.as(this.evaluateExpression(getNode(node, "expression")), { kind: "none" })
case "VariableDeclaration": case "VariableDeclaration":
return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" }))
case "ReturnStatement": { case "ReturnStatement": {
@@ -834,11 +841,6 @@ class Interpreter<R> {
const statement = asNode(statementValue, "body") const statement = asNode(statementValue, "body")
const result = yield* self.evaluateStatement(statement) const result = yield* self.evaluateStatement(statement)
if (result.kind === "value") {
self.lastValue = result.value
continue
}
if (result.kind !== "none") { if (result.kind !== "none") {
return result return result
} }
@@ -930,7 +932,6 @@ class Interpreter<R> {
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
if (result.kind === "break") return { kind: "none" } satisfies StatementResult if (result.kind === "break") return { kind: "none" } satisfies StatementResult
if (result.kind === "return" || result.kind === "continue") return result if (result.kind === "return" || result.kind === "continue") return result
if (result.kind === "value") self.lastValue = result.value
} }
} }
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
@@ -958,9 +959,6 @@ class Interpreter<R> {
return result return result
} }
if (result.kind === "value") {
self.lastValue = result.value
}
} }
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
@@ -988,9 +986,6 @@ class Interpreter<R> {
return result return result
} }
if (result.kind === "value") {
self.lastValue = result.value
}
} while (yield* self.evaluateExpression(testNode)) } while (yield* self.evaluateExpression(testNode))
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
@@ -1046,10 +1041,6 @@ class Interpreter<R> {
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
} }
if (result.kind === "value") {
self.lastValue = result.value
}
if (iterationScope) { if (iterationScope) {
const loopScope = self.currentScope() const loopScope = self.currentScope()
for (const name of perIterationBindings) { for (const name of perIterationBindings) {
@@ -1134,10 +1125,6 @@ class Interpreter<R> {
return { kind: "none" } return { kind: "none" }
} }
if (result.kind === "value") {
self.lastValue = result.value
}
if (result.kind === "continue") { if (result.kind === "continue") {
continue continue
} }
@@ -1227,10 +1214,6 @@ class Interpreter<R> {
return { kind: "none" } return { kind: "none" }
} }
if (result.kind === "value") {
self.lastValue = result.value
}
if (result.kind === "continue") { if (result.kind === "continue") {
continue continue
} }
@@ -2028,13 +2011,15 @@ class Interpreter<R> {
} }
if (callable instanceof GlobalMethodReference) { if (callable instanceof GlobalMethodReference) {
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
if (callable.namespace === "JSON") return yield* self.invokeJsonMethod(callable.name, args, node)
if (callable.namespace === "Object" && args[0] instanceof ToolReference) { if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
return self.invokeObjectMethodOnTools(callable.name, args[0], node) return self.invokeObjectMethodOnTools(callable.name, args[0], node)
} }
if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) {
return invokeGlobalMethod(callable, args, node) return invokeGlobalMethod(callable, args, node)
} }
if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) {
return invokeGlobalMethod(callable, args, node)
}
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
} }
if (callable instanceof CoercionFunction) { if (callable instanceof CoercionFunction) {
@@ -2374,7 +2359,7 @@ class Interpreter<R> {
if (fn.body.type === "BlockStatement") { if (fn.body.type === "BlockStatement") {
const result = yield* invocation.evaluateStatement(fn.body) const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" || result.kind === "value" ? result.value : undefined return result.kind === "return" ? result.value : undefined
} }
return yield* invocation.evaluateExpression(fn.body) return yield* invocation.evaluateExpression(fn.body)
@@ -2494,129 +2479,6 @@ class Interpreter<R> {
}) })
} }
private invokeJsonMethod(
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const callback = args[1]
const callable =
callback instanceof CodeModeFunction ||
callback instanceof CoercionFunction ||
callback instanceof GlobalMethodReference ||
callback instanceof UriFunction
if (name === "parse" && callable) return this.parseJsonWithReviver(args, node)
if (name === "stringify" && (callable || Array.isArray(callback))) {
return this.stringifyJsonWithReplacer(args, node)
}
return Effect.sync(() => invokeJsonMethod(name, args, node))
}
private parseJsonWithReviver(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
const apply = this.applySettledCollectionCallback(args[1], "JSON.parse", node)
const visit = (key: string, item: unknown): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
if (Array.isArray(item)) {
for (let index = 0; index < item.length; index += 1) {
if (!(index in item)) continue
const revived = yield* visit(String(index), item[index])
if (revived === undefined) {
delete item[index]
continue
}
item[index] = revived
}
return yield* apply([key, item])
}
if (item !== null && typeof item === "object" && !isSandboxValue(item)) {
const object = item as SafeObject
for (const childKey of Object.keys(object)) {
const revived = yield* visit(childKey, object[childKey])
if (revived === undefined) {
delete object[childKey]
continue
}
object[childKey] = revived
}
}
return yield* apply([key, item])
})
return visit("", invokeJsonMethod("parse", [args[0]], node))
}
private stringifyJsonWithReplacer(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
const callback = args[1]
const apply = Array.isArray(callback)
? undefined
: this.applySettledCollectionCallback(callback, "JSON.stringify", node)
const ancestors = new Set<object>()
const propertyList = Array.isArray(callback)
? Array.from(
new Set(
callback
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String),
),
)
: undefined
const visit = (key: string, item: unknown, depth: number): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const resolved = yield* (() => {
if (apply === undefined) return Effect.succeed(item)
return apply([
key,
item instanceof SandboxDate || item instanceof SandboxURL
? copyIn(item, "JSON.stringify value")
: item,
])
})()
if (typeofValue(resolved) === "function") return undefined
if (resolved === null || typeof resolved !== "object") return resolved
if (isSandboxValue(resolved)) {
return apply === undefined
? copyIn(resolved, "JSON.stringify replacer result")
: (Object.create(null) as SafeObject)
}
if (!Array.isArray(resolved)) {
const prototype = Object.getPrototypeOf(resolved)
if (prototype !== Object.prototype && prototype !== null) {
return copyIn(resolved, "JSON.stringify replacer result")
}
}
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError(
"InvalidDataValue",
`JSON.stringify replacer result exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`,
)
}
if (ancestors.has(resolved)) {
throw new ToolRuntimeError("InvalidDataValue", "JSON.stringify replacer result contains a circular value.")
}
ancestors.add(resolved)
return yield* Effect.gen(function* () {
if (Array.isArray(resolved)) {
const output: Array<unknown> = []
const length = resolved.length
for (let index = 0; index < length; index += 1) {
output[index] = yield* visit(String(index), resolved[index], depth + 1)
}
return output
}
const output: SafeObject = Object.create(null) as SafeObject
for (const childKey of propertyList ?? Object.keys(resolved)) {
if (isBlockedMember(childKey)) continue
if (apply === undefined && !Object.hasOwn(resolved, childKey)) continue
const child = yield* visit(childKey, (resolved as SafeObject)[childKey], depth + 1)
if (child !== undefined) output[childKey] = child
}
return output
}).pipe(Effect.ensuring(Effect.sync(() => ancestors.delete(resolved))))
})
return Effect.map(visit("", args[0], 0), (value) =>
invokeJsonMethod("stringify", [value, propertyList, args[2]], node),
)
}
// Runs a collection callback accepting a user function or supported builtin callable, // Runs a collection callback accepting a user function or supported builtin callable,
// mirroring the array-method callback contract. // mirroring the array-method callback contract.
private applyCollectionCallback( private applyCollectionCallback(
@@ -2627,7 +2489,6 @@ class Interpreter<R> {
if ( if (
!(callback instanceof CodeModeFunction) && !(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) && !(callback instanceof CoercionFunction) &&
!(callback instanceof GlobalMethodReference) &&
!(callback instanceof UriFunction) !(callback instanceof UriFunction)
) { ) {
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
@@ -2635,25 +2496,11 @@ class Interpreter<R> {
return (callbackArgs) => return (callbackArgs) =>
callback instanceof CoercionFunction callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) ? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: callback instanceof GlobalMethodReference
? Effect.succeed(invokeGlobalMethod(callback, callbackArgs, node))
: callback instanceof UriFunction : callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: this.invokeFunction(callback, callbackArgs) : this.invokeFunction(callback, callbackArgs)
} }
private applySettledCollectionCallback(
callback: unknown,
name: string,
node: AstNode,
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
const apply = this.applyCollectionCallback(callback, name, node)
return (args) =>
Effect.flatMap(apply(args), (value) =>
value instanceof SandboxPromise ? this.settlePromise(value, node) : Effect.succeed(value),
)
}
private invokeMapMethod( private invokeMapMethod(
target: SandboxMap, target: SandboxMap,
name: string, name: string,
+16 -6
View File
@@ -1,4 +1,9 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import {
type AstNode,
CodeModeFunction,
InterpreterRuntimeError,
supportedSyntaxMessage,
} from "../interpreter/model.js"
import { copyIn, copyOut } from "../tool-runtime.js" import { copyIn, copyOut } from "../tool-runtime.js"
export const jsonStatics = new Set(["stringify", "parse"]) export const jsonStatics = new Set(["stringify", "parse"])
@@ -7,13 +12,18 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
switch (name) { switch (name) {
case "stringify": { case "stringify": {
const replacer = args[1]
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
throw new InterpreterRuntimeError(
"JSON.stringify replacers are not supported in CodeMode.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
const space = args[2] const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined const indent = typeof space === "number" || typeof space === "string" ? space : undefined
return JSON.stringify( return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
copyOut(copyIn(args[0], "JSON.stringify value")),
Array.isArray(args[1]) ? args[1] : null,
indent,
)
} }
case "parse": { case "parse": {
const text = args[0] const text = args[0]
+2
View File
@@ -1,6 +1,7 @@
export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
export const mathMethods = new Set([ export const mathMethods = new Set([
"random",
"max", "max",
"min", "min",
"abs", "abs",
@@ -40,6 +41,7 @@ export const mathMethods = new Set([
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => { export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
if (name === "random") return Math.random()
const nums = args.map((arg) => { const nums = args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg return arg
+14 -2
View File
@@ -1,6 +1,15 @@
export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"])
export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) export const numberConstants = new Set([
"MAX_SAFE_INTEGER",
"MIN_SAFE_INTEGER",
"MAX_VALUE",
"MIN_VALUE",
"EPSILON",
"NaN",
"POSITIVE_INFINITY",
"NEGATIVE_INFINITY",
])
export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
@@ -32,6 +41,9 @@ export const invokeNumberMethod = (value: number, name: string, args: Array<unkn
result = value.toString(radix) result = value.toString(radix)
break break
} }
case "valueOf":
result = value
break
default: default:
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
} }
+18 -15
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember } from "../tool-runtime.js" import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js" import { boundedData, coerceToString } from "./value.js"
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
@@ -10,13 +10,23 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
const requireObject = (): Record<string, unknown> => { const requireObject = (): Record<string, unknown> => {
const input = args[0] const input = args[0]
const value = boundedData(args[0], `Object.${name} input`)
if (Array.isArray(input)) return input as unknown as Record<string, unknown> if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (isSandboxValue(value)) return {} if (isSandboxValue(input)) return {}
if (value === null || typeof value !== "object") { if (input instanceof SandboxPromise) {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node) throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
} }
return value as Record<string, unknown> if (input === null || typeof input !== "object") {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
const prototype = Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
return input as Record<string, unknown>
} }
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => { const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
@@ -28,15 +38,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
guardedSet(out, coerceToString(key), item) guardedSet(out, coerceToString(key), item)
} }
switch (name) { switch (name) {
case "keys": { case "keys":
const value = boundedData(args[0], "Object.keys input") return Object.keys(requireObject())
if (isSandboxValue(value)) return []
if (Array.isArray(value)) return Object.keys(value)
if (value === null || typeof value !== "object") {
throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node)
}
return Object.keys(value)
}
case "values": case "values":
return Object.values(requireObject()) return Object.values(requireObject())
case "entries": case "entries":
+2 -1
View File
@@ -119,7 +119,7 @@ export class ToolReference {
* limit) purely because it produces a clearer diagnostic than a native stack-overflow * limit) purely because it produces a clearer diagnostic than a native stack-overflow
* RangeError would. * RangeError would.
*/ */
export const MAX_VALUE_DEPTH = 32 const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error { export class ToolRuntimeError extends Error {
constructor( constructor(
@@ -617,6 +617,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"", "",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
] ]
+21
View File
@@ -658,6 +658,9 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals") expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations") expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain(
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
)
expect(instructions).toContain( expect(instructions).toContain(
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
) )
@@ -1081,6 +1084,24 @@ describe("CodeMode public contract", () => {
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
}) })
test("returns the final top-level expression when return is omitted", async () => {
const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` }))
expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] })
})
test("does not implicitly return expressions nested in control flow", async () => {
const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` }))
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
})
test("returns null when the final top-level statement is not an expression", async () => {
const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` }))
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
})
test("rejects invalid configuration and discovery limits", async () => { test("rejects invalid configuration and discovery limits", async () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
+6
View File
@@ -245,6 +245,12 @@ describe("promises at data boundaries", () => {
expect(diagnostic.message).toContain("await tools.ns.tool(...)") expect(diagnostic.message).toContain("await tools.ns.tool(...)")
}) })
test("collection helpers do not let un-awaited promises cross the result boundary", async () => {
const diagnostic = await error(`return Array.from([Promise.resolve(1)])`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue") expect(diagnostic.kind).toBe("InvalidDataValue")
+96 -145
View File
@@ -19,158 +19,25 @@ const error = async (code: string) => {
return result.error return result.error
} }
describe("JSON", () => { describe("Number and Math", () => {
test("parse revivers run post-order including the root", async () => { test("Math.random returns a number in [0, 1)", async () => {
expect( expect(await value(`const n = Math.random(); return typeof n === "number" && n >= 0 && n < 1`)).toBe(true)
await value(`
const order = []
const parsed = JSON.parse('{"item":{"count":2}}', (key, item) => {
order.push(key)
if (key === "count") return item + 1
if (key === "") return { parsed: item }
return item
})
return { parsed, order }
`),
).toEqual({ parsed: { parsed: { item: { count: 3 } } }, order: ["count", "item", ""] })
expect(await value(`return JSON.parse("1", (key, value) => undefined) === undefined`)).toBe(true)
}) })
test("parse reviver deletion removes properties and creates array holes", async () => { test("Number exposes native non-finite constants", async () => {
expect( expect(
await value(` await value(
const parsed = JSON.parse('{"keep":1,"drop":2,"items":[1,2,3]}', (key, item) => `return [Number.isNaN(Number.NaN), Number.POSITIVE_INFINITY === Infinity, Number.NEGATIVE_INFINITY === -Infinity]`,
key === "drop" || key === "1" ? undefined : item ),
) ).toEqual([true, true, true])
return { json: JSON.stringify(parsed), hasSecond: 1 in parsed.items, length: parsed.items.length }
`),
).toEqual({ json: '{"keep":1,"items":[1,null,3]}', hasSecond: false, length: 3 })
}) })
test("stringify function replacers run root-first and apply JSON deletion rules", async () => { test("Number valueOf returns its primitive receiver", async () => {
expect( expect(await value(`return (42).valueOf()`)).toBe(42)
await value(`
const order = []
const json = JSON.stringify({ keep: 1, drop: 2, items: [1, 2] }, (key, item) => {
order.push(key)
if (key === "drop" || key === "1") return undefined
return item
})
return { json, order }
`),
).toEqual({ json: '{"keep":1,"items":[1,null]}', order: ["", "keep", "drop", "items", "0", "1"] })
expect(
await value(`
return [
JSON.stringify(1, (key, item) => key === "" ? new Date(0) : item),
JSON.stringify(1, (key, item) => key === "" ? new URL("https://example.test") : item),
]
`),
).toEqual(["{}", "{}"])
}) })
test("stringify function replacers observe live mutations and sandbox values", async () => { test("Number valueOf does not enable boxed numbers", async () => {
expect( expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax")
await value(`
const item = { a: 1, b: 2 }
const visited = []
const mutated = JSON.stringify(item, (key, value) => {
visited.push(key)
if (key === "a") item.b = 3
return value
})
const mapped = JSON.stringify(new Map([["x", 1]]), (key, value) =>
value instanceof Map ? Object.fromEntries(value) : value
)
const removed = { a: 1, b: 2 }
const deleted = JSON.stringify(removed, (key, value) => {
if (key === "a") removed.b = undefined
if (key === "b") visited.push(value === undefined ? "missing" : "present")
return value
})
return { mutated, mapped, deleted, visited }
`),
).toEqual({
mutated: '{"a":1,"b":3}',
mapped: '{"x":1}',
deleted: '{"a":1}',
visited: ["", "a", "b", "missing"],
})
})
test("stringify replacer arrays coerce, dedupe, and recursively filter object keys", async () => {
expect(
await value(`
return JSON.stringify(
{ 1: "one", keep: { keep: 2, drop: 3 }, drop: 4, list: [{ keep: 5, drop: 6 }] },
["keep", 1, "keep", null, {}, "list"],
)
`),
).toBe('{"keep":{"keep":2},"1":"one","list":[{"keep":5}]}')
})
test("JSON callbacks settle async tool calls sequentially", async () => {
const calls: Array<number> = []
const transform = Tool.make({
description: "Transform a number",
input: Schema.Number,
output: Schema.Number,
run: (input) =>
Effect.sync(() => {
calls.push(input)
return input * 10
}),
})
const parse = await Effect.runPromise(
CodeMode.execute({
tools: { host: { transform } },
code: `return JSON.parse("[1,2]", async (key, item) => key === "" ? item : await tools.host.transform(item))`,
}),
)
expect(parse.ok && parse.value).toEqual([10, 20])
const stringify = await Effect.runPromise(
CodeMode.execute({
tools: { host: { transform } },
code: `return JSON.stringify([1,2], async (key, item) => key === "" ? item : await tools.host.transform(item))`,
}),
)
expect(stringify.ok && stringify.value).toBe("[10,20]")
expect(calls).toEqual([1, 2, 1, 2])
})
test("non-callable JSON callback arguments are ignored", async () => {
expect(await value(`return [JSON.parse("{\\"a\\":1}", 42), JSON.stringify({ a: 1 }, { nope: true })]`)).toEqual([
{ a: 1 },
'{"a":1}',
])
expect(await value(`return [JSON.parse("1", Boolean), JSON.stringify({ a: 1 }, String)]`)).toEqual([false, '""'])
})
test("supported global methods work as JSON callbacks", async () => {
expect(
await value(`return [JSON.parse("[1,2]", Number.isFinite), JSON.stringify({ a: 1 }, Number.isFinite)]`),
).toEqual([false, "false"])
})
test("stringify replacers can prune values before the depth limit", async () => {
expect(
await value(`
let item = 1
for (let index = 0; index < 33; index++) item = { x: item }
let calls = 0
return JSON.stringify(item, (key, value) => ++calls === 34 ? undefined : value)
`),
).toBe(`${'{"x":'.repeat(32)}{}${"}".repeat(32)}`)
})
test("stringify omits callable replacer results", async () => {
expect(
await value(`
return JSON.stringify({ object: 1, array: [1] }, (key, value) =>
key === "object" || key === "0" ? Number.isFinite : value
)
`),
).toBe('{"array":[null]}')
}) })
}) })
@@ -906,6 +773,43 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
) )
}) })
test("Object.values/entries preserve nested object identity", async () => {
expect(
await value(`
const child = { selected: false }
const rows = { a: child }
Object.values(rows)[0].selected = true
return child.selected
`),
).toBe(true)
expect(
await value(`
const child = { selected: false }
const rows = { a: child }
Object.entries(rows)[0][1].selected = true
return child.selected
`),
).toBe(true)
})
test("Object enumeration preserves promises and callable references", async () => {
expect(
await value(`
const pending = Promise.resolve(1)
const source = { pending }
return [Object.keys(source), Object.hasOwn(source, "pending"), await Object.values(source)[0], await Object.entries(source)[0][1]]
`),
).toEqual([["pending"], true, 1, 1])
expect(await value(`return Object.values({ max: Math.max })[0](1, 2)`)).toBe(2)
})
test("Object enumeration rejects invalid receivers and gives promises an await hint", async () => {
const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("await")
expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue")
})
test("Object.assign keeps Maps usable", async () => { test("Object.assign keeps Maps usable", async () => {
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(
1, 1,
@@ -928,6 +832,53 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
}) })
test("Array.from and Array.of preserve nested object identity", async () => {
expect(
await value(`
const child = { selected: false }
Array.from([child])[0].selected = true
return child.selected
`),
).toBe(true)
expect(
await value(`
const child = { selected: false }
Array.of(child)[0].selected = true
return child.selected
`),
).toBe(true)
})
test("Array.from and Array.of preserve promises and callable references", async () => {
expect(
await value(`
const pending = Promise.resolve(1)
return [await Array.from([pending])[0], await Array.of(pending)[0]]
`),
).toEqual([1, 1])
expect(await value(`return [Array.from([Math.max])[0](1, 2), Array.of(Math.max)[0](3, 4)]`)).toEqual([2, 4])
})
test("Array.from preserves identity across supported collection shapes", async () => {
expect(
await value(`
const child = { selected: false }
const fromArrayLike = Array.from({ 0: child, length: 1 })
const fromMap = Array.from(new Map([["child", child]]))
const fromSet = Array.from(new Set([child]))
fromArrayLike[0].selected = true
return [fromMap[0][1] === child, fromSet[0] === child, child.selected]
`),
).toEqual([true, true, true])
})
test("Array.from rejects invalid receivers and gives promises an await hint", async () => {
const diagnostic = await error(`return Array.from(Promise.resolve([1]))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("await")
expect((await error(`return Array.from(() => 1)`)).kind).toBe("InvalidDataValue")
})
test("regexes stay callable through Object.values", async () => { test("regexes stay callable through Object.values", async () => {
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
}) })
+127
View File
@@ -0,0 +1,127 @@
export * as CodeModeV2 from "./code-mode"
import type { CodeMode } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "./effect/app-node"
import { Flag } from "./flag/flag"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./code-mode/execute"
import { permission, RegistrationError, type AnyTool } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
type Registration = {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly path: readonly [string, ...string[]]
}
export interface MaterializeInput {
readonly permissions?: PermissionV2.Ruleset
}
export interface Interface {
readonly enabled: boolean
readonly register: (
source: (draft: CodeMode.Draft) => void,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly materialize: (input: MaterializeInput) => Effect.Effect<AnyTool | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const enabled = Flag.CODEMODE_ENABLED
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
return Service.of({
enabled,
register: Effect.fn("CodeMode.register")(function* (source) {
const pending: Array<{ readonly path: readonly [string, ...string[]]; readonly tool: AnyTool }> = []
yield* Effect.sync(() =>
source({
add: (path, tool) => pending.push({ path, tool }),
}),
)
if (pending.length === 0) return
const entries = pending.map((entry) => ({
...entry,
key: entry.path.join("\0"),
name: entry.path.join("_"),
}))
const invalid = entries.find((entry) => entry.path.some((segment) => segment.length === 0))
if (invalid)
return yield* new RegistrationError({
name: invalid.path.join("."),
message: "Code Mode paths cannot contain empty segments",
})
const keys = new Set<string>()
const duplicate = entries.find((entry) => {
if (keys.has(entry.key)) return true
keys.add(entry.key)
return false
})
if (duplicate)
return yield* new RegistrationError({
name: duplicate.path.join("."),
message: `Duplicate Code Mode path: ${duplicate.path.join(".")}`,
})
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
identity: {},
tool: entry.tool,
name: entry.name,
path: entry.path,
},
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
)
}),
)
}),
materialize: Effect.fn("CodeMode.materialize")(function* (input) {
if (!enabled || whollyDisabled("execute", input.permissions ?? [])) return undefined
const registrations = new Map<string, Registration>()
for (const [key, entries] of local) {
const registration = entries.at(-1)?.registration
if (
registration &&
!whollyDisabled(permission(registration.tool, registration.name), input.permissions ?? [])
)
registrations.set(key, registration)
}
if (registrations.size === 0) return undefined
return ExecuteTool.create({
registrations,
current: (key) => local.get(key)?.at(-1)?.registration,
})
}),
})
}),
)
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
export const node = makeLocationNode({ service: Service, layer, deps: [] })
@@ -3,7 +3,7 @@ export * as ExecuteTool from "./execute"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/llm" import { ToolOutput } from "@opencode-ai/llm"
import { Effect, Ref, Schema } from "effect" import { Effect, Ref, Schema } from "effect"
import { definition, make, settle, type AnyTool } from "./tool" import { definition, make, settle, type AnyTool } from "../tool/tool"
const ExecuteFile = Schema.Struct({ const ExecuteFile = Schema.Struct({
data: Schema.String, data: Schema.String,
@@ -40,7 +40,11 @@ export interface Registration {
readonly identity: object readonly identity: object
readonly tool: AnyTool readonly tool: AnyTool
readonly name: string readonly name: string
readonly group?: string readonly path: readonly [string, ...string[]]
}
interface CodeModeTools {
[name: string]: Tool.Definition<never> | CodeModeTools
} }
export const create = (options: { export const create = (options: {
@@ -51,33 +55,16 @@ export const create = (options: {
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>, invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks, hooks?: CodeMode.ToolCallHooks,
) => { ) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {} const tools: CodeModeTools = Object.create(null)
for (const [name, registration] of options.registrations) { for (const [key, registration] of options.registrations) {
const child = definition(name, registration.tool) const child = definition(registration.name, registration.tool)
const value = Tool.make({ const value = Tool.make({
description: child.description, description: child.description,
input: child.inputSchema, input: child.inputSchema,
output: child.outputSchema, output: child.outputSchema,
run: (input) => invoke(name, registration, input), run: (input) => invoke(key, registration, input),
}) })
if (registration.group === undefined) { addTool(tools, registration.path, value)
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
entries[path] = value
tools[namespace] = entries
} }
return CodeMode.make<typeof tools>({ tools, ...hooks }) return CodeMode.make<typeof tools>({ tools, ...hooks })
} }
@@ -112,15 +99,15 @@ export const create = (options: {
), ),
) )
const result = yield* runtime( const result = yield* runtime(
(name, registration, input) => (key, registration, input) =>
Effect.gen(function* () { Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const current = options.current(name) const current = options.current(key)
if (!current || current.identity !== registration.identity) if (!current || current.identity !== registration.identity)
return yield* Effect.fail(toolError(`Stale tool call: ${name}`)) return yield* Effect.fail(toolError(`Stale tool call: ${registration.path.join(".")}`))
const output = yield* settle( const output = yield* settle(
current.tool, current.tool,
{ type: "tool-call", id: context.toolCallID, name, input }, { type: "tool-call", id: context.toolCallID, name: registration.name, input },
{ {
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
@@ -163,6 +150,21 @@ export const create = (options: {
}) })
} }
function addTool(tools: CodeModeTools, path: readonly string[], value: Tool.Definition<never>) {
const [name, ...rest] = path
if (name === undefined) return
if (rest.length === 0) {
if (Object.hasOwn(tools, name)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
tools[name] = value
return
}
const current = tools[name]
if (Tool.isDefinition(current)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
const child: CodeModeTools = current ?? Object.create(null)
tools[name] = child
addTool(child, rest, value)
}
function displayInput(input: unknown): Record<string, unknown> | undefined { function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input } if (typeof input !== "object" || Array.isArray(input)) return { input }
+15
View File
@@ -2,6 +2,7 @@ import { Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
import { Catalog } from "./catalog" import { Catalog } from "./catalog"
import { CodeModeV2 } from "./code-mode"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { Config } from "./config" import { Config } from "./config"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
@@ -119,6 +120,7 @@ const locationServiceNodes = [
MCP.node, MCP.node,
PermissionV2.node, PermissionV2.node,
ToolOutputStore.node, ToolOutputStore.node,
CodeModeV2.node,
ToolRegistry.node, ToolRegistry.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
Image.node, Image.node,
@@ -150,8 +152,14 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap( export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [], replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> { ): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
// different RcMap keys. The RcMap caches by the raw key before the build
// callback runs, so canonicalize at the map boundary to the key-present shape.
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
return Layer.effect( return Layer.effect(
LocationServiceMap.Service, LocationServiceMap.Service,
Effect.map(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const startedAt = performance.now() const startedAt = performance.now()
@@ -176,6 +184,13 @@ export function buildLocationServiceMap(
}, },
{ idleTimeToLive: "60 minutes" }, { idleTimeToLive: "60 minutes" },
), ),
(inner) => ({
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
) )
} }
+115 -13
View File
@@ -3,7 +3,7 @@ export * as MCPClient from "./client"
import path from "node:path" import path from "node:path"
import { execFile } from "node:child_process" import { execFile } from "node:child_process"
import { pathToFileURL } from "node:url" import { pathToFileURL } from "node:url"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
@@ -21,6 +21,7 @@ import {
ListToolsResultSchema, ListToolsResultSchema,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
PromptSchema, PromptSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification, type LoggingMessageNotification,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
@@ -68,11 +69,13 @@ export interface ToolDefinition {
export interface PromptDefinition { export interface PromptDefinition {
readonly name: string readonly name: string
readonly description: string | undefined readonly description: string | undefined
readonly arguments: ReadonlyArray<{ readonly arguments:
| ReadonlyArray<{
readonly name: string readonly name: string
readonly description: string | undefined readonly description: string | undefined
readonly required: boolean | undefined readonly required: boolean | undefined
}> | undefined }>
| undefined
} }
export interface PromptMessage { export interface PromptMessage {
@@ -84,6 +87,28 @@ export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage> readonly messages: ReadonlyArray<PromptMessage>
} }
export interface ResourceDefinition {
readonly name: string
readonly uri: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export interface ResourceTemplateDefinition {
readonly name: string
readonly uriTemplate: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export type ResourceContentPart =
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined }
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined }
export interface ReadResourceResult {
readonly contents: ReadonlyArray<ResourceContentPart>
}
export type CallToolContent = export type CallToolContent =
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string }
@@ -124,6 +149,12 @@ export interface Connection {
readonly tools: () => Effect.Effect<ToolDefinition[], Error> readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error> readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Lists the server's resources; returns [] when the server doesn't advertise resource support. */
readonly resources: () => Effect.Effect<ResourceDefinition[], Error>
/** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateDefinition[], Error>
/** Reads one resource; returns undefined when the server doesn't advertise resource support. */
readonly readResource: (input: { readonly uri: string }) => Effect.Effect<ReadResourceResult | undefined, Error>
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */ /** Invokes a prompt on the server. Interruption aborts the in-flight request. */
readonly prompt: (input: { readonly prompt: (input: {
readonly name: string readonly name: string
@@ -141,6 +172,8 @@ export interface Connection {
readonly onToolsChanged: (callback: () => void) => void readonly onToolsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
readonly onPromptsChanged: (callback: () => void) => void readonly onPromptsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its resource catalog changed. */
readonly onResourcesChanged: (callback: () => void) => void
} }
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
@@ -168,7 +201,8 @@ export const connect = Effect.fnUntraced(function* (
}, },
}) })
} }
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), { return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined, requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider, authProvider,
@@ -202,10 +236,7 @@ export const connect = Effect.fnUntraced(function* (
}).pipe(Effect.exit) }).pipe(Effect.exit)
if (Exit.isSuccess(exit)) { if (Exit.isSuccess(exit)) {
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
cleanupStdioDescendants(transport).pipe( cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
Effect.andThen(Effect.promise(() => client.close())),
Effect.ignore,
),
) )
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
@@ -257,7 +288,9 @@ export const connect = Effect.fnUntraced(function* (
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), Effect.tapError((error) =>
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
),
) )
return prompts.map((prompt) => ({ return prompts.map((prompt) => ({
name: prompt.name, name: prompt.name,
@@ -269,6 +302,74 @@ export const connect = Effect.fnUntraced(function* (
})), })),
})) }))
}), }),
resources: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const resources = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.resources,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
),
)
return resources.map((resource) => ({
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}))
}),
resourceTemplates: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const templates = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
timeout: catalogTimeout,
}),
(result) => result.resourceTemplates,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
),
)
return templates.map((template) => ({
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}))
}),
readResource: (input) =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return undefined
const result = yield* Effect.tryPromise({
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
),
)
return {
contents: result.contents.map(
(part): ResourceContentPart =>
"text" in part
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
),
}
}),
prompt: (input) => prompt: (input) =>
Effect.tryPromise({ Effect.tryPromise({
try: (signal) => try: (signal) =>
@@ -328,13 +429,14 @@ export const connect = Effect.fnUntraced(function* (
if (!client.getServerCapabilities()?.prompts?.listChanged) return if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
}, },
onResourcesChanged: (callback) => {
if (!client.getServerCapabilities()?.resources?.listChanged) return
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback())
},
} satisfies Connection } satisfies Connection
} }
yield* cleanupStdioDescendants(transport).pipe( yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
Effect.andThen(Effect.promise(() => transport.close())),
Effect.ignore,
)
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
+17 -13
View File
@@ -1,7 +1,7 @@
export * as McpGuidance from "./guidance" export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { Flag } from "../flag/flag" import { CodeModeV2 } from "../code-mode"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
@@ -15,10 +15,10 @@ const Summary = Schema.Struct({
}) })
type Summary = typeof Summary.Type type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) => const entries = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
servers.flatMap((server) => [ servers.flatMap((server) => [
` <server name="${server.server}">`, ` <server name="${server.server}">`,
...(Flag.CODEMODE_ENABLED ...(codeMode
? [ ? [
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
] ]
@@ -27,10 +27,10 @@ const entries = (servers: ReadonlyArray<Summary>) =>
" </server>", " </server>",
]) ])
const render = (servers: ReadonlyArray<Summary>) => const render = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n") ["<mcp_instructions>", ...entries(servers, codeMode), "</mcp_instructions>"].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => { const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>, codeMode: boolean) => {
const diff = Instructions.diffByKey( const diff = Instructions.diffByKey(
previous, previous,
current, current,
@@ -41,12 +41,15 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [ return [
"The available MCP server instructions have changed. This list supersedes the previous one.", "The available MCP server instructions have changed. This list supersedes the previous one.",
render(current), render(current, codeMode),
].join("\n") ].join("\n")
return [ return [
...(diff.added.length === 0 ...(diff.added.length === 0
? [] ? []
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]), : [
"New MCP server instructions are available in addition to those previously listed:",
...entries(diff.added, codeMode),
]),
...(diff.removed.length === 0 ...(diff.removed.length === 0
? [] ? []
: [ : [
@@ -64,13 +67,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
return Service.of({ return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) { load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info const agent = selection.info
if (!agent) return Instructions.empty if (!agent) return Instructions.empty
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") if (codeMode.enabled && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return Instructions.empty return Instructions.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded", concurrency: "unbounded",
@@ -80,7 +84,7 @@ export const layer = Layer.effect(
.filter((item) => { .filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server) const owned = tools.filter((tool) => tool.server === item.server)
return ( return (
(!Flag.CODEMODE_ENABLED && owned.length === 0) || (!codeMode.enabled && owned.length === 0) ||
owned.some( owned.some(
(tool) => (tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
@@ -93,8 +97,8 @@ export const layer = Layer.effect(
key: Instructions.Key.make("core/mcp-guidance"), key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)), codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible), load: Effect.succeed(visible),
baseline: render, baseline: (servers) => render(servers, codeMode.enabled),
update, update: (previous, current) => update(previous, current, codeMode.enabled),
removed: () => "MCP server instructions are no longer available.", removed: () => "MCP server instructions are no longer available.",
}) })
}), }),
@@ -102,4 +106,4 @@ export const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [CodeModeV2.node, MCP.node] })
+80 -50
View File
@@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")
messages: Schema.Array(PromptMessage), messages: Schema.Array(PromptMessage),
}) {} }) {}
export class Resource extends Schema.Class<Resource>("MCP.Resource")({ export const Resource = Mcp.Resource
server: ServerName, export type Resource = Mcp.Resource
name: Schema.String, export const ResourceTemplate = Mcp.ResourceTemplate
uri: Schema.String, export type ResourceTemplate = Mcp.ResourceTemplate
description: Schema.String.pipe(Schema.optional), export const ResourceCatalog = Mcp.ResourceCatalog
mimeType: Schema.String.pipe(Schema.optional), export type ResourceCatalog = Mcp.ResourceCatalog
}) {} export const ResourceContentPart = Mcp.ResourceContentPart
export type ResourceContentPart = Mcp.ResourceContentPart
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({ export const ResourceContent = Mcp.ResourceContent
server: ServerName, export type ResourceContent = Mcp.ResourceContent
name: Schema.String,
uriTemplate: Schema.String,
description: Schema.String.pipe(Schema.optional),
mimeType: Schema.String.pipe(Schema.optional),
}) {}
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
resources: Schema.Array(Resource),
templates: Schema.Array(ResourceTemplate),
}) {}
export const ResourceContentPart = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
uri: Schema.String,
text: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
Schema.Struct({
type: Schema.Literal("blob"),
uri: Schema.String,
blob: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
]).pipe(Schema.toTaggedUnion("type"))
export type ResourceContentPart = typeof ResourceContentPart.Type
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
server: ServerName,
uri: Schema.String,
contents: Schema.Array(ResourceContentPart),
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", { export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName, server: ServerName,
@@ -415,6 +383,24 @@ export const layer = Layer.effect(
), ),
}) })
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
uri: def.uri,
description: def.description,
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
uriTemplate: def.uriTemplate,
description: def.description,
mimeType: def.mimeType,
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe( connection.tools().pipe(
Effect.map((defs) => { Effect.map((defs) => {
@@ -443,6 +429,7 @@ export const layer = Layer.effect(
entry.prompts = undefined entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" } entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
}) })
@@ -458,6 +445,10 @@ export const layer = Layer.effect(
connection.onPromptsChanged(() => { connection.onPromptsChanged(() => {
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
}) })
connection.onResourcesChanged(() => {
if (entry.client !== connection) return
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
})
} }
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
@@ -501,6 +492,7 @@ export const layer = Layer.effect(
// after the initial registration sweep and emits no list-changed notification would otherwise // after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model. // stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
return return
@@ -557,11 +549,6 @@ export const layer = Layer.effect(
concurrency: "unbounded", concurrency: "unbounded",
discard: true, discard: true,
}) })
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
const target = yield* requireServer(server)
yield* Deferred.await(target.entry.startup)
})
return Service.of({ return Service.of({
servers: Effect.fn("MCP.servers")(function* () { servers: Effect.fn("MCP.servers")(function* () {
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
@@ -637,11 +624,54 @@ export const layer = Layer.effect(
}), }),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady yield* whenAllReady
return new ResourceCatalog({ resources: [], templates: [] }) const catalogs = yield* Effect.forEach(
Array.from(runtime),
([name, entry]) => {
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
return Effect.all(
{
resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))),
templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))),
},
{ concurrency: "unbounded" },
).pipe(
Effect.map((catalog) => ({
resources: catalog.resources.map((def) => toResource(name, def)),
templates: catalog.templates.map((def) => toResourceTemplate(name, def)),
})),
)
},
{ concurrency: "unbounded" },
)
return ResourceCatalog.make({
resources: catalogs
.flatMap((catalog) => catalog.resources)
.toSorted(
(a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
),
templates: catalogs
.flatMap((catalog) => catalog.templates)
.toSorted(
(a, b) =>
a.server.localeCompare(b.server) ||
a.name.localeCompare(b.name) ||
a.uriTemplate.localeCompare(b.uriTemplate),
),
})
}), }),
readResource: Effect.fn("MCP.readResource")(function* (input) { readResource: Effect.fn("MCP.readResource")(function* (input) {
yield* gate(input.server) const target = yield* requireServer(input.server)
return undefined yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.readResource({ uri: input.uri })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result) return undefined
return ResourceContent.make({
server: target.name,
uri: input.uri,
contents: result.contents,
})
}), }),
}) })
}), }),
+2
View File
@@ -7,6 +7,7 @@ import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
import { Catalog } from "./catalog" import { Catalog } from "./catalog"
import { CodeModeV2 } from "./code-mode"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Integration } from "./integration" import { Integration } from "./integration"
@@ -124,6 +125,7 @@ export const node = makeLocationNode({
AgentV2.node, AgentV2.node,
AISDK.node, AISDK.node,
Catalog.node, Catalog.node,
CodeModeV2.node,
CommandV2.node, CommandV2.node,
Integration.node, Integration.node,
Location.node, Location.node,
+5
View File
@@ -6,6 +6,7 @@ import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk" import { AISDK } from "../aisdk"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { CodeModeV2 } from "../code-mode"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
import { Credential } from "../credential" import { Credential } from "../credential"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@@ -28,6 +29,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const codeMode = yield* CodeModeV2.Service
const commands = yield* CommandV2.Service const commands = yield* CommandV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
@@ -158,6 +160,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
callback(draft) callback(draft)
}), }),
}, },
codemode: {
register: codeMode.register,
},
event: { event: {
subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)), subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)),
}, },
+2
View File
@@ -243,6 +243,7 @@ export interface Interface {
text: string text: string
description?: string description?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
resume?: boolean
}) => Effect.Effect<void, NotFoundError> }) => Effect.Effect<void, NotFoundError>
readonly revert: { readonly revert: {
readonly stage: (input: { readonly stage: (input: {
@@ -682,6 +683,7 @@ const layer = Layer.effect(
description: input.description, description: input.description,
metadata: input.metadata, metadata: input.metadata,
}) })
if (input.resume === false) return
yield* execution yield* execution
.resume(input.sessionID) .resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
+11 -9
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Flag } from "../flag/flag" import { CodeModeV2 } from "../code-mode"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -21,6 +21,7 @@ export const name = (server: string, tool: string) => `${group(server)}_${tool.r
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const codeMode = yield* CodeModeV2.Service
const tools = yield* Tools.Service const tools = yield* Tools.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
@@ -105,13 +106,14 @@ export const layer = Layer.effectDiscard(
groups.set(tool.server, group) groups.set(tool.server, group)
} }
const next = yield* Scope.fork(scope) const next = yield* Scope.fork(scope)
yield* Effect.forEach( const register = codeMode.enabled
groups, ? codeMode.register((draft) => {
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }), for (const [server, record] of groups)
{ for (const [tool, value] of Object.entries(record))
discard: true, draft.add([group(server), tool.replace(/[^a-zA-Z0-9_-]/g, "_")], value)
}, })
).pipe(Scope.provide(next), Effect.orDie) : Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), { discard: true })
yield* register.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void) if (current) yield* Scope.close(current, Exit.void)
current = next current = next
}), }),
@@ -128,5 +130,5 @@ export const layer = Layer.effectDiscard(
export const node = makeLocationNode({ export const node = makeLocationNode({
name: "mcp-tools", name: "mcp-tools",
layer, layer,
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node], deps: [CodeModeV2.node, ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
}) })
+9 -27
View File
@@ -3,13 +3,12 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent" import type { AgentV2 } from "../agent"
import { Flag } from "../flag/flag" import { CodeModeV2 } from "../code-mode"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message" import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store" import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard" import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
import { ToolHooks } from "./hooks" import { ToolHooks } from "./hooks"
@@ -55,14 +54,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const registryLayer = Layer.effect( const registryLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const resources = yield* ToolOutputStore.Service const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service const toolHooks = yield* ToolHooks.Service
type Registration = { type Registration = {
readonly identity: object readonly identity: object
readonly tool: AnyTool readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly deferred: boolean
} }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>() const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -150,7 +147,7 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools, options) { register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group) const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return if (entries.length === 0) return
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute") const reserved = entries.find((entry) => entry.key === "execute")
if (reserved) if (reserved)
return yield* Effect.fail( return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
@@ -166,9 +163,6 @@ const registryLayer = Layer.effect(
registration: { registration: {
identity: {}, identity: {},
tool: entry.tool, tool: entry.tool,
name: entry.name,
group: entry.group,
deferred: options?.deferred ?? false,
}, },
}, },
]) ])
@@ -195,30 +189,18 @@ const registryLayer = Layer.effect(
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) { for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if ( if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
)
registrations.delete(name) registrations.delete(name)
} }
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) const execute = yield* codeMode.materialize({ permissions: input.permissions })
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
const execute =
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
})
: undefined
return { return {
definitions: [ definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), ...Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []), ...(execute ? [definition("execute", execute)] : []),
], ],
settle: (input) => { settle: (input) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute) if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name) const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity) if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` }, result: { type: "error", value: `Unknown tool: ${input.call.name}` },
@@ -244,11 +226,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [ToolOutputStore.node, ToolHooks.node], deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
}) })
export const toolsNode = makeLocationNode({ export const toolsNode = makeLocationNode({
service: Tools.Service, service: Tools.Service,
layer, layer,
deps: [ToolOutputStore.node, ToolHooks.node], deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
}) })
+16 -1
View File
@@ -4,16 +4,27 @@ import {
CallToolRequestSchema, CallToolRequestSchema,
GetPromptRequestSchema, GetPromptRequestSchema,
ListPromptsRequestSchema, ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema, ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } }) const server = new Server(
{ name: "timeout", version: "1.0.0" },
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
}) })
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(ListResourcesRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
return { resources: [{ name: "slow", uri: "test://slow" }] }
})
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler(CallToolRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async () => {
await Bun.sleep(100) await Bun.sleep(100)
return { content: [] } return { content: [] }
@@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
await Bun.sleep(100) await Bun.sleep(100)
return { messages: [] } return { messages: [] }
}) })
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
await Bun.sleep(100)
return { contents: [{ uri: request.params.uri, text: "slow" }] }
})
await server.connect(new StdioServerTransport()) await server.connect(new StdioServerTransport())
+2 -5
View File
@@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]), instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]), prompts: () => Effect.succeed([]),
prompt: () => Effect.succeed(undefined), prompt: () => Effect.succeed(undefined),
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined),
}), }),
) )
export const emptyConfigLayer = Layer.succeed( export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
Config.Service,
Config.Service.of({ entries: () => Effect.succeed([]) }),
)
export const testLocationLayer = Layer.succeed( export const testLocationLayer = Layer.succeed(
Location.Service, Location.Service,
+31 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config" import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
@@ -208,6 +208,36 @@ describe("LocationServiceMap", () => {
), ),
) )
it.live("normalizes ref key shapes to one cached location graph", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.scoped(
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(dir.path)
const absent = Location.Ref.make({ directory })
const present = Location.Ref.make({ directory, workspaceID: undefined })
// The two shapes are not structurally Equal: own-key sets differ.
expect(Object.keys(absent)).toEqual(["directory"])
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
expect(Equal.equals(absent, present)).toBe(false)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
),
),
)
it.live("isolates catalog state by location", () => it.live("isolates catalog state by location", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
+298 -2
View File
@@ -3,26 +3,165 @@ import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { ConfigMCP } from "@opencode-ai/core/config/mcp" import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client" import { MCPClient } from "@opencode-ai/core/mcp/client"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp" import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
let calls = 0 let calls = 0
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
nextCursor?: string
}
type ResourceTemplatePage = {
items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
nextCursor?: string
}
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
return Effect.acquireRelease(
Effect.promise(async () => {
const state = {
resources: [] as ResourcePage["items"],
templates: [] as ResourceTemplatePage["items"],
resourcePages: undefined as Record<string, ResourcePage> | undefined,
templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
contents: [
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
resourceLists: 0,
templateLists: 0,
}
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
{
capabilities: {
tools: {},
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
fetch: (request) => transport.handleRequest(request),
})
return {
state,
url: http.url.toString(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
close: async () => {
await protocol.close().catch(() => {})
await http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function resourceMcpLayer(url: string) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
}),
}),
}),
]),
}),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, {
subscribe: () => Stream.never,
publish: (definition, data) =>
Effect.succeed({
id: EventV2.ID.create(),
type: definition.type,
data,
} as EventV2.Payload<typeof definition>),
}),
Layer.mock(Form.Service, {}),
Layer.mock(Integration.Service, {
connection: {
active: unusedIntegration,
resolve: unusedIntegration,
key: unusedIntegration,
oauth: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
},
attempt: {
status: unusedIntegration,
complete: unusedIntegration,
cancel: unusedIntegration,
},
}),
Layer.mock(Credential.Service, {}),
),
),
)
}
const mcp = Layer.mock(MCP.Service, { const mcp = Layer.mock(MCP.Service, {
tools: () => tools: () =>
Effect.succeed([ Effect.succeed([
@@ -241,6 +380,163 @@ test("applies the configured MCP execution timeout to prompts", async () => {
await expect(result).rejects.toThrow("Request timed out") await expect(result).rejects.toThrow("Request timed out")
}) })
test("applies configured MCP timeouts to resource operations", async () => {
const catalog = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-catalog-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
}),
import.meta.dir,
)
return yield* connection.resources()
}),
),
)
await expect(catalog).rejects.toThrow("Request timed out")
const read = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-read-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.readResource({ uri: "test://slow" })
}),
),
)
await expect(read).rejects.toThrow("Request timed out")
})
test("lists, reads, and reports MCP resource changes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ listChanged: true })
server.state.resourcePages = {
initial: {
items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
nextCursor: "resources-2",
},
"resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
}
server.state.templatePages = {
initial: {
items: [{ name: "File", uriTemplate: "docs://{path}" }],
nextCursor: "templates-2",
},
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
}
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([
{ name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
{ name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
])
expect(yield* connection.resourceTemplates()).toEqual([
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
])
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
const changed = yield* Deferred.make<void>()
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
yield* Effect.promise(server.sendResourceListChanged)
yield* Deferred.await(changed)
}),
),
)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false })
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([])
expect(yield* connection.resourceTemplates()).toEqual([])
expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
resources: 0,
templates: 0,
})
}),
),
)
})
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
yield* Effect.gen(function* () {
const service = yield* MCP.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
server: "resources",
name: "Readme",
uri: "docs://readme",
description: undefined,
mimeType: undefined,
},
],
templates: [
{
server: "resources",
name: "File",
uriTemplate: "docs://{path}",
description: undefined,
mimeType: undefined,
},
],
})
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
server: "resources",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
}),
),
)
})
it.effect("advertises MCP output schemas to Code Mode", () => it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
+11 -4
View File
@@ -191,7 +191,7 @@ describe("PluginV2", () => {
}), }),
) )
it.effect("groups tool names and defers registrations from direct exposure", () => it.effect("registers direct and Code Mode tools through separate plugin domains", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
@@ -205,13 +205,17 @@ describe("PluginV2", () => {
const plugin = define({ const plugin = define({
id: "grouped-tools", id: "grouped-tools",
effect: (ctx) => effect: (ctx) =>
ctx.tool Effect.gen(function* () {
yield* ctx.tool
.transform((draft) => { .transform((draft) => {
draft.add("plain", tool("Plain")) draft.add("plain", tool("Plain"))
draft.add("look/up", tool("Lookup"), { group: "context 7" }) draft.add("look/up", tool("Lookup"), { group: "context 7" })
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
}) })
.pipe(Effect.orDie), .pipe(Effect.orDie)
yield* ctx.codemode
.register((draft) => draft.add(["context_7", "search"], tool("Search")))
.pipe(Effect.orDie)
}),
}) })
yield* plugins.activate([{ plugin }]) yield* plugins.activate([{ plugin }])
@@ -221,6 +225,9 @@ describe("PluginV2", () => {
"context_7_look_up", "context_7_look_up",
"execute", "execute",
]) ])
yield* plugins.activate([])
expect((yield* registry.materialize({ model: testModel })).definitions).toEqual([])
}), }),
) )
+2
View File
@@ -1,6 +1,7 @@
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk" import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
import { CommandV2 } from "@opencode-ai/core/command" import { CommandV2 } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -43,6 +44,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
AgentV2.node, AgentV2.node,
AISDK.node, AISDK.node,
Catalog.node, Catalog.node,
CodeModeV2.node,
CommandV2.node, CommandV2.node,
Integration.node, Integration.node,
PluginRuntime.node, PluginRuntime.node,
+3
View File
@@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused command.transform"), transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"), reload: () => Effect.die("unused command.reload"),
}, },
codemode: overrides.codemode ?? {
register: () => Effect.die("unused codemode.register"),
},
event: overrides.event ?? { event: overrides.event ?? {
subscribe: () => Stream.empty, subscribe: () => Stream.empty,
}, },
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
import type { PermissionV2 } from "@opencode-ai/core/permission" import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
@@ -28,7 +30,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
) )
}, },
}) })
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const registryLayer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, CodeModeV2.node]), [
[ToolOutputStore.node, outputStore],
])
const it = testEffect(registryLayer) const it = testEffect(registryLayer)
const identity = { const identity = {
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
@@ -53,6 +57,30 @@ const make = (permission?: string) => {
} }
describe("ToolRegistry", () => { describe("ToolRegistry", () => {
it.effect("materializes nested Code Mode sources as execute", () =>
Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const service = yield* ToolRegistry.Service
yield* codeMode.register((draft) => draft.add(["opencode", "v2", "echo"], make()))
const definitions = yield* toolDefinitions(service)
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(definitions[0]?.description).toContain("tools.opencode.v2.echo")
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: {
type: "tool-call",
id: "call-code-mode",
name: "execute",
input: { code: 'return await tools.opencode.v2.echo({ text: "hello" })' },
},
}),
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
+5 -13
View File
@@ -17,7 +17,12 @@
"tabs": [ "tabs": [
{ {
"tab": "Docs", "tab": "Docs",
"groups": [
{
"group": "Get started",
"pages": ["index", "config", "plugins", "troubleshooting"] "pages": ["index", "config", "plugins", "troubleshooting"]
}
]
}, },
{ {
"tab": "SDK", "tab": "SDK",
@@ -39,19 +44,6 @@
], ],
"global": {} "global": {}
}, },
"navbar": {
"links": [
{
"label": "Discord",
"href": "https://opencode.ai/discord"
}
],
"primary": {
"type": "button",
"label": "GitHub",
"href": "https://github.com/anomalyco/opencode"
}
},
"contextual": { "contextual": {
"options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"] "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
}, },
@@ -40,7 +40,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
@@ -3,7 +3,6 @@ import { Agent } from "@/agent/agent"
import { Job } from "@/job" import { Job } from "@/job"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -34,10 +33,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const worktreeSvc = yield* Worktree.Service const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const jobs = yield* Job.Service const jobs = yield* Job.Service
const flags = yield* RuntimeFlags.Service
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
return { backgroundSubagents: flags.experimentalBackgroundSubagents } return { backgroundSubagents: true }
}) })
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
@@ -159,7 +157,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: { const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: {
params: { sessionID: SessionID } params: { sessionID: SessionID }
}) { }) {
if (!flags.experimentalBackgroundSubagents) return false
return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
}) })
+1 -14
View File
@@ -1,6 +1,5 @@
import * as Tool from "./tool" import * as Tool from "./tool"
import DESCRIPTION from "./task.txt" import DESCRIPTION from "./task.txt"
import { ToolJsonSchema } from "./json-schema"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Job } from "@/job" import { Job } from "@/job"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -12,7 +11,6 @@ import type { SessionPrompt } from "../session/prompt"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Effect, Exit, Schema, Scope } from "effect" import { Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
export interface TaskPromptOps { export interface TaskPromptOps {
@@ -51,8 +49,6 @@ const BaseParameterFields = {
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
} }
const BaseParameters = Schema.Struct(BaseParameterFields)
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
...BaseParameterFields, ...BaseParameterFields,
background: Schema.optional(Schema.Boolean).annotate({ background: Schema.optional(Schema.Boolean).annotate({
@@ -86,7 +82,6 @@ export const TaskTool = Tool.define(
const config = yield* Config.Service const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
const database = yield* Database.Service const database = yield* Database.Service
const run = Effect.fn("TaskTool.execute")(function* ( const run = Effect.fn("TaskTool.execute")(function* (
@@ -95,11 +90,6 @@ export const TaskTool = Tool.define(
) { ) {
const cfg = yield* config.get() const cfg = yield* config.get()
const runInBackground = params.background === true const runInBackground = params.background === true
if (runInBackground && !flags.experimentalBackgroundSubagents) {
return yield* Effect.fail(
new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"),
)
}
if (!ctx.extra?.bypassAgentCheck) { if (!ctx.extra?.bypassAgentCheck) {
yield* ctx.ask({ yield* ctx.ask({
@@ -333,11 +323,8 @@ export const TaskTool = Tool.define(
}) })
return { return {
description: flags.experimentalBackgroundSubagents description: [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n"),
? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n")
: DESCRIPTION,
parameters: Parameters, parameters: Parameters,
jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters),
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
run(params, ctx).pipe(Effect.orDie), run(params, ctx).pipe(Effect.orDie),
} }
@@ -161,7 +161,6 @@ async function renderFooter(
currentModel?: RunInput["model"] currentModel?: RunInput["model"]
currentVariant?: string currentVariant?: string
subagents?: FooterSubagentState subagents?: FooterSubagentState
backgroundSubagents?: boolean
width?: number width?: number
height?: number height?: number
state?: Partial<FooterState> state?: Partial<FooterState>
@@ -199,7 +198,6 @@ async function renderFooter(
subagent={subagents} subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)} theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
tuiConfig={config} tuiConfig={config}
backgroundSubagents={input.backgroundSubagents ?? true}
agent="opencode" agent="opencode"
onSubmit={input.onSubmit ?? (() => true)} onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}} onPermissionReply={() => {}}
@@ -998,7 +996,6 @@ test("direct footer shows editable prompts and additional queued work while runn
]} ]}
theme={() => RUN_THEME_FALLBACK} theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig} tuiConfig={tuiConfig}
backgroundSubagents={true}
agent="opencode" agent="opencode"
onSubmit={() => true} onSubmit={() => true}
onPermissionReply={() => {}} onPermissionReply={() => {}}
@@ -1071,7 +1068,7 @@ test("direct footer shows editable prompts and additional queued work while runn
} }
}) })
test("direct footer separates a lone context hint from model and command hint", async () => { test("direct footer always offers backgrounding for a foreground subagent", async () => {
const app = await renderFooter({ const app = await renderFooter({
providers: [provider()], providers: [provider()],
currentModel: { providerID: "opencode", modelID: "gpt-5" }, currentModel: { providerID: "opencode", modelID: "gpt-5" },
@@ -1082,7 +1079,6 @@ test("direct footer separates a lone context hint from model and command hint",
permissions: [], permissions: [],
questions: [], questions: [],
}, },
backgroundSubagents: false,
width: 160, width: 160,
}) })
@@ -1091,8 +1087,8 @@ test("direct footer separates a lone context hint from model and command hint",
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5") expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
expect(frame).not.toContain("ctrl+b background") expect(frame).toContain("ctrl+b background")
expect(frame).not.toContain("queued") expect(frame).not.toContain("queued")
} finally { } finally {
app.cleanup() app.cleanup()
@@ -1110,7 +1106,6 @@ test("direct footer hides the subagent hint when only completed subagents remain
permissions: [], permissions: [],
questions: [], questions: [],
}, },
backgroundSubagents: false,
width: 160, width: 160,
}) })
@@ -134,7 +134,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async () => { createRuntimeLifecycle: async () => {
@@ -233,7 +232,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async () => { createRuntimeLifecycle: async () => {
@@ -406,7 +404,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: true, thinking: true,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async (input) => { createRuntimeLifecycle: async (input) => {
@@ -496,7 +493,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async (input) => { createRuntimeLifecycle: async (input) => {
@@ -557,7 +553,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async () => { createRuntimeLifecycle: async () => {
@@ -603,7 +598,6 @@ describe("run interactive runtime", () => {
variant: undefined, variant: undefined,
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async (input) => { createRuntimeLifecycle: async (input) => {
@@ -716,7 +710,6 @@ describe("run interactive runtime", () => {
variant: "low", variant: "low",
files: [], files: [],
thinking: false, thinking: false,
backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async (input) => { createRuntimeLifecycle: async (input) => {
@@ -51,7 +51,6 @@ describe("RuntimeFlags", () => {
expect(flags.enableExperimentalModels).toBe(true) expect(flags.enableExperimentalModels).toBe(true)
expect(flags.enableQuestionTool).toBe(true) expect(flags.enableQuestionTool).toBe(true)
expect(flags.experimentalReferences).toBe(true) expect(flags.experimentalReferences).toBe(true)
expect(flags.experimentalBackgroundSubagents).toBe(true)
expect(flags.experimentalLspTy).toBe(false) expect(flags.experimentalLspTy).toBe(false)
expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalLspTool).toBe(true)
expect(flags.experimentalOxfmt).toBe(true) expect(flags.experimentalOxfmt).toBe(true)
@@ -579,8 +579,10 @@ const scenarios: Scenario[] = [
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array), .json(200, array),
http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => {
check(typeof body === "object" && body !== null, "capabilities should be an object") check(
check("backgroundSubagents" in body, "capabilities should report background subagents") typeof body === "object" && body !== null && "backgroundSubagents" in body && body.backgroundSubagents === true,
"capabilities should report background subagents as available",
)
}), }),
http.protected http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background") .post("/experimental/session/{sessionID}/background", "experimental.session.background")
@@ -1,12 +1,13 @@
import { afterEach, describe, expect, mock } from "bun:test" import { afterEach, describe, expect, mock } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer } from "effect" import { Effect, Fiber, Layer } from "effect"
import { Session as SessionNs } from "@/session/session" import { Session as SessionNs } from "@/session/session"
import { Job } from "@/job"
import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { pollWithTimeout, testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer" import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(Job.node), httpApiLayer))
afterEach(async () => { afterEach(async () => {
mock.restore() mock.restore()
@@ -14,6 +15,19 @@ afterEach(async () => {
}) })
describe("session action routes", () => { describe("session action routes", () => {
it.instance(
"reports background subagents as available",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* requestInDirectory("/experimental/capabilities", test.directory)
expect(res.status).toBe(200)
expect(yield* res.json).toEqual({ backgroundSubagents: true })
}),
{ git: true },
)
it.instance( it.instance(
"session routes expose metadata on create, update, get, and fork", "session routes expose metadata on create, update, get, and fork",
() => () =>
@@ -107,4 +121,33 @@ describe("session action routes", () => {
}), }),
{ git: true }, { git: true },
) )
it.instance(
"experimental background route backgrounds a synchronous subagent",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
SessionNs.use.remove(created.id).pipe(Effect.ignore),
)
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "task", run: Effect.never })
const waiting = yield* jobs.block({ id: job.id, sessionID: session.id }).pipe(Effect.forkChild)
const backgrounded = yield* pollWithTimeout(
requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
method: "POST",
}).pipe(
Effect.flatMap((res) => res.json),
Effect.map((value) => (value === true ? true : undefined)),
),
"background route never released the synchronous subagent",
)
expect(backgrounded).toBe(true)
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
yield* jobs.cancel(job.id)
}),
{ git: true },
)
}) })
+4 -3
View File
@@ -150,7 +150,7 @@ describe("tool.registry", () => {
}), }),
) )
it.instance("hides task background parameter unless experimental background subagents are enabled", () => it.instance("exposes the task background parameter by default", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service const agent = yield* Agent.Service
@@ -162,8 +162,9 @@ describe("tool.registry", () => {
agent: build, agent: build,
})).find((tool) => tool.id === "task") })).find((tool) => tool.id === "task")
expect(task?.jsonSchema).toBeDefined() if (!task) throw new Error("task tool not found")
expect((task?.jsonSchema?.properties as Record<string, unknown> | undefined)?.background).toBeUndefined() const jsonSchema = ToolJsonSchema.fromTool(task)
expect((jsonSchema.properties as Record<string, unknown> | undefined)?.background).toBeDefined()
}), }),
) )
+9 -41
View File
@@ -34,7 +34,7 @@ const ref = {
modelID: ModelV2.ID.make("test-model"), modelID: ModelV2.ID.make("test-model"),
} }
const layer = (flags: Partial<RuntimeFlags.Info> = {}) => const layer = () =>
LayerNode.compile( LayerNode.compile(
LayerNode.group([ LayerNode.group([
Agent.node, Agent.node,
@@ -52,11 +52,10 @@ const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
RuntimeFlags.node, RuntimeFlags.node,
Ripgrep.node, Ripgrep.node,
]), ]),
[[RuntimeFlags.node, RuntimeFlags.layer(flags)]], [[RuntimeFlags.node, RuntimeFlags.layer()]],
) )
const it = testEffect(layer()) const it = testEffect(layer())
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
function defer<T>() { function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void let resolve!: (value: T | PromiseLike<T>) => void
@@ -456,37 +455,6 @@ describe("tool.task", () => {
}, },
) )
it.instance("rejects background execution when the experiment is disabled", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const exit = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps: stubOps() },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
it.instance("backgrounds a running foreground task without restarting it", () => it.instance("backgrounds a running foreground task without restarting it", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
@@ -558,7 +526,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("execute launches background tasks without waiting for completion", () => it.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
@@ -596,7 +564,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("running task_id reports the existing background task", () => it.instance("running task_id reports the existing background task", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
@@ -661,7 +629,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("background tasks complete through the job service", () => it.instance("background tasks complete through the job service", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
@@ -694,7 +662,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("background task completion does not wait for the parent async prompt", () => it.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
@@ -732,7 +700,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("removing the parent session cancels running background tasks", () => it.instance("removing the parent session cancels running background tasks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
@@ -771,7 +739,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("removing the child task session cancels its running background task", () => it.instance("removing the child task session cancels its running background task", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
@@ -810,7 +778,7 @@ describe("tool.task", () => {
}), }),
) )
background.instance("cancelling the parent run cancels running background tasks", () => it.instance("cancelling the parent run cancels running background tasks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service const runState = yield* SessionRunState.Service
+1
View File
@@ -475,6 +475,7 @@ export type TuiHostSlotMap = {
} }
sidebar_footer: { sidebar_footer: {
session_id: string session_id: string
directory: string
} }
} }
@@ -0,0 +1,12 @@
import type { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "./tool.js"
export type Path = readonly [string, ...string[]]
export interface Draft {
add(path: Path, tool: AnyTool): void
}
export interface Domain {
readonly register: (source: (draft: Draft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
+2
View File
@@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
import type { AISDKHooks } from "./aisdk.js" import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js" import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js" import type { CommandHooks } from "./command.js"
import type { Domain } from "./code-mode.js"
import type { EventHooks } from "./event.js" import type { EventHooks } from "./event.js"
import type { IntegrationHooks } from "./integration.js" import type { IntegrationHooks } from "./integration.js"
import type { PluginDomain } from "./plugin.js" import type { PluginDomain } from "./plugin.js"
@@ -17,6 +18,7 @@ export interface PluginContext {
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly catalog: CatalogHooks readonly catalog: CatalogHooks
readonly command: CommandHooks readonly command: CommandHooks
readonly codemode: Domain
readonly event: EventHooks readonly event: EventHooks
readonly integration: IntegrationHooks readonly integration: IntegrationHooks
readonly plugin: PluginDomain readonly plugin: PluginDomain
+1
View File
@@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
export type { AISDKHooks } from "./aisdk.js" export type { AISDKHooks } from "./aisdk.js"
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
export type { CommandDraft, CommandHooks } from "./command.js" export type { CommandDraft, CommandHooks } from "./command.js"
export * as CodeMode from "./code-mode.js"
export type { EventHooks } from "./event.js" export type { EventHooks } from "./event.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
-1
View File
@@ -246,7 +246,6 @@ export interface ToolExecuteAfterEvent {
export interface RegisterOptions { export interface RegisterOptions {
readonly group?: string readonly group?: string
readonly deferred?: boolean
} }
export interface ToolDraft { export interface ToolDraft {
@@ -27,6 +27,7 @@ test.each([
expect(entrypoint.Skill).toBe(Skill) expect(entrypoint.Skill).toBe(Skill)
expect(Object.keys(entrypoint).sort()).toEqual([ expect(Object.keys(entrypoint).sort()).toEqual([
"Agent", "Agent",
...(name === "effect" ? ["CodeMode"] : []),
"Command", "Command",
"Connection", "Connection",
"Credential", "Credential",
+15 -1
View File
@@ -19,4 +19,18 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." })) .add(
HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", {
query: LocationQuery,
success: Location.response(Mcp.ResourceCatalog),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.resource.catalog",
summary: "List MCP resources",
description: "Retrieve resources and resource templates from connected MCP servers.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." }))
+20
View File
@@ -269,6 +269,25 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}), }),
), ),
) )
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
destination: Schema.Struct({ directory: AbsolutePath }),
moveChanges: Schema.Boolean.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, InvalidRequestError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.move",
summary: "Move session",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
.add( .add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
@@ -344,6 +363,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
text: Schema.String, text: Schema.String,
description: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional),
metadata: SessionMessage.Synthetic.fields.metadata, metadata: SessionMessage.Synthetic.fields.metadata,
resume: Schema.Boolean.pipe(Schema.optional),
}), }),
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: SessionNotFoundError, error: SessionNotFoundError,
+1
View File
@@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js"
test("classifies public events by type", () => { test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
}) })
+1
View File
@@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory(
...InstallationEvent.Definitions, ...InstallationEvent.Definitions,
...VcsEvent.Definitions, ...VcsEvent.Definitions,
McpEvent.StatusChanged, McpEvent.StatusChanged,
McpEvent.ResourcesChanged,
// Shared transitional: V1 contracts the current TUI still consumes during // Shared transitional: V1 contracts the current TUI still consumes during
// the migration (permission.asked/replied, question.asked, session.error). // the migration (permission.asked/replied, question.asked, session.error).
// Remove when the TUI moves to the current permission/question surfaces. // Remove when the TUI moves to the current permission/question surfaces.
+1
View File
@@ -9,6 +9,7 @@ export { Form } from "./form.js"
export { Integration } from "./integration.js" export { Integration } from "./integration.js"
export { LLM } from "./llm.js" export { LLM } from "./llm.js"
export { Location } from "./location.js" export { Location } from "./location.js"
export { Mcp } from "./mcp.js"
export { Model } from "./model.js" export { Model } from "./model.js"
export { Permission } from "./permission.js" export { Permission } from "./permission.js"
export { PermissionSaved } from "./permission-saved.js" export { PermissionSaved } from "./permission-saved.js"
+8 -1
View File
@@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({
}, },
}) })
export const ResourcesChanged = Event.ephemeral({
type: "mcp.resources.changed",
schema: {
server: Schema.String,
},
})
export const BrowserOpenFailed = Event.ephemeral({ export const BrowserOpenFailed = Event.ephemeral({
type: "mcp.browser.open.failed", type: "mcp.browser.open.failed",
schema: { schema: {
@@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({
}, },
}) })
export const Definitions = Event.inventory(ToolsChanged, StatusChanged) export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged)
+50 -8
View File
@@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) }).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type export type Status = typeof Status.Type
export const Status = Schema.Union([ export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Connected, Schema.toTaggedUnion("status"),
Pending, )
Disabled,
Failed,
NeedsAuth,
NeedsClientRegistration,
]).pipe(Schema.toTaggedUnion("status"))
export interface Server extends Schema.Schema.Type<typeof Server> {} export interface Server extends Schema.Schema.Type<typeof Server> {}
export const Server = Schema.Struct({ export const Server = Schema.Struct({
@@ -42,3 +37,50 @@ export const Server = Schema.Struct({
// without matching by name, which could collide with provider or plugin integrations. // without matching by name, which could collide with provider or plugin integrations.
integrationID: optional(IntegrationID), integrationID: optional(IntegrationID),
}).annotate({ identifier: "Mcp.Server" }) }).annotate({ identifier: "Mcp.Server" })
export interface Resource extends Schema.Schema.Type<typeof Resource> {}
export const Resource = Schema.Struct({
server: Schema.String,
name: Schema.String,
uri: Schema.String,
description: optional(Schema.String),
mimeType: optional(Schema.String),
}).annotate({ identifier: "Mcp.Resource" })
export interface ResourceTemplate extends Schema.Schema.Type<typeof ResourceTemplate> {}
export const ResourceTemplate = Schema.Struct({
server: Schema.String,
name: Schema.String,
uriTemplate: Schema.String,
description: optional(Schema.String),
mimeType: optional(Schema.String),
}).annotate({ identifier: "Mcp.ResourceTemplate" })
export interface ResourceCatalog extends Schema.Schema.Type<typeof ResourceCatalog> {}
export const ResourceCatalog = Schema.Struct({
resources: Schema.Array(Resource),
templates: Schema.Array(ResourceTemplate),
}).annotate({ identifier: "Mcp.ResourceCatalog" })
export const ResourceContentPart = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
uri: Schema.String,
text: Schema.String,
mimeType: optional(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("blob"),
uri: Schema.String,
blob: Schema.String,
mimeType: optional(Schema.String),
}),
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" }))
export type ResourceContentPart = typeof ResourceContentPart.Type
export interface ResourceContent extends Schema.Schema.Type<typeof ResourceContent> {}
export const ResourceContent = Schema.Struct({
server: Schema.String,
uri: Schema.String,
contents: Schema.Array(ResourceContentPart),
}).annotate({ identifier: "Mcp.ResourceContent" })
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect" import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js" import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js" import { FileSystem } from "../src/filesystem.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js" import { Model } from "../src/model.js"
import { Project } from "../src/project.js" import { Project } from "../src/project.js"
import { Provider } from "../src/provider.js" import { Provider } from "../src/provider.js"
@@ -51,6 +52,11 @@ describe("contract hygiene", () => {
const identifiers = [ const identifiers = [
Agent.Color, Agent.Color,
FileSystem.Submatch, FileSystem.Submatch,
Mcp.Resource,
Mcp.ResourceTemplate,
Mcp.ResourceCatalog,
Mcp.ResourceContentPart,
Mcp.ResourceContent,
Model.Ref, Model.Ref,
Model.Capabilities, Model.Capabilities,
Model.Cost, Model.Cost,
+2 -1
View File
@@ -51,6 +51,7 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated)
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
expect(Agent.Event.Updated.durable).toBeUndefined() expect(Agent.Event.Updated.durable).toBeUndefined()
@@ -76,7 +77,7 @@ describe("public event manifest", () => {
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Mcp } from "../src/mcp.js"
describe("Mcp resources", () => {
test("decodes resource catalogs and omits absent metadata", () => {
const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
})
expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
})
})
test("preserves text and base64 blob contents", () => {
expect(
Schema.decodeUnknownSync(Mcp.ResourceContent)({
server: "docs",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
}),
).toEqual({
server: "docs",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
})
})
+9 -1
View File
@@ -6,7 +6,8 @@ import { response } from "../location"
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
return handlers.handle( return handlers
.handle(
"mcp.list", "mcp.list",
Effect.fn(function* () { Effect.fn(function* () {
const service = yield* MCP.Service const service = yield* MCP.Service
@@ -21,5 +22,12 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
) )
}), }),
) )
.handle(
"mcp.resource.catalog",
Effect.fn(function* () {
const service = yield* MCP.Service
return yield* response(service.resourceCatalog())
}),
)
}), }),
) )
+41 -1
View File
@@ -1,5 +1,6 @@
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
@@ -8,8 +9,8 @@ import {
ConflictError, ConflictError,
CommandEvaluationError, CommandEvaluationError,
CommandNotFoundError, CommandNotFoundError,
InvalidCursorError,
InvalidRequestError, InvalidRequestError,
InvalidCursorError,
MessageNotFoundError, MessageNotFoundError,
ServiceUnavailableError, ServiceUnavailableError,
SessionBusyError, SessionBusyError,
@@ -24,6 +25,7 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const moveSession = yield* MoveSession.Service
return handlers return handlers
.handle( .handle(
@@ -201,6 +203,43 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle(
"session.move",
Effect.fn(function* (ctx) {
yield* moveSession.moveSession({
sessionID: ctx.params.sessionID,
destination: ctx.payload.destination,
moveChanges: ctx.payload.moveChanges,
}).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
),
Effect.catchTag("MoveSession.ApplyChangesError", () =>
Effect.fail(
new InvalidRequestError({
message:
"Unable to apply your changes in the destination directory. The files may conflict with existing changes.",
}),
),
),
Effect.catchTag("MoveSession.CaptureChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
Effect.catchTag("MoveSession.ResetSourceChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle( .handle(
"session.prompt", "session.prompt",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -329,6 +368,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
text: ctx.payload.text, text: ctx.payload.text,
description: ctx.payload.description, description: ctx.payload.description,
metadata: ctx.payload.metadata, metadata: ctx.payload.metadata,
resume: ctx.payload.resume,
}) })
.pipe( .pipe(
Effect.catchTag("Session.NotFoundError", (error) => Effect.catchTag("Session.NotFoundError", (error) =>
+2
View File
@@ -8,6 +8,7 @@ import { Observability } from "@opencode-ai/core/observability"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -37,6 +38,7 @@ const applicationServices = LayerNode.group([
httpClient, httpClient,
ToolOutputStore.cleanupNode, ToolOutputStore.cleanupNode,
Job.node, Job.node,
MoveSession.node,
Project.node, Project.node,
SessionV2.node, SessionV2.node,
PluginRuntime.providerNode, PluginRuntime.providerNode,
+1
View File
@@ -16,6 +16,7 @@
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1,7 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import { SimulationProtocol } from "../protocol" import { SimulationProtocol } from "../protocol"
import { SimulationLLMExchange } from "./llm-exchange" import { SimulationLLMExchange } from "./llm-exchange"
import { SimulationNetwork } from "./network"
/** /**
* Backend-hosted simulation control WebSocket. * Backend-hosted simulation control WebSocket.
@@ -19,7 +18,6 @@ import { SimulationNetwork } from "./network"
* - `llm.finish` { id, reason? } finish an exchange * - `llm.finish` { id, reason? } finish an exchange
* - `llm.disconnect` { id } abruptly terminate an exchange without a finish * - `llm.disconnect` { id } abruptly terminate an exchange without a finish
* - `llm.pending` list open exchanges * - `llm.pending` list open exchanges
* - `network.log` simulated network request log
*/ */
type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }>
@@ -58,8 +56,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.Backend
} }
case "llm.pending": case "llm.pending":
return { exchanges: SimulationLLMExchange.pending() } return { exchanges: SimulationLLMExchange.pending() }
case "network.log":
return { entries: SimulationNetwork.log() }
} }
} }
+72 -31
View File
@@ -1,8 +1,11 @@
import type { CliRenderer, Renderable } from "@opentui/core" import { mkdtemp } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { CapturedFrame, CliRenderer, Renderable } from "@opentui/core"
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
import type { SimulationProtocol } from "../protocol" import type { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer" import { SimulationRenderer } from "./renderer"
import { SimulationTrace } from "./trace" import { SimulationPng } from "./png"
export type Action = SimulationProtocol.Frontend.Action export type Action = SimulationProtocol.Frontend.Action
export type Element = SimulationProtocol.Frontend.Element export type Element = SimulationProtocol.Frontend.Element
@@ -91,57 +94,95 @@ export function elements(renderer: CliRenderer): Element[] {
.filter((element) => element.focusable || element.clickable || element.editor) .filter((element) => element.focusable || element.clickable || element.editor)
} }
export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] {
const items = elements(renderer)
return [
...(renderer.currentFocusedEditor
? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[])
: []),
...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })),
...items
.filter((item) => item.clickable)
.map((item) => ({
type: "click" as const,
target: item.num,
x: Math.floor(item.x + item.width / 2),
y: Math.floor(item.y + item.height / 2),
})),
{ type: "pressArrow", direction: "down" },
{ type: "pressArrow", direction: "up" },
]
}
export function state(harness: Harness) { export function state(harness: Harness) {
return { return {
screen: harness.screen(),
focused: { focused: {
renderable: harness.renderer.currentFocusedRenderable?.num, renderable: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor), editor: Boolean(harness.renderer.currentFocusedEditor),
}, },
elements: elements(harness.renderer), elements: elements(harness.renderer),
actions: actions(harness.renderer),
} }
} }
export async function screenshot(harness: Harness) {
await harness.renderOnce()
const image = SimulationPng.screenshot(harness.renderer)
const path = join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png")
await Bun.write(path, image.data)
return path
}
export function frame(harness: Harness): CapturedFrame {
const buffer = harness.renderer.currentRenderBuffer
return {
cols: buffer.width,
rows: buffer.height,
cursor: [0, 0],
lines: buffer.getSpanLines().map((line) => ({
spans: line.spans.map((span) => ({
text: span.text,
fg: span.fg,
bg: span.bg,
attributes: span.attributes,
width: span.width,
})),
})),
}
}
export async function video(frames: CapturedFrame[]) {
const directory = await mkdtemp(join(tmpdir(), "opencode-drive-recording-"))
await Promise.all(
frames.map((frame, index) =>
Bun.write(
join(directory, `frame-${index.toString().padStart(6, "0")}.png`),
SimulationPng.screenshotFrame(frame).data,
),
),
)
const path = join(directory, "recording.mp4")
const process = Bun.spawn(
[
"ffmpeg",
"-loglevel",
"error",
"-framerate",
"10",
"-i",
join(directory, "frame-%06d.png"),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-y",
path,
],
{ stderr: "pipe" },
)
if ((await process.exited) !== 0) throw new Error(`ffmpeg failed: ${await new Response(process.stderr).text()}`)
return path
}
export async function execute(harness: Harness, action: Action) { export async function execute(harness: Harness, action: Action) {
SimulationTrace.add("ui.action", { action })
switch (action.type) { switch (action.type) {
case "typeText": case "ui.type":
await harness.mockInput.typeText(action.text) await harness.mockInput.typeText(action.text)
break break
case "pressKey": case "ui.press":
harness.mockInput.pressKey(action.key, action.modifiers) harness.mockInput.pressKey(action.key, action.modifiers)
break break
case "pressEnter": case "ui.enter":
harness.mockInput.pressEnter() harness.mockInput.pressEnter()
break break
case "pressArrow": case "ui.arrow":
harness.mockInput.pressArrow(action.direction) harness.mockInput.pressArrow(action.direction)
break break
case "focus": case "ui.focus":
all(harness.renderer.root).find((item) => item.num === action.target)?.focus() all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
break break
case "click": case "ui.click":
await harness.mockMouse.click(action.x, action.y) await harness.mockMouse.click(action.x, action.y)
break break
} }
+84
View File
@@ -0,0 +1,84 @@
import { fileURLToPath } from "node:url"
import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core"
const CellWidth = 10
const CellHeight = 20
const FontSize = 16
const FontFamily = "OpenCode Screenshot"
GlobalFonts.registerFromPath(
fileURLToPath(new URL("../../../ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2", import.meta.url)),
FontFamily,
)
export function screenshot(renderer: CliRenderer) {
return screenshotFrame({
cols: renderer.currentRenderBuffer.width,
rows: renderer.currentRenderBuffer.height,
cursor: [0, 0],
lines: renderer.currentRenderBuffer.getSpanLines(),
})
}
export function screenshotFrame(frame: CapturedFrame) {
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
const context = canvas.getContext("2d")
context.fillStyle = "#080808"
context.fillRect(0, 0, canvas.width, canvas.height)
context.textBaseline = "top"
frame.lines.forEach((line, row) => {
let column = 0
line.spans.forEach((span) => {
const attributes = span.attributes & 0xff
const inverse = Boolean(attributes & TextAttributes.INVERSE)
const hidden = Boolean(attributes & TextAttributes.HIDDEN)
const foreground = inverse ? span.bg : span.fg
const background = inverse ? span.fg : span.bg
const chars = [...span.text]
let remaining = span.width
chars.forEach((char, index) => {
const cells = Math.max(1, remaining - (chars.length - index - 1))
if (background.a) {
context.fillStyle = color(background)
context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight)
}
if (!hidden && char.codePointAt(0) !== 0x0a00) {
context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1)
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"`
context.fillText(char, column * CellWidth, row * CellHeight + 1)
if (attributes & TextAttributes.UNDERLINE) {
context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1)
}
if (attributes & TextAttributes.STRIKETHROUGH) {
context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1)
}
}
column += cells
remaining -= cells
})
while (remaining-- > 0) {
if (background.a) {
context.fillStyle = color(background)
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
}
column++
}
})
})
return {
width: canvas.width,
height: canvas.height,
data: canvas.toBuffer("image/png"),
}
}
function color(value: RGBA, opacity = 1) {
const [red, green, blue, alpha] = value.toInts()
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
}
export * as SimulationPng from "./png"
+68 -26
View File
@@ -1,6 +1,6 @@
import type { CapturedFrame } from "@opentui/core"
import { SimulationProtocol } from "../protocol" import { SimulationProtocol } from "../protocol"
import { SimulationActions, type Harness } from "./actions" import { SimulationActions, type Harness } from "./actions"
import { SimulationTrace } from "./trace"
export interface Server { export interface Server {
readonly url: string readonly url: string
@@ -11,47 +11,90 @@ function parseRequest(input: string | Buffer) {
return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
} }
async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) { interface Recording {
readonly frames: CapturedFrame[]
readonly timer: ReturnType<typeof setInterval>
pending: Promise<void>
}
async function handle(
harness: Harness,
request: SimulationProtocol.Frontend.Request,
recording: { current?: Recording },
headless: boolean,
) {
switch (request.method) { switch (request.method) {
case "ui.screenshot":
return SimulationActions.screenshot(harness)
case "ui.state": { case "ui.state": {
if (headless) await harness.renderOnce() return SimulationActions.state(harness)
const result = SimulationActions.state(harness)
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
return result
} }
case "ui.action": case "ui.start-record": {
return SimulationActions.execute(harness, request.params.action) if (recording.current) throw new Error("UI recording is already active")
case "trace.list": const frames = [SimulationActions.frame(harness)]
return { records: SimulationTrace.list() } const current: Recording = {
case "trace.clear": frames,
SimulationTrace.clear() timer: setInterval(() => {
return { cleared: true } current.pending = current.pending.then(async () => {
case "trace.export": if (headless) await harness.renderOnce()
return SimulationTrace.exportTrace() frames.push(SimulationActions.frame(harness))
})
}, 100),
pending: Promise.resolve(),
}
recording.current = current
return { recording: true }
}
case "ui.end-record": {
if (!recording.current) throw new Error("UI recording is not active")
const current = recording.current
clearInterval(current.timer)
await current.pending
if (headless) await harness.renderOnce()
current.frames.push(SimulationActions.frame(harness))
recording.current = undefined
return SimulationActions.video(current.frames)
}
case "ui.type":
return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text })
case "ui.enter":
return SimulationActions.execute(harness, { type: "ui.enter" })
case "ui.press":
return SimulationActions.execute(harness, {
type: "ui.press",
key: request.params.key,
modifiers: request.params.modifiers,
})
case "ui.arrow":
return SimulationActions.execute(harness, { type: "ui.arrow", direction: request.params.direction })
case "ui.focus":
return SimulationActions.execute(harness, { type: "ui.focus", target: request.params.target })
case "ui.click":
return SimulationActions.execute(harness, {
type: "ui.click",
target: request.params.target,
x: request.params.x,
y: request.params.y,
})
} }
} }
export function start(harness: Harness, endpoint: string, headless: boolean): Server { export function start(harness: Harness, endpoint: string, headless: boolean): Server {
const url = new URL(endpoint) const url = new URL(endpoint)
const server = Bun.serve<{ readonly drive: true }>({ const recording: { current?: Recording } = {}
const server = Bun.serve<{ readonly drive: true; readonly headless: boolean }>({
hostname: url.hostname, hostname: url.hostname,
port: Number(url.port), port: Number(url.port),
fetch(request, server) { fetch(request, server) {
if (server.upgrade(request, { data: { drive: true } })) return undefined if (server.upgrade(request, { data: { drive: true, headless } })) return undefined
return new Response("opencode drive ui websocket", { status: 426 }) return new Response("opencode drive ui websocket", { status: 426 })
}, },
websocket: { websocket: {
open() {
SimulationTrace.add("control.connect")
},
close() {
SimulationTrace.add("control.disconnect")
},
async message(socket, message) { async message(socket, message) {
let request: SimulationProtocol.Frontend.Request | undefined let request: SimulationProtocol.Frontend.Request | undefined
try { try {
request = parseRequest(message) request = parseRequest(message)
const result = await handle(harness, request, headless) const result = await handle(harness, request, recording, headless)
const next = SimulationProtocol.JsonRpc.success(request.id, result) const next = SimulationProtocol.JsonRpc.success(request.id, result)
if (next) socket.send(JSON.stringify(next)) if (next) socket.send(JSON.stringify(next))
} catch (error) { } catch (error) {
@@ -60,11 +103,10 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se
}, },
}, },
}) })
SimulationTrace.add("control.start", { url: endpoint })
return { return {
url: endpoint, url: endpoint,
stop: () => { stop: () => {
SimulationTrace.add("control.stop", { url: endpoint }) if (recording.current) clearInterval(recording.current.timer)
server.stop(true) server.stop(true)
}, },
} }
-37
View File
@@ -1,37 +0,0 @@
export type TraceRecord = {
readonly id: number
readonly time: string
readonly type: string
readonly data?: unknown
}
const records: TraceRecord[] = []
let nextID = 0
export function add(type: string, data?: unknown) {
const record = {
id: ++nextID,
time: new Date().toISOString(),
type,
...(data === undefined ? {} : { data }),
} satisfies TraceRecord
records.push(record)
return record
}
export function list() {
return [...records]
}
export function clear() {
records.length = 0
nextID = 0
}
export function exportTrace() {
return {
records: list(),
}
}
export * as SimulationTrace from "./trace"
+42 -23
View File
@@ -59,12 +59,12 @@ export namespace Frontend {
export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {} export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {}
export const Action = Schema.Union([ export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
Schema.Struct({ type: Schema.Literal("pressEnter") }), Schema.Struct({ type: Schema.Literal("ui.enter") }),
Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
]) ])
export type Action = Schema.Schema.Type<typeof Action> export type Action = Schema.Schema.Type<typeof Action>
@@ -83,39 +83,58 @@ export namespace Frontend {
export interface Element extends Schema.Schema.Type<typeof Element> {} export interface Element extends Schema.Schema.Type<typeof Element> {}
export const State = Schema.Struct({ export const State = Schema.Struct({
screen: Schema.String,
focused: Schema.Struct({ focused: Schema.Struct({
renderable: Schema.optional(Schema.Number), renderable: Schema.optional(Schema.Number),
editor: Schema.Boolean, editor: Schema.Boolean,
}), }),
elements: Schema.Array(Element), elements: Schema.Array(Element),
actions: Schema.Array(Action),
}) })
export interface State extends Schema.Schema.Type<typeof State> {} export interface State extends Schema.Schema.Type<typeof State> {}
export const ActionParams = Schema.Struct({ action: Action }) export const Screenshot = Schema.String
export interface ActionParams extends Schema.Schema.Type<typeof ActionParams> {} export type Screenshot = Schema.Schema.Type<typeof Screenshot>
export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) })
export interface StartRecord extends Schema.Schema.Type<typeof StartRecord> {}
export const EndRecord = Schema.String
export type EndRecord = Schema.Schema.Type<typeof EndRecord>
export const TypeParams = Schema.Struct({ text: Schema.String })
export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) })
export interface PressParams extends Schema.Schema.Type<typeof PressParams> {}
export const ArrowParams = Schema.Struct({ direction: Schema.Literals(["up", "down", "left", "right"]) })
export interface ArrowParams extends Schema.Schema.Type<typeof ArrowParams> {}
export const FocusParams = Schema.Struct({ target: Schema.Number })
export interface FocusParams extends Schema.Schema.Type<typeof FocusParams> {}
export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
export const Request = Schema.Union([ export const Request = Schema.Union([
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
Schema.Struct({ Schema.Struct({
...JsonRpc.RequestFields, ...JsonRpc.RequestFields,
method: Schema.Literals(["ui.state", "trace.list", "trace.clear", "trace.export"]), method: Schema.Literals([
"ui.enter",
"ui.screenshot",
"ui.state",
"ui.start-record",
"ui.end-record",
]),
}), }),
]) ])
export type Request = Schema.Schema.Type<typeof Request> export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request) export const decodeRequest = Schema.decodeUnknownSync(Request)
export const TraceRecord = Schema.Struct({
id: Schema.Number,
time: Schema.String,
type: Schema.String,
data: Schema.optional(Schema.Json),
})
export interface TraceRecord extends Schema.Schema.Type<typeof TraceRecord> {}
export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) })
export interface TraceList extends Schema.Schema.Type<typeof TraceList> {}
} }
export namespace Backend { export namespace Backend {
@@ -148,7 +167,7 @@ export namespace Backend {
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
Schema.Struct({ Schema.Struct({
...JsonRpc.RequestFields, ...JsonRpc.RequestFields,
method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]), method: Schema.Literals(["llm.attach", "llm.pending"]),
}), }),
]) ])
export type Request = Schema.Schema.Type<typeof Request> export type Request = Schema.Schema.Type<typeof Request>
@@ -19,8 +19,9 @@ import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise" import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number] type ProjectDirectory = ProjectDirectoriesOutput[number]
type DialogMoveSessionProps = { type DialogMoveSessionProps = {
@@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (await removedCurrent(deletingCurrent)) return if (await removedCurrent(deletingCurrent)) return
} }
async function create() {
const name = await DialogProjectCopyName.show(dialog)
if (name === null) return
props.onSelect({ type: "new", name })
}
const fullHeight = createMemo(() => const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
) )
@@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{ {
command: "dialog.move_session.new", command: "dialog.move_session.new",
title: "new", title: "new",
onTrigger: () => props.onSelect({ type: "new" }), onTrigger: () => void create(),
}, },
{ {
command: "dialog.move_session.delete", command: "dialog.move_session.delete",
@@ -0,0 +1,95 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useTuiConfig } from "../config"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
function generate() {
input.value = Slug.create()
input.gotoLineEnd()
}
function confirm() {
props.onConfirm(slugify(input.value) || Slug.create())
}
useBindings(() => ({
target: inputTarget,
enabled: inputTarget() !== undefined,
priority: 1,
commands: [
{
name: "dialog.project_copy.generate",
title: "Generate project copy name",
category: "Dialog",
run: generate,
},
],
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!input || input.isDestroyed) return
input.focus()
}, 1)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Name project copy
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<input
ref={(value: InputRenderable) => {
input = value
setInputTarget(value)
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
</text>
</box>
</box>
)
}
DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
})
function slugify(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
}
+7 -4
View File
@@ -218,7 +218,10 @@ export function Prompt(props: PromptProps) {
const editorContextLabelState = createMemo(() => editor.labelState()) const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>() const [auto, setAuto] = createSignal<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID) const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const move = usePromptMove({
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
sessionID: () => props.sessionID,
})
const [cursorVersion, setCursorVersion] = createSignal(0) const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected() const connected = useConnected()
@@ -955,13 +958,13 @@ export function Prompt(props: PromptProps) {
if (workspace.creating() || move.creating()) return false if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false if (auto()?.visible) return false
if (!store.prompt.text) return false if (!store.prompt.text) return false
const agent = local.agent.current()
if (!agent) return false
const trimmed = store.prompt.text.trim() const trimmed = store.prompt.text.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return true return true
} }
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current() const selectedModel = local.model.current()
if (!selectedModel) { if (!selectedModel) {
void promptModelWarning() void promptModelWarning()
@@ -991,7 +994,7 @@ export function Prompt(props: PromptProps) {
const selectedWorkspace = workspace.selection() const selectedWorkspace = workspace.selection()
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
const directory = await move.getDirectory(store.prompt.text) const directory = await move.getDirectory()
if (move.pending() && !directory) return false if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress()) finishMoveProgress = Boolean(move.progress())
const location = data.location.default() const location = data.location.default()
+42 -57
View File
@@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination" import { useHomeSessionDestination } from "../../routes/home/session-destination"
import { useProject } from "../../context/project" import { useProject } from "../../context/project"
import { useData } from "../../context/data"
function moveReminderText(directory: string) { function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>` return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
@@ -18,38 +18,33 @@ function moveReminderText(directory: string) {
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync()
const toast = useToast() const toast = useToast()
const homeDestination = useHomeSessionDestination() const homeDestination = useHomeSessionDestination()
const project = useProject() const project = useProject()
const data = useData()
const paths = useTuiPaths() const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false) const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3) const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>() const [progress, setProgress] = createSignal<string>()
async function create(context?: string) { async function create(name: string) {
const projectID = input.projectID() const projectID = await resolveProjectID()
if (!projectID) return if (!projectID) return
setCreating(true) setCreating(true)
setProgress("Creating copy") setProgress("Creating copy")
try { try {
const generated = await sdk.client.experimental.projectCopy.generateName(
{ projectID, context },
{ throwOnError: true },
)
const result = await sdk.api.projectCopy.create({ const result = await sdk.api.projectCopy.create({
projectID, projectID,
location: { directory: project.instance.directory() || paths.cwd }, location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree", strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)), directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name, name,
}) })
const directory = result.directory const directory = result.directory
if (!directory) throw new Error("No project copy directory returned") if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped // Call a location-based route to make sure it's bootstrapped before moving on.
// before moving on await sdk.api.location.get({ location: { directory } })
await sdk.client.path.get({ directory }, { throwOnError: true })
setProgress("Creating session") setProgress("Creating session")
return directory return directory
@@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
} }
} }
function open() { async function open() {
const projectID = input.projectID() const projectID = await resolveProjectID()
if (!projectID) return if (!projectID) {
toast.show({ message: "Unable to determine current project", variant: "error" })
return
}
const sessionID = input.sessionID() const sessionID = input.sessionID()
const session = sessionID ? sync.session.get(sessionID) : undefined const session = sessionID ? await resolveSession(sessionID) : undefined
dialog.replace(() => ( dialog.replace(() => (
<DialogMoveSession <DialogMoveSession
projectID={projectID} projectID={projectID}
@@ -75,8 +73,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
(session (session
? { ? {
type: "directory", type: "directory",
directory: session.directory, directory: session.location.directory,
subdirectory: !!session.path, subdirectory: !!session.subpath,
} }
: { : {
type: "directory", type: "directory",
@@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
)) ))
} }
function sessionContext(sessionID: string) {
const session = sync.session.get(sessionID)
const messages = (sync.data.message[sessionID] ?? [])
.slice(-6)
.map((message) =>
[
message.role + ":",
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
].join(" "),
)
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = sync.session.get(sessionID) const session = await resolveSession(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return if (!choice) return
dialog.clear() dialog.clear()
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) { if (!directory) {
setProgress(undefined) setProgress(undefined)
dialog.clear() dialog.clear()
@@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
} }
setProgress("Moving session") setProgress("Moving session")
try { try {
await sdk.client.experimental.controlPlane.moveSession( await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
{ await sdk.api.session
sessionID, .synthetic({ sessionID, text: moveReminderText(directory), resume: false })
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
await sdk.client.session
.promptAsync({
sessionID,
directory,
noReply: true,
parts: [
{
type: "text",
text: moveReminderText(directory),
synthetic: true,
},
],
})
.catch(() => undefined) .catch(() => undefined)
dialog.clear() dialog.clear()
} catch (error) { } catch (error) {
@@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
} }
} }
async function resolveProjectID() {
const projectID = input.projectID()
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
return sdk.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } })
.then((project) => project.id)
.catch(() => undefined)
}
async function resolveSession(sessionID: string) {
const session = data.session.get(sessionID)
if (session) return session
await data.session.refresh(sessionID).catch(() => undefined)
return data.session.get(sessionID)
}
const pending = createMemo(() => Boolean(homeDestination?.destination())) const pending = createMemo(() => Boolean(homeDestination?.destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
async function getDirectory(context?: string) { async function getDirectory() {
const value = homeDestination?.destination() const value = homeDestination?.destination()
if (!value) return if (!value) return
if (value.type === "directory") { if (value.type === "directory") {
return value.directory return value.directory
} }
return await create(context) return await create(value.name)
} }
function startSubmit() { function startSubmit() {
+1
View File
@@ -207,6 +207,7 @@ export const Definitions = {
"dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
+6
View File
@@ -304,6 +304,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title) setStore("session", "info", event.data.sessionID, "title", event.data.title)
break break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location))
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
}
break
case "session.prompt.promoted": { case "session.prompt.promoted": {
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID) const position = index.get(event.data.inputID)
-6
View File
@@ -42,9 +42,6 @@ export const {
provider_default: Record<string, string> provider_default: Record<string, string>
provider_next: ProviderListResponse provider_next: ProviderListResponse
console_state: ConsoleState console_state: ConsoleState
capabilities: {
experimentalBackgroundSubagents: boolean
}
provider_auth: Record<string, ProviderAuthMethod[]> provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
@@ -71,9 +68,6 @@ export const {
connected: [], connected: [],
}, },
console_state: emptyConsoleState, console_state: emptyConsoleState,
capabilities: {
experimentalBackgroundSubagents: false,
},
provider_auth: {}, provider_auth: {},
agent: [], agent: [],
command: [], command: [],
@@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
const id = "internal:sidebar-footer" const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; sessionID: string }) { function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths() const paths = useTuiPaths()
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const has = createMemo(() => const has = createMemo(() =>
@@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done()) const show = createMemo(() => !has() && !done())
const path = createMemo(() => { const path = createMemo(() => {
const session = props.api.state.session.get(props.sessionID) const out = abbreviateHome(props.directory, paths.home)
const dir = session?.directory || props.api.state.path.directory || paths.cwd const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const out = abbreviateHome(dir, paths.home)
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out const text = branch ? out + ":" + branch : out
const list = text.split("/") const list = text.split("/")
return { return {
@@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => {
order: 100, order: 100,
slots: { slots: {
sidebar_footer(_ctx, props) { sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} /> return <View api={api} directory={props.directory} />
}, },
}, },
}) })
@@ -10,7 +10,7 @@ import {
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { useTuiPaths } from "../../context/runtime" import { useTuiPaths } from "../../context/runtime"
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
type Context = { type Context = {
destination: Accessor<HomeSessionDestination | undefined> destination: Accessor<HomeSessionDestination | undefined>
+6 -1
View File
@@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</scrollbox> </scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}> <box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}> <pluginRuntime.Slot
name="sidebar_footer"
mode="single_winner"
session_id={props.sessionID}
directory={session()?.location.directory ?? ""}
>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b> <span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
+62
View File
@@ -108,6 +108,68 @@ test("refreshes resources into reactive getters", async () => {
} }
}) })
test("updates session location when moved", async () => {
const events = createEventStream()
const destination = "/tmp/opencode-moved"
const calls = createFetch((url) => {
if (url.pathname === "/api/session/ses_test")
return json({
data: {
id: "ses_test",
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Test session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
data = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await data.session.refresh("ses_test")
emitEvent(events, {
id: "evt_moved_1",
created: 1,
type: "session.moved",
durable: durable("ses_test"),
data: {
sessionID: "ses_test",
location: { directory: destination },
subpath: "packages/cli",
},
})
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
} finally {
app.renderer.destroy()
}
})
test("restores running manual compaction before applying live deltas", async () => { test("restores running manual compaction before applying live deltas", async () => {
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
+1 -1
View File
@@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({}) return json({})
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} }) if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false }) if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } }) if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree }) if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
-1
View File
@@ -608,7 +608,6 @@ opencode upgrade v0.1.48
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | تفعيل ميزات Exa التجريبية | | `OPENCODE_EXPERIMENTAL_EXA` | boolean | تفعيل ميزات Exa التجريبية |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | تمكين TY LSP لملفات python | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | تمكين TY LSP لملفات python |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | تفعيل وضع الخطة | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | تفعيل وضع الخطة |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | تفعيل مهام subagent في الخلفية |
| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | تفعيل نظام الأحداث التجريبي | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | تفعيل نظام الأحداث التجريبي |
| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | تفعيل مسار طلبات LLM الأصلي | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | تفعيل مسار طلبات LLM الأصلي |
| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | تفعيل تنفيذ بحث الويب بالتوازي | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | تفعيل تنفيذ بحث الويب بالتوازي |
-1
View File
@@ -606,7 +606,6 @@ Ove varijable okruženja omogućavaju eksperimentalne karakteristike koje se mog
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije | | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući TY LSP za python datoteke | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući TY LSP za python datoteke |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući Plan mod | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući Plan mod |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Omogući pozadinske zadatke subagenata |
| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Omogući eksperimentalni sistem događaja | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Omogući eksperimentalni sistem događaja |
| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Omogući nativnu putanju LLM zahtjeva | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Omogući nativnu putanju LLM zahtjeva |
| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Omogući paralelno izvršavanje web pretrage | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Omogući paralelno izvršavanje web pretrage |
-1
View File
@@ -725,7 +725,6 @@ These environment variables enable experimental features that may change or be r
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features | | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable TY LSP for python files | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable TY LSP for python files |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Enable background subagent tasks |
| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Enable experimental event system | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Enable experimental event system |
| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Enable native LLM request path | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Enable native LLM request path |
| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Enable parallel web search execution | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Enable parallel web search execution |
-1
View File
@@ -609,7 +609,6 @@ Disse miljøvariabler muliggør eksperimentelle funktioner, der kan ændres elle
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Aktive eksperimenter Exa-funktioner | | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Aktive eksperimenter Exa-funktioner |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Aktiver TY LSP for python-filer | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Aktiver TY LSP for python-filer |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Aktiver plantilstand | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Aktiver plantilstand |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Aktiver baggrundsopgaver for subagenter |
| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Aktiver eksperimentelt hændelsessystem | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Aktiver eksperimentelt hændelsessystem |
| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Aktiver native LLM-anmodningssti | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Aktiver native LLM-anmodningssti |
| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Aktiver parallel udførelse af websøgning | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Aktiver parallel udførelse af websøgning |
-1
View File
@@ -608,7 +608,6 @@ Diese Umgebungsvariablen ermöglichen experimentelle Funktionen, die sich änder
| `OPENCODE_EXPERIMENTAL_EXA` | boolescher Wert | Experimentelle Exa-Funktionen aktivieren | | `OPENCODE_EXPERIMENTAL_EXA` | boolescher Wert | Experimentelle Exa-Funktionen aktivieren |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolescher Wert | TY LSP für Python-Dateien aktivieren | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolescher Wert | TY LSP für Python-Dateien aktivieren |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolescher Wert | Planmodus aktivieren | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolescher Wert | Planmodus aktivieren |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolescher Wert | Hintergrundaufgaben für Subagenten aktivieren |
| `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolescher Wert | Experimentelles Ereignissystem aktivieren | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolescher Wert | Experimentelles Ereignissystem aktivieren |
| `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolescher Wert | Nativen LLM-Anfragepfad aktivieren | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolescher Wert | Nativen LLM-Anfragepfad aktivieren |
| `OPENCODE_EXPERIMENTAL_PARALLEL` | boolescher Wert | Parallele Websuche aktivieren | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolescher Wert | Parallele Websuche aktivieren |

Some files were not shown because too many files have changed in this diff Show More