Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
LukeParkerDev
2026-04-19 12:10:22 +10:00
215 changed files with 12360 additions and 4160 deletions

View File

@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"options": {},
},
},
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.7",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -322,15 +322,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@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-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -365,8 +365,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -386,7 +386,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -458,23 +458,23 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99",
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100",
},
"optionalPeers": [
"@opentui/core",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.7",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.7",
"version": "1.4.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -675,6 +675,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -690,7 +692,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -738,7 +740,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "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-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@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-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -758,11 +760,11 @@
"@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-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -2454,7 +2456,7 @@
"@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=="],
@@ -2514,7 +2516,7 @@
"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=="],
@@ -3312,7 +3314,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5152,7 +5154,7 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
@@ -5170,7 +5172,7 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
@@ -5700,8 +5702,6 @@
"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/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=="],
@@ -5920,7 +5920,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],

View File

@@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-OPbZUo/fQv2Xsf+NEZV08GLBMN/DXovhRvn2JkesFtY=",
"aarch64-linux": "sha256-WK7xlVLuirKDN5LaqjBn7qpv5bYVtYHZw0qRNKX4xXg=",
"aarch64-darwin": "sha256-BAoAdeLQ+lXDD7Klxoxij683OVVug8KXEMRUqIQAjc8=",
"x86_64-darwin": "sha256-ZOBwNR2gZgc5f+y3VIBBT4qZpeZfg7Of6AaGDOfqsG8="
"x86_64-linux": "sha256-Spc0qzg4he0d0GwHcqj7uBmvK4DIF1tEbsZ6M+pxpWc=",
"aarch64-linux": "sha256-gwz/PKBbT+72hr7vUG28cdx4Z7/Sf06PNMr9JBjAYg0=",
"aarch64-darwin": "sha256-Lj8p9P/QzLqxiM1OVSwcbtTsms8AcW3A6H0575ERufw=",
"x86_64-darwin": "sha256-y0e+TnXj6wKDqSC5hQAWjpKadaFvL6GJ6Mba5anBM+Y="
}
}

View File

@@ -34,6 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -51,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

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

View File

@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)

View File

@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
bootstrapPromise: Promise.resolve(),
})
children[directory] = child
disposers.set(directory, dispose)

View File

@@ -72,7 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
bootstrapPromise: Promise<void>
}
export type VcsCache = {

View File

@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
{(project) => (
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
icon="plus-small"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("command.session.new")}
{language.t("workspace.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show>
</div>
</>
)}
</Show>
<div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/>
)
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{(autoselecting(), loading()) ?? ""}
{autoselecting() ?? ""}
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">

View File

@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const loading = () => query.isLoading
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
loading={loading}
loading={() => query.isLoading && count() === 0}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
loading={() => query.isLoading}
loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}

View File

@@ -52,7 +52,12 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())

View File

@@ -70,7 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle

View File

@@ -469,7 +469,7 @@ export function persisted<T>(
state,
setState,
init,
Object.assign(() => ready() === true, {
Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined,
}),
]

View File

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

View File

@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
"workspace.redeem.title": "استرداد قسيمة",
"workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
"workspace.redeem.placeholder": "أدخل رمز القسيمة",
"workspace.redeem.redeem": "استرداد",
"workspace.redeem.redeeming": "جارٍ الاسترداد...",
"workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
"workspace.reload.title": "إعادة الشحن التلقائي",
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
"workspace.reload.disabled.state": "معطّل",

View File

@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
"workspace.redeem.title": "Resgatar Cupom",
"workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
"workspace.redeem.placeholder": "Digite o código do cupom",
"workspace.redeem.redeem": "Resgatar",
"workspace.redeem.redeeming": "Resgatando...",
"workspace.redeem.success": "Cupom resgatado com sucesso.",
"workspace.reload.title": "Recarga Automática",
"workspace.reload.disabled.before": "A recarga automática está",
"workspace.reload.disabled.state": "desativada",

View File

@@ -563,6 +563,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Indløs kupon",
"workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
"workspace.redeem.placeholder": "Indtast kuponkode",
"workspace.redeem.redeem": "Indløs",
"workspace.redeem.redeeming": "Indløser...",
"workspace.redeem.success": "Kuponen blev indløst.",
"workspace.reload.title": "Automatisk genopfyldning",
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
"workspace.reload.disabled.state": "deaktiveret",

View File

@@ -566,6 +566,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
"workspace.redeem.title": "Gutschein einlösen",
"workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
"workspace.redeem.placeholder": "Gutscheincode eingeben",
"workspace.redeem.redeem": "Einlösen",
"workspace.redeem.redeeming": "Wird eingelöst...",
"workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
"workspace.reload.title": "Auto-Reload",
"workspace.reload.disabled.before": "Auto-Reload ist",
"workspace.reload.disabled.state": "deaktiviert",

View File

@@ -559,6 +559,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
"workspace.redeem.title": "Redeem Coupon",
"workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
"workspace.redeem.placeholder": "Enter coupon code",
"workspace.redeem.redeem": "Redeem",
"workspace.redeem.redeeming": "Redeeming...",
"workspace.redeem.success": "Coupon redeemed successfully.",
"workspace.reload.title": "Auto Reload",
"workspace.reload.disabled.before": "Auto reload is",
"workspace.reload.disabled.state": "disabled",

View File

