Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline
0597d225a3 ignore: add truncation namespace 2026-01-07 01:24:50 -06:00
98 changed files with 3244 additions and 40326 deletions

View File

@@ -172,7 +172,7 @@ jobs:
- name: Install tauri-cli from portable appimage branch
if: contains(matrix.settings.host, 'ubuntu')
run: |
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch truly-portable-appimage --force
echo "Installed tauri-cli version:"
cargo tauri --version

View File

@@ -28,8 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch

View File

@@ -28,8 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
brew install opencode # macOS 和 Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支

View File

@@ -28,8 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支

View File

@@ -193,4 +193,3 @@
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |

View File

@@ -1,8 +1,7 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID unnecessary destructuring of variables
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,10 +173,9 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -202,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -231,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -247,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.6",
"version": "1.1.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -277,7 +276,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -350,7 +349,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -370,7 +369,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.6",
"version": "1.1.4",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -381,7 +380,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -394,7 +393,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -433,7 +432,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"zod": "catalog:",
},
@@ -444,7 +443,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.6",
"version": "1.1.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -911,8 +910,6 @@
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1097,7 +1094,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1905,9 +1902,7 @@
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2411,7 +2406,7 @@
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
@@ -2787,9 +2782,7 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3391,8 +3384,6 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -3767,6 +3758,8 @@
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -4065,11 +4058,9 @@
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
@@ -4413,6 +4404,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],

View File

@@ -76,7 +76,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"customer.created",
"customer.deleted",
"customer.updated",

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
"nodeModules": "sha256-Vi6auFnjZ6Ko7yGy73kyjE3gToreuhD81mZgcnxxxww="
}

View File

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

View File

@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -20,12 +20,10 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -33,7 +31,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
}
}
@@ -49,25 +47,6 @@ const defaultServerUrl = iife(() => {
return window.location.origin
})
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
@@ -77,56 +56,71 @@ function ServerKey(props: ParentProps) {
)
}
export function AppInterface() {
export function App() {
return (
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}

View File

@@ -248,8 +248,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const isFocused = createFocusSignal(() => editorRef)
createEffect(() => {
params.id
editorRef.focus()
@@ -260,6 +258,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => clearInterval(interval))
})
const isFocused = createFocusSignal(() => editorRef)
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
@@ -293,13 +292,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
event.preventDefault()
event.stopPropagation()
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
@@ -307,6 +305,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
event.preventDefault()
event.stopPropagation()
const plainText = clipboardData.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -347,11 +347,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
@@ -1506,7 +1508,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
contenteditable="true"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}

View File

@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { App } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
@@ -55,9 +55,7 @@ const platform: Platform = {
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
<App />
</PlatformProvider>
),
root!,

View File

@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { App } from "./app"

View File

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

View File

@@ -2,7 +2,6 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -147,242 +146,6 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
}),
)
}
if (body.type === "customer.subscription.created") {
const data = {
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
object: "event",
api_version: "2025-07-30.basil",
created: 1767766916,
data: {
object: {
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
object: "subscription",
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: false,
liability: null,
},
billing_cycle_anchor: 1770445200,
billing_cycle_anchor_config: null,
billing_mode: {
flexible: {
proration_discounts: "included",
},
type: "flexible",
updated_at: 1770445200,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: "charge_automatically",
created: 1770445200,
currency: "usd",
customer: "cus_TkKmZZvysJ2wej",
customer_account: null,
days_until_due: null,
default_payment_method: null,
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
default_tax_rates: [],
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: "self",
},
},
items: {
object: "list",
data: [
{
id: "si_TkKnBKXFX76t0O",
object: "subscription_item",
billing_thresholds: null,
created: 1770445200,
current_period_end: 1772864400,
current_period_start: 1770445200,
discounts: [],
metadata: {},
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
price: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "price",
active: true,
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
recurring: {
interval: "month",
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: "licensed",
},
tax_behavior: "unspecified",
tiers_mode: null,
transform_quantity: null,
type: "recurring",
unit_amount: 20000,
unit_amount_decimal: "20000",
},
quantity: 1,
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
tax_rates: [],
},
],
has_more: false,
total_count: 1,
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
},
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
livemode: false,
metadata: {},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: "off",
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
quantity: 1,
schedule: null,
start_date: 1770445200,
status: "active",
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: "create_invoice",
},
},
trial_start: null,
},
},
livemode: false,
pending_webhooks: 0,
request: {
id: "req_6YO9stvB155WJD",
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
},
type: "customer.subscription.created",
}
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
})
}
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })

View File

@@ -1,8 +1,2 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
}

View File

@@ -1,57 +1,11 @@
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
}, "sessionUrl")
export function BlackSection() {
const params = useParams()
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const [store, setStore] = createStore({
sessionRedirecting: false,
})
async function onClickSession() {
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("sessionRedirecting", true)
window.location.href = result.data
}
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<div data-slot="title-row">
<p>You are subscribed to OpenCode Black for $200 per month.</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
</button>
</div>
<h2>Black</h2>
<p>You are subscribed to Black.</p>
</div>
</section>
)

View File

@@ -16,7 +16,7 @@ export default function () {
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID}>
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection />

View File

@@ -1 +0,0 @@
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -316,13 +316,6 @@
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
}
]
}

View File

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

View File

