Merge branch 'dev' into openai-compaction

This commit is contained in:
Aiden Cline 2026-04-17 16:26:26 -05:00 committed by GitHub
commit 55cd69805c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 1790 additions and 1477 deletions

View file

@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -83,7 +83,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -117,7 +117,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -144,7 +144,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -192,7 +192,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -225,7 +225,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
@ -268,7 +268,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/shared": "workspace:*", "@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -297,7 +297,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -313,7 +313,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.4.9", "version": "1.4.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -328,7 +328,7 @@
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
@ -458,7 +458,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@ -493,7 +493,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@ -508,7 +508,7 @@
}, },
"packages/shared": { "packages/shared": {
"name": "@opencode-ai/shared", "name": "@opencode-ai/shared",
"version": "1.4.9", "version": "1.4.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -532,7 +532,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -567,7 +567,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -616,7 +616,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -693,7 +693,7 @@
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
@ -761,7 +761,7 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
@ -2457,7 +2457,7 @@
"@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/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@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=="],
@ -2517,7 +2517,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@ -5703,8 +5703,6 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=", "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=", "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=", "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc=" "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
} }
} }

View file

@ -53,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48", "effect": "4.0.0-beta.48",
"ai": "6.0.158", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.9", "version": "1.4.10",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.9", "version": "1.4.10",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.9", "version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.9", "version": "1.4.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.9", "version": "1.4.10",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.4.9" version = "1.4.10"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.9", "version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.9", "version": "1.4.10",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -85,7 +85,7 @@
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",

View file

@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule: SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte - every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema - if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging ### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception: Temporary exception:
@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject` endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged` - `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply` endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial) - `provider` - `bridged`
bridged endpoint: `GET /provider/auth` endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
not yet ported: `GET /provider`, OAuth mutations - `config` - `bridged` (partial)
- `config` - `next` bridged endpoint: `GET /config/providers`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config` later endpoint: `GET /config`
defer `PATCH /config` for now defer `PATCH /config` for now
- `project` - `later` - `project` - `bridged` (partial)
best small reads: `GET /project`, `GET /project/current` bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first defer git-init mutation first
- `workspace` - `later` - `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first defer create/remove mutations first
- `file` - `later` - `file` - `later`
@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer` - `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike: Recommended near-term sequence:
1. `provider` auth read endpoint 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` providers read endpoint 2. `config` full read endpoint (`GET /config`)
3. `project` read endpoints 3. `file` JSON read endpoints
4. `workspace` read endpoints 4. `mcp` JSON read endpoints
## Checklist ## Checklist
@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel - [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes - [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations) - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints - [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default - [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb ## Rule of thumb

View file

@ -139,15 +139,10 @@ export function DialogSessionList() {
{desc}{" "} {desc}{" "}
<span <span
style={{ style={{
fg: fg: workspaceStatus === "connected" ? theme.success : theme.error,
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}} }}
> >
</span> </span>
</> </>
) )

View file

@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total, total: result.data.total,
}) })
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => { input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", { log.error("session restore refresh failed", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,

View file

@ -0,0 +1,81 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}

View file

@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border" import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal" import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args" import { useArgs } from "@tui/context/args"
export type PromptProps = { export type PromptProps = {
@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs() const args = useArgs()
const sdk = useSDK() const sdk = useSDK()
const route = useRoute() const route = useRoute()
const project = useProject()
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit", keybind: "input_submit",
category: "Prompt", category: "Prompt",
hidden: true, hidden: true,
onSelect: (dialog) => { onSelect: async (dialog) => {
if (!input.focused) return if (!input.focused) return
void submit() const handled = await submit()
if (!handled) return
dialog.clear() dialog.clear()
}, },
}, },
@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText) setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts() syncExtmarksWithPromptParts()
} }
if (props.disabled) return if (props.disabled) return false
if (autocomplete?.visible) return if (autocomplete?.visible) return false
if (!store.prompt.input) return if (!store.prompt.input) return false
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return if (!agent) return false
const trimmed = store.prompt.input.trim() const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return return true
} }
const selectedModel = local.model.current() const selectedModel = local.model.current()
if (!selectedModel) { if (!selectedModel) {
void promptModelWarning() void promptModelWarning()
return return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
} }
let sessionID = props.sessionID let sessionID = props.sessionID
@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error", variant: "error",
}) })
return return true
} }
sessionID = res.data.id sessionID = res.data.id
@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) {
}) })
}, 50) }, 50)
input.clear() input.clear()
return true
} }
const exit = useExit() const exit = useExit()

View file

@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional(), keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(), plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)

View file

@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/effect/runtime" import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util" import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })

View file

@ -1,6 +1,6 @@
import { Layer } from "effect" import { Layer } from "effect"
import { TuiConfig } from "./config/tui" import { TuiConfig } from "./config/tui"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
import { Observability } from "@/effect/observability" import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

View file

@ -1,3 +1,4 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) { export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID)) const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return ( return (
@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}> <text fg={theme.text}>
<b>{session()!.title}</b> <b>{session()!.title}</b>
</text> </text>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}> <Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text> <text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show> </Show>

View file

@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui" import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
return return
} }
const cwd = Filesystem.resolve(process.cwd()) const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, { const worker = new Worker(file, {
env: Object.fromEntries( env,
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
}) })
worker.onerror = (e) => { worker.onerror = (e) => {
Log.Default.error("thread error", { Log.Default.error("thread error", {

View file

@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@/util/opencode-process"
ensureProcessMetadata("worker")
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),

View file

@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
export const Info = z export const Info = z
.object({ .object({
model: ConfigModelID.optional(), model: ConfigModelID.zod.optional(),
variant: z variant: z
.string() .string()
.optional() .optional()

View file

@ -1,10 +1,12 @@
export * as ConfigCommand from "./command" export * as ConfigCommand from "./command"
import { Log } from "../util" import { Log } from "../util"
import z from "zod" import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob" import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name" import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error" import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown" import * as ConfigMarkdown from "./markdown"
@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
export const Info = z.object({ export const Info = Schema.Struct({
template: z.string(), template: Schema.String,
description: z.string().optional(), description: Schema.optional(Schema.String),
agent: z.string().optional(), agent: Schema.optional(Schema.String),
model: ConfigModelID.optional(), model: Schema.optional(ConfigModelID),
subtask: z.boolean().optional(), subtask: Schema.optional(Schema.Boolean),
}) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info> export type Info = Schema.Schema.Type<typeof Info>
export async function load(dir: string) { export async function load(dir: string) {
const result: Record<string, Info> = {} const result: Record<string, Info> = {}
@ -49,7 +51,7 @@ export async function load(dir: string) {
...md.data, ...md.data,
template: md.content.trim(), template: md.content.trim(),
} }
const parsed = Info.safeParse(config) const parsed = Info.zod.safeParse(config)
if (parsed.success) { if (parsed.success) {
result[config.name] = parsed.data result[config.name] = parsed.data
continue continue

View file

@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter" import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp" import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable" import { ConfigVariable } from "./variable"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@ -97,10 +97,10 @@ export const Info = z
logLevel: Log.Level.optional().describe("Log level"), logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"), server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z command: z
.record(z.string(), ConfigCommand.Info) .record(z.string(), ConfigCommand.Info.zod)
.optional() .optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"), .describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"), skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
watcher: z watcher: z
.object({ .object({
ignore: z.array(z.string()).optional(), ignore: z.array(z.string()).optional(),
@ -113,7 +113,7 @@ export const Info = z
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
), ),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.array().optional(), plugin: ConfigPlugin.Spec.zod.array().optional(),
share: z share: z
.enum(["manual", "auto", "disabled"]) .enum(["manual", "auto", "disabled"])
.optional() .optional()
@ -135,10 +135,10 @@ export const Info = z
.array(z.string()) .array(z.string())
.optional() .optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.describe( small_model: ConfigModelID.zod
"Small model to use for tasks like title generation in the format of provider/model", .describe("Small model to use for tasks like title generation in the format of provider/model")
).optional(), .optional(),
default_agent: z default_agent: z
.string() .string()
.optional() .optional()
@ -178,7 +178,7 @@ export const Info = z
.record( .record(
z.string(), z.string(),
z.union([ z.union([
ConfigMCP.Info, ConfigMCP.Info.zod,
z z
.object({ .object({
enabled: z.boolean(), enabled: z.boolean(),
@ -188,8 +188,8 @@ export const Info = z
) )
.optional() .optional()
.describe("MCP (Model Context Protocol) server configurations"), .describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.optional(), formatter: ConfigFormatter.Info.zod.optional(),
lsp: ConfigLSP.Info.optional(), lsp: ConfigLSP.Info.zod.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."), layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(), permission: ConfigPermission.Info.optional(),

View file

@ -1,15 +1,16 @@
import z from "zod" import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export const ConsoleState = z.object({ export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: z.array(z.string()), consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: z.string().optional(), activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: z.number().int().nonnegative(), switchableOrgCount: Schema.Number,
}) }) {
static readonly zod = zod(this)
}
export type ConsoleState = z.infer<typeof ConsoleState> export const emptyConsoleState: ConsoleState = ConsoleState.make({
export const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [], consoleManagedProviders: [],
activeOrgName: undefined, activeOrgName: undefined,
switchableOrgCount: 0, switchableOrgCount: 0,
} })

View file

@ -1,13 +1,17 @@
export * as ConfigFormatter from "./formatter" export * as ConfigFormatter from "./formatter"
import z from "zod" import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Entry = z.object({ export const Entry = Schema.Struct({
disabled: z.boolean().optional(), disabled: Schema.optional(Schema.Boolean),
command: z.array(z.string()).optional(), command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
environment: z.record(z.string(), z.string()).optional(), environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
extensions: z.array(z.string()).optional(), extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]) export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
export type Info = z.infer<typeof Info> withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>

View file

@ -1,37 +1,45 @@
export * as ConfigLSP from "./lsp" export * as ConfigLSP from "./lsp"
import z from "zod" import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as LSPServer from "../lsp/server" import * as LSPServer from "../lsp/server"
export const Disabled = z.object({ export const Disabled = Schema.Struct({
disabled: z.literal(true), disabled: Schema.Literal(true),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = Schema.Union([
Disabled,
Schema.Struct({
command: Schema.mutable(Schema.Array(Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
disabled: Schema.optional(Schema.Boolean),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
]).pipe(withStatics((s) => ({ zod: zod(s) })))
/**
* For custom (non-builtin) LSP server entries, `extensions` is required so the
* client knows which files the server should attach to. Builtin server IDs and
* explicitly disabled entries are exempt.
*/
export const requiresExtensionsForCustomServers = Schema.makeFilter<
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
>((data) => {
if (typeof data === "boolean") return undefined
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
const ok = Object.entries(data).every(([id, config]) => {
if ("disabled" in config && config.disabled) return true
if (serverIds.has(id)) return true
return "extensions" in config && Boolean(config.extensions)
})
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
}) })
export const Entry = z.union([ export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
Disabled, .check(requiresExtensionsForCustomServers)
z.object({ .pipe(withStatics((s) => ({ zod: zod(s) })))
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine( export type Info = Schema.Schema.Type<typeof Info>
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>

View file

@ -1,68 +1,62 @@
import z from "zod" import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Local = z export class Local extends Schema.Class<Local>("McpLocalConfig")({
.object({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
type: z.literal("local").describe("Type of MCP server connection"), command: Schema.mutable(Schema.Array(Schema.String)).annotate({
command: z.string().array().describe("Command and arguments to run the MCP server"), description: "Command and arguments to run the MCP server",
environment: z }),
.record(z.string(), z.string()) environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
.optional() description: "Environment variables to set when running the MCP server",
.describe("Environment variables to set when running the MCP server"), }),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), enabled: Schema.optional(Schema.Boolean).annotate({
timeout: z description: "Enable or disable the MCP server on startup",
.number() }),
.int() timeout: Schema.optional(Schema.Number).annotate({
.positive() description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
.optional() }),
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), }) {
}) static readonly zod = zod(this)
.strict() }
.meta({
ref: "McpLocalConfig",
})
export const OAuth = z export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
.object({ clientId: Schema.optional(Schema.String).annotate({
clientId: z description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
.string() }),
.optional() clientSecret: Schema.optional(Schema.String).annotate({
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), description: "OAuth client secret (if required by the authorization server)",
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), }),
scope: z.string().optional().describe("OAuth scopes to request during authorization"), scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
redirectUri: z redirectUri: Schema.optional(Schema.String).annotate({
.string() description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
.optional() }),
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) {
}) static readonly zod = zod(this)
.strict() }
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const Remote = z export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
.object({ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
type: z.literal("remote").describe("Type of MCP server connection"), url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
url: z.string().describe("URL of the remote MCP server"), enabled: Schema.optional(Schema.Boolean).annotate({
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), description: "Enable or disable the MCP server on startup",
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), }),
oauth: z headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
.union([OAuth, z.literal(false)]) description: "Headers to send with the request",
.optional() }),
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."), oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
timeout: z description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
.number() }),
.int() timeout: Schema.optional(Schema.Number).annotate({
.positive() description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
.optional() }),
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), }) {
}) static readonly zod = zod(this)
.strict() }
.meta({
ref: "McpRemoteConfig",
})
export const Info = z.discriminatedUnion("type", [Local, Remote]) export const Info = Schema.Union([Local, Remote])
export type Info = z.infer<typeof Info> .annotate({ discriminator: "type" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigMCP from "./mcp" export * as ConfigMCP from "./mcp"

View file

@ -1,3 +1,14 @@
import { Schema } from "effect"
import z from "zod" import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) // The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>

View file

@ -1,5 +1,8 @@
export * as ConfigPermission from "./permission" export * as ConfigPermission from "./permission"
import { Schema } from "effect"
import z from "zod" import z from "zod"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const permissionPreprocess = (val: unknown) => { const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) { if (typeof val === "object" && val !== null && !Array.isArray(val)) {
@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => {
return val return val
} }
export const Action = z.enum(["ask", "allow", "deny"]).meta({ export const Action = Schema.Literals(["ask", "allow", "deny"])
ref: "PermissionActionConfig", .annotate({ identifier: "PermissionActionConfig" })
}) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = z.infer<typeof Action> export type Action = Schema.Schema.Type<typeof Action>
export const Object = z.record(z.string(), Action).meta({ export const Object = Schema.Record(Schema.String, Action)
ref: "PermissionObjectConfig", .annotate({ identifier: "PermissionObjectConfig" })
}) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type Object = z.infer<typeof Object> export type Object = Schema.Schema.Type<typeof Object>
export const Rule = z.union([Action, Object]).meta({ export const Rule = Schema.Union([Action, Object])
ref: "PermissionRuleConfig", .annotate({ identifier: "PermissionRuleConfig" })
}) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = z.infer<typeof Rule> export type Rule = Schema.Schema.Type<typeof Rule>
const transform = (x: unknown): Record<string, Rule> => { const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action } if (typeof x === "string") return { "*": x as Action }
@ -41,25 +44,25 @@ export const Info = z
z z
.object({ .object({
__originalKeys: z.string().array().optional(), __originalKeys: z.string().array().optional(),
read: Rule.optional(), read: Rule.zod.optional(),
edit: Rule.optional(), edit: Rule.zod.optional(),
glob: Rule.optional(), glob: Rule.zod.optional(),
grep: Rule.optional(), grep: Rule.zod.optional(),
list: Rule.optional(), list: Rule.zod.optional(),
bash: Rule.optional(), bash: Rule.zod.optional(),
task: Rule.optional(), task: Rule.zod.optional(),
external_directory: Rule.optional(), external_directory: Rule.zod.optional(),
todowrite: Action.optional(), todowrite: Action.zod.optional(),
question: Action.optional(), question: Action.zod.optional(),
webfetch: Action.optional(), webfetch: Action.zod.optional(),
websearch: Action.optional(), websearch: Action.zod.optional(),
codesearch: Action.optional(), codesearch: Action.zod.optional(),
lsp: Rule.optional(), lsp: Rule.zod.optional(),
doom_loop: Action.optional(), doom_loop: Action.zod.optional(),
skill: Rule.optional(), skill: Rule.zod.optional(),
}) })
.catchall(Rule) .catchall(Rule.zod)
.or(Action), .or(Action.zod),
) )
.transform(transform) .transform(transform)
.meta({ .meta({

View file

@ -1,16 +1,20 @@
import { Glob } from "@opencode-ai/shared/util/glob" import { Glob } from "@opencode-ai/shared/util/glob"
import z from "zod" import { Schema } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import path from "path" import path from "path"
const Options = z.record(z.string(), z.unknown()) export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Options = z.infer<typeof Options> export type Options = Schema.Schema.Type<typeof Options>
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from. // It answers "what should we load?" but says nothing about where that value came from.
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
export type Spec = z.infer<typeof Spec> withStatics((s) => ({ zod: zod(s) })),
)
export type Spec = Schema.Schema.Type<typeof Spec>
export type Scope = "global" | "local" export type Scope = "global" | "local"

View file

@ -1,13 +1,16 @@
import z from "zod" import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Info = z.object({ export const Info = Schema.Struct({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), paths: Schema.optional(Schema.Array(Schema.String)).annotate({
urls: z description: "Additional paths to skill folders",
.array(z.string()) }),
.optional() urls: Schema.optional(Schema.Array(Schema.String)).annotate({
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
}) }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info> export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigSkills from "./skills" export * as ConfigSkills from "./skills"

View file

@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
name: string name: string
description: string description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void> create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void> remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target> target(info: WorkspaceInfo): Target | Promise<Target>
} }

View file

@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id, OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true", OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
} }
await adaptor.create(config, env) await adaptor.create(config, env)

View file

@ -46,7 +46,7 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ShareNext } from "@/share" import { ShareNext } from "@/share"
import { SessionShare } from "@/share" import { SessionShare } from "@/share"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
import { memoMap } from "./memo-map" import { memoMap } from "./memo-map"
export const AppLayer = Layer.mergeAll( export const AppLayer = Layer.mergeAll(

View file

@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger" import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version" import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
) )
: undefined : undefined
const resource = { function resource() {
serviceName: "opencode", const processMetadata = ensureProcessMetadata("main")
serviceVersion: InstallationVersion, return {
attributes: { serviceName: "opencode",
"deployment.environment.name": InstallationChannel, serviceVersion: InstallationVersion,
"opencode.client": Flag.OPENCODE_CLIENT, attributes: {
}, "deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
} }
const logs = Logger.layer( function logs() {
[ return Logger.layer(
EffectLogger.logger, [
OtlpLogger.make({ EffectLogger.logger,
url: `${base}/v1/logs`, OtlpLogger.make({
resource, url: `${base}/v1/logs`,
headers, resource: resource(),
}), headers,
], }),
{ mergeWithExisting: false }, ],
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) { mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => { const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk") const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@ -58,7 +68,7 @@ const traces = async () => {
context.setGlobalContextManager(mgr) context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({ return NodeSdk.layer(() => ({
resource, resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor( spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({ new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`, url: `${base}/v1/traces`,
@ -73,7 +83,7 @@ export const layer = !base
: Layer.unwrap( : Layer.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const trace = yield* Effect.promise(traces) const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs) return Layer.mergeAll(trace, logs())
}), }),
) )