@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
"workspace.redeem.title": "Canjear cupón",
"workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
"workspace.redeem.placeholder": "Introduce el código del cupón",
"workspace.redeem.redeem": "Canjear",
"workspace.redeem.redeeming": "Canjeando...",
"workspace.redeem.success": "Cupón canjeado correctamente.",
"workspace.reload.title": "Auto Recarga",
"workspace.reload.disabled.before": "La auto recarga está",
"workspace.reload.disabled.state": "deshabilitada",

View File

@@ -569,6 +569,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
"workspace.redeem.title": "Utiliser un coupon",
"workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
"workspace.redeem.placeholder": "Saisissez le code promo",
"workspace.redeem.redeem": "Utiliser",
"workspace.redeem.redeeming": "Utilisation...",
"workspace.redeem.success": "Coupon utilisé avec succès.",
"workspace.reload.title": "Rechargement automatique",
"workspace.reload.disabled.before": "Le rechargement automatique est",
"workspace.reload.disabled.state": "désactivé",

View File

@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
"workspace.redeem.title": "Riscatta Coupon",
"workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
"workspace.redeem.placeholder": "Inserisci il codice coupon",
"workspace.redeem.redeem": "Riscatta",
"workspace.redeem.redeeming": "Riscatto in corso...",
"workspace.redeem.success": "Coupon riscattato con successo.",
"workspace.reload.title": "Ricarica Auto",
"workspace.reload.disabled.before": "La ricarica auto è",
"workspace.reload.disabled.state": "disabilitata",

View File

@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
"workspace.redeem.title": "クーポンを利用",
"workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
"workspace.redeem.placeholder": "クーポンコードを入力",
"workspace.redeem.redeem": "利用する",
"workspace.redeem.redeeming": "利用中...",
"workspace.redeem.success": "クーポンを利用しました。",
"workspace.reload.title": "自動チャージ",
"workspace.reload.disabled.before": "自動チャージは",
"workspace.reload.disabled.state": "無効",

View File

@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
"workspace.redeem.title": "쿠폰 사용",
"workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
"workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
"workspace.redeem.redeem": "사용",
"workspace.redeem.redeeming": "사용 중...",
"workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
"workspace.reload.title": "자동 충전",
"workspace.reload.disabled.before": "자동 충전이",
"workspace.reload.disabled.state": "비활성화",

View File

@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Løs inn kupong",
"workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
"workspace.redeem.placeholder": "Skriv inn kupongkode",
"workspace.redeem.redeem": "Løs inn",
"workspace.redeem.redeeming": "Løser inn...",
"workspace.redeem.success": "Kupongen ble løst inn.",
"workspace.reload.title": "Auto-påfyll",
"workspace.reload.disabled.before": "Auto-påfyll er",
"workspace.reload.disabled.state": "deaktivert",

View File

@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
"workspace.redeem.title": "Zrealizuj kupon",
"workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
"workspace.redeem.placeholder": "Wpisz kod kuponu",
"workspace.redeem.redeem": "Zrealizuj",
"workspace.redeem.redeeming": "Realizowanie...",
"workspace.redeem.success": "Kupon został zrealizowany.",
"workspace.reload.title": "Automatyczne doładowanie",
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
"workspace.reload.disabled.state": "wyłączone",

View File

@@ -571,6 +571,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
"workspace.redeem.title": "Активировать купон",
"workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
"workspace.redeem.placeholder": "Введите код купона",
"workspace.redeem.redeem": "Активировать",
"workspace.redeem.redeeming": "Активация...",
"workspace.redeem.success": "Купон успешно активирован.",
"workspace.reload.title": "Автопополнение",
"workspace.reload.disabled.before": "Автопополнение",
"workspace.reload.disabled.state": "отключено",

View File

@@ -560,6 +560,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
"workspace.redeem.title": "แลกคูปอง",
"workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
"workspace.redeem.placeholder": "กรอกรหัสคูปอง",
"workspace.redeem.redeem": "แลก",
"workspace.redeem.redeeming": "กำลังแลก...",
"workspace.redeem.success": "แลกคูปองสำเร็จ",
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",

View File

@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
"workspace.redeem.title": "Kupon Kullan",
"workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
"workspace.redeem.placeholder": "Kupon kodunu girin",
"workspace.redeem.redeem": "Kullan",
"workspace.redeem.redeeming": "Kullanılıyor...",
"workspace.redeem.success": "Kupon başarıyla kullanıldı.",
"workspace.reload.title": "Otomatik Yeniden Yükleme",
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
"workspace.reload.disabled.state": "devre dışı",

View File

@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
"workspace.redeem.title": "兑换优惠券",
"workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
"workspace.redeem.placeholder": "输入优惠码",
"workspace.redeem.redeem": "兑换",
"workspace.redeem.redeeming": "兑换中...",
"workspace.redeem.success": "优惠券兑换成功。",
"workspace.reload.title": "自动充值",
"workspace.reload.disabled.before": "自动充值已",
"workspace.reload.disabled.state": "禁用",

View File

@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
"workspace.redeem.title": "兌換優惠券",
"workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
"workspace.redeem.placeholder": "輸入優惠碼",
"workspace.redeem.redeem": "兌換",
"workspace.redeem.redeeming": "兌換中...",
"workspace.redeem.success": "優惠券兌換成功。",
"workspace.reload.title": "自動儲值",
"workspace.reload.disabled.before": "自動儲值已",
"workspace.reload.disabled.state": "停用",

View File