@@ -82,14 +82,6 @@ const invoice = invoices.data[0]
const invoiceID = invoice?.id
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
// Get the default payment method from the customer
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
| string
| null
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null
// Look up the user by email via AuthTable
const auth = await Database.use((tx) =>
tx
@@ -124,15 +116,12 @@ await Billing.stripe().customers.update(customerID, {
})
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
// Set customer id and subscription id on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
})
.where(eq(BillingTable.workspaceID, workspaceID))
@@ -158,9 +147,6 @@ await Database.transaction(async (tx) => {
console.log(`Successfully onboarded workspace ${workspaceID}`)
console.log(` Customer ID: ${customerID}`)
console.log(` Subscription ID: ${subscriptionID}`)
console.log(
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
)
console.log(` User ID: ${user.id}`)
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
console.log(` Payment ID: ${paymentID ?? "(none)"}`)

View File

@@ -23,11 +23,7 @@ export const BillingTable = mysqlTable(
timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
},
(table) => [
...workspaceIndexes(table),
uniqueIndex("global_customer_id").on(table.customerID),
uniqueIndex("global_subscription_id").on(table.subscriptionID),
],
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)],
)
export const PaymentTable = mysqlTable(

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.6",
"version": "1.1.4",
"type": "module",
"license": "MIT",
"scripts": {
@@ -14,7 +14,6 @@
},
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",

View File

@@ -1177,21 +1177,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1199,7 +1184,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1267,7 +1251,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2792,7 +2775,6 @@ dependencies = [
name = "opencode-desktop"
version = "0.0.0"
dependencies = [
"futures",
"gtk",
"listeners",
"semver",

View File

@@ -36,7 +36,6 @@ serde_json = "1"
tokio = "1.48.0"
listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -12,19 +12,5 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@@ -2,7 +2,6 @@ mod cli;
mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use futures::FutureExt;
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
@@ -10,101 +9,23 @@ use std::{
time::{Duration, Instant},
};
use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
WebviewWindow,
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
status: status.shared(),
}
}
pub fn set_child(&self, child: Option<CommandChild>) {
*self.child.lock().unwrap() = child;
}
}
struct ServerState(Arc<Mutex<Option<CommandChild>>>);
#[derive(Clone)]
struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200;
const GLOBAL_STORAGE: &str = "opencode.global.dat";
/// Check if a URL's origin matches any configured server in the store.
/// Returns true if the URL should be allowed for internal navigation.
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
// Always allow localhost and 127.0.0.1
if let Some(host) = url.host_str() {
if host == "localhost" || host == "127.0.0.1" {
return true;
}
}
// Try to read the server list from the store
let Ok(store) = app.store(GLOBAL_STORAGE) else {
return false;
};
let Some(server_data) = store.get("server") else {
return false;
};
// Parse the server list from the stored JSON
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
return false;
};
// Get the origin of the navigation URL (scheme + host + port)
let url_origin = format!(
"{}://{}{}",
url.scheme(),
url.host_str().unwrap_or(""),
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
// Check if any configured server matches the URL's origin
for server in list {
let Some(server_url) = server.as_str() else {
continue;
};
// Parse the server URL to extract its origin
let Ok(parsed) = tauri::Url::parse(server_url) else {
continue;
};
let server_origin = format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
if url_origin == server_origin {
return true;
}
}
false
}
#[tauri::command]
fn kill_sidecar(app: AppHandle) {
@@ -114,7 +35,7 @@ fn kill_sidecar(app: AppHandle) {
};
let Some(server_state) = server_state
.child
.0
.lock()
.expect("Failed to acquire mutex lock")
.take()
@@ -128,6 +49,25 @@ fn kill_sidecar(app: AppHandle) {
println!("Killed server");
}
#[tauri::command]
async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
let logs = log_state
.0
.lock()
.map_err(|_| "Failed to acquire log lock")?;
let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
app.clipboard()
.write_text(log_text)
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
Ok(())
}
#[tauri::command]
async fn get_logs(app: AppHandle) -> Result<String, String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
@@ -139,15 +79,6 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
}
#[tauri::command]
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
state
.status
.clone()
.await
.map_err(|_| "Failed to get server status".to_string())?
}
fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")
.map(|s| s.to_string())
@@ -199,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([
"-il",
"-c",
&format!("\"{}\" serve --port={}", sidecar.display(), port),
&format!("{} serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
@@ -278,8 +209,9 @@ pub fn run() {
.plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_started
copy_logs_to_clipboard,
get_logs,
install_cli
])
.setup(move |app| {
let app = app.handle().clone();
@@ -287,110 +219,94 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
// Get port and create window immediately for faster perceived startup
let port = get_sidecar_port();
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false
let app_for_nav = app.clone();
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.on_navigation(move |url| {
// Allow internal navigation (tauri:// scheme)
if url.scheme() == "tauri" {
return true;
}
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
if is_allowed_server(&app_for_nav, url) {
return true;
}
// Open external http/https URLs in default browser
if url.scheme() == "http" || url.scheme() == "https" {
let _ = app_for_nav.shell().open(url.as_str(), None);
return false; // Cancel internal navigation
}
true
})
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
let port = get_sidecar_port();
let window = window_builder.build().expect("Failed to create window");
let should_spawn_sidecar = !is_server_running(port).await;
let (tx, rx) = tokio::sync::oneshot::channel();
app.manage(ServerState::new(None, rx));
let child = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
let should_spawn_sidecar = !is_server_running(port).await;
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
let res = app.dialog()
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
let (child, res) = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"),
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
}
}
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
app.exit(1);
tokio::time::sleep(Duration::from_millis(10)).await;
return;
}
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
println!("Server ready after {:?}", timestamp.elapsed());
break;
}
}
(Some(child), res)
} else {
(None, Ok(()))
};
println!("Server ready after {:?}", timestamp.elapsed());
app.state::<ServerState>().set_child(child);
Some(child)
} else {
None
};
if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
}
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
let _ = tx.send(res);
});
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
window_builder.build().expect("Failed to create window");
app.manage(ServerState(Arc::new(Mutex::new(child))));
});
}
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
}
});
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
}
});
}
Ok(())

View File

@@ -1,24 +1,21 @@
// @refresh reload
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { App, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Suspense, createResource, ParentProps } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import pkg from "../package.json"
import { Show } from "solid-js"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -272,36 +269,7 @@ render(() => {
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<AppBaseProviders>
<ServerGate>
<AppInterface />
</ServerGate>
</AppBaseProviders>
<App />
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: ParentProps) {
const [status] = createResource(async () => {
if (window.__OPENCODE__?.serverReady) return
return await invoke("ensure_server_started")
})
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={status.state !== "pending"}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
</div>
}
>
{/* Trigger error boundary without rendering the returned value */}
{(status(), null)}
{props.children}
</Show>
)
}

View File

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

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.6"
version = "1.1.4"
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.1.6/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/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.1.6/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/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.1.6/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.6",
"version": "1.1.4",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -72,7 +72,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",

View File

@@ -4,7 +4,6 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -47,11 +46,7 @@ export namespace Agent {
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
},
question: "deny",
external_directory: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -66,13 +61,7 @@ export namespace Agent {
build: {
name: "build",
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
}),
user,
),
permission: PermissionNext.merge(defaults, user),
mode: "primary",
native: true,
},
@@ -82,7 +71,6 @@ export namespace Agent {
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
@@ -107,6 +95,7 @@ export namespace Agent {
options: {},
mode: "subagent",
native: true,
hidden: true,
},
explore: {
name: "explore",
@@ -122,9 +111,6 @@ export namespace Agent {
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
},
}),
user,
),
@@ -155,7 +141,6 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({

View File

@@ -12,11 +12,8 @@ Your output must be:
</task>
<rules>
- Title must be grammatically correct and read naturally - no word salad
- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
- Focus on the main topic or question the user needs to retrieve
- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
- Use -ing verbs for actions (Debugging, Implementing, Analyzing)
- Keep exact: technical terms, numbers, filenames, HTTP codes
- Remove: the, this, my, a, an
- Never assume tech stack
@@ -32,12 +29,8 @@ Your output must be:
<examples>
"debug 500 errors in production" → Debugging production 500 errors
"refactor user service" → Refactoring user service
"why is app.js failing" → app.js failure investigation
"implement rate limiting" → Rate limiting implementation
"how do I connect postgres to my API" → Postgres API connection
"why is app.js failing" → Analyzing app.js failure
"implement rate limiting" → Implementing rate limiting
"how do I connect postgres to my API" → Connecting Postgres to API
"best practices for React hooks" → React hooks best practices
"@src/auth.ts can you add refresh token support" → Auth refresh token support
"@utils/parser.ts this is broken" → Parser bug fix
"look at @config.json" → Config review
"@App.tsx add dark mode toggle" → Dark mode toggle in App
</examples>

View File

@@ -515,15 +515,7 @@ export const GithubRunCommand = cmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await Session.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
})
session = await Session.create({})
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return

View File

@@ -292,28 +292,7 @@ export const RunCommand = cmd({
: args.title
: undefined
const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
const result = await sdk.session.create(title ? { title } : {})
return result.data?.id
})()

View File