View file

@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { Log } from "@/util" import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
const log = Log.create({ service: "ripgrep" }) const log = Log.create({ service: "ripgrep" })
@ -157,9 +158,7 @@ type WorkerError = {
} }
function env() { function env() {
const env = Object.fromEntries( const env = sanitizedProcessEnv()
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH delete env.RIPGREP_CONFIG_PATH
return env return env
} }

View file

@ -1,9 +1,8 @@
import { ripgrep } from "ripgrep" import { ripgrep } from "ripgrep"
import { sanitizedProcessEnv } from "@/util/opencode-process"
function env() { function env() {
const env = Object.fromEntries( const env = sanitizedProcessEnv()
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH delete env.RIPGREP_CONFIG_PATH
return env return env
} }

View file

@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug" import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap" import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite" import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "./util/opencode-process"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", { Log.Default.error("rejection", {
@ -108,6 +111,8 @@ const cli = yargs(args)
Log.Default.info("opencode", { Log.Default.info("opencode", {
version: InstallationVersion, version: InstallationVersion,
args: process.argv.slice(2), args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
}) })
const marker = path.join(Global.Path.data, "opencode.db") const marker = path.join(Global.Path.data, "opencode.db")

View file

@ -1,258 +0,0 @@
export * as Npm from "./effect"
import path from "path"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: { add: string[] },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = Option.none()
}
return {
directory: dir,
entrypoint,
}
}
interface ArboristNode {
name: string
path: string
}
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arborist = new Arborist({
path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add: input?.add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add: input?.add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
return Service.of({
add,
install,
outdated,
which,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
}

View file

@ -1,198 +1,271 @@
import semver from "semver" export * as Npm from "."
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Global } from "../global"
import { Log } from "../util"
import path from "path" import path from "path"
import { readdir, rm } from "fs/promises" import semver from "semver"
import { Filesystem } from "@/util" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { Flock } from "@opencode-ai/shared/util/flock" import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: { add: string[] },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
export function sanitize(pkg: string) { export function sanitize(pkg: string) {
if (!illegal) return pkg if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
} }
function directory(pkg: string) { const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
return path.join(Global.Path.cache, "packages", sanitize(pkg)) let entrypoint: Option.Option<string>
}
function resolveEntryPoint(name: string, dir: string) {
let entrypoint: string | undefined
try { try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {} entrypoint = Option.some(resolved)
const result = { } catch {
entrypoint = Option.none()
}
return {
directory: dir, directory: dir,
entrypoint, entrypoint,
} }
return result
} }
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> { interface ArboristNode {
const response = await fetch(`https://registry.npmjs.org/${pkg}`) name: string
if (!response.ok) { path: string
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
} }
export async function add(pkg: string) { interface ArboristTree {
const { Arborist } = await import("@npmcli/arborist") edgesOut: Map<string, { to?: ArboristNode }>
const dir = directory(pkg) }
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
const arborist = new Arborist({ export const layer = Layer.effect(
path: dir, Service,
binLinks: true, Effect.gen(function* () {
progress: false, const afs = yield* AppFileSystem.Service
savePrefix: "", const global = yield* Global.Service
ignoreScripts: true, const fs = yield* FileSystem.FileSystem
}) const flock = yield* EffectFlock.Service
const tree = await arborist.loadVirtual().catch(() => {}) const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
if (tree) { const reify = (input: { dir: string; add?: string[] }) =>
const first = tree.edgesOut.values().next().value?.to Effect.gen(function* () {
if (first) { yield* flock.acquire(`npm-install:${input.dir}`)
return resolveEntryPoint(first.name, first.path) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
} const arborist = new Arborist({
} path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const result = await arborist const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
.reify({ const response = yield* Effect.tryPromise({
add: [pkg], try: () => fetch(`https://registry.npmjs.org/${pkg}`),
save: true, catch: () => undefined,
saveType: "prod", }).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}) })
.catch((cause) => {
throw new InstallFailedError( const add = Effect.fn("Npm.add")(function* (pkg: string) {
{ pkg }, const dir = directory(pkg)
{
cause, const tree = yield* reify({ dir, add: [pkg] })
}, const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add: input?.add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add: input?.add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
) )
}) })
const first = result.edgesOut.values().next().value?.to return Service.of({
if (!first) throw new InstallFailedError({ pkg }) add,
return resolveEntryPoint(first.name, first.path) install,
} outdated,
which,
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
const reify = async () => {
const { Arborist } = await import("@npmcli/arborist")
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
}) })
await arb.reify().catch(() => {}) }),
} )
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) { export const defaultLayer = layer.pipe(
log.info("node_modules missing, reifying") Layer.provide(EffectFlock.layer),
await reify() Layer.provide(AppFileSystem.layer),
return Layer.provide(Global.layer),
} Layer.provide(NodeFileSystem.layer),
)
type PackageDeps = Record<string, string> const { runPromise } = makeRuntime(Service, defaultLayer)
type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([ export async function install(...args: Parameters<Interface["install"]>) {
...Object.keys(pkg.dependencies || {}), return runPromise((svc) => svc.install(...args))
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
} }
export async function which(pkg: string) { export async function add(...args: Parameters<Interface["add"]>) {
const dir = directory(pkg) const entry = await runPromise((svc) => svc.add(...args))
const binDir = path.join(dir, "node_modules", ".bin") return {
directory: entry.directory,
const pick = async () => { entrypoint: Option.getOrUndefined(entry.entrypoint),
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
} }
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) return
return path.join(binDir, resolved)
} }
export * as Npm from "." export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
}

View file

@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version" import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader { export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = { export type Plan = {
spec: string spec: string
options: ConfigPlugin.Options | undefined options: ConfigPlugin.Options | undefined
deprecated: boolean deprecated: boolean
} }
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & { export type Resolved = Plan & {
source: PluginSource source: PluginSource
target: string target: string
entry: string entry: string
pkg?: PluginPackage pkg?: PluginPackage
} }
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & { export type Missing = Plan & {
source: PluginSource source: PluginSource
target: string target: string
pkg?: PluginPackage pkg?: PluginPackage
message: string message: string
} }
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & { export type Loaded = Resolved & {
mod: Record<string, unknown> mod: Record<string, unknown>
} }
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = { type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: ( error?: (
candidate: Candidate, candidate: Candidate,
retry: boolean, retry: boolean,
@ -46,11 +56,16 @@ export namespace PluginLoader {
) => void ) => void
} }
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan { function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item) const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
} }
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve( export async function resolve(
plan: Plan, plan: Plan,
kind: PluginKind, kind: PluginKind,
@ -59,6 +74,7 @@ export namespace PluginLoader {
| { ok: false; stage: "missing"; value: Missing } | { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> { > {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = "" let target = ""
try { try {
target = await resolvePluginTarget(plan.spec) target = await resolvePluginTarget(plan.spec)
@ -67,6 +83,7 @@ export namespace PluginLoader {
} }
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base let base
try { try {
base = await createPluginEntry(plan.spec, target, kind) base = await createPluginEntry(plan.spec, target, kind)
@ -86,6 +103,8 @@ export namespace PluginLoader {
}, },
} }
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") { if (base.source === "npm") {
try { try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@ -96,6 +115,7 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
} }
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod let mod
try { try {
@ -107,6 +127,8 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } } return { ok: true, value: { ...row, mod } }
} }
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>( async function attempt<R>(
candidate: Candidate, candidate: Candidate,
kind: PluginKind, kind: PluginKind,
@ -116,11 +138,17 @@ export namespace PluginLoader {
report: Report | undefined, report: Report | undefined,
): Promise<R | undefined> { ): Promise<R | undefined> {
const plan = candidate.plan const plan = candidate.plan
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return if (plan.deprecated) return
report?.start?.(candidate, retry) report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind) const resolved = await resolve(plan, kind)
if (!resolved.ok) { if (!resolved.ok) {
if (resolved.stage === "missing") { if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) { if (missing) {
const value = await missing(resolved.value, candidate.origin, retry) const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value if (value !== undefined) return value
@ -131,11 +159,15 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error) report?.error?.(candidate, retry, resolved.stage, resolved.error)
return return
} }
const loaded = await load(resolved.value) const loaded = await load(resolved.value)
if (!loaded.ok) { if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value) report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return return
} }
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry) return finish(loaded.value, candidate.origin, retry)
} }
@ -149,6 +181,11 @@ export namespace PluginLoader {
report?: Report report?: Report
} }
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> { export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = [] const list: Array<Promise<R | undefined>> = []
@ -160,6 +197,9 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) { for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue if (out[i] !== undefined) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i] const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait() deps ??= input.wait()
@ -167,6 +207,8 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
} }
} }
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = [] const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item) for (const item of out) if (item !== undefined) ready.push(item)
return ready return ready