@@ -1,5 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { AWS } from "@opencode-ai/console-core/aws.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
@@ -14,6 +15,64 @@ interface EnterpriseFormData {
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function getEmailOctopusApiKey() {
if (process.env.EMAILOCTOPUS_API_KEY) return process.env.EMAILOCTOPUS_API_KEY
try {
return Resource.EMAILOCTOPUS_API_KEY.value
} catch {
return
}
}
function subscribe(email: string, fullName: string) {
const apiKey = getEmailOctopusApiKey()
if (!apiKey) {
console.warn("Skipping EmailOctopus subscribe: missing API key")
return Promise.resolve(false)
}
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
(res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText)
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
@@ -41,7 +100,7 @@ ${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail] = await Promise.all([
const [lead, mail, octopus] = await Promise.all([
createLead({
name: body.name,
role: body.role,
@@ -49,6 +108,9 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
email: body.email,
phone: body.phone,
message: body.message,
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
AWS.sendEmail({
to: "contact@anoma.ly",
@@ -62,9 +124,14 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
return false
},
),
subscribe(body.email, body.name),
])
if (!lead && !mail) {
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}

View File

@@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { User } from "@opencode-ai/console-core/user.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -109,6 +110,8 @@ export async function POST(input: APIEvent) {
if (type === "lite") {
const workspaceID = body.data.object.metadata?.workspaceID
const userID = body.data.object.metadata?.userID
const userEmail = body.data.object.metadata?.userEmail
const coupon = body.data.object.metadata?.coupon
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
@@ -156,6 +159,10 @@ export async function POST(input: APIEvent) {
id: Identifier.create("lite"),
userID: userID,
})
if (userEmail && coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
}
})
})
}

View File

@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -24,6 +25,7 @@ export default function () {
<Show when={billingInfo()?.customerID}>
<ReloadSection />
<MonthlyLimitSection />
<RedeemSection />
<PaymentSection />
</Show>
</Show>

View File

@@ -0,0 +1,61 @@
.root {
[data-slot="redeem-container"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 20rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
}
[data-slot="redeem-form"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-slot="input-row"] {
display: flex;
gap: var(--space-2);
align-items: stretch;
@media (max-width: 30rem) {
flex-direction: column;
}
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
[data-slot="form-success"] {
color: var(--color-success, var(--color-accent));
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}

View File

@@ -0,0 +1,71 @@
import { json, action, useParams, useSubmission } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./redeem-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const redeem = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const code = (form.get("code") as string | null)?.trim().toUpperCase()
if (!code) return { error: "Coupon code is required." }
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
return json(
await withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAuthEmail(actor.properties.userID)
if (!email) return { error: "No email on account." }
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
.then(() => ({ error: undefined, data: true }))
.catch((e) => ({ error: e.message as string }))
}, workspaceID),
{ revalidate: queryBillingInfo.key },
)
}, "billing.redeemCoupon")
export function RedeemSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(redeem)
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.redeem.title")}</h2>
<p>{i18n.t("workspace.redeem.subtitle")}</p>
</div>
<div data-slot="redeem-container">
<form action={redeem} method="post" data-slot="redeem-form">
<div data-slot="input-row">
<input
required
data-component="input"
name="code"
type="text"
autocomplete="off"
placeholder={i18n.t("workspace.redeem.placeholder")}
/>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
</button>
</div>
<Show when={submission.result && (submission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
</form>
</div>
</section>
)
}

View File