@@ -653,10 +653,8 @@ export function Autocomplete(props: {
})
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
positionTick()
return Math.min(10, count, Math.max(1, props.anchor().y))
if (options().length) return Math.min(10, options().length)
return 1
})
let scroll: ScrollBoxRenderable

View File

@@ -8,7 +8,6 @@ import type {
Todo,
Command,
PermissionRequest,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
@@ -43,9 +42,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
config: Config
session: Session[]
session_status: {
@@ -84,7 +80,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading",
agent: [],
permission: {},
question: {},
command: [],
provider: [],
provider_default: {},
@@ -147,44 +142,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break

View File

@@ -102,16 +102,15 @@ type Theme = ThemeColors & {
thinkingOpacity: number
}
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
export function selectedForeground(theme: Theme): RGBA {
// If theme explicitly defines selectedListItemText, use it
if (theme._hasSelectedListItemText) {
return theme.selectedListItemText
}
// For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary)
// For transparent backgrounds, calculate contrast based on primary color
if (theme.background.a === 0) {
const targetColor = bg ?? theme.primary
const { r, g, b } = targetColor
const { r, g, b } = theme.primary
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
}
@@ -289,11 +288,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => {
const theme = sync.data.config.theme
console.log("theme", theme)
if (theme) setStore("active", theme)
})
function init() {
resolveSystemTheme()
createEffect(() => {
getCustomThemes()
.then((custom) => {
setStore(
@@ -310,18 +309,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("ready", true)
}
})
}
onMount(init)
})
function resolveSystemTheme() {
console.log("resolveSystemTheme")
console.log("resolved system theme")
renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
@@ -345,9 +341,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
const renderer = useRenderer()
process.on("SIGUSR2", async () => {
renderer.clearPaletteCache()
init()
resolveSystemTheme()
const sdk = useSDK()
sdk.event.on("server.instance.disposed", () => {
resolveSystemTheme()
})
const values = createMemo(() => {

View File

@@ -61,10 +61,6 @@
"dark": "darkStep11",
"light": "lightStep11"
},
"selectedListItemText": {
"dark": "#0a0a0a",
"light": "#ffffff"
},
"background": {
"dark": "transparent",
"light": "transparent"

View File

@@ -77,10 +77,6 @@
"dark": "darkStep11",
"light": "lightStep11"
},
"selectedListItemText": {
"dark": "#0a0a0a",
"light": "#ffffff"
},
"background": {
"dark": "darkStep1",
"light": "lightStep1"

View File

@@ -41,7 +41,6 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -70,7 +69,6 @@ import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
@@ -120,13 +118,9 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => {
if (session()?.parentID) return []
if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
const questions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1043,20 +1037,13 @@ export function Session() {
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={!session()?.parentID && permissions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
// Apply initial prompt when prompt component mounts (e.g., from fork)
if (route.initialPrompt) {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0 || questions().length > 0}
disabled={permissions().length > 0}
onSubmit={() => {
toBottom()
}}
@@ -1390,9 +1377,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
</Match>
<Match when={props.part.tool === "question"}>
<Question {...toolprops} />
</Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
@@ -1454,12 +1438,7 @@ function InlineTool(props: {
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
const denied = createMemo(
() =>
error()?.includes("rejected permission") ||
error()?.includes("specified a rule") ||
error()?.includes("user dismissed"),
)
const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
return (
<box
@@ -1833,34 +1812,6 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
)
}
function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
return (
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
Asked {count()} question{count() !== 1 ? "s" : ""}
</InlineTool>
</Match>
</Switch>
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {

View File

@@ -3,7 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
import { useTheme } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
@@ -395,7 +395,7 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>

View File

@@ -1,287 +0,0 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
const [store, setStore] = createStore({
tab: 0,
answers: [] as string[],
custom: [] as string[],
selected: 0,
editing: false,
})
let textarea: TextareaRenderable | undefined
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
function submit() {
// Fill in empty answers with empty strings
const answers = questions().map((_, i) => store.answers[i] ?? "")
sdk.client.question.reply({
requestID: props.request.id,
answers,
})
}
function reject() {
sdk.client.question.reject({
requestID: props.request.id,
})
}
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = answer
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
}
if (single()) {
sdk.client.question.reply({
requestID: props.request.id,
answers: [answer],
})
return
}
setStore("tab", store.tab + 1)
setStore("selected", 0)
}
const dialog = useDialog()
useKeyboard((evt) => {
// When editing "Other" textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
evt.preventDefault()
setStore("editing", false)
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim()
if (text) {
pick(text, true)
setStore("editing", false)
}
return
}
// Let textarea handle all other keys
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const next = (store.tab - 1 + tabs()) % tabs()
setStore("tab", next)
setStore("selected", 0)
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const next = (store.tab + 1) % tabs()
setStore("tab", next)
setStore("selected", 0)
}
if (confirm()) {
if (evt.name === "return") {
evt.preventDefault()
submit()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
} else {
const opts = options()
const total = opts.length + 1 // options + "Other"
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
setStore("selected", (store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
setStore("selected", (store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
setStore("editing", true)
} else {
const opt = opts[store.selected]
if (opt) {
pick(opt.label)
}
}
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<For each={questions()}>
{(q, index) => {
const isActive = () => index() === store.tab
const isAnswered = () => store.answers[index()] !== undefined
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
{q.header}
</text>
</box>
)
}}
</For>
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>{question()?.question}</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab] === opt.label
return (
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{i() + 1}. {opt.label}
</text>
</box>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
{options().length + 1}. Other
</text>
</box>
<text fg={theme.success}>{input() ? "✓" : ""}</text>
</box>
<Show when={store.editing}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</Show>
<Show when={!store.editing && input()}>
<text fg={theme.textMuted}>{input()}</text>
</Show>
</box>
</box>
</box>
</Show>
<Show when={confirm() && !single()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
</box>
<For each={questions()}>
{(q, index) => {
const answer = () => store.answers[index()]
return (
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{q.header}:</text>
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
</box>
)
}}
</For>
</Show>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={!confirm()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
</box>
</box>
)
}

View File

@@ -37,40 +37,14 @@ export namespace Config {
export const state = Instance.state(async () => {
const auth = await Auth.all()
let result = await global()
// Load remote/well-known config first as the base layer (lowest precedence)
// This allows organizations to provide default configs that users can override
let result: Info = {}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
const response = await fetch(`${key}/.well-known/opencode`)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
)
log.debug("loaded remote config from well-known", { url: key })
}
}
// Global user config overrides remote config
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config has highest precedence (overrides global and remote)
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
@@ -78,12 +52,19 @@ export namespace Config {
}
}
// Inline config content has highest precedence
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
@@ -450,7 +431,6 @@ export namespace Config {
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
question: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),

View File

@@ -6,11 +6,9 @@ export namespace Identifier {
session: "ses",
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
} as const
export function schema(prefix: keyof typeof prefixes) {
@@ -72,12 +70,4 @@ export namespace Identifier {
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
}

View File

@@ -111,8 +111,8 @@ export namespace Installation {
)
async function getBrewFormula() {
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const tapFormula = await $`brew list --formula sst/tap/opencode`.throws(false).quiet().text()
if (tapFormula.includes("opencode")) return "sst/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"

View File

@@ -497,10 +497,6 @@ export namespace ProviderTransform {
return { reasoningEffort: "minimal" }
}
if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
if (model.api.id.includes("gemini-3")) {
return { thinkingConfig: { thinkingLevel: "minimal" } }
}
return { thinkingConfig: { thinkingBudget: 0 } }
}
if (model.providerID === "openrouter") {

View File

@@ -1,162 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
export namespace Question {
const log = Log.create({ service: "question" })
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
ref: "QuestionOption",
})
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
})
.meta({
ref: "QuestionInfo",
})
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: Identifier.schema("question"),
sessionID: Identifier.schema("session"),
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "QuestionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.object({
answers: z.array(z.string()).describe("User answers in order of questions"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
answers: z.array(z.string()),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: z.string(),
requestID: z.string(),
}),
),
}
const state = Instance.state(async () => {
const pending: Record<
string,
{
info: Request
resolve: (answers: string[]) => void
reject: (e: any) => void
}
> = {}
return {
pending,
}
})
export async function ask(input: {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<string[]> {
const s = await state()
const id = Identifier.ascending("question")
log.info("asking", { id, questions: input.questions.length })
return new Promise<string[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
delete s.pending[input.requestID]
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
existing.resolve(input.answers)
}
export async function reject(requestID: string): Promise<void> {
const s = await state()
const existing = s.pending[requestID]
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
delete s.pending[requestID]
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
existing.reject(new RejectedError())
}
export class RejectedError extends Error {
constructor() {
super("The user dismissed this question")
}
}
export async function list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info))
}
}

View File

@@ -1,95 +0,0 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../question"
import z from "zod"
import { errors } from "./error"
export const QuestionRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ answers: z.array(z.string()) })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -82,12 +82,16 @@ export namespace LLM {
}
const provider = await Provider.getProvider(input.model.providerID)
const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options(input.model, input.sessionID, provider.options)
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
const options = pipe(
ProviderTransform.options(input.model, input.sessionID, provider.options),
mergeDeep(small),
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
const params = await Plugin.trigger(
"chat.params",

View File

@@ -14,7 +14,6 @@ import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -209,10 +208,7 @@ export namespace SessionProcessor {
},
})
if (
value.error instanceof PermissionNext.RejectedError ||
value.error instanceof Question.RejectedError
) {
if (value.error instanceof PermissionNext.RejectedError) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]

View File

@@ -37,7 +37,7 @@ import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status"
@@ -383,7 +383,7 @@ export namespace SessionPrompt {
sessionID: sessionID,
abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
extra: { userInvokedAgents: [task.agent] },
async metadata(input) {
await Session.updatePart({
...part,
@@ -545,9 +545,11 @@ export namespace SessionPrompt {
abort,
})
// Check if user explicitly invoked an agent via @ in this turn
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
// Track agents explicitly invoked by user via @ autocomplete
const userInvokedAgents = msgs
.filter((m) => m.info.role === "user")
.flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[])
.map((p) => p.name)
const tools = await resolveTools({
agent,
@@ -555,7 +557,7 @@ export namespace SessionPrompt {
model,
tools: lastUser.tools,
processor,
bypassAgentCheck,
userInvokedAgents,
})
if (step === 1) {
@@ -644,7 +646,7 @@ export namespace SessionPrompt {
session: Session.Info
tools?: Record<string, boolean>
processor: SessionProcessor.Info
bypassAgentCheck: boolean
userInvokedAgents: string[]
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
@@ -654,7 +656,7 @@ export namespace SessionPrompt {
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
extra: { model: input.model, userInvokedAgents: input.userInvokedAgents },
agent: input.agent.name,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
@@ -798,6 +800,28 @@ export namespace SessionPrompt {
tools[key] = item
}
// Regenerate task tool description with filtered subagents
if (tools.task) {
const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const filtered = filterSubagents(all, input.agent.permission)
// If no subagents are permitted, remove the task tool entirely
if (filtered.length === 0) {
delete tools.task
} else {
const description = TASK_DESCRIPTION.replace(
"{agents}",
filtered
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
tools.task = {
...tools.task,
description,
}
}
}
return tools
}

View File

@@ -0,0 +1,60 @@
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export interface Result {
content: string
truncated: boolean
}
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
export function output(text: string, options: Options = {}): Result {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}
const out: string[] = []
var i = 0
var bytes = 0
var hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "chars" : "lines"
return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
}
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "chars" : "lines"
return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
}
}

View File

@@ -15,9 +15,8 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
const MAX_METADATA_LENGTH = 30_000
const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
export const log = Log.create({ service: "bash-tool" })
@@ -56,9 +55,7 @@ export const BashTool = Tool.define("bash", async () => {
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
description: DESCRIPTION.replaceAll("${directory}", Instance.directory),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
@@ -175,14 +172,15 @@ export const BashTool = Tool.define("bash", async () => {
})
const append = (chunk: Buffer) => {
output += chunk.toString()
ctx.metadata({
metadata: {
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
description: params.description,
},
})
if (output.length <= MAX_OUTPUT_LENGTH) {
output += chunk.toString()
ctx.metadata({
metadata: {
output,
description: params.description,
},
})
}
}
proc.stdout?.on("data", append)
@@ -230,7 +228,12 @@ export const BashTool = Tool.define("bash", async () => {
})
})
const resultMetadata: string[] = []
let resultMetadata: String[] = ["<bash_metadata>"]
if (output.length > MAX_OUTPUT_LENGTH) {
output = output.slice(0, MAX_OUTPUT_LENGTH)
resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`)
}
if (timedOut) {
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
@@ -240,14 +243,15 @@ export const BashTool = Tool.define("bash", async () => {
resultMetadata.push("User aborted the command")
}
if (resultMetadata.length > 0) {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
if (resultMetadata.length > 1) {
resultMetadata.push("</bash_metadata>")
output += "\n\n" + resultMetadata.join("\n")
}
return {
title: params.description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
output,
exit: proc.exitCode,
description: params.description,
},

View File

@@ -22,9 +22,10 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)

View File

@@ -1,28 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import { Question } from "../question"
import DESCRIPTION from "./question.txt"
export const QuestionTool = Tool.define("question", {
description: DESCRIPTION,
parameters: z.object({
questions: z.array(Question.Info).describe("Questions to ask"),
}),
async execute(params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
},
})

View File

@@ -1,9 +0,0 @@
Use this tool when you need to ask the user questions during execution. This allows you to:
1. Gather user preferences or requirements
2. Clarify ambiguous instructions
3. Get decisions on implementation choices as you work
4. Offer choices to the user about what direction to take.
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

View File

@@ -11,7 +11,6 @@ import { Identifier } from "../id/id"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_BYTES = 50 * 1024
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
@@ -78,7 +77,6 @@ export const ReadTool = Tool.define("read", {
output: msg,
metadata: {
preview: msg,
truncated: false,
},
attachments: [
{
@@ -99,21 +97,9 @@ export const ReadTool = Tool.define("read", {
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const lines = await file.text().then((text) => text.split("\n"))
const raw: string[] = []
let bytes = 0
let truncatedByBytes = false
for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
break
}
raw.push(line)
bytes += size
}
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
})
@@ -123,13 +109,10 @@ export const ReadTool = Tool.define("read", {
output += content.join("\n")
const totalLines = lines.length
const lastReadLine = offset + raw.length
const lastReadLine = offset + content.length
const hasMoreLines = totalLines > lastReadLine
const truncated = hasMoreLines || truncatedByBytes
if (truncatedByBytes) {
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
} else if (hasMoreLines) {
if (hasMoreLines) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
@@ -145,7 +128,6 @@ export const ReadTool = Tool.define("read", {
output,
metadata: {
preview,
truncated,
},
}
},

View File

@@ -1,4 +1,3 @@
import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -24,7 +23,6 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -61,16 +59,15 @@ export namespace ToolRegistry {
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
init: async () => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const result = await def.execute(args as any, ctx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
output: result,
metadata: {},
}
},
}),
@@ -93,7 +90,6 @@ export namespace ToolRegistry {
return [
InvalidTool,
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,

View File

@@ -12,37 +12,35 @@ import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { PermissionNext } from "@/permission/next"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
session_id: z.string().describe("Existing Task session to continue").optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
export { DESCRIPTION as TASK_DESCRIPTION }
export const TaskTool = Tool.define("task", async (ctx) => {
export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) {
return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny")
}
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const description = DESCRIPTION.replace(
"{agents}",
accessibleAgents
agents
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
session_id: z.string().describe("Existing Task session to continue").optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
if (!ctx.extra?.bypassAgentCheck) {
const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[]
// Skip permission check when invoked from a command subtask (user already approved by invoking the command)
if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) {
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],

View File

@@ -2,7 +2,6 @@ import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next"
import { Truncate } from "./truncation"
export namespace Tool {
interface Metadata {
@@ -50,10 +49,10 @@ export namespace Tool {
): Info<Parameters, Result> {
return {
id,
init: async (initCtx) => {
const toolInfo = init instanceof Function ? await init(initCtx) : init
init: async (ctx) => {
const toolInfo = init instanceof Function ? await init(ctx) : init
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
toolInfo.execute = (args, ctx) => {
try {
toolInfo.parameters.parse(args)
} catch (error) {
@@ -65,21 +64,7 @@ export namespace Tool {
{ cause: error },
)
}
const result = await execute(args, ctx)
// skip truncation for tools that handle it themselves
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
return execute(args, ctx)
}
return toolInfo
},

View File

@@ -1,98 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
await fs.unlink(path.join(DIR, entry)).catch(() => {})
}
}
const init = lazy(cleanup)
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
return rule.action !== "deny"
}
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
return { content: message, truncated: true, outputPath: filepath }
}
}

View File

@@ -82,7 +82,7 @@ test("general agent denies todo tools", async () => {
const general = await Agent.get("general")
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBeUndefined()
expect(general?.hidden).toBe(true)
expect(evalPerm(general, "todoread")).toBe("deny")
expect(evalPerm(general, "todowrite")).toBe("deny")
},

View File

@@ -1,7 +1,6 @@
import { test, expect, mock, afterEach } from "bun:test"
import { test, expect } from "bun:test"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
@@ -914,234 +913,3 @@ test("permission config preserves key order", async () => {
},
})
})
// MCP config merging tests
test("project config can override MCP server enabled status", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Simulates a base config (like from remote .well-known) with disabled MCP
await Bun.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: false,
},
wiki: {
type: "remote",
url: "https://wiki.example.com/mcp",
enabled: false,
},
},
}),
)
// Project config enables just jira
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
})
// wiki should still be disabled (not overridden)
expect(config.mcp?.wiki).toEqual({
type: "remote",
url: "https://wiki.example.com/mcp",
enabled: false,
})
},
})
})
test("MCP config deep merges preserving base config properties", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Base config with full MCP definition
await Bun.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
myserver: {
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: false,
headers: {
"X-Custom-Header": "value",
},
},
},
}),
)
// Override just enables it, should preserve other properties
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
myserver: {
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.mcp?.myserver).toEqual({
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: true,
headers: {
"X-Custom-Header": "value",
},
})
},
})
})
test("local .opencode config can override MCP from project config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Project config with disabled MCP
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
docs: {
type: "remote",
url: "https://docs.example.com/mcp",
enabled: false,
},
},
}),
)
// Local .opencode directory config enables it
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
docs: {
type: "remote",
url: "https://docs.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.mcp?.docs?.enabled).toBe(true)
},
})
})
test("project config overrides remote well-known config", async () => {
const originalFetch = globalThis.fetch
let fetchedUrl: string | undefined
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = url.toString()
if (urlStr.includes(".well-known/opencode")) {
fetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
config: {
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: false,
},
},
},
}),
{ status: 200 },
),
)
}
return originalFetch(url)
})
globalThis.fetch = mockFetch as unknown as typeof fetch
const originalAuthAll = Auth.all
Auth.all = mock(() =>
Promise.resolve({
"https://example.com": {
type: "wellknown" as const,
key: "TEST_TOKEN",
token: "test-token",
},
}),
)
try {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
// Project config enables jira (overriding remote default)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// Verify fetch was called for wellknown config
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
// Project config (enabled: true) should override remote (enabled: false)
expect(config.mcp?.jira?.enabled).toBe(true)
},
})
} finally {
globalThis.fetch = originalFetch
Auth.all = originalAuthAll
}
})

View File

@@ -1,9 +1,147 @@
import { describe, test, expect } from "bun:test"
import type { Agent } from "../src/agent/agent"
import { filterSubagents } from "../src/tool/task"
import { PermissionNext } from "../src/permission/next"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("filterSubagents - permission.task filtering", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
action,
}))
const mockAgents = [
{ name: "general", mode: "subagent", permission: [], options: {} },
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
{ name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
] as Agent.Info[]
test("returns all agents when permissions config is empty", () => {
const result = filterSubagents(mockAgents, [])
expect(result).toHaveLength(4)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
})
test("excludes agents with explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "deny" })
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
})
test("includes agents with explicit allow", () => {
const ruleset = createRuleset({
"code-reviewer": "allow",
general: "deny",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
})
test("includes agents with ask permission (user approval is runtime behavior)", () => {
const ruleset = createRuleset({
"code-reviewer": "ask",
general: "deny",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
})
test("includes agents with undefined permission (default allow)", () => {
const ruleset = createRuleset({
general: "deny",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
})
test("supports wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" })
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(2)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
})
test("supports wildcard patterns with allow", () => {
const ruleset = createRuleset({
"*": "allow",
"orchestrator-fast": "deny",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
})
test("supports wildcard patterns with ask", () => {
const ruleset = createRuleset({
"orchestrator-*": "ask",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(4)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
})
test("longer pattern takes precedence over shorter pattern", () => {
const ruleset = createRuleset({
"orchestrator-*": "deny",
"orchestrator-fast": "allow",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
})
test("edge case: all agents denied", () => {
const ruleset = createRuleset({ "*": "deny" })
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(0)
expect(result).toEqual([])
})
test("edge case: mixed patterns with multiple wildcards", () => {
const ruleset = createRuleset({
"*": "ask",
"orchestrator-*": "deny",
"orchestrator-fast": "allow",
})
const result = filterSubagents(mockAgents, ruleset)
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
})
test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
const agents = [
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
{ name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
{ name: "orchestrator", mode: "subagent", permission: [], options: {} },
] as Agent.Info[]
const result = filterSubagents(agents, [])
expect(result).toHaveLength(3)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
})
test("hidden: true agents can be filtered by permission.task deny", () => {
const agents = [
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
{ name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
] as Agent.Info[]
const ruleset = createRuleset({ general: "deny" })
const result = filterSubagents(agents, ruleset)
expect(result).toHaveLength(1)
expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
})
})
describe("PermissionNext.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
@@ -139,6 +277,12 @@ describe("PermissionNext.disabled for task tool", () => {
// Integration tests that load permissions from real config files
describe("permission.task with real config files", () => {
const mockAgents = [
{ name: "general", mode: "subagent", permission: [], options: {} },
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
] as Agent.Info[]
test("loads task permissions from opencode.json config", async () => {
await using tmp = await tmpdir({
git: true,
@@ -156,10 +300,8 @@ describe("permission.task with real config files", () => {
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
const result = filterSubagents(mockAgents, ruleset)
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
},
})
})
@@ -181,10 +323,8 @@ describe("permission.task with real config files", () => {
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
const result = filterSubagents(mockAgents, ruleset)
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
},
})
})

View File

@@ -1,300 +0,0 @@
import { test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
expect(promise).toBeInstanceOf(Promise)
},
})
})
test("ask - adds to pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
},
})
})
// reply tests
test("reply - resolves the pending ask with answers", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
const askPromise = Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
const requestID = pending[0].id
await Question.reply({
requestID,
answers: ["Option 1"],
})
const answers = await askPromise
expect(answers).toEqual(["Option 1"])
},
})
})
test("reply - removes from pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(1)
await Question.reply({
requestID: pending[0].id,
answers: ["Option 1"],
})
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reply({
requestID: "que_unknown",
answers: ["Option 1"],
})
// Should not throw
},
})
})
// reject tests
test("reject - throws RejectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
await Question.reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
test("reject - removes from pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(1)
await Question.reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
test("reject - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject("que_unknown")
// Should not throw
},
})
})
// multiple questions tests
test("ask - handles multiple questions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Build", description: "Build the project" },
{ label: "Test", description: "Run tests" },
],
},
{
question: "Which environment?",
header: "Env",
options: [
{ label: "Dev", description: "Development" },
{ label: "Prod", description: "Production" },
],
},
]
const askPromise = Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
await Question.reply({
requestID: pending[0].id,
answers: ["Build", "Dev"],
})
const answers = await askPromise
expect(answers).toEqual(["Build", "Dev"])
},
})
})
// list tests
test("list - returns all pending requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test1",
questions: [
{
question: "Question 1?",
header: "Q1",
options: [{ label: "A", description: "A" }],
},
],
})
Question.ask({
sessionID: "ses_test2",
questions: [
{
question: "Question 2?",
header: "Q2",
options: [{ label: "B", description: "B" }],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(2)
},
})
})
test("list - returns empty when no pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending.length).toBe(0)
},
})
})

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,79 @@
import { describe, test, expect } from "bun:test"
import { Truncate } from "../../src/session/truncation"
import path from "path"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
describe("Truncate", () => {
describe("output", () => {
test("truncates large json file by bytes", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = Truncate.output(content)
expect(result.truncated).toBe(true)
expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
expect(result.content).toContain("truncated...")
})
test("returns content unchanged when under limits", () => {
const content = "line1\nline2\nline3"
const result = Truncate.output(content)
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
})
test("truncates by line count", () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
expect(result.content).toContain("...90 lines truncated...")
})
test("truncates by byte count", () => {
const content = "a".repeat(1000)
const result = Truncate.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
})
test("truncates from head by default", () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9")
})
test("truncates from tail when direction is tail", () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
expect(result.content).toContain("line8")
expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0")
})
test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
})
test("large single-line file truncates with byte message", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = Truncate.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("chars truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
})
})
})

View File

@@ -4,7 +4,6 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
import { Truncate } from "../../src/tool/truncation"
const ctx = {
sessionID: "test",
@@ -231,90 +230,3 @@ describe("tool.bash permissions", () => {
})
})
})
describe("tool.bash truncation", () => {
test("truncates output exceeding line limit", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const lineCount = Truncate.MAX_LINES + 500
const result = await bash.execute(
{
command: `seq 1 ${lineCount}`,
description: "Generate lines exceeding limit",
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
},
})
})
test("truncates output exceeding byte limit", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const byteCount = Truncate.MAX_BYTES + 10000
const result = await bash.execute(
{
command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
description: "Generate bytes exceeding limit",
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
},
})
})
test("does not truncate small output", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
{
command: "echo hello",
description: "Echo hello",
},
ctx,
)
expect((result.metadata as any).truncated).toBe(false)
expect(result.output).toBe("hello\n")
},
})
})
test("full output is saved to file when truncated", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const lineCount = Truncate.MAX_LINES + 100
const result = await bash.execute(
{
command: `seq 1 ${lineCount}`,
description: "Generate lines for file check",
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
const filepath = (result.metadata as any).outputPath
expect(filepath).toBeTruthy()
const saved = await Bun.file(filepath).text()
const lines = saved.trim().split("\n")
expect(lines.length).toBe(lineCount)
expect(lines[0]).toBe("1")
expect(lines[lineCount - 1]).toBe(String(lineCount))
},
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,6 @@ import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ctx = {
sessionID: "test",
messageID: "",
@@ -167,123 +165,3 @@ describe("tool.read env file blocking", () => {
})
})
})
describe("tool.read truncation", () => {
test("truncates large file by bytes and sets truncated metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
await Bun.write(path.join(dir, "large.json"), content)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output truncated at")
expect(result.output).toContain("bytes")
},
})
})
test("truncates by line count when limit is specified", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
await Bun.write(path.join(dir, "many-lines.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("File has more lines")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
},
})
})
test("does not truncate small file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "small.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
},
})
})
test("respects offset parameter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
await Bun.write(path.join(dir, "offset.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
},
})
})
test("truncates long lines", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const longLine = "x".repeat(3000)
await Bun.write(path.join(dir, "long-line.txt"), longLine)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
expect(result.output).toContain("...")
expect(result.output.length).toBeLessThan(3000)
},
})
})
test("image files set truncated to false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// 1x1 red PNG
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
await Bun.write(path.join(dir, "image.png"), png)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
},
})
})
})

View File

@@ -1,159 +0,0 @@
import { describe, test, expect, afterAll } from "bun:test"
import { Truncate } from "../../src/tool/truncation"
import { Identifier } from "../../src/id/id"
import fs from "fs/promises"
import path from "path"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
describe("Truncate", () => {
describe("output", () => {
test("truncates large json file by bytes", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = await Truncate.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined()
})
test("returns content unchanged when under limits", async () => {
const content = "line1\nline2\nline3"
const result = await Truncate.output(content)
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
})
test("truncates by line count", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
})
test("truncates by byte count", async () => {
const content = "a".repeat(1000)
const result = await Truncate.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
})
test("truncates from head by default", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9")
})
test("truncates from tail when direction is tail", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
expect(result.content).toContain("line8")
expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0")
})
test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
})
test("large single-line file truncates with byte message", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = await Truncate.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
})
test("writes full output to file when truncated", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_")
const written = await Bun.file(result.outputPath).text()
expect(written).toBe(lines)
})
test("suggests Task tool when agent has task permission", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool")
})
test("omits Task tool hint when agent lacks task permission", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool")
})
test("does not write file when not truncated", async () => {
const content = "short content"
const result = await Truncate.output(content)
expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
})
})
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
let oldFile: string
let recentFile: string
afterAll(async () => {
await fs.unlink(oldFile).catch(() => {})
await fs.unlink(recentFile).catch(() => {})
})
test("deletes files older than 7 days and preserves recent files", async () => {
await fs.mkdir(Truncate.DIR, { recursive: true })
// Create an old file (10 days ago)
const oldTimestamp = Date.now() - 10 * DAY_MS
const oldId = Identifier.create("tool", false, oldTimestamp)
oldFile = path.join(Truncate.DIR, oldId)
await Bun.write(Bun.file(oldFile), "old content")
// Create a recent file (3 days ago)
const recentTimestamp = Date.now() - 3 * DAY_MS
const recentId = Identifier.create("tool", false, recentTimestamp)
recentFile = path.join(Truncate.DIR, recentId)
await Bun.write(Bun.file(recentFile), "recent content")
await Truncate.cleanup()
// Old file should be deleted
expect(await Bun.file(oldFile).exists()).toBe(false)
// Recent file should still exist
expect(await Bun.file(recentFile).exists()).toBe(true)
})
})
})

View File

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

View File

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

View File

@@ -19,11 +19,9 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
}
if (config?.directory) {
const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory
config.headers = {
...config.headers,
"x-opencode-directory": encodedDirectory,
"x-opencode-directory": config.directory,
}
}

View File

@@ -84,11 +84,6 @@ import type {
PtyRemoveResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
QuestionReplyErrors,
QuestionReplyResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -1786,94 +1781,6 @@ export class Permission extends HeyApiClient {
}
}
export class Question extends HeyApiClient {
/**
* List pending questions
*
* Get all pending question requests across all sessions.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
url: "/question",
...options,
...params,
})
}
/**
* Reply to question request
*
* Provide answers to a question request from the AI assistant.
*/
public reply<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
answers?: Array<string>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "body", key: "answers" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionReplyResponses, QuestionReplyErrors, ThrowOnError>({
url: "/question/{requestID}/reply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Reject question request
*
* Reject a question request from the AI assistant.
*/
public reject<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionRejectResponses, QuestionRejectErrors, ThrowOnError>({
url: "/question/{requestID}/reject",
...options,
...params,
})
}
}
export class Command extends HeyApiClient {
/**
* List commands
@@ -3005,8 +2912,6 @@ export class OpencodeClient extends HeyApiClient {
permission = new Permission({ client: this.client })
question = new Question({ client: this.client })
command = new Command({ client: this.client })
provider = new Provider({ client: this.client })

View File

@@ -524,67 +524,6 @@ export type EventSessionCompacted = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 12 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<string>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
@@ -850,9 +789,6 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventFileEdited
| EventTodoUpdated
| EventTuiPromptAppend
@@ -3609,92 +3545,6 @@ export type PermissionListResponses = {
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
export type QuestionListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/question"
}
export type QuestionListResponses = {
/**
* List of pending questions
*/
200: Array<QuestionRequest>
}
export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
export type QuestionReplyData = {
body?: {
answers: Array<string>
}
path: {
requestID: string
}
query?: {
directory?: string
}
url: "/question/{requestID}/reply"
}
export type QuestionReplyErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
export type QuestionReplyResponses = {
/**
* Question answered successfully
*/
200: boolean
}
export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]
export type QuestionRejectData = {
body?: never
path: {
requestID: string
}
query?: {
directory?: string
}
url: "/question/{requestID}/reject"
}
export type QuestionRejectErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
export type QuestionRejectResponses = {
/**
* Question rejected successfully
*/
200: boolean
}
export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
export type CommandListData = {
body?: never
path?: never

View File

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

View File

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

View File

@@ -77,8 +77,7 @@
[data-slot="user-message-text"] {
white-space: pre-wrap;
word-break: break-all;
overflow: hidden;
overflow-x: auto;
background: var(--surface-base);
padding: 8px 12px;
border-radius: 4px;

View File

@@ -1,6 +1,6 @@
import fuzzysort from "fuzzysort"
import { entries, flatMap, groupBy, map, pipe } from "remeda"
import { createEffect, createMemo, createResource, on } from "solid-js"
import { createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createList } from "solid-list"
@@ -86,14 +86,9 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
}
}
createEffect(
on(grouped, () => {
reset()
}),
)
const onInput = (value: string) => {
setStore("filter", value)
reset()
}
return {

View File

@@ -372,17 +372,3 @@ input:where([type="button"], [type="reset"], [type="submit"]),
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
/*
Prevent iOS Safari from auto-zooming on input focus.
iOS WebKit zooms on any input with font-size < 16px as an accessibility feature.
*/
@media (hover: none) and (pointer: coarse) {
input,
select,
textarea,
[contenteditable="true"] {
font-size: 16px !important;
}
}

View File

@@ -9,7 +9,6 @@ import catppuccinThemeJson from "./themes/catppuccin.json"
import ayuThemeJson from "./themes/ayu.json"
import oneDarkProThemeJson from "./themes/onedarkpro.json"
import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json"
import nightowlThemeJson from "./themes/nightowl.json"
export const oc1Theme = oc1ThemeJson as DesktopTheme
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
@@ -21,7 +20,6 @@ export const catppuccinTheme = catppuccinThemeJson as DesktopTheme
export const ayuTheme = ayuThemeJson as DesktopTheme
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme
export const nightowlTheme = nightowlThemeJson as DesktopTheme
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
"oc-1": oc1Theme,
@@ -34,5 +32,4 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
ayu: ayuTheme,
onedarkpro: oneDarkProTheme,
shadesofpurple: shadesOfPurpleTheme,
nightowl: nightowlTheme,
}

View File

@@ -41,5 +41,4 @@ export {
ayuTheme,
oneDarkProTheme,
shadesOfPurpleTheme,
nightowlTheme,
} from "./default-themes"

View File

@@ -1,131 +0,0 @@
{
"$schema": "https://opencode.ai/desktop-theme.json",
"name": "Night Owl",
"id": "nightowl",
"light": {
"seeds": {
"neutral": "#f0f0f0",
"primary": "#4876d6",
"success": "#2aa298",
"warning": "#c96765",
"error": "#de3d3b",
"info": "#4876d6",
"interactive": "#4876d6",
"diffAdd": "#2aa298",
"diffDelete": "#de3d3b"
},
"overrides": {
"background-base": "#fbfbfb",
"background-weak": "#f0f0f0",
"background-strong": "#ffffff",
"background-stronger": "#ffffff",
"border-weak-base": "#d9d9d9",
"border-weak-hover": "#cccccc",
"border-weak-active": "#bfbfbf",
"border-weak-selected": "#4876d6",
"border-weak-disabled": "#e6e6e6",
"border-weak-focus": "#4876d6",
"border-base": "#c0c0c0",
"border-hover": "#b3b3b3",
"border-active": "#a6a6a6",
"border-selected": "#4876d6",
"border-disabled": "#d9d9d9",
"border-focus": "#4876d6",
"border-strong-base": "#90a7b2",
"border-strong-hover": "#7d9aa6",
"border-strong-active": "#6a8d9a",
"border-strong-selected": "#4876d6",
"border-strong-disabled": "#c0c0c0",
"border-strong-focus": "#4876d6",
"surface-diff-add-base": "#eaf8f6",
"surface-diff-delete-base": "#fbe9e9",
"surface-diff-hidden-base": "#e8f0fc",
"text-base": "#403f53",
"text-weak": "#7a8181",
"text-strong": "#1a1a1a",
"syntax-string": "#c96765",
"syntax-primitive": "#aa0982",
"syntax-property": "#4876d6",
"syntax-type": "#994cc3",
"syntax-constant": "#2aa298",
"syntax-info": "#4876d6",
"markdown-heading": "#4876d6",
"markdown-text": "#403f53",
"markdown-link": "#4876d6",
"markdown-link-text": "#2aa298",
"markdown-code": "#2aa298",
"markdown-block-quote": "#7a8181",
"markdown-emph": "#994cc3",
"markdown-strong": "#c96765",
"markdown-horizontal-rule": "#90a7b2",
"markdown-list-item": "#4876d6",
"markdown-list-enumeration": "#2aa298",
"markdown-image": "#4876d6",
"markdown-image-text": "#2aa298",
"markdown-code-block": "#403f53"
}
},
"dark": {
"seeds": {
"neutral": "#011627",
"primary": "#82aaff",
"success": "#c5e478",
"warning": "#ecc48d",
"error": "#ef5350",
"info": "#82aaff",
"interactive": "#82aaff",
"diffAdd": "#c5e478",
"diffDelete": "#ef5350"
},
"overrides": {
"background-base": "#011627",
"background-weak": "#0b253a",
"background-strong": "#001122",
"background-stronger": "#000c17",
"border-weak-base": "#1d3b53",
"border-weak-hover": "#234561",
"border-weak-active": "#2a506f",
"border-weak-selected": "#82aaff",
"border-weak-disabled": "#0f2132",
"border-weak-focus": "#82aaff",
"border-base": "#3a5a75",
"border-hover": "#456785",
"border-active": "#507494",
"border-selected": "#82aaff",
"border-disabled": "#1a3347",
"border-focus": "#82aaff",
"border-strong-base": "#5f7e97",
"border-strong-hover": "#6e8da6",
"border-strong-active": "#7d9cb5",
"border-strong-selected": "#82aaff",
"border-strong-disabled": "#2c4a63",
"border-strong-focus": "#82aaff",
"surface-diff-add-base": "#0a2e1a",
"surface-diff-delete-base": "#2d1b1b",
"surface-diff-hidden-base": "#0b253a",
"text-base": "#d6deeb",
"text-weak": "#5f7e97",
"text-strong": "#ffffff",
"syntax-string": "#ecc48d",
"syntax-primitive": "#f78c6c",
"syntax-property": "#82aaff",
"syntax-type": "#c5e478",
"syntax-constant": "#7fdbca",
"syntax-info": "#82aaff",
"markdown-heading": "#82aaff",
"markdown-text": "#d6deeb",
"markdown-link": "#82aaff",
"markdown-link-text": "#7fdbca",
"markdown-code": "#c5e478",
"markdown-block-quote": "#5f7e97",
"markdown-emph": "#c792ea",
"markdown-strong": "#ecc48d",
"markdown-horizontal-rule": "#5f7e97",
"markdown-list-item": "#82aaff",
"markdown-list-enumeration": "#7fdbca",
"markdown-image": "#82aaff",
"markdown-image-text": "#7fdbca",
"markdown-code-block": "#d6deeb"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.6",
"version": "1.1.4",
"private": true,
"type": "module",
"license": "MIT",

View File

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

View File

@@ -32,74 +32,21 @@ different order of precedence.
Configuration files are **merged together**, not replaced.
:::
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
---
### Precedence order
Config sources are loaded in this order (later sources override earlier ones):
1. **Remote config** (from `.well-known/opencode`) - organizational defaults
2. **Global config** (`~/.config/opencode/opencode.json`) - user preferences
3. **Custom config** (`OPENCODE_CONFIG` env var) - custom overrides
4. **Project config** (`opencode.json` in project) - project-specific settings
5. **`.opencode` directories** - agents, commands, plugins
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
This means project configs can override global defaults, and global configs can override remote organizational defaults.
---
### Remote
Organizations can provide default configuration via the `.well-known/opencode` endpoint. This is fetched automatically when you authenticate with a provider that supports it.
Remote config is loaded first, serving as the base layer. All other config sources (global, project) can override these defaults.
For example, if your organization provides MCP servers that are disabled by default:
```json title="Remote config from .well-known/opencode"
{
"mcp": {
"jira": {
"type": "remote",
"url": "https://jira.example.com/mcp",
"enabled": false
}
}
}
```
You can enable specific servers in your local config:
```json title="opencode.json"
{
"mcp": {
"jira": {
"type": "remote",
"url": "https://jira.example.com/mcp",
"enabled": true
}
}
}
```
---
### Global
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
Global config overrides remote organizational defaults.
Place your global OpenCode config in `~/.config/opencode/opencode.json`. You'll want to use the global config for things like themes, providers, or keybinds.
---
### Per project
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
You can also add a `opencode.json` in your project. Settings from this config are merged with and can override the global config. This is useful for configuring providers or modes specific to your project.
:::tip
Place project specific config in the root of your project.
@@ -113,20 +60,20 @@ This is also safe to be checked into Git and uses the same schema as the global
### Custom path
Specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
```bash
export OPENCODE_CONFIG=/path/to/my/custom-config.json
opencode run "Hello world"
```
Custom config is loaded between global and project configs in the precedence order.
Settings from this config are merged with and **can override** the global and project configs.
---
### Custom directory
Specify a custom config directory using the `OPENCODE_CONFIG_DIR`
You can specify a custom config directory using the `OPENCODE_CONFIG_DIR`
environment variable. This directory will be searched for agents, commands,
modes, and plugins just like the standard `.opencode` directory, and should
follow the same structure.

View File

@@ -76,11 +76,9 @@ You can also install it with the following commands:
- **Using Homebrew on macOS and Linux**
```bash
brew install anomalyco/tap/opencode
brew install opencode
```
> We recommend using the OpenCode tap for the most up to date releases. The official `brew install opencode` formula is maintained by the Homebrew team and is updated less frequently.
- **Using Paru on Arch Linux**
```bash

View File

@@ -44,29 +44,6 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
---
### Overriding remote defaults
Organizations can provide default MCP servers via their `.well-known/opencode` endpoint. These servers may be disabled by default, allowing users to opt-in to the ones they need.
To enable a specific server from your organization's remote config, add it to your local config with `enabled: true`:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"jira": {
"type": "remote",
"url": "https://jira.example.com/mcp",
"enabled": true
}
}
}
```
Your local config values override the remote defaults. See [config precedence](/docs/config#precedence-order) for more details.
---
## Local
Add local MCP servers using `type` to `"local"` within the MCP object.

View File

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