View file

@ -4,7 +4,7 @@ import npa from "npm-package-arg"
import semver from "semver" import semver from "semver"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
// Old npm package names for plugins that are now built-in // Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]

View file

@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
family: model.family, family: model.family,
api: { api: {
id: model.id, id: model.id,
url: model.provider?.api ?? provider.api!, url: model.provider?.api ?? provider.api ?? "",
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
}, },
status: model.status ?? "active", status: model.status ?? "active",
@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
output: model.limit.output, output: model.limit.output,
}, },
capabilities: { capabilities: {
temperature: model.temperature, temperature: model.temperature ?? false,
reasoning: model.reasoning, reasoning: model.reasoning ?? false,
attachment: model.attachment, attachment: model.attachment ?? false,
toolcall: model.tool_call, toolcall: model.tool_call ?? true,
input: { input: {
text: model.modalities?.input?.includes("text") ?? false, text: model.modalities?.input?.includes("text") ?? false,
audio: model.modalities?.input?.includes("audio") ?? false, audio: model.modalities?.input?.includes("audio") ?? false,
@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
}, },
interleaved: model.interleaved ?? false, interleaved: model.interleaved ?? false,
}, },
release_date: model.release_date, release_date: model.release_date ?? "",
variants: {}, variants: {},
} }
@ -1143,7 +1143,7 @@ const layer: Layer.Layer<
existingModel?.api.npm ?? existingModel?.api.npm ??
modelsDev[providerID]?.npm ?? modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible", "@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
}, },
status: model.status ?? existingModel?.status ?? "active", status: model.status ?? existingModel?.status ?? "active",
name, name,

View file

@ -7,7 +7,6 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
import z from "zod" import z from "zod"
import { errors } from "../../error" import { errors } from "../../error"
import { WorkspaceRoutes } from "./workspace"
export function ControlPlaneRoutes(): Hono { export function ControlPlaneRoutes(): Hono {
const app = new Hono() const app = new Hono()
@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
return c.json(true) return c.json(true)
}, },
) )
.route("/experimental/workspace", WorkspaceRoutes())
} }

View file