@@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -121,6 +122,8 @@ export async function handler(
const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
const modelTpmLimits = await modelTpmLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@@ -133,6 +136,7 @@ export async function handler(
trialProviders,
retry,
stickyProvider,
modelTpmLimits,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@@ -229,6 +233,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(json.usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -278,6 +283,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@@ -433,12 +439,16 @@ export async function handler(
trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
// instead of using the sticky provider for the same session
if (authInfo?.provider?.credentials) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
// Always use the same provider for the same session
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
@@ -451,10 +461,20 @@ export async function handler(
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
const providers = modelInfo.providers
const allProviders = modelInfo.providers
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
const topPriority = Math.min(...allProviders.map((p) => p.priority))
const providers = allProviders
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip

View File

@@ -0,0 +1,51 @@
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
if (keys.length === 0) return
const yyyyMMddHHmm = new Date(Date.now())
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
return {
check: async () => {
const data = await Database.use((tx) =>
tx
.select()
.from(ModelRateLimitTable)
.where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
)
// convert to map of model to count
return data.reduce(
(acc, curr) => {
acc[curr.key] = curr.count
return acc
},
{} as Record<string, number>,
)
},
track: async (id: string, model: string, usageInfo: UsageInfo) => {
const key = `${id}/${model}`
if (!keys.includes(key)) return
const usage =
usageInfo.inputTokens +
usageInfo.outputTokens +
(usageInfo.reasoningTokens ?? 0) +
(usageInfo.cacheReadTokens ?? 0) +
(usageInfo.cacheWrite5mTokens ?? 0) +
(usageInfo.cacheWrite1hTokens ?? 0)
if (usage <= 0) return
await Database.use((tx) =>
tx
.insert(ModelRateLimitTable)
.values({ key, interval: yyyyMMddHHmm, count: usage })
.onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
)
},
}
}

View File

@@ -0,0 +1,6 @@
CREATE TABLE `model_rate_limit` (
`key` varchar(255) NOT NULL,
`interval` varchar(40) NOT NULL,
`count` int NOT NULL,
CONSTRAINT PRIMARY KEY(`key`,`interval`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
CREATE TABLE `coupon` (
`email` varchar(255),
`type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
`time_redeemed` timestamp(3),
CONSTRAINT PRIMARY KEY(`email`,`type`)
);

View File

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

View File

@@ -0,0 +1,24 @@
import { Database } from "../src/drizzle/index.js"
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
const email = process.argv[2]
const type = process.argv[3] as (typeof CouponType)[number]
if (!email || !type) {
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
process.exit(1)
}
if (!(CouponType as readonly string[]).includes(type)) {
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
process.exit(1)
}
await Database.use((tx) =>
tx.insert(CouponTable).values({
email,
type,
}),
)
console.log(`Created ${type} coupon for ${email}`)

View File

@@ -1,6 +1,14 @@
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { and, Database, eq, isNull, sql } from "./drizzle"
import {
BillingTable,
CouponTable,
CouponType,
LiteTable,
PaymentTable,
SubscriptionTable,
UsageTable,
} from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -147,6 +155,37 @@ export namespace Billing {
return amountInMicroCents
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
await Database.use((tx) =>
tx
.update(CouponTable)
.set({ timeRedeemed: sql`now()` })
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
)
}
export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
return await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
.then((rows) => rows.length > 0),
)
}
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
@@ -245,16 +284,19 @@ export namespace Billing {
const user = Actor.assert("user")
const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const email = (await User.getAuthEmail(user.properties.userID))!
const billing = await Billing.get()
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
? LiteData.firstMonth100Coupon
: LiteData.firstMonth50Coupon
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
discounts: [{ coupon }],
...(billing.customerID
? {
customer: billing.customerID,
@@ -264,7 +306,7 @@ export namespace Billing {
},
}
: {
customer_email: email!,
customer_email: email,
}),
...(() => {
if (method === "alipay") {
@@ -312,6 +354,8 @@ export namespace Billing {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
userEmail: email,
coupon,
type: "lite",
},
},

View File

@@ -11,11 +11,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonthCoupon = fn(z.string(), (email) => {
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
return invitees.includes(email)
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
})
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -34,6 +34,8 @@ export namespace ZenData {
z.object({
id: z.string(),
model: z.string(),
priority: z.number().optional(),
tpmLimit: z.number().optional(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
@@ -123,10 +125,16 @@ export namespace ZenData {
),
models: (() => {
const normalize = (model: z.infer<typeof ModelSchema>) => {
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
const providers = model.providers.map((p) => ({
...p,
priority: p.priority ?? Infinity,
weight: p.weight ?? 1,
}))
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
if (!composite)
return {
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
providers,
}
const weightMulti = compositeProviders[composite.id].length
@@ -137,17 +145,16 @@ export namespace ZenData {
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
return [model.trialProvider]
})(),
providers: model.providers.flatMap((p) =>
providers: providers.flatMap((p) =>
p.id === composite.id
? compositeProviders[p.id].map((sub) => ({
...p,
id: sub.id,
weight: p.weight ?? 1,
}))
: [
{
...p,
weight: (p.weight ?? 1) * weightMulti,
weight: p.weight * weightMulti,
},
],
),

View File

@@ -1,4 +1,15 @@
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import {
bigint,
boolean,
index,
int,
json,
mysqlEnum,
mysqlTable,
primaryKey,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -121,3 +132,14 @@ export const UsageTable = mysqlTable(
},
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
export const CouponTable = mysqlTable(
"coupon",
{
email: varchar("email", { length: 255 }),
type: mysqlEnum("type", CouponType).notNull(),
timeRedeemed: utc("time_redeemed"),
},
(table) => [primaryKey({ columns: [table.email, table.type] })],
)

View File

@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)
export const ModelRateLimitTable = mysqlTable(
"model_rate_limit",
{
key: varchar("key", { length: 255 }).notNull(),
interval: varchar("interval", { length: 40 }).notNull(),
count: int("count").notNull(),
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

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

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.7",
"version": "1.4.11",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -79,15 +79,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@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-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -122,8 +122,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -143,7 +143,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",

View File

@@ -7,6 +7,20 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
if (await published(name, version)) {
console.log(`already published ${name}@${version}`)
return
}
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const pkg = await Bun.file(`./dist/${filepath}`).json()
@@ -40,14 +54,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
)
const tasks = Object.entries(binaries).map(async ([name]) => {
if (process.platform !== "win32") {
await $`chmod -R 755 .`.cwd(`./dist/${name}`)
}
await $`bun pm pack`.cwd(`./dist/${name}`)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`)
await publish(`./dist/${name}`, name, binaries[name])
})
await Promise.all(tasks)
await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}`
await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
@@ -104,6 +114,7 @@ if (!Script.preview) {
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
@@ -176,6 +187,8 @@ if (!Script.preview) {
await $`git clone ${tap} ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
}
}

View File

@@ -1,12 +1,13 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
Current status on this branch:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -15,8 +16,9 @@ Recent progress:
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
## Remaining work
Recommended next five, in order:
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
- [x] `src/file/index.ts` (`File`) - facades removed and merged
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites

View File

@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
@@ -155,9 +155,9 @@ This gives:
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `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
- 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
- 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)
- 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
### 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:
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
@@ -250,20 +286,20 @@ Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/server.ts`standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts`bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Standalone server
### 3. Bridged server
- the Effect HTTP server is self-contained in `httpapi/server.ts`
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the experimental endpoints
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
The first slice is successful if:
- the standalone Effect server starts and serves the endpoints independently of the Hono server
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `later`
- `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer`
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
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` full read endpoint (`GET /config`)
3. `file` JSON read endpoints
4. `mcp` JSON read endpoints
## Checklist
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [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
## Rule of thumb

View File

@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `54` files in `packages/opencode/src`.
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
@@ -177,13 +177,13 @@ Migration rule:
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/instance/middleware.ts`
- `src/server/instance/index.ts`
- `src/server/instance/project.ts`
- `src/server/instance/workspace.ts`
- `src/server/instance/file.ts`
- `src/server/instance/experimental.ts`
- `src/server/instance/global.ts`
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
@@ -239,7 +239,7 @@ Migration rule:
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/config/tui-migrate.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`

View File

@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes

View File

@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```ts
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
```
Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
- Use `Effect.fn("Foo.method")` for Effect methods
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification

View File

@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
Current instance route files live under `src/server/routes/instance`.
The main migration targets are:
Files that are already mostly on the intended service-yielding shape:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts`still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
- [x] `server/routes/instance/pty.ts`handlers yield `Pty.Service`
Additional route files that still participate in the migration:
Files still worth tracking here:
- [ ] `server/instance/index.ts`Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts`Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts`MCP facade-heavy
- [ ] `server/instance/permission.ts`Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
- [ ] `server/routes/instance/session.ts`still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
- [ ] `server/routes/instance/index.ts`mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
- [ ] `server/routes/instance/file.ts`most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
- [ ] `server/routes/instance/experimental.ts`mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.

View File

@@ -1,12 +1,19 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
Practical reference for migrating data types in `packages/opencode` from
Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@/util/effect-zod`.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
## Preferred shapes
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
If the class cannot reference itself cleanly during initialization, use the
two-step `withStatics` pattern:
```ts
const _Info = Schema.Struct({
export const Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
}).pipe(withStatics((s) => ({ zod: zod(s) })))
```
### Errors
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
Keep branded/schema-backed IDs as Effect schemas and expose
`static readonly zod` for compatibility when callers still expect Zod.
### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()`
in every schema. The `effect-zod` walker translates the Effect versions into
the corresponding zod methods, so JSON Schema output (`type: integer`,
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
```
See `test/util/effect-zod.test.ts` for the full set of translated checks.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
During migration, route validators, tool parameters, and any existing
Zod-based boundary should consume the derived `.zod` schema instead of
maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
- new domain models should not start Zod-first unless there is a concrete
boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
When this happens, prefer leaving a short note or TODO rather than silently
creating a parallel schema source of truth.
## Escape hatches
The walker in `@/util/effect-zod` exposes three explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
### `ZodOverride` annotation
Replaces the entire derivation with a hand-crafted zod schema. Used when:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
(e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
### `ZodPreprocess` annotation
Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
`config/permission.ts` to inject `__originalKeys` before parsing, because
`Schema.StructWithRest` canonicalises output (known fields first, catchall
after) and destroys the user's original property order — which permission
rule precedence depends on.
Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
the `__originalKeys` hack can both be deleted.
### Local `DeepMutable<T>` in `config/config.ts`
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
`Config` service) mutates `Info` objects directly, so a readonly-stripping
utility is needed when casting the derived zod schema's output type.
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
`unknown` to `{}` in the fallback branch — a bug that affects any schema
using `Schema.Record(String, Schema.Unknown)`.
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
`Types.DeepMutable` used directly.
## Ordering
@@ -81,19 +147,179 @@ Migrate in this order:
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
This keeps shared types canonical first and makes boundary updates mostly
mechanical.
## Checklist
## Progress tracker
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
### `src/config/` ✅ complete
All of `packages/opencode/src/config/` has been migrated. Files that still
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
- [x] keybinds
- [x] permission#Info
- [x] agent
- [x] config.ts root
### `src/*/schema.ts` leaf modules
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
- [ ] `src/control-plane/schema.ts`
- [ ] `src/permission/schema.ts`
- [ ] `src/project/schema.ts`
- [ ] `src/provider/schema.ts`
- [ ] `src/pty/schema.ts`
- [ ] `src/question/schema.ts`
- [ ] `src/session/schema.ts`
- [ ] `src/sync/schema.ts`
- [ ] `src/tool/schema.ts`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
- [ ] `src/session/prompt.ts`
- [ ] `src/session/revert.ts`
- [ ] `src/session/session.ts`
- [ ] `src/session/status.ts`
- [ ] `src/session/summary.ts`
- [ ] `src/session/todo.ts`
### Provider domain
- [ ] `src/provider/auth.ts`
- [ ] `src/provider/models.ts`
- [ ] `src/provider/provider.ts`
### Tool schemas
Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/apply_patch.ts`
- [ ] `src/tool/bash.ts`
- [ ] `src/tool/codesearch.ts`
- [ ] `src/tool/edit.ts`
- [ ] `src/tool/glob.ts`
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
- [ ] `src/tool/multiedit.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
- [ ] `src/tool/registry.ts`
- [ ] `src/tool/skill.ts`
- [ ] `src/tool/task.ts`
- [ ] `src/tool/todo.ts`
- [ ] `src/tool/tool.ts`
- [ ] `src/tool/webfetch.ts`
- [ ] `src/tool/websearch.ts`
- [ ] `src/tool/write.ts`
### HTTP route boundaries
Every file in `src/server/routes/` uses hono-openapi with zod validators for
route inputs/outputs. Migrating these individually is the last step; most
will switch to `.zod` derived from the Schema-migrated domain types above,
which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [ ] `src/server/event.ts`
- [ ] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
- [ ] `src/server/routes/instance/index.ts`
- [ ] `src/server/routes/instance/config.ts`
- [ ] `src/server/routes/instance/event.ts`
- [ ] `src/server/routes/instance/experimental.ts`
- [ ] `src/server/routes/instance/file.ts`
- [ ] `src/server/routes/instance/mcp.ts`
- [ ] `src/server/routes/instance/permission.ts`
- [ ] `src/server/routes/instance/project.ts`
- [ ] `src/server/routes/instance/provider.ts`
- [ ] `src/server/routes/instance/pty.ts`
- [ ] `src/server/routes/instance/question.ts`
- [ ] `src/server/routes/instance/session.ts`
- [ ] `src/server/routes/instance/sync.ts`
- [ ] `src/server/routes/instance/tui.ts`
The bigger prize for this group is the `@effect/platform` HTTP migration
described in `specs/effect/http-api.md`. Once that lands, every one of
these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
so the Schema-first domain types become a prerequisite rather than a
sibling task.
### Everything else
Small / shared / control-plane / CLI. Mostly independent; can be done
piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [ ] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
- [ ] `src/cli/cmd/tui/config/tui.ts`
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [ ] `src/control-plane/adaptors/worktree.ts`
- [ ] `src/control-plane/types.ts`
- [ ] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
- [ ] `src/format/index.ts`
- [ ] `src/id/id.ts`
- [ ] `src/ide/index.ts`
- [ ] `src/installation/index.ts`
- [ ] `src/lsp/client.ts`
- [ ] `src/lsp/lsp.ts`
- [ ] `src/mcp/auth.ts`
- [ ] `src/patch/index.ts`
- [ ] `src/plugin/github-copilot/models.ts`
- [ ] `src/project/project.ts`
- [ ] `src/project/vcs.ts`
- [ ] `src/pty/index.ts`
- [ ] `src/skill/index.ts`
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [ ] `src/sync/index.ts`
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
- [ ] `src/worktree/index.ts`
### Do-not-migrate
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
(it's what emits zod from Schema). Goes away only when the `.zod`
compatibility layer is no longer needed anywhere.
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
- Use `@/util/effect-zod` for all Schema Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
valuable than converting every boundary in the same change.
- Every migrated file should leave the generated SDK output (`packages/sdk/
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
unless the change is deliberately user-visible.

View File

@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
- that experimental slice is mounted under `/experimental/httpapi/question`
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
@@ -209,17 +209,19 @@ Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/index.ts`
- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current experimental `HttpApi` slice:
Current bridged `HttpApi` slices:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `src/server/instance/experimental.ts`
- `test/server/question-httpapi.test.ts`
- `src/server/routes/instance/httpapi/question.ts`
- `src/server/routes/instance/httpapi/permission.ts`
- `src/server/routes/instance/httpapi/provider.ts`
- `src/server/routes/instance/httpapi/config.ts`
- `src/server/routes/instance/httpapi/project.ts`
- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/*.ts`
- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
## First vertical slice
The first slice for the package split is the existing experimental `question` group.
The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
@@ -357,7 +358,7 @@ Done means:
Scope:
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/experimental/httpapi/question`
- `/experimental/httpapi/question/doc`
- `/question`
- `/question/:requestID/reply`
- `/question/:requestID/reject`
Rules:
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant route test, especially `test/server/question-httpapi.test.ts`
- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.

View File

@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- There is no current `ls.ts` tool file on this branch.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`

View File

@@ -25,7 +25,19 @@ export const GenerateCommand = {
]
}
}
const json = JSON.stringify(specs, null, 2)
const raw = JSON.stringify(specs, null, 2)
// Format through prettier so output is byte-identical to committed file
// regardless of whether ./script/format.ts runs afterward.
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
const format = prettier.format ?? prettier.default?.format
const json = await format(raw, {
parser: "json",
plugins: [babel.default ?? babel, estree.default ?? estree],
printWidth: 120,
})
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {

View File

@@ -150,7 +150,7 @@ export function tui(input: {
<ToastProvider>
<RouteProvider
initialRoute={
(input.args.sessionID || input.args.continue) && !input.args.fork
input.args.continue
? {
type: "session",
sessionID: "dummy",
@@ -342,6 +342,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
local.model.set({ providerID, modelID }, { recent: true })
}
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
sessionID: args.sessionID,
})
}
})
})
@@ -600,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle theme mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")

View File

@@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {

View File

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

View File

@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
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", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -229,6 +235,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),

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

@@ -5,10 +5,11 @@ import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
onSelect: async (dialog) => {
if (!input.focused) return
void submit()
const handled = await submit()
if (!handled) return
dialog.clear()
},
},
@@ -463,19 +469,25 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
input.blur()
if (input.focused) input.blur()
return
}
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
input.focus()
if (!input.focused) input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
@@ -622,20 +634,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (props.disabled) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return
if (!agent) return false
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return
return true
}
const selectedModel = local.model.current()
if (!selectedModel) {
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
@@ -650,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
return
return true
}
sessionID = res.data.id
@@ -764,6 +804,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
return true
}
const exit = useExit()
@@ -870,6 +911,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -931,7 +973,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
@@ -1147,10 +1189,11 @@ export function Prompt(props: PromptProps) {
{(agent) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}{" "}
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
@@ -1183,7 +1226,7 @@ export function Prompt(props: PromptProps) {
<box
height={1}
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",

View File

@@ -132,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
}
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
const files = [
...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}

View File

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

View File

@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
@@ -89,15 +89,13 @@ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = await ConfigPaths.directories(ctx.directory)
// One-time migration: extract tui keys (theme/keybinds/tui) from existing
// opencode.json files into sibling tui.json files.
await migrateTuiConfig({ directories, cwd: ctx.directory })
const directories = yield* ConfigPaths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const acc: Acc = {
result: {},
@@ -105,18 +103,19 @@ async function loadState(ctx: { directory: string }) {
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
@@ -127,7 +126,7 @@ async function loadState(ctx: { directory: string }) {
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
@@ -146,20 +145,25 @@ async function loadState(ctx: { directory: string }) {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
@@ -176,7 +180,7 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
const current = this.current()
if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]

View File

@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController()
sse = ctrl
;(async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal })
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else {
startSSE()
}

View File

@@ -467,7 +467,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return true
if (process.env.OPENCODE_FAST_BOOT) return true
return store.status !== "loading"
},

View File

@@ -397,7 +397,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
// renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => {
renderer.clearPaletteCache()

View File

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

View File

@@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog"
import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
@@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string>[] => {
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const result = [] as DialogSelectOption<string>[]
const fullSession = {
title: "Full session",
value: undefined,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
@@ -57,9 +69,8 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
},
})
}
result.reverse()
return result
return [fullSession, ...result.reverse()]
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}

View File

@@ -451,7 +451,7 @@ export function Session() {
},
},
{
title: "Fork from message",
title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
@@ -462,6 +462,7 @@ export function Session() {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})

View File

@@ -1,17 +1,31 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
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))
return (
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<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}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>

View File

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

View File

@@ -8,20 +8,23 @@ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Sc
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate], previous) => {
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || !previous) {
if (!animate || revealed) {
revealed = true
setAlpha(1)
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {

View File

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

View File

@@ -1,10 +1,12 @@
export * as ConfigAgent from "./agent"
import { Log } from "../util"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -13,89 +15,102 @@ import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
export const Info = z
.object({
model: ConfigModelID.optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z
.boolean()
.optional()
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: ConfigPermission.Info.optional(),
})
.catchall(z.any())
.transform((agent, _ctx) => {
const knownKeys = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
// shape lives outside the Effect Schema type system), so the walker reaches it
// via ZodOverride rather than a pure Schema reference. This preserves the
// `$ref: PermissionConfig` emitted in openapi.json.
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(ConfigModelID),
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
}),
disable: Schema.optional(Schema.Boolean),
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(PermissionRef),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Post-parse normalisation:
// - Promote any unknown-but-present keys into `options` so they survive the
// round-trip in a well-known field.
// - Translate the deprecated `tools: { name: boolean }` map into the new
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
const normalize = (agent: z.infer<typeof Info>) => {
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!KNOWN_KEYS.has(key)) options[key] = value
}
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
permission[tool] = action
}
globalThis.Object.assign(permission, agent.permission)
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
permission[tool] = action
}
Object.assign(permission, agent.permission)
return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
}
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
>
export type Info = z.infer<typeof Info>
export async function load(dir: string) {

View File

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

View File

@@ -21,24 +21,27 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm"
import { zod, ZodOverride } from "@/util/effect-zod"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
import { ConfigLSP } from "./lsp"
import { ConfigManaged } from "./managed"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigPermission } from "./permission"
import { ConfigPlugin } from "./plugin"
import { ConfigProvider } from "./provider"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "config" })
@@ -73,170 +76,186 @@ async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(
return config
}
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({
ref: "ServerConfig",
})
export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
export type Layout = z.infer<typeof Layout>
// Schemas that still live at the zod layer (have .transform / .preprocess /
// .meta not expressible in current Effect Schema) get referenced via a
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), ConfigCommand.Info)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
})
.optional(),
snapshot: z
.boolean()
.optional()
.describe(
"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.
plugin: ConfigPlugin.Spec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
.describe(
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
),
autoshare: z
.boolean()
.optional()
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z
.union([z.boolean(), z.literal("notify")])
.optional()
.describe(
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.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(),
small_model: ConfigModelID.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()
.describe(
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
),
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
mode: z
.object({
build: ConfigAgent.Info.optional(),
plan: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const InfoSchema = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
server: Schema.optional(ConfigServer.Server).annotate({
description: "Server configuration for opencode serve and web commands",
}),
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
watcher: Schema.optional(
Schema.Struct({
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}),
),
snapshot: Schema.optional(Schema.Boolean).annotate({
description:
"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.
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
description:
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
}),
autoshare: Schema.optional(Schema.Boolean).annotate({
description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
}),
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
description:
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
}),
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Disable providers that are loaded automatically",
}),
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
}),
model: Schema.optional(ConfigModelID).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),
small_model: Schema.optional(ConfigModelID).annotate({
description: "Small model to use for tasks like title generation in the format of provider/model",
}),
default_agent: Schema.optional(Schema.String).annotate({
description:
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
}),
username: Schema.optional(Schema.String).annotate({
description: "Custom username to display in conversations instead of system username",
}),
mode: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
build: Schema.optional(AgentRef),
plan: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "@deprecated Use `agent` field instead." }),
agent: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
// primary
plan: ConfigAgent.Info.optional(),
build: ConfigAgent.Info.optional(),
plan: Schema.optional(AgentRef),
build: Schema.optional(AgentRef),
// subagent
general: ConfigAgent.Info.optional(),
explore: ConfigAgent.Info.optional(),
general: Schema.optional(AgentRef),
explore: Schema.optional(AgentRef),
// specialized
title: ConfigAgent.Info.optional(),
summary: ConfigAgent.Info.optional(),
compaction: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), ConfigProvider.Info)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(
z.string(),
z.union([
ConfigMCP.Info,
z
.object({
enabled: z.boolean(),
})
.strict(),
]),
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
compaction: z
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reserved: z
.number()
.int()
.min(0)
.optional()
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
.boolean()
.optional()
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
primary_tools: z
.array(z.string())
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
mcp_timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
})
title: Schema.optional(AgentRef),
summary: Schema.optional(AgentRef),
compaction: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
description: "Custom provider configurations and model overrides",
}),
mcp: Schema.optional(
Schema.Record(
Schema.String,
Schema.Union([
ConfigMCP.Info,
// Matches the legacy `{ enabled: false }` form used to disable a server.
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
]),
),
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
formatter: Schema.optional(ConfigFormatter.Info),
lsp: Schema.optional(ConfigLSP.Info),
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(PermissionRef),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}),
),
compaction: Schema.optional(
Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({
description: "Enable automatic compaction when context is full (default: true)",
}),
prune: Schema.optional(Schema.Boolean).annotate({
description: "Enable pruning of old tool outputs (default: true)",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
}),
),
experimental: Schema.optional(
Schema.Struct({
disable_paste_summary: Schema.optional(Schema.Boolean),
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
openTelemetry: Schema.optional(Schema.Boolean).annotate({
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
}),
),
})
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
// readonly recursively so callers get the same mutable shape zod inferred.
//
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
// fields like `ConfigPlugin.Options`. The local version gates on
// `extends object` so `unknown` passes through.
//
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
// shape (otherwise the general array branch widens it to an array).
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict()
.meta({
ref: "Config",
})
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
export type Info = z.output<typeof Info> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
@@ -413,260 +432,266 @@ export const layer = Layer.effect(
}
})
const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
return Effect.void
}),
)
}
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
}
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
)
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
},
Effect.provideService(AppFileSystem.Service, fs),
)
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
return yield* loadInstanceState(ctx).pipe(Effect.orDie)
}),
)

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({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
}
})

View File

@@ -1,13 +1,17 @@
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({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
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 type Info = z.infer<typeof Info>
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>

View File

@@ -1,166 +1,127 @@
export * as ConfigKeybinds from "./keybinds"
import z from "zod"
import { Effect, Schema } from "effect"
import type z from "zod"
import { zod } from "@/util/effect-zod"
export const Keybinds = z
.object({
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
status_view: z.string().optional().default("<leader>s").describe("View status"),
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.describe("Scroll messages down by one page"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
messages_next: z.string().optional().default("none").describe("Navigate to next message"),
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
messages_toggle_conceal: z
.string()
.optional()
.default("<leader>h")
.describe("Toggle code block concealment in messages"),
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
variant_list: z.string().optional().default("none").describe("List model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
.optional()
.default("shift+return,ctrl+return,alt+return,ctrl+j")
.describe("Insert newline in input"),
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
input_select_visual_line_home: z
.string()
.optional()
.default("alt+shift+a")
.describe("Select to start of visual line in input"),
input_select_visual_line_end: z
.string()
.optional()
.default("alt+shift+e")
.describe("Select to end of visual line in input"),
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
input_select_buffer_home: z
.string()
.optional()
.default("shift+home")
.describe("Select to start of buffer in input"),
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
input_undo: z
.string()
.optional()
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
.describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
.optional()
.default("alt+f,alt+right,ctrl+right")
.describe("Move word forward in input"),
input_word_backward: z
.string()
.optional()
.default("alt+b,alt+left,ctrl+left")
.describe("Move word backward in input"),
input_select_word_forward: z
.string()
.optional()
.default("alt+shift+f,alt+shift+right")
.describe("Select word forward in input"),
input_select_word_backward: z
.string()
.optional()
.default("alt+shift+b,alt+shift+left")
.describe("Select word backward in input"),
input_delete_word_forward: z
.string()
.optional()
.default("alt+d,alt+delete,ctrl+delete")
.describe("Delete word forward in input"),
input_delete_word_backward: z
.string()
.optional()
.default("ctrl+w,ctrl+backspace,alt+backspace")
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
terminal_suspend: z
.string()
.optional()
.default("ctrl+z")
.transform((v) => (process.platform === "win32" ? "none" : v))
.describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
})
.strict()
.meta({
ref: "KeybindsConfig",
})
// Every keybind field has the same shape: an optional string with a default
// binding and a human description. `keybind()` keeps the declaration list
// below dense and readable.
const keybind = (value: string, description: string) =>
Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
const KeybindsSchema = Schema.Struct({
leader: keybind("ctrl+x", "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
username_toggle: keybind("none", "Toggle username visibility"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_compact: keybind("<leader>c", "Compact the session"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
command_list: keybind("ctrl+p", "List available commands"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind("ctrl+v", "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind(inputUndoDefault, "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
// `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
// but `tui.ts` already forces the binding to "none" on win32 before calling
// `Keybinds.parse(...)`, so the schema-level transform was redundant.
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
}).annotate({ identifier: "KeybindsConfig" })
export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
// which requires the runtime type to be a ZodObject, not just ZodType. Every
// field is `string().optional().default(...)` at runtime, so widen to that.
export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
>

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