@ -5,7 +5,6 @@ import { Config } from "@/config"
import { Provider } from "@/provider" import { Provider } from "@/provider"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest } from "./trace" import { jsonRequest } from "./trace"
export const ConfigRoutes = lazy(() => export const ConfigRoutes = lazy(() =>
@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
}, },
}), }),
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) =>
const config = c.req.valid("json") jsonRequest("ConfigRoutes.update", c, function* () {
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) const config = c.req.valid("json")
return c.json(config) const cfg = yield* Config.Service
}, yield* cfg.update(config)
return config
}),
) )
.get( .get(
"/providers", "/providers",

View file

@ -12,11 +12,11 @@ import { Config } from "@/config"
import { ConsoleState } from "@/config/console-state" import { ConsoleState } from "@/config/console-state"
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema" import { AccountID, OrgID } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { jsonRequest, runRequest } from "./trace"
const ConsoleOrgOption = z.object({ const ConsoleOrgOption = z.object({
accountID: z.string(), accountID: z.string(),
@ -49,28 +49,24 @@ export const ExperimentalRoutes = lazy(() =>
description: "Active Console provider metadata", description: "Active Console provider metadata",
content: { content: {
"application/json": { "application/json": {
schema: resolver(ConsoleState), schema: resolver(ConsoleState.zod),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) =>
const result = await AppRuntime.runPromise( jsonRequest("ExperimentalRoutes.console.get", c, function* () {
Effect.gen(function* () { const config = yield* Config.Service
const config = yield* Config.Service const account = yield* Account.Service
const account = yield* Account.Service const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { concurrency: "unbounded",
concurrency: "unbounded", })
}) return {
return { ...state,
...state, switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), }
} }),
}),
)
return c.json(result)
},
) )
.get( .get(
"/console/orgs", "/console/orgs",
@ -89,28 +85,25 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const orgs = await AppRuntime.runPromise( jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
Effect.gen(function* () { const account = yield* Account.Service
const account = yield* Account.Service const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { concurrency: "unbounded",
concurrency: "unbounded", })
}) const info = Option.getOrUndefined(active)
const info = Option.getOrUndefined(active) const orgs = groups.flatMap((group) =>
return groups.flatMap((group) => group.orgs.map((org) => ({
group.orgs.map((org) => ({ accountID: group.account.id,
accountID: group.account.id, accountEmail: group.account.email,
accountEmail: group.account.email, accountUrl: group.account.url,
accountUrl: group.account.url, orgID: org.id,
orgID: org.id, orgName: org.name,
orgName: org.name, active: !!info && info.id === group.account.id && info.active_org_id === org.id,
active: !!info && info.id === group.account.id && info.active_org_id === org.id, })),
})), )
) return { orgs }
}), }),
)
return c.json({ orgs })
},
) )
.post( .post(
"/console/switch", "/console/switch",
@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
validator("json", ConsoleSwitchBody), validator("json", ConsoleSwitchBody),
async (c) => { async (c) =>
const body = c.req.valid("json") jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
await AppRuntime.runPromise( const body = c.req.valid("json")
Effect.gen(function* () { const account = yield* Account.Service
const account = yield* Account.Service yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) return true
}), }),
)
return c.json(true)
},
) )
.get( .get(
"/tool/ids", "/tool/ids",
@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400), ...errors(400),
}, },
}), }),
async (c) => { async (c) =>
const ids = await AppRuntime.runPromise( jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
Effect.gen(function* () { const registry = yield* ToolRegistry.Service
const registry = yield* ToolRegistry.Service return yield* registry.ids()
return yield* registry.ids() }),
}),
)
return c.json(ids)
},
) )
.get( .get(
"/tool", "/tool",
@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { provider, model } = c.req.valid("query") const { provider, model } = c.req.valid("query")
const tools = await AppRuntime.runPromise( const tools = await runRequest(
"ExperimentalRoutes.tool.list",
c,
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
validator("json", Worktree.CreateInput.optional()), validator("json", Worktree.CreateInput.optional()),
async (c) => { async (c) =>
const body = c.req.valid("json") jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body))) const body = c.req.valid("json")
return c.json(worktree) const svc = yield* Worktree.Service
}, return yield* svc.create(body)
}),
) )
.get( .get(
"/worktree", "/worktree",
@ -272,10 +261,11 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id))) jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
return c.json(sandboxes) const svc = yield* Project.Service
}, return yield* svc.sandboxes(Instance.project.id)
}),
) )
.delete( .delete(
"/worktree", "/worktree",
@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
validator("json", Worktree.RemoveInput), validator("json", Worktree.RemoveInput),
async (c) => { async (c) =>
const body = c.req.valid("json") jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) const body = c.req.valid("json")
await AppRuntime.runPromise( const worktree = yield* Worktree.Service
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)), const project = yield* Project.Service
) yield* worktree.remove(body)
return c.json(true) yield* project.removeSandbox(Instance.project.id, body.directory)
}, return true
}),
) )
.post( .post(
"/worktree/reset", "/worktree/reset",
@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
validator("json", Worktree.ResetInput), validator("json", Worktree.ResetInput),
async (c) => { async (c) =>
const body = c.req.valid("json") jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body))) const body = c.req.valid("json")
return c.json(true) const svc = yield* Worktree.Service
}, yield* svc.reset(body)
return true
}),
) )
.get( .get(
"/session", "/session",
@ -406,15 +399,10 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json( jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
await AppRuntime.runPromise( const mcp = yield* MCP.Service
Effect.gen(function* () { return yield* mcp.resources()
const mcp = yield* MCP.Service }),
return yield* mcp.resources()
}),
),
)
},
), ),
) )

View file

@ -1,13 +1,12 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "@/file" import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep" import { Ripgrep } from "@/file/ripgrep"
import { LSP } from "@/lsp" import { LSP } from "@/lsp"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const FileRoutes = lazy(() => export const FileRoutes = lazy(() =>
new Hono() new Hono()
@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
pattern: z.string(), pattern: z.string(),
}), }),
), ),
async (c) => { async (c) =>
const pattern = c.req.valid("query").pattern jsonRequest("FileRoutes.findText", c, function* () {
const result = await AppRuntime.runPromise( const pattern = c.req.valid("query").pattern
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), const svc = yield* Ripgrep.Service
) const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
return c.json(result.items) return result.items
}, }),
) )
.get( .get(
"/find/file", "/find/file",
@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
limit: z.coerce.number().int().min(1).max(200).optional(), limit: z.coerce.number().int().min(1).max(200).optional(),
}), }),
), ),
async (c) => { async (c) =>
const query = c.req.valid("query").query jsonRequest("FileRoutes.findFile", c, function* () {
const dirs = c.req.valid("query").dirs const query = c.req.valid("query")
const type = c.req.valid("query").type const svc = yield* File.Service
const limit = c.req.valid("query").limit return yield* svc.search({
const results = await AppRuntime.runPromise( query: query.query,
Effect.gen(function* () { limit: query.limit ?? 10,
return yield* File.Service.use((svc) => dirs: query.dirs !== "false",
svc.search({ type: query.type,
query, })
limit: limit ?? 10, }),
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results)
},
) )
.get( .get(
"/find/symbol", "/find/symbol",
@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
path: z.string(), path: z.string(),
}), }),
), ),
async (c) => { async (c) =>
const path = c.req.valid("query").path jsonRequest("FileRoutes.list", c, function* () {
const content = await AppRuntime.runPromise( const svc = yield* File.Service
Effect.gen(function* () { return yield* svc.list(c.req.valid("query").path)
return yield* File.Service.use((svc) => svc.list(path)) }),
}),
)
return c.json(content)
},
) )
.get( .get(
"/file/content", "/file/content",
@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
path: z.string(), path: z.string(),
}), }),
), ),
async (c) => { async (c) =>
const path = c.req.valid("query").path jsonRequest("FileRoutes.read", c, function* () {
const content = await AppRuntime.runPromise( const svc = yield* File.Service
Effect.gen(function* () { return yield* svc.read(c.req.valid("query").path)
return yield* File.Service.use((svc) => svc.read(path)) }),
}),
)
return c.json(content)
},
) )
.get( .get(
"/file/status", "/file/status",
@ -198,13 +181,10 @@ export const FileRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const content = await AppRuntime.runPromise( jsonRequest("FileRoutes.status", c, function* () {
Effect.gen(function* () { const svc = yield* File.Service
return yield* File.Service.use((svc) => svc.status()) return yield* svc.status()
}), }),
)
return c.json(content)
},
), ),
) )

View file

@ -26,7 +26,8 @@ import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider" import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event" import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync" import { SyncRoutes } from "./sync"
import { AppRuntime } from "@/effect/app-runtime" import { InstanceMiddleware } from "./middleware"
import { jsonRequest } from "./trace"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono() const app = new Hono()
@ -140,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json( jsonRequest("InstanceRoutes.vcs.get", c, function* () {
await AppRuntime.runPromise( const vcs = yield* Vcs.Service
Effect.gen(function* () { const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
const vcs = yield* Vcs.Service concurrency: 2,
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { })
concurrency: 2, return { branch, default_branch }
}) }),
return { branch, default_branch }
}),
),
)
},
) )
.get( .get(
"/vcs/diff", "/vcs/diff",
@ -177,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
mode: Vcs.Mode, mode: Vcs.Mode,
}), }),
), ),
async (c) => { async (c) =>
return c.json( jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
await AppRuntime.runPromise( const vcs = yield* Vcs.Service
Effect.gen(function* () { return yield* vcs.diff(c.req.valid("query").mode)
const vcs = yield* Vcs.Service }),
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
},
) )
.get( .get(
"/command", "/command",
@ -205,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) jsonRequest("InstanceRoutes.command.list", c, function* () {
return c.json(commands) const svc = yield* Command.Service
}, return yield* svc.list()
}),
) )
.get( .get(
"/agent", "/agent",
@ -227,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) jsonRequest("InstanceRoutes.agent.list", c, function* () {
return c.json(modes) const svc = yield* Agent.Service
}, return yield* svc.list()
}),
) )
.get( .get(
"/skill", "/skill",
@ -249,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
const skills = await AppRuntime.runPromise( jsonRequest("InstanceRoutes.skill.list", c, function* () {
Effect.gen(function* () { const skill = yield* Skill.Service
const skill = yield* Skill.Service return yield* skill.all()
return yield* skill.all() }),
}),
)
return c.json(skills)
},
) )
.get( .get(
"/lsp", "/lsp",
@ -276,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) jsonRequest("InstanceRoutes.lsp.status", c, function* () {
return c.json(items) const lsp = yield* LSP.Service
}, return yield* lsp.status()
}),
) )
.get( .get(
"/formatter", "/formatter",
@ -298,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) jsonRequest("InstanceRoutes.formatter.status", c, function* () {
}, const svc = yield* Format.Service
return yield* svc.status()
}),
) )
} }

View file

@ -2,12 +2,11 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { Config } from "@/config"
import { ConfigMCP } from "@/config/mcp" import { ConfigMCP } from "@/config/mcp"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Effect } from "effect" import { Effect } from "effect"
import { jsonRequest, runRequest } from "./trace"
export const McpRoutes = lazy(() => export const McpRoutes = lazy(() =>
new Hono() new Hono()
@ -28,9 +27,11 @@ export const McpRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) jsonRequest("McpRoutes.status", c, function* () {
}, const mcp = yield* MCP.Service
return yield* mcp.status()
}),
) )
.post( .post(
"/", "/",
@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
"json", "json",
z.object({ z.object({
name: z.string(), name: z.string(),
config: ConfigMCP.Info, config: ConfigMCP.Info.zod,
}), }),
), ),
async (c) => { async (c) =>
const { name, config } = c.req.valid("json") jsonRequest("McpRoutes.add", c, function* () {
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) const { name, config } = c.req.valid("json")
return c.json(result.status) const mcp = yield* MCP.Service
}, const result = yield* mcp.add(name, config)
return result.status
}),
) )
.post( .post(
"/:name/auth", "/:name/auth",
@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const result = await AppRuntime.runPromise( const result = await runRequest(
"McpRoutes.auth.start",
c,
Effect.gen(function* () { Effect.gen(function* () {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name) const supports = yield* mcp.supportsOAuth(name)
@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
code: z.string().describe("Authorization code from OAuth callback"), code: z.string().describe("Authorization code from OAuth callback"),
}), }),
), ),
async (c) => { async (c) =>
const name = c.req.param("name") jsonRequest("McpRoutes.auth.callback", c, function* () {
const { code } = c.req.valid("json") const name = c.req.param("name")
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) const { code } = c.req.valid("json")
return c.json(status) const mcp = yield* MCP.Service
}, return yield* mcp.finishAuth(name, code)
}),
) )
.post( .post(
"/:name/auth/authenticate", "/:name/auth/authenticate",
@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const result = await AppRuntime.runPromise( const result = await runRequest(
"McpRoutes.auth.authenticate",
c,
Effect.gen(function* () { Effect.gen(function* () {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name) const supports = yield* mcp.supportsOAuth(name)
@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
...errors(404), ...errors(404),
}, },
}), }),
async (c) => { async (c) =>
const name = c.req.param("name") jsonRequest("McpRoutes.auth.remove", c, function* () {
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) const name = c.req.param("name")
return c.json({ success: true as const }) const mcp = yield* MCP.Service
}, yield* mcp.removeAuth(name)
return { success: true as const }
}),
) )
.post( .post(
"/:name/connect", "/:name/connect",
@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) =>
const { name } = c.req.valid("param") jsonRequest("McpRoutes.connect", c, function* () {
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) const { name } = c.req.valid("param")
return c.json(true) const mcp = yield* MCP.Service
}, yield* mcp.connect(name)
return true
}),
) )
.post( .post(
"/:name/disconnect", "/:name/disconnect",
@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) =>
const { name } = c.req.valid("param") jsonRequest("McpRoutes.disconnect", c, function* () {
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) const { name } = c.req.valid("param")
return c.json(true) const mcp = yield* MCP.Service
}, yield* mcp.disconnect(name)
return true
}),
), ),
) )

View file

@ -0,0 +1,35 @@
import type { MiddlewareHandler } from "hono"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceID } from "@/control-plane/schema"
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = AppFileSystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
}

View file

@ -1,11 +1,11 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const PermissionRoutes = lazy(() => export const PermissionRoutes = lazy(() =>
new Hono() new Hono()
@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
}), }),
), ),
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("PermissionRoutes.reply", c, function* () {
const json = c.req.valid("json") const params = c.req.valid("param")
await AppRuntime.runPromise( const json = c.req.valid("json")
Permission.Service.use((svc) => const svc = yield* Permission.Service
svc.reply({ yield* svc.reply({
requestID: params.requestID, requestID: params.requestID,
reply: json.reply, reply: json.reply,
message: json.message, message: json.message,
}), })
), return true
) }),
return c.json(true)
},
) )
.get( .get(
"/", "/",
@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) jsonRequest("PermissionRoutes.list", c, function* () {
return c.json(permissions) const svc = yield* Permission.Service
}, return yield* svc.list()
}),
), ),
) )

View file

@ -9,6 +9,7 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest, runRequest } from "./trace"
export const ProjectRoutes = lazy(() => export const ProjectRoutes = lazy(() =>
new Hono() new Hono()
@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => { async (c) => {
const dir = Instance.directory const dir = Instance.directory
const prev = Instance.project const prev = Instance.project
const next = await AppRuntime.runPromise( const next = await runRequest(
"ProjectRoutes.initGit",
c,
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
) )
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
}), }),
validator("param", z.object({ projectID: ProjectID.zod })), validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })), validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => { async (c) =>
const projectID = c.req.valid("param").projectID jsonRequest("ProjectRoutes.update", c, function* () {
const body = c.req.valid("json") const projectID = c.req.valid("param").projectID
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID }))) const body = c.req.valid("json")
return c.json(project) const svc = yield* Project.Service
}, return yield* svc.update({ ...body, projectID })
}),
), ),
) )

View file

@ -6,11 +6,11 @@ import { Provider } from "@/provider"
import { ModelsDev } from "@/provider" import { ModelsDev } from "@/provider"
import { ProviderAuth } from "@/provider" import { ProviderAuth } from "@/provider"
import { ProviderID } from "@/provider/schema" import { ProviderID } from "@/provider/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { mapValues } from "remeda" import { mapValues } from "remeda"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Effect } from "effect" import { Effect } from "effect"
import { jsonRequest } from "./trace"
export const ProviderRoutes = lazy(() => export const ProviderRoutes = lazy(() =>
new Hono() new Hono()
@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const result = await AppRuntime.runPromise( jsonRequest("ProviderRoutes.list", c, function* () {
Effect.gen(function* () { const svc = yield* Provider.Service
const svc = yield* Provider.Service const cfg = yield* Config.Service
const cfg = yield* Config.Service const config = yield* cfg.get()
const config = yield* cfg.get() const all = yield* Effect.promise(() => ModelsDev.get())
const all = yield* Effect.promise(() => ModelsDev.get()) const disabled = new Set(config.disabled_providers ?? [])
const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record<string, (typeof all)[string]> = {}
const filtered: Record<string, (typeof all)[string]> = {} for (const [key, value] of Object.entries(all)) {
for (const [key, value] of Object.entries(all)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { filtered[key] = value
filtered[key] = value
}
} }
const connected = yield* svc.list() }
const providers = Object.assign( const connected = yield* svc.list()
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), const providers = Object.assign(
connected, mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
) connected,
return { )
all: Object.values(providers), return {
default: Provider.defaultModelIDs(providers), all: Object.values(providers),
connected: Object.keys(connected), default: Provider.defaultModelIDs(providers),
} connected: Object.keys(connected),
}), }
) }),
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
})
},
) )
.get( .get(
"/auth", "/auth",
@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) jsonRequest("ProviderRoutes.auth", c, function* () {
}, const svc = yield* ProviderAuth.Service
return yield* svc.methods()
}),
) )
.post( .post(
"/:providerID/oauth/authorize", "/:providerID/oauth/authorize",
@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
}), }),
), ),
validator("json", ProviderAuth.AuthorizeInput.zod), validator("json", ProviderAuth.AuthorizeInput.zod),
async (c) => { async (c) =>
const providerID = c.req.valid("param").providerID jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
const { method, inputs } = c.req.valid("json") const providerID = c.req.valid("param").providerID
const result = await AppRuntime.runPromise( const { method, inputs } = c.req.valid("json")
ProviderAuth.Service.use((svc) => const svc = yield* ProviderAuth.Service
svc.authorize({ return yield* svc.authorize({
providerID, providerID,
method, method,
inputs, inputs,
}), })
), }),
)
return c.json(result)
},
) )
.post( .post(
"/:providerID/oauth/callback", "/:providerID/oauth/callback",
@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
}), }),
), ),
validator("json", ProviderAuth.CallbackInput.zod), validator("json", ProviderAuth.CallbackInput.zod),
async (c) => { async (c) =>
const providerID = c.req.valid("param").providerID jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
const { method, code } = c.req.valid("json") const providerID = c.req.valid("param").providerID
await AppRuntime.runPromise( const { method, code } = c.req.valid("json")
ProviderAuth.Service.use((svc) => const svc = yield* ProviderAuth.Service
svc.callback({ yield* svc.callback({
providerID, providerID,
method, method,
code, code,
}), })
), return true
) }),
return c.json(true)
},
), ),
) )

View file

@ -8,6 +8,7 @@ import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema" import { PtyID } from "@/pty/schema"
import { NotFoundError } from "@/storage" import { NotFoundError } from "@/storage"
import { errors } from "../../error" import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono() return new Hono()
@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}, },
}), }),
async (c) => { async (c) =>
return c.json( jsonRequest("PtyRoutes.list", c, function* () {
await AppRuntime.runPromise( const pty = yield* Pty.Service
Effect.gen(function* () { return yield* pty.list()
const pty = yield* Pty.Service }),
return yield* pty.list()
}),
),
)
},
) )
.post( .post(
"/", "/",
@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}), }),
validator("json", Pty.CreateInput), validator("json", Pty.CreateInput),
async (c) => { async (c) =>
const info = await AppRuntime.runPromise( jsonRequest("PtyRoutes.create", c, function* () {
Effect.gen(function* () { const pty = yield* Pty.Service
const pty = yield* Pty.Service return yield* pty.create(c.req.valid("json"))
return yield* pty.create(c.req.valid("json")) }),
}),
)
return c.json(info)
},
) )
.get( .get(
"/:ptyID", "/:ptyID",
@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
const info = await AppRuntime.runPromise( const info = await runRequest(
"PtyRoutes.get",
c,
Effect.gen(function* () { Effect.gen(function* () {
const pty = yield* Pty.Service const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID) return yield* pty.get(c.req.valid("param").ptyID)
@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput), validator("json", Pty.UpdateInput),
async (c) => { async (c) =>
const info = await AppRuntime.runPromise( jsonRequest("PtyRoutes.update", c, function* () {
Effect.gen(function* () { const pty = yield* Pty.Service
const pty = yield* Pty.Service return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) }),
}),
)
return c.json(info)
},
) )
.delete( .delete(
"/:ptyID", "/:ptyID",
@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) =>
await AppRuntime.runPromise( jsonRequest("PtyRoutes.remove", c, function* () {
Effect.gen(function* () { const pty = yield* Pty.Service
const pty = yield* Pty.Service yield* pty.remove(c.req.valid("param").ptyID)
yield* pty.remove(c.req.valid("param").ptyID) return true
}), }),
)
return c.json(true)
},
) )
.get( .get(
"/:ptyID/connect", "/:ptyID/connect",
@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
})() })()
let handler: Handler | undefined let handler: Handler | undefined
if ( if (
!(await AppRuntime.runPromise( !(await runRequest(
"PtyRoutes.connect",
c,
Effect.gen(function* () { Effect.gen(function* () {
const pty = yield* Pty.Service const pty = yield* Pty.Service
return yield* pty.get(id) return yield* pty.get(id)
@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
Effect.gen(function* () { Effect.gen(function* () {
const pty = yield* Pty.Service const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor) return yield* pty.connect(id, socket, cursor)
}), }).pipe(Effect.withSpan("PtyRoutes.connect.open")),
) )
ready = true ready = true
for (const msg of pending) handler?.onMessage(msg) for (const msg of pending) handler?.onMessage(msg)

View file

@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi" import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema" import { QuestionID } from "@/question/schema"
import { Question } from "@/question" import { Question } from "@/question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod" import z from "zod"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
const Reply = z.object({ const Reply = z.object({
answers: Question.Answer.zod answers: Question.Answer.zod
@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
}, },
}, },
}), }),
async (c) => { async (c) =>
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) jsonRequest("QuestionRoutes.list", c, function* () {
return c.json(questions) const svc = yield* Question.Service
}, return yield* svc.list()
}),
) )
.post( .post(
"/:requestID/reply", "/:requestID/reply",
@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
}), }),
), ),
validator("json", Reply), validator("json", Reply),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("QuestionRoutes.reply", c, function* () {
const json = c.req.valid("json") const params = c.req.valid("param")
await AppRuntime.runPromise( const json = c.req.valid("json")
Question.Service.use((svc) => const svc = yield* Question.Service
svc.reply({ yield* svc.reply({
requestID: params.requestID, requestID: params.requestID,
answers: json.answers, answers: json.answers,
}), })
), return true
) }),
return c.json(true)
},
) )
.post( .post(
"/:requestID/reject", "/:requestID/reject",
@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod, requestID: QuestionID.zod,
}), }),
), ),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("QuestionRoutes.reject", c, function* () {
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID))) const params = c.req.valid("param")
return c.json(true) const svc = yield* Question.Service
}, yield* svc.reject(params.requestID)
return true
}),
), ),
) )

View file

@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { Effect } from "effect" import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Command } from "@/command" import { Command } from "@/command"
@ -26,7 +25,7 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { jsonRequest } from "./trace" import { jsonRequest, runRequest } from "./trace"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
}, },
}), }),
validator("json", Session.CreateInput), validator("json", Session.CreateInput),
async (c) => { async (c) =>
const body = c.req.valid("json") ?? {} jsonRequest("SessionRoutes.create", c, function* () {
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body))) const body = c.req.valid("json") ?? {}
return c.json(session) const svc = yield* SessionShare.Service
}, return yield* svc.create(body)
}),
) )
.delete( .delete(
"/:sessionID", "/:sessionID",
@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
sessionID: Session.RemoveInput, sessionID: Session.RemoveInput,
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.delete", c, function* () {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) const sessionID = c.req.valid("param").sessionID
return c.json(true) const svc = yield* Session.Service
}, yield* svc.remove(sessionID)
return true
}),
) )
.patch( .patch(
"/:sessionID", "/:sessionID",
@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
.optional(), .optional(),
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.update", c, function* () {
const updates = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise( const updates = c.req.valid("json")
Effect.gen(function* () { const session = yield* Session.Service
const session = yield* Session.Service const current = yield* session.get(sessionID)
const current = yield* session.get(sessionID)
if (updates.title !== undefined) { if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title }) yield* session.setTitle({ sessionID, title: updates.title })
} }
if (updates.permission !== undefined) { if (updates.permission !== undefined) {
yield* session.setPermission({ yield* session.setPermission({
sessionID, sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission), permission: Permission.merge(current.permission ?? [], updates.permission),
}) })
} }
if (updates.time?.archived !== undefined) { if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived }) yield* session.setArchived({ sessionID, time: updates.time.archived })
} }
return yield* session.get(sessionID) return yield* session.get(sessionID)
}), }),
)
return c.json(session)
},
) )
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post( .post(
@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod, messageID: MessageID.zod,
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.init", c, function* () {
const body = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
await AppRuntime.runPromise( const body = c.req.valid("json")
SessionPrompt.Service.use((svc) => const svc = yield* SessionPrompt.Service
svc.command({ yield* svc.command({
sessionID, sessionID,
messageID: body.messageID, messageID: body.messageID,
model: body.providerID + "/" + body.modelID, model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT, command: Command.Default.INIT,
arguments: "", arguments: "",
}), })
), return true
) }),
return c.json(true)
},
) )
.post( .post(
"/:sessionID/fork", "/:sessionID/fork",
@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
validator("json", Session.ForkInput.omit({ sessionID: true })), validator("json", Session.ForkInput.omit({ sessionID: true })),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.fork", c, function* () {
const body = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID }))) const body = c.req.valid("json")
return c.json(result) const svc = yield* Session.Service
}, return yield* svc.fork({ ...body, sessionID })
}),
) )
.post( .post(
"/:sessionID/abort", "/:sessionID/abort",
@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
async (c) => { async (c) =>
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID))) jsonRequest("SessionRoutes.abort", c, function* () {
return c.json(true) const svc = yield* SessionPrompt.Service
}, yield* svc.cancel(c.req.valid("param").sessionID)
return true
}),
) )
.post( .post(
"/:sessionID/share", "/:sessionID/share",
@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.share", c, function* () {
const session = await AppRuntime.runPromise( const sessionID = c.req.valid("param").sessionID
Effect.gen(function* () { const share = yield* SessionShare.Service
const share = yield* SessionShare.Service const session = yield* Session.Service
const session = yield* Session.Service yield* share.share(sessionID)
yield* share.share(sessionID) return yield* session.get(sessionID)
return yield* session.get(sessionID) }),
}),
)
return c.json(session)
},
) )
.get( .get(
"/:sessionID/diff", "/:sessionID/diff",
@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
messageID: SessionSummary.DiffInput.shape.messageID, messageID: SessionSummary.DiffInput.shape.messageID,
}), }),
), ),
async (c) => { async (c) =>
const query = c.req.valid("query") jsonRequest("SessionRoutes.diff", c, function* () {
const params = c.req.valid("param") const query = c.req.valid("query")
const result = await AppRuntime.runPromise( const params = c.req.valid("param")
SessionSummary.Service.use((summary) => const summary = yield* SessionSummary.Service
summary.diff({ return yield* summary.diff({
sessionID: params.sessionID, sessionID: params.sessionID,
messageID: query.messageID, messageID: query.messageID,
}), })
), }),
)
return c.json(result)
},
) )
.delete( .delete(
"/:sessionID/share", "/:sessionID/share",
@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.unshare", c, function* () {
const session = await AppRuntime.runPromise( const sessionID = c.req.valid("param").sessionID
Effect.gen(function* () { const share = yield* SessionShare.Service
const share = yield* SessionShare.Service const session = yield* Session.Service
const session = yield* Session.Service yield* share.unshare(sessionID)
yield* share.unshare(sessionID) return yield* session.get(sessionID)
return yield* session.get(sessionID) }),
}),
)
return c.json(session)
},
) )
.post( .post(
"/:sessionID/summarize", "/:sessionID/summarize",
@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
auto: z.boolean().optional().default(false), auto: z.boolean().optional().default(false),
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.summarize", c, function* () {
const body = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
await AppRuntime.runPromise( const body = c.req.valid("json")
Effect.gen(function* () { const session = yield* Session.Service
const session = yield* Session.Service const revert = yield* SessionRevert.Service
const revert = yield* SessionRevert.Service const compact = yield* SessionCompaction.Service
const compact = yield* SessionCompaction.Service const prompt = yield* SessionPrompt.Service
const prompt = yield* SessionPrompt.Service const agent = yield* Agent.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(sessionID)) yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID }) const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent() const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info const info = msgs[i].info
if (info.role === "user") { if (info.role === "user") {
currentAgent = info.agent || defaultAgent currentAgent = info.agent || defaultAgent
break break
}
} }
}
yield* compact.create({ yield* compact.create({
sessionID, sessionID,
agent: currentAgent, agent: currentAgent,
model: { model: {
providerID: body.providerID, providerID: body.providerID,
modelID: body.modelID, modelID: body.modelID,
}, },
auto: body.auto, auto: body.auto,
}) })
yield* prompt.loop({ sessionID }) yield* prompt.loop({ sessionID })
}), return true
) }),
return c.json(true)
},
) )
.get( .get(
"/:sessionID/message", "/:sessionID/message",
@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
const query = c.req.valid("query") const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined || query.limit === 0) { if (query.limit === undefined || query.limit === 0) {
const messages = await AppRuntime.runPromise( const messages = await runRequest(
"SessionRoutes.messages",
c,
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
yield* session.get(sessionID) yield* session.get(sessionID)
@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod, messageID: MessageID.zod,
}), }),
), ),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("SessionRoutes.deleteMessage", c, function* () {
await AppRuntime.runPromise( const params = c.req.valid("param")
Effect.gen(function* () { const state = yield* SessionRunState.Service
const state = yield* SessionRunState.Service const session = yield* Session.Service
const session = yield* Session.Service yield* state.assertNotBusy(params.sessionID)
yield* state.assertNotBusy(params.sessionID) yield* session.removeMessage({
yield* session.removeMessage({ sessionID: params.sessionID,
sessionID: params.sessionID, messageID: params.messageID,
messageID: params.messageID, })
}) return true
}), }),
)
return c.json(true)
},
) )
.delete( .delete(
"/:sessionID/message/:messageID/part/:partID", "/:sessionID/message/:messageID/part/:partID",
@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod, partID: PartID.zod,
}), }),
), ),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("SessionRoutes.deletePart", c, function* () {
await AppRuntime.runPromise( const params = c.req.valid("param")
Session.Service.use((svc) => const svc = yield* Session.Service
svc.removePart({ yield* svc.removePart({
sessionID: params.sessionID, sessionID: params.sessionID,
messageID: params.messageID, messageID: params.messageID,
partID: params.partID, partID: params.partID,
}), })
), return true
) }),
return c.json(true)
},
) )
.patch( .patch(
"/:sessionID/message/:messageID/part/:partID", "/:sessionID/message/:messageID/part/:partID",
@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
) )
} }
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body))) return jsonRequest("SessionRoutes.updatePart", c, function* () {
return c.json(part) const svc = yield* Session.Service
return yield* svc.updatePart(body)
})
}, },
) )
.post( .post(
@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => { return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const msg = await AppRuntime.runPromise( const msg = await runRequest(
"SessionRoutes.prompt",
c,
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
) )
void stream.write(JSON.stringify(msg)) void stream.write(JSON.stringify(msg))
@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch( void runRequest(
(err) => { "SessionRoutes.prompt_async",
log.error("prompt_async failed", { sessionID, error: err }) c,
void Bus.publish(Session.Event.Error, { SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
sessionID, ).catch((err) => {
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), log.error("prompt_async failed", { sessionID, error: err })
}) void Bus.publish(Session.Event.Error, {
}, sessionID,
) error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
return c.body(null, 204) return c.body(null, 204)
}, },
@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.command", c, function* () {
const body = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID }))) const body = c.req.valid("json")
return c.json(msg) const svc = yield* SessionPrompt.Service
}, return yield* svc.command({ ...body, sessionID })
}),
) )
.post( .post(
"/:sessionID/shell", "/:sessionID/shell",
@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.shell", c, function* () {
const body = c.req.valid("json") const sessionID = c.req.valid("param").sessionID
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID }))) const body = c.req.valid("json")
return c.json(msg) const svc = yield* SessionPrompt.Service
}, return yield* svc.shell({ ...body, sessionID })
}),
) )
.post( .post(
"/:sessionID/revert", "/:sessionID/revert",
@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json")) log.info("revert", c.req.valid("json"))
const session = await AppRuntime.runPromise( return jsonRequest("SessionRoutes.revert", c, function* () {
SessionRevert.Service.use((svc) => const svc = yield* SessionRevert.Service
svc.revert({ return yield* svc.revert({
sessionID, sessionID,
...c.req.valid("json"), ...c.req.valid("json"),
}), })
), })
)
return c.json(session)
}, },
) )
.post( .post(
@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
async (c) => { async (c) =>
const sessionID = c.req.valid("param").sessionID jsonRequest("SessionRoutes.unrevert", c, function* () {
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID }))) const sessionID = c.req.valid("param").sessionID
return c.json(session) const svc = yield* SessionRevert.Service
}, return yield* svc.unrevert({ sessionID })
}),
) )
.post( .post(
"/:sessionID/permissions/:permissionID", "/:sessionID/permissions/:permissionID",
@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
validator("json", z.object({ response: Permission.Reply.zod })), validator("json", z.object({ response: Permission.Reply.zod })),
async (c) => { async (c) =>
const params = c.req.valid("param") jsonRequest("SessionRoutes.permissionRespond", c, function* () {
await AppRuntime.runPromise( const params = c.req.valid("param")
Permission.Service.use((svc) => const svc = yield* Permission.Service
svc.reply({ yield* svc.reply({
requestID: params.permissionID, requestID: params.permissionID,
reply: c.req.valid("json").response, reply: c.req.valid("json").response,
}), })
), return true
) }),
return c.json(true)
},
), ),
) )

View file

@ -4,10 +4,10 @@ import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Session } from "@/session" import { Session } from "@/session"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue" import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
const TuiRequest = z.object({ const TuiRequest = z.object({
path: z.string(), path: z.string(),
@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties), validator("json", TuiEvent.SessionSelect.properties),
async (c) => { async (c) => {
const { sessionID } = c.req.valid("json") const { sessionID } = c.req.valid("json")
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) await runRequest(
"TuiRoutes.sessionSelect",
c,
Session.Service.use((svc) => svc.get(sessionID)),
)
await Bus.publish(TuiEvent.SessionSelect, { sessionID }) await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true) return c.json(true)
}, },

View file

@ -1,16 +1,10 @@
import { generateSpecs } from "hono-openapi" import { generateSpecs } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import type { MiddlewareHandler } from "hono"
import { adapter } from "#hono" import { adapter } from "#hono"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Log } from "@/util" import { Log } from "@/util"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { MDNS } from "./mdns" import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence" import { FenceMiddleware } from "./fence"
@ -20,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control"
import { UIRoutes } from "./routes/ui" import { UIRoutes } from "./routes/ui"
import { GlobalRoutes } from "./routes/global" import { GlobalRoutes } from "./routes/global"
import { WorkspaceRouterMiddleware } from "./workspace" import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@ -48,34 +44,6 @@ function create(opts: { cors?: string[] }) {
const runtime = adapter.create(app) const runtime = adapter.create(app)
function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = AppFileSystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
}
if (Flag.OPENCODE_WORKSPACE_ID) { if (Flag.OPENCODE_WORKSPACE_ID) {
return { return {
app: app app: app
@ -88,9 +56,14 @@ function create(opts: { cors?: string[] }) {
return { return {
app: app app: app
.use(InstanceMiddleware())
.route("/", ControlPlaneRoutes()) .route("/", ControlPlaneRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) .route(
"/",
new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
)
.route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()), .route("/", UIRoutes()),
runtime, runtime,

View file

@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { Log } from "@/util" import { Log } from "@/util"
import { ServerProxy } from "./proxy" import { ServerProxy } from "./proxy"
@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url) const id = getSessionID(url)
if (!id) return null if (!id) return null
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) const session = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
).catch(() => undefined)
return session?.workspaceID return session?.workspaceID
} }

View file

@ -213,7 +213,7 @@ export const layer: Layer.Layer<
return true return true
}) })
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) { switch (value.type) {
case "start": case "start":
yield* status.set(ctx.sessionID, { type: "busy" }) yield* status.set(ctx.sessionID, { type: "busy" })

View file

@ -649,7 +649,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return input.partID return input.partID
}) })
const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: { const updatePartDelta = Effect.fnUntraced(function* (input: {
sessionID: SessionID sessionID: SessionID
messageID: MessageID messageID: MessageID
partID: PartID partID: PartID

View file

@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
export type Share = typeof ShareSchema.Type export type Share = typeof ShareSchema.Type
type State = { type State = {
queue: Map<string, { data: Map<string, Data> }> queue: Map<SessionID, Map<string, Data>>
scope: Scope.Closeable scope: Scope.Closeable
shared: Map<SessionID, Share | null>
} }
type Data = type Data =
@ -118,17 +119,20 @@ export const layer = Layer.effect(
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> { function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
return Effect.gen(function* () { return Effect.gen(function* () {
if (disabled) return if (disabled) return
const share = yield* getCached(sessionID)
if (!share) return
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const existing = s.queue.get(sessionID) const existing = s.queue.get(sessionID)
if (existing) { if (existing) {
for (const item of data) { for (const item of data) {
existing.data.set(key(item), item) existing.set(key(item), item)
} }
return return
} }
const next = new Map(data.map((item) => [key(item), item])) const next = new Map(data.map((item) => [key(item), item]))
s.queue.set(sessionID, { data: next }) s.queue.set(sessionID, next)
yield* flush(sessionID).pipe( yield* flush(sessionID).pipe(
Effect.delay(1000), Effect.delay(1000),
Effect.catchCause((cause) => Effect.catchCause((cause) =>
@ -143,13 +147,14 @@ export const layer = Layer.effect(
const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>( const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) { Effect.fn("ShareNext.state")(function* (_ctx) {
const cache: State = { queue: new Map(), scope: yield* Scope.make() } const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Scope.close(cache.scope, Exit.void).pipe( Scope.close(cache.scope, Exit.void).pipe(
Effect.andThen( Effect.andThen(
Effect.sync(() => { Effect.sync(() => {
cache.queue.clear() cache.queue.clear()
cache.shared.clear()
}), }),
), ),
), ),
@ -227,6 +232,18 @@ export const layer = Layer.effect(
return { id: row.id, secret: row.secret, url: row.url } satisfies Share return { id: row.id, secret: row.secret, url: row.url } satisfies Share
}) })
const getCached = Effect.fnUntraced(function* (sessionID: SessionID) {
const s = yield* InstanceState.get(state)
if (s.shared.has(sessionID)) {
const cached = s.shared.get(sessionID)
return cached === null ? undefined : cached
}
const share = yield* get(sessionID)
s.shared.set(sessionID, share ?? null)
return share
})
const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
if (disabled) return if (disabled) return
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
@ -235,13 +252,13 @@ export const layer = Layer.effect(
s.queue.delete(sessionID) s.queue.delete(sessionID)
const share = yield* get(sessionID) const share = yield* getCached(sessionID)
if (!share) return if (!share) return
const req = yield* request() const req = yield* request()
const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
HttpClientRequest.setHeaders(req.headers), HttpClientRequest.setHeaders(req.headers),
HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }), HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }),
Effect.flatMap((r) => http.execute(r)), Effect.flatMap((r) => http.execute(r)),
) )
@ -307,6 +324,7 @@ export const layer = Layer.effect(
.run(), .run(),
) )
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
s.shared.set(sessionID, result)
yield* full(sessionID).pipe( yield* full(sessionID).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => { Effect.sync(() => {
@ -321,8 +339,13 @@ export const layer = Layer.effect(
const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
if (disabled) return if (disabled) return
log.info("removing share", { sessionID }) log.info("removing share", { sessionID })
const share = yield* get(sessionID) const s = yield* InstanceState.get(state)
if (!share) return const share = yield* getCached(sessionID)
if (!share) {
s.shared.delete(sessionID)
s.queue.delete(sessionID)
return
}
const req = yield* request() const req = yield* request()
yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
@ -332,6 +355,8 @@ export const layer = Layer.effect(
) )
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
s.shared.delete(sessionID)
s.queue.delete(sessionID)
}) })
return Service.of({ init, url, request, create, remove }) return Service.of({ init, url, request, create, remove })

View file

@ -16,13 +16,34 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override if (override) return override
const out = body(ast) let out = body(ast)
for (const check of ast.checks ?? []) {
out = applyCheck(out, check, ast)
}
const desc = SchemaAST.resolveDescription(ast) const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast) const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out const next = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next return ref ? next.meta({ ref }) : next
} }
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
if (check._tag === "FilterGroup") {
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
}
return out.superRefine((value, ctx) => {
const issue = check.run(value, ast, {} as any)
if (!issue) return
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
})
}
function issueMessage(issue: any): string | undefined {
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
if (typeof issue?.message === "string") return issue.message
return undefined
}
function body(ast: SchemaAST.AST): z.ZodTypeAny { function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast) if (SchemaAST.isOptional(ast)) return opt(ast)
@ -98,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
} }
function array(ast: SchemaAST.Arrays): z.ZodTypeAny { function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
if (ast.elements.length > 0) return fail(ast) // Pure variadic arrays: { elements: [], rest: [item] }
if (ast.rest.length !== 1) return fail(ast) if (ast.elements.length === 0) {
return z.array(walk(ast.rest[0])) if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
// Tuples with a variadic tail (...rest) are not yet supported.
if (ast.rest.length > 0) return fail(ast)
const items = ast.elements.map(walk)
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
} }
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {

View file

@ -0,0 +1,24 @@
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
export function ensureRunID() {
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
}
export function ensureProcessRole(fallback: "main" | "worker") {
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
}
export function ensureProcessMetadata(fallback: "main" | "worker") {
return {
runID: ensureRunID(),
processRole: ensureProcessRole(fallback),
}
}
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
return overrides ? Object.assign(env, overrides) : env
}

View file

@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@ -117,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@ -179,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@ -241,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@ -299,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const warn = spyOn(console, "warn").mockImplementation(() => {}) const warn = spyOn(console, "warn").mockImplementation(() => {})
const error = spyOn(console, "error").mockImplementation(() => {}) const error = spyOn(console, "error").mockImplementation(() => {})
@ -468,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
} }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })

View file

@ -27,7 +27,7 @@ import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@/npm/effect" import { Npm } from "@/npm"
const emptyAccount = Layer.mock(Account.Service)({ const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()), active: () => Effect.succeed(Option.none()),

View file

@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigLSP } from "../../src/config/lsp"
// The LSP config refinement enforces: any custom (non-builtin) LSP server
// entry must declare an `extensions` array so the client knows which files
// the server should attach to. Builtin server IDs and explicitly disabled
// entries are exempt.
//
// Both validation paths must honor this rule:
// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
// - `ConfigLSP.Info.zod.parse(...)` (derived Zod)
//
// `typescript` is a builtin server id (see src/lsp/server.ts).
describe("ConfigLSP.Info refinement", () => {
const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
describe("accepted inputs", () => {
test("true and false pass (top-level toggle)", () => {
expect(decodeEffect(true)).toBe(true)
expect(decodeEffect(false)).toBe(false)
expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
})
test("builtin server with no extensions passes", () => {
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("custom server WITH extensions passes", () => {
const input = {
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("disabled custom server passes (no extensions needed)", () => {
const input = { "my-lsp": { disabled: true as const } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("mix of builtin and custom with extensions passes", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
})
describe("rejected inputs", () => {
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
test("custom server WITHOUT extensions fails via Effect decode", () => {
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
})
test("custom server WITHOUT extensions fails via derived Zod", () => {
const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
expect(result.success).toBe(false)
expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
})
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
// Boolean(['']) is true, so a non-empty array of strings is fine.
// Boolean([]) is also true in JS, so empty arrays are accepted by the
// refinement. This test documents current behavior.
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("custom server without extensions mixed with a valid builtin still fails", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"] },
}
expect(() => decodeEffect(input)).toThrow(expectedMessage)
expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
})
})
})

View file

@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
}) })
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme } if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope } return { directory: tmp.extra.scope, entrypoint: undefined }
}) })
try { try {
@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
try { try {
await load(tmp.path) await load(tmp.path)
@ -927,7 +927,7 @@ export default {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const missing: string[] = [] const missing: string[] = []
try { try {
@ -996,7 +996,7 @@ export default {
}, },
}) })
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
const loaded = await PluginLoader.loadExternal({ const loaded = await PluginLoader.loadExternal({

View file

@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => {
}, },
}, },
}, },
} as ModelsDev.Provider } as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
expect(model.cost.input).toEqual(5) expect(model.cost.input).toEqual(5)
@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => {
}) })
}) })
test("models.dev normalization fills required response fields", () => {
const provider = {
id: "gateway",
name: "Gateway",
env: [],
models: {
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
cost: {
input: 2.5,
output: 15,
},
limit: {
context: 1_050_000,
input: 922_000,
output: 128_000,
},
},
},
} as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
expect(model.api.url).toBe("")
expect(model.capabilities.temperature).toBe(false)
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.release_date).toBe("")
})
test("model variants are generated for reasoning models", async () => { test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {

View file

@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
}) })
}) })
test("throws for unsupported tuple schemas", () => { describe("Tuples", () => {
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") test("fixed-length tuple parses matching array", () => {
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
expect(out.parse(["a", 1])).toEqual(["a", 1])
expect(out.safeParse(["a"]).success).toBe(false)
expect(out.safeParse(["a", "b"]).success).toBe(false)
})
test("single-element tuple parses a one-element array", () => {
const out = zod(Schema.Tuple([Schema.Boolean]))
expect(out.parse([true])).toEqual([true])
expect(out.safeParse([true, false]).success).toBe(false)
})
test("tuple inside a union picks the right branch", () => {
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
expect(out.parse("hello")).toBe("hello")
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
expect(out.safeParse(["foo"]).success).toBe(false)
})
test("plain arrays still work (no element positions)", () => {
const out = zod(Schema.Array(Schema.String))
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
expect(out.parse([])).toEqual([])
})
}) })
test("string literal unions produce z.enum with enum in JSON Schema", () => { test("string literal unions produce z.enum with enum in JSON Schema", () => {
@ -186,4 +210,57 @@ describe("util.effect-zod", () => {
const schema = json(zod(Parent)) as any const schema = json(zod(Parent)) as any
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
}) })
describe("Schema.check translation", () => {
test("filter returning string triggers refinement with that message", () => {
const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
const schema = zod(Schema.Number.check(isEven))
expect(schema.parse(4)).toBe(4)
const result = schema.safeParse(3)
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("expected an even number")
})
test("filter returning false triggers refinement with fallback message", () => {
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
const schema = zod(Schema.String.check(nonEmpty))
expect(schema.parse("hi")).toBe("hi")
const result = schema.safeParse("")
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toMatch(/./)
})
test("filter returning undefined passes validation", () => {
const alwaysOk = Schema.makeFilter(() => undefined)
const schema = zod(Schema.Number.check(alwaysOk))
expect(schema.parse(42)).toBe(42)
})
test("annotations.message on the filter is used when filter returns false", () => {
const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
const schema = zod(Schema.Number.check(positive))
const result = schema.safeParse(-1)
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("must be positive")
})
test("cross-field check on a record flags missing key", () => {
const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
"required" in data ? undefined : "missing 'required' key",
)
const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
expect(schema.parse({ required: { enabled: true } })).toEqual({
required: { enabled: true },
})
const result = schema.safeParse({ other: { enabled: true } })
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("missing 'required' key")
})
})
}) })

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1807,6 +1807,12 @@ export type Provider = {
} }
} }
export type ConsoleState = {
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
export type ToolIds = Array<string> export type ToolIds = Array<string>
export type ToolListItem = { export type ToolListItem = {
@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
/** /**
* Active Console provider metadata * Active Console provider metadata
*/ */
200: { 200: ConsoleState
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
} }
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]

View file

@ -1607,24 +1607,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "$ref": "#/components/schemas/ConsoleState"
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
} }
} }
} }
@ -11428,13 +11411,10 @@
}, },
"timeout": { "timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "integer", "type": "number"
"exclusiveMinimum": 0,
"maximum": 9007199254740991
} }
}, },
"required": ["type", "command"], "required": ["type", "command"]
"additionalProperties": false
}, },
"McpOAuthConfig": { "McpOAuthConfig": {
"type": "object", "type": "object",
@ -11455,8 +11435,7 @@
"description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
"type": "string" "type": "string"
} }
}, }
"additionalProperties": false
}, },
"McpRemoteConfig": { "McpRemoteConfig": {
"type": "object", "type": "object",
@ -11498,13 +11477,10 @@
}, },
"timeout": { "timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "integer", "type": "number"
"exclusiveMinimum": 0,
"maximum": 9007199254740991
} }
}, },
"required": ["type", "url"], "required": ["type", "url"]
"additionalProperties": false
}, },
"LayoutConfig": { "LayoutConfig": {
"description": "@deprecated Always uses stretch layout.", "description": "@deprecated Always uses stretch layout.",
@ -12366,6 +12342,24 @@
}, },
"required": ["id", "name", "source", "env", "options", "models"] "required": ["id", "name", "source", "env", "options", "models"]
}, },
"ConsoleState": {
"type": "object",
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "number"
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
},
"ToolIDs": { "ToolIDs": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.9", "version": "1.4.10",
"name": "@opencode-ai/shared", "name": "@opencode-ai/shared",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -165,55 +165,60 @@ export namespace EffectFlock {
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) { const tryAcquireLockDir = (lockDir: string, key: string) =>
const token = randomUUID() Effect.gen(function* () {
const metaPath = path.join(lockDir, "meta.json") const token = randomUUID()
const heartbeatPath = path.join(lockDir, "heartbeat") const metaPath = path.join(lockDir, "meta.json")
const heartbeatPath = path.join(lockDir, "heartbeat")
// Atomic mkdir — the POSIX lock primitive // Atomic mkdir — the POSIX lock primitive
const created = yield* atomicMkdir(lockDir) const created = yield* atomicMkdir(lockDir)
if (!created) { if (!created) {
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
// Stale — race for breaker ownership // Stale — race for breaker ownership
const breakerPath = lockDir + ".breaker" const breakerPath = lockDir + ".breaker"
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
Effect.as(true), Effect.as(true),
Effect.catchIf( Effect.catchIf(
(e) => e.reason._tag === "AlreadyExists", (e) => e.reason._tag === "AlreadyExists",
() => cleanStaleBreaker(breakerPath), () => cleanStaleBreaker(breakerPath),
), ),
Effect.catchIf(isPathGone, () => Effect.succeed(false)), Effect.catchIf(isPathGone, () => Effect.succeed(false)),
Effect.orDie, Effect.orDie,
) )
if (!claimed) return yield* new NotAcquired() if (!claimed) return yield* new NotAcquired()
// We own the breaker — double-check staleness, nuke, recreate // We own the breaker — double-check staleness, nuke, recreate
const recreated = yield* Effect.gen(function* () { const recreated = yield* Effect.gen(function* () {
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
yield* forceRemove(lockDir) yield* forceRemove(lockDir)
return yield* atomicMkdir(lockDir) return yield* atomicMkdir(lockDir)
}).pipe(Effect.ensuring(forceRemove(breakerPath))) }).pipe(Effect.ensuring(forceRemove(breakerPath)))
if (!recreated) return yield* new NotAcquired() if (!recreated) return yield* new NotAcquired()
} }
// We own the lock dir — write heartbeat + meta with exclusive create // We own the lock dir — write heartbeat + meta with exclusive create
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
}) }).pipe(
Effect.withSpan("EffectFlock.tryAcquire", {
attributes: { key },
}),
)
// -- retry wrapper (preserves Handle type) -- // -- retry wrapper (preserves Handle type) --
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> => const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
tryAcquireLockDir(lockfile).pipe( tryAcquireLockDir(lockfile, key).pipe(
Effect.retry({ Effect.retry({
while: (err) => err._tag === "NotAcquired", while: (err) => err._tag === "NotAcquired",
schedule: retrySchedule, schedule: retrySchedule,

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.9", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {

View file

@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.4.9", "version": "1.4.10",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.4.9", "version": "1.4.10",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",