Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad
6e22664485 fix(opencode): resolve npmrc with @npmcli/config 2026-04-09 10:43:44 -04:00
111 changed files with 2050 additions and 2254 deletions

View File

@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -223,8 +223,14 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
@@ -232,41 +238,24 @@
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
"marked": "^15",
"solid-js": "catalog:",
"tree-kill": "^1.2.2",
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"typescript": "~5.6.2",
"vite": "catalog:",
"zod-openapi": "5.4.6",
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -295,7 +284,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -311,7 +300,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.1",
"version": "1.3.17",
"bin": {
"opencode": "./bin/opencode",
},
@@ -347,9 +336,10 @@
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:",
"@lydell/node-pty": "1.2.0-beta.10",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -378,7 +368,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.0.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -439,7 +429,6 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"openapi-types": "12.1.3",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -448,7 +437,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -482,7 +471,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -497,7 +486,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -532,7 +521,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -581,7 +570,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"zod": "catalog:",
},
@@ -592,7 +581,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.1",
"version": "1.3.17",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -633,10 +622,8 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"ai@6.0.149": "patches/ai@6.0.149.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"hono-openapi@1.1.2": "patches/hono-openapi@1.1.2.patch",
},
"overrides": {
"@types/bun": "catalog:",
@@ -647,7 +634,6 @@
"@effect/platform-node": "4.0.0-beta.43",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18",
@@ -1438,6 +1424,8 @@
"@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
"@npmcli/config": ["@npmcli/config@10.8.0", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-YkhoXZQU7zxyGi3V7J0zdK2pghzF9YXHiRdpRX8QNhsefk/zAJZJjRsbbw1hD67hlMp2gSygUGgW4y7FlrUThw=="],
"@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
"@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],
@@ -2328,8 +2316,6 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3182,7 +3168,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4594,8 +4580,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -4674,6 +4658,8 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
@@ -4828,8 +4814,6 @@
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -4986,8 +4970,6 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
"aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
"aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
"x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
"x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=",
"aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=",
"aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=",
"x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg="
}
}

View File

@@ -71,8 +71,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
"vite-plugin-solid": "2.11.10"
}
},
"devDependencies": {
@@ -120,8 +119,6 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"hono-openapi@1.1.2": "patches/hono-openapi@1.1.2.patch",
"ai@6.0.149": "patches/ai@6.0.149.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}

View File

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

View File

@@ -174,7 +174,6 @@ export const Terminal = (props: TerminalProps) => {
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -520,12 +519,8 @@ export const Terminal = (props: TerminalProps) => {
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
next.username = username
next.password = password
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"

View File

@@ -14,7 +14,6 @@ import type {
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -163,7 +162,7 @@ export function applyDirectoryEvent(input: {
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break
}
case "todo.updated": {
@@ -178,7 +177,7 @@ export function applyDirectoryEvent(input: {
break
}
case "message.updated": {
const info = clean((event.properties as { info: Message }).info)
const info = (event.properties as { info: Message }).info
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])

View File

@@ -13,7 +13,6 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -301,7 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
@@ -510,7 +509,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
},

View File

@@ -58,7 +58,6 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
@@ -431,7 +430,7 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
@@ -612,7 +611,7 @@ export default function Page() {
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
@@ -650,7 +649,7 @@ export default function Page() {
return open
}, desktopReviewOpen())
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
@@ -670,11 +669,15 @@ export default function Page() {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
return turnDiffs()
})
const reviewCount = createMemo(() => reviewDiffs().length)
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git

View File

@@ -1,74 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
const item = {
file: "src/app.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
expect(diffs([item])).toEqual([item])
})
test("wraps a single diff object", () => {
expect(diffs(item)).toEqual([item])
})
test("reads keyed diff objects", () => {
expect(diffs({ a: item })).toEqual([item])
})
test("drops invalid entries", () => {
expect(
diffs([
item,
{ file: "src/bad.ts", additions: 1, deletions: 1 },
{ patch: item.patch, additions: 1, deletions: 1 },
]),
).toEqual([item])
})
})
describe("message", () => {
test("normalizes user summaries with object diffs", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: {
title: "Edit",
diffs: { a: item },
},
} as unknown as Message
expect(message(input)).toMatchObject({
summary: {
title: "Edit",
diffs: [item],
},
})
})
test("drops invalid user summaries", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: true,
} as unknown as Message
expect(message(input)).toMatchObject({ summary: undefined })
})
})

View File

@@ -1,49 +0,0 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
if (!("additions" in value) || typeof value.additions !== "number") return false
if (!("deletions" in value) || typeof value.deletions !== "number") return false
if (!("status" in value) || value.status === undefined) return true
return value.status === "added" || value.status === "deleted" || value.status === "modified"
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): Diff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]
if (!object(value)) return []
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
if (value.role !== "user") return value
const raw = value.summary as unknown
if (raw === undefined) return value
if (!object(raw)) return { ...value, summary: undefined }
const title = typeof raw.title === "string" ? raw.title : undefined
const body = typeof raw.body === "string" ? raw.body : undefined
const next = diffs(raw.diffs)
if (title === raw.title && body === raw.body && next === raw.diffs) return value
return {
...value,
summary: {
...(title === undefined ? {} : { title }),
...(body === undefined ? {} : { body }),
diffs: next,
},
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,11 @@ const getBase = (): Configuration => ({
},
files: ["out/**/*", "resources/**/*"],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
{
from: "native/",
to: "native/",

View File

@@ -1,6 +1,5 @@
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -8,10 +7,6 @@ const channel = (() => {
return "dev"
})()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
export default defineConfig({
main: {
define: {
@@ -21,33 +16,7 @@ export default defineConfig({
rollupOptions: {
input: { index: "src/main/index.ts" },
},
externalizeDeps: { include: [nodePtyPkg] },
},
plugins: [
{
name: "opencode:node-pty-narrower",
enforce: "pre",
resolveId(s) {
if (s === "@lydell/node-pty") return nodePtyPkg
},
},
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
async writeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (!l.endsWith(".wasm")) continue
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
},
preload: {
build: {

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.1",
"version": "1.3.17",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -13,7 +13,7 @@
"typecheck": "tsgo -b",
"predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev",
"prebuild": "bun ./scripts/prebuild.ts",
"prebuild": "bun ./scripts/copy-icons.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "electron-builder --config electron-builder.config.ts",
@@ -24,42 +24,31 @@
},
"main": "./out/main/index.js",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
"marked": "^15"
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
"marked": "^15",
"solid-js": "catalog:",
"tree-kill": "^1.2.2"
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"typescript": "~5.6.2",
"vite": "catalog:",
"zod-openapi": "5.4.6"
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
"@lydell/node-pty-win32-x64": "1.2.0-beta.10"
"vite": "catalog:"
}
}

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`

View File

@@ -1,5 +1,17 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts`
const RUST_TARGET = Bun.env.RUST_TARGET
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

View File

@@ -1,9 +1,25 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
await import("./prebuild")
import { Script } from "@opencode-ai/script"
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
const pkg = await Bun.file("./package.json").json()
pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await $`rm -rf ${dir}`

View File

@@ -0,0 +1,283 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const user = shell || getUserShell()
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}

View File

@@ -5,25 +5,3 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -11,8 +11,6 @@ import pkg from "electron-updater"
import contextMenu from "electron-context-menu"
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
@@ -29,6 +27,8 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
@@ -36,13 +36,12 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -97,9 +96,11 @@ function setupApp() {
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
setDockIcon()
setupAutoUpdater()
syncCli()
await initialize()
})
}
@@ -133,8 +134,8 @@ async function initialize() {
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
serverReady.resolve({
url,
username: "opencode",
@@ -144,7 +145,7 @@ async function initialize() {
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@@ -197,6 +198,9 @@ function wireMenu() {
if (!mainWindow) return
createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => {
void checkForUpdates(true)
},
@@ -211,6 +215,7 @@ function wireMenu() {
registerIpcHandlers({
killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -242,9 +247,16 @@ registerIpcHandlers({
})
function killSidecar() {
if (!server) return
server.stop()
server = null
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
}
function ensureLoopbackNoProxy() {

View File

@@ -13,6 +13,7 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -33,6 +34,7 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)

View File

@@ -5,6 +5,7 @@ import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void
reload: () => void
relaunch: () => void
@@ -23,6 +24,10 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(),
},
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{
label: "Reload Webview",
click: () => deps.reload(),

View File

@@ -1,6 +1,5 @@
import { app } from "electron"
import { serve, type CommandChild } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store"
export type WslConfig = { enabled: boolean }
@@ -30,14 +29,8 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN", print: true })
const listener = await Server.listen({
port,
hostname,
})
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
const wait = (async () => {
const url = `http://${hostname}:${port}`
@@ -49,26 +42,19 @@ export async function spawnLocalServer(hostname: string, port: number, password:
}
}
await ready()
const terminated = async () => {
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
})()
return { listener, health: { wait } }
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: app.getPath("userData"),
}
Object.assign(process.env, env)
return { child, health: { wait }, events }
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -96,3 +82,5 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false
}
}
export type { CommandChild }

View File

@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process"
import { basename } from "node:path"
const TIMEOUT = 5_000
const SHELL_ENV_TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
@@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) {
return env
}
function probe(shell: string, mode: "-il" | "-l"): Probe {
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"],
timeout: TIMEOUT,
timeout: SHELL_ENV_TIMEOUT,
windowsHide: true,
})
const err = out.error as NodeJS.ErrnoException | undefined
if (err) {
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" }
}
if (out.status !== 0) {
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" }
}
const env = parseShellEnv(out.stdout)
if (Object.keys(env).length === 0) {
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" }
}
@@ -56,27 +56,27 @@ export function isNushell(shell: string) {
export function loadShellEnv(shell: string) {
if (isNushell(shell)) {
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probe(shell, "-il")
const interactive = probeShellEnv(shell, "-il")
if (interactive.type === "Loaded") {
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probe(shell, "-l")
const login = probeShellEnv(shell, "-l")
if (login.type === "Loaded") {
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
console.warn(`[server] Falling back to app environment: ${shell}`)
console.warn(`[cli] Falling back to app environment: ${shell}`)
return null
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,4 +2,5 @@ research
dist
gen
app.log
src/provider/models-snapshot.ts
src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.1",
"version": "1.3.17",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -69,7 +69,6 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"openapi-types": "12.1.3",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -107,9 +106,10 @@
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:",
"@lydell/node-pty": "1.2.0-beta.10",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -138,7 +138,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.0.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { Script } from "@opencode-ai/script"
import fs from "fs"
import path from "path"
@@ -8,6 +9,15 @@ import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
const root = path.resolve(dir, "../..")
function linker(): "hoisted" | "isolated" {
// jsonc-parser is only declared in packages/opencode, so its install location
// tells us whether Bun used a hoisted or isolated workspace layout.
if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated"
if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted"
throw new Error("Could not detect Bun linker from jsonc-parser")
}
process.chdir(dir)
@@ -41,20 +51,21 @@ const migrations = await Promise.all(
)
console.log(`Loaded ${migrations.length} migrations`)
const link = linker()
await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/node-pty@1.2.0-beta.10`
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist/node",
outdir: "./dist",
format: "esm",
sourcemap: "linked",
external: ["jsonc-parser", "@lydell/node-pty"],
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
files: {
"opencode-web-ui.gen.ts": "",
},
})
console.log("Build complete")

View File

@@ -21,8 +21,12 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as Record<string, unknown>\n`,
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")

View File

@@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
@@ -75,7 +74,7 @@ const MigrateCommand = cmd({
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
const stats = await JsonMigration.run(sqlite, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return

View File

@@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
@@ -680,7 +680,7 @@ export const RunCommand = cmd({
await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.Default().app.fetch(request)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk)

View File

@@ -1,99 +0,0 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
const GO_URL = "https://opencode.ai/go"
export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void
}
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
open(GO_URL).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0)
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0))
return
}
if (evt.name !== "return") return
if (selected() === 0) subscribe(props, dialog)
else dismiss(props, dialog)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1} paddingBottom={1}>
<text fg={theme.textMuted}>
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
$5/month.
</text>
<box flexDirection="row" gap={1}>
<Link href={GO_URL} fg={theme.primary} />
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(0)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
</box>
</box>
)
}
DialogGoUpsell.show = (dialog: DialogContext) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})
}

View File

@@ -8,6 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() {
const sync = useSync()
@@ -46,7 +47,11 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -84,7 +89,9 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
@@ -135,7 +142,7 @@ export function DialogModel(props: { providerID?: string }) {
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return value.name
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
})
function onSelect(providerID: string, modelID: string) {

View File

@@ -13,7 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -49,7 +49,11 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected ? <text fg={theme.success}></text> : undefined,
gutter: consoleManaged ? (
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
async onSelect() {
if (consoleManaged) return

View File

@@ -36,6 +36,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = {
sessionID?: string
@@ -95,8 +96,15 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1)
const currentProviderLabel = createMemo(() => {
const current = local.model.current()
const provider = local.model.parsed().provider
if (!current) return provider
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
})
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
function promptModelWarning() {
toast.show({
@@ -1112,6 +1120,17 @@ export function Prompt(props: PromptProps) {
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={activeOrgName()}>
<text
fg={theme.textMuted}
onMouseUp={() => {
if (!canSwitchOrgs()) return
command.trigger("console.org.switch")
}}
>
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
</text>
</Show>
</box>
</Show>
</box>
@@ -1143,7 +1162,7 @@ export function Prompt(props: PromptProps) {
}
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"

View File

@@ -83,15 +83,9 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
addDefaultParsers(parsers.parsers)
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const context = createContext<{
width: number
sessionID: string
@@ -224,23 +218,6 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
sdk.event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
if (dialog.stack.length > 0) return
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
if (kv.get(GO_UPSELL_DONT_SHOW)) return
DialogGoUpsell.show(dialog).then((dontShowAgain) => {
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
})
})
// Allow exit when in child session (prompt is hidden)
const exit = useExit()

View File

@@ -1,3 +1,5 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
@@ -5,3 +7,14 @@ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, provi
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)
export const consoleManagedProviderSuffix = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
export const consoleManagedProviderLabel = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
providerName: string,
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`

View File

@@ -138,7 +138,7 @@ export const rpc = {
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const response = await Server.Default().fetch(request)
const body = await response.text()
return {
status: response.status,

View File

@@ -786,81 +786,28 @@ export namespace Config {
})
export type Layout = z.infer<typeof Layout>
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
export const Provider = ModelsDev.Provider.partial()
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z
.record(
z.string(),
ModelsDev.Model.partial().extend({
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
}),
)
.optional(),
options: z
.object({
apiKey: z.string().optional(),
@@ -893,14 +840,11 @@ export namespace Config {
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model).optional(),
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export type Provider = z.infer<typeof Provider>
export const Info = z

View File

@@ -46,7 +46,7 @@ export namespace FileTime {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,

View File

@@ -36,7 +36,6 @@ import { Database } from "./storage/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -120,7 +119,7 @@ const cli = yargs(args)
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
await JsonMigration.run(Database.Client().$client, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return

View File

@@ -245,7 +245,7 @@ export namespace LSP {
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
log.info("spawned lsp server", { serverID: server.id })
const client = await LSPClient.create({
serverID: server.id,

View File

@@ -867,7 +867,7 @@ export namespace LSPServer {
export const Clangd: Info = {
id: "clangd",
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
const args = ["--background-index", "--clang-tidy"]

View File

@@ -501,7 +501,7 @@ export namespace MCP {
return
}
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result) return
s.status[key] = result.status

View File

@@ -1,6 +1 @@
export { Config } from "./config/config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util/log"
export { Database } from "./storage/db"
export { JsonMigration } from "./storage/json-migration"

View File

@@ -0,0 +1,91 @@
import { createRequire } from "module"
import path from "path"
import Config from "@npmcli/config"
import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Global } from "@/global"
export namespace NpmConfig {
type Data = Record<string, unknown>
type Where = "project" | "user" | "global"
export interface Interface {
readonly config: (dir: string) => Effect.Effect<Data, Error>
readonly paths: (dir: string) => Effect.Effect<string[], Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/NpmConfig") {}
const require = createRequire(import.meta.url)
const npmPath = (() => {
try {
return path.dirname(require.resolve("npm/package.json"))
} catch {
return path.join(Global.Path.cache, "npm")
}
})()
function source(conf: Config, where: Where) {
return conf.data.get(where)?.source
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const load = Effect.fnUntraced(function* (dir: string) {
const conf = new Config({
argv: [],
cwd: AppFileSystem.resolve(dir),
definitions,
env: { ...process.env },
execPath: process.execPath,
flatten,
npmPath,
platform: process.platform,
shorthands,
warn: false,
})
yield* Effect.tryPromise({
try: () => conf.load(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
return conf
})
const config = Effect.fn("NpmConfig.config")(function* (dir: string) {
return (yield* load(dir)).flat as Data
})
const paths = Effect.fn("NpmConfig.paths")(function* (dir: string) {
const conf = yield* load(dir)
const list = yield* Effect.forEach(["project", "user", "global"] as const, (where) =>
Effect.gen(function* () {
const file = source(conf, where)
if (!file || !path.isAbsolute(file)) return
const resolved = AppFileSystem.resolve(file)
if (!(yield* fs.existsSafe(resolved))) return
return resolved
}),
)
return list.filter((item): item is string => item !== undefined)
})
return Service.of({ config, paths })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function config(dir: string) {
return runPromise((svc) => svc.config(dir))
}
export async function paths(dir: string) {
return runPromise((svc) => svc.paths(dir))
}
}

View File

@@ -1,13 +1,17 @@
import path from "path"
import semver from "semver"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { NamedError } from "@opencode-ai/util/error"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { Arborist } from "@npmcli/arborist"
import { NpmConfig } from "./config"
import { withTransientReadRetry } from "@/util/effect-http-client"
export namespace Npm {
const log = Log.create({ service: "npm" })
@@ -20,6 +24,37 @@ export namespace Npm {
}),
)
export interface Interface {
readonly add: (
pkg: string,
) => Effect.Effect<
{ directory: string; entrypoint: string | undefined },
Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>
>
readonly install: (dir: string) => Effect.Effect<void, Error | AppFileSystem.Error>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (
pkg: string,
) => Effect.Effect<string | undefined, Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>>
}
type Pkg = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
type Lock = {
packages?: Record<string, Pkg>
}
type Bin = {
bin?: string | Record<string, string>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Npm") {}
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
@@ -34,155 +69,217 @@ export namespace Npm {
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {}
const result = {
return {
directory: dir,
entrypoint,
}
return result
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cfg = yield* NpmConfig.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
return resolveEntryPoint(first.name, first.path)
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
const create = Effect.fnUntraced(function* (dir: string) {
return new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
...(yield* cfg.config(dir)),
})
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
const lock = <A, E>(key: string, body: Effect.Effect<A, E>) =>
Effect.scoped(
Effect.gen(function* () {
yield* Effect.acquireRelease(Effect.promise(() => Flock.acquire(key)).pipe(Effect.orDie), (lease) =>
Effect.promise(() => lease.release()).pipe(Effect.orDie),
)
return yield* body
}),
)
const readPkg = <A>(file: string, fallback: A) =>
fs.readJson(file).pipe(
Effect.catch(() => Effect.succeed(fallback)),
Effect.map((value) => value as A),
)
const reify = Effect.fnUntraced(function* (dir: string) {
const arb = yield* create(dir)
yield* Effect.promise(() => arb.reify()).pipe(Effect.catch(() => Effect.void))
})
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const url = `https://registry.npmjs.org/${pkg}`
const data = yield* HttpClientRequest.get(url).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap((res) => res.json),
Effect.catch(() =>
Effect.sync(() => {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return undefined
}),
),
)
const latestVersion =
data &&
typeof data === "object" &&
"dist-tags" in data &&
data["dist-tags"] &&
typeof data["dist-tags"] === "object"
? (data["dist-tags"] as { latest?: string }).latest
: undefined
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const key = `npm-install:${AppFileSystem.resolve(dir)}`
return yield* lock(
key,
Effect.gen(function* () {
log.info("installing package", { pkg })
const arb = yield* create(dir)
const tree = yield* Effect.promise(() => arb.loadVirtual()).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
const cached = tree?.edgesOut.values().next().value?.to
if (cached) return resolveEntryPoint(cached.name, cached.path)
const result = yield* Effect.tryPromise({
try: () => arb.reify({ add: [pkg], save: true, saveType: "prod" }),
catch: (cause) =>
new InstallFailedError(
{ pkg },
{
cause,
},
),
})
const first = result.edgesOut.values().next().value?.to
if (!first) return yield* Effect.fail(new InstallFailedError({ pkg }))
return resolveEntryPoint(first.name, first.path)
}),
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return resolveEntryPoint(first.name, first.path)
const install = Effect.fn("Npm.install")(function* (dir: string) {
const key = `npm-install:${dir}`
yield* lock(
key,
Effect.gen(function* () {
log.info("checking dependencies", { dir })
if (!(yield* fs.existsSafe(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
yield* reify(dir)
return
}
const pkg = yield* readPkg<Pkg>(path.join(dir, "package.json"), {})
const lock = yield* readPkg<Lock>(path.join(dir, "package-lock.json"), {})
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (locked.has(name)) continue
log.info("dependency not in lock file, reifying", { name })
yield* reify(dir)
return
}
log.info("dependencies in sync")
}),
)
})
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
const pkgJson = yield* readPkg<Bin | undefined>(
path.join(dir, "node_modules", pkg, "package.json"),
undefined,
)
if (!pkgJson?.bin) return files[0]
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
if (typeof pkgJson.bin === "string") return unscoped
const keys = Object.keys(pkgJson.bin)
if (keys.length === 1) return keys[0]
return pkgJson.bin[unscoped] ? unscoped : keys[0]
})
const bin = yield* pick()
if (bin) return path.join(binDir, bin)
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.catch(() => Effect.void))
yield* add(pkg)
const resolved = yield* pick()
if (!resolved) return
return path.join(binDir, resolved)
})
return Service.of({ add, install, outdated, which })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NpmConfig.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function add(pkg: string) {
return runPromise((svc) => svc.add(pkg))
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
return runPromise((svc) => svc.install(dir))
}
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
export async function outdated(pkg: string, cachedVersion: string) {
return runPromise((svc) => svc.outdated(pkg, cachedVersion))
}
export async function which(pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = async () => {
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) return
return path.join(binDir, resolved)
return runPromise((svc) => svc.which(pkg))
}
}

View File

@@ -0,0 +1,29 @@
declare module "@npmcli/config" {
type Data = Record<string, unknown>
type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli"
export default class Config {
constructor(input: {
argv: string[]
cwd: string
definitions: Data
env: NodeJS.ProcessEnv
execPath: string
flatten: (input: Data, flat?: Data) => Data
npmPath: string
platform: NodeJS.Platform
shorthands: Record<string, string[]>
warn?: boolean
})
readonly data: Map<Where, { source: string | null }>
readonly flat: Data
load(): Promise<void>
}
}
declare module "@npmcli/config/lib/definitions/index.js" {
export const definitions: Record<string, unknown>
export const shorthands: Record<string, string[]>
export const flatten: (input: Record<string, unknown>, flat?: Record<string, unknown>) => Record<string, unknown>
}

View File

@@ -119,7 +119,7 @@ export namespace Plugin {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().app.fetch(...args),
fetch: async (...args) => Server.Default().fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {

View File

@@ -158,7 +158,7 @@ export namespace Project {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.void),
Effect.catch(() => Effect.succeed(undefined)),
)
})

File diff suppressed because one or more lines are too long

View File

@@ -70,7 +70,10 @@ export namespace ModelsDev {
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer<typeof Model>

View File

@@ -574,7 +574,6 @@ export namespace Provider {
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const model = sdk.workflowChat(sdkModelID, {
featureFlags,
workflowDefinition: options?.workflowDefinition as string | undefined,
})
if (workflowRef) {
model.selectedModelRef = workflowRef
@@ -938,8 +937,8 @@ export namespace Provider {
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
headers: {},
options: {},
headers: model.headers ?? {},
options: model.options ?? {},
cost: {
input: model.cost?.input ?? 0,
output: model.cost?.output ?? 0,

View File

@@ -376,8 +376,7 @@ export namespace ProviderTransform {
id.includes("mistral") ||
id.includes("kimi") ||
id.includes("k2p5") ||
id.includes("qwen") ||
id.includes("big-pickle")
id.includes("qwen")
)
return {}

View File

@@ -1,6 +1,5 @@
import { resolver } from "hono-openapi"
import z from "zod"
import "openapi-types"
import { NotFoundError } from "../storage/db"
export const ERRORS = {

View File

@@ -4,7 +4,6 @@ import { proxy } from "hono/proxy"
import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { createHash } from "node:crypto"
import * as fs from "node:fs/promises"
import { Log } from "../util/log"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
@@ -29,7 +28,6 @@ import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { errorHandler } from "./middleware"
import { getMimeType } from "hono/utils/mime"
const log = Log.create({ service: "server" })
@@ -287,14 +285,13 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
c.header("Content-Type", mime)
if (mime.startsWith("text/html")) {
const file = Bun.file(match)
if (await file.exists()) {
c.header("Content-Type", file.type)
if (file.type.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(new Uint8Array(await fs.readFile(match)))
return c.body(await file.arrayBuffer())
} else {
return c.json({ error: "Not Found" }, 404)
}

View File

@@ -1,6 +1,7 @@
import type { Target } from "@/control-plane/types"
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { upgradeWebSocket } from "hono/bun"
const hop = new Set([
"connection",
@@ -52,10 +53,10 @@ function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data:
return ws.send(data)
}
const app = (upgrade: UpgradeWebSocket) =>
const app = lazy(() =>
new Hono().get(
"/__workspace_ws",
upgrade((c) => {
upgradeWebSocket((c) => {
const url = c.req.header("x-opencode-proxy-url")
const queue: Msg[] = []
let remote: WebSocket | undefined
@@ -95,7 +96,8 @@ const app = (upgrade: UpgradeWebSocket) =>
},
}
}),
)
),
)
export namespace ServerProxy {
export function http(target: Extract<Target, { type: "remote" }>, req: Request) {
@@ -110,18 +112,13 @@ export namespace ServerProxy {
)
}
export function websocket(
upgrade: UpgradeWebSocket,
target: Extract<Target, { type: "remote" }>,
req: Request,
env: unknown,
) {
export function websocket(target: Extract<Target, { type: "remote" }>, req: Request, env: unknown) {
const url = new URL(req.url)
url.pathname = "/__workspace_ws"
url.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target.url))
return app(upgrade).fetch(
return app().fetch(
new Request(url, {
method: req.method,
headers: next,

View File

@@ -89,7 +89,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
return ServerProxy.websocket(upgrade, target, c.req.raw, c.env)
return ServerProxy.websocket(target, c.req.raw, c.env)
}
const headers = new Headers(c.req.raw.headers)

View File

@@ -192,9 +192,7 @@ export const ExperimentalRoutes = lazy(() =>
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
parameters: (t.parameters as any)?._def
? (zodToJsonSchema(t.parameters as any) as any)
: (t.parameters as any),
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
})),
)
},
@@ -352,7 +350,7 @@ export const ExperimentalRoutes = lazy(() =>
const hasMore = sessions.length > limit
const list = hasMore ? sessions.slice(0, limit) : sessions
if (hasMore && list.length > 0) {
c.header("x-next-cursor", String(list[list.length - 1]!.time.updated))
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
}
return c.json(list)
},

View File

@@ -28,7 +28,7 @@ export const ProviderRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
all: Provider.Info.array(),
all: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),

View File

@@ -843,17 +843,19 @@ export const SessionRoutes = lazy(() =>
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
})
return c.body(null, 204)
},
)
.post(
@@ -904,7 +906,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.WithParts),
schema: resolver(MessageV2.Assistant),
},
},
},

View File

@@ -2,7 +2,6 @@ import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { compress } from "hono/compress"
import { createNodeWebSocket } from "@hono/node-ws"
import { cors } from "hono/cors"
import { basicAuth } from "hono/basic-auth"
import type { UpgradeWebSocket } from "hono/ws"
@@ -10,6 +9,8 @@ import z from "zod"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { ProviderID } from "../provider/schema"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { WorkspaceRouterMiddleware } from "./router"
import { errors } from "./error"
import { GlobalRoutes } from "./routes/global"
@@ -18,7 +19,6 @@ import { lazy } from "@/util/lazy"
import { errorHandler } from "./middleware"
import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -42,7 +42,7 @@ export namespace Server {
return false
}
export const Default = lazy(() => create({}))
export const Default = lazy(() => create({}).app)
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
return app
@@ -54,9 +54,6 @@ export namespace Server {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {

View File

@@ -253,21 +253,23 @@ When constructing the summary, try to stick to this template:
sessionID: input.sessionID,
model,
})
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
})
const result = yield* processor
.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
})
.pipe(Effect.onInterrupt(() => processor.abort()))
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({

View File

@@ -1,6 +1,7 @@
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import { Effect, Layer, Record, ServiceMap } from "effect"
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
import * as Queue from "effect/Queue"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda"
@@ -14,10 +15,6 @@ import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Bus } from "@/bus"
import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
@@ -234,7 +231,6 @@ export namespace LLM {
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
@@ -257,57 +253,6 @@ export namespace LLM {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await Permission.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
})
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
return streamText({

View File

@@ -573,12 +573,6 @@ export namespace MessageV2 {
}))
}
function providerMeta(metadata: Record<string, any> | undefined) {
if (!metadata) return undefined
const { providerExecuted: _, ...rest } = metadata
return Object.keys(rest).length > 0 ? rest : undefined
}
export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
@@ -747,8 +741,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
}
if (part.state.status === "error")
@@ -758,9 +751,10 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
@@ -768,8 +762,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
}
if (part.type === "reasoning") {

View File

@@ -30,6 +30,7 @@ export namespace SessionProcessor {
export interface Handle {
readonly message: MessageV2.Assistant
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
readonly abort: () => Effect.Effect<void>
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
}
@@ -161,7 +162,6 @@ export namespace SessionProcessor {
tool: value.toolName,
callID: value.id,
state: { status: "pending", input: {}, raw: "" },
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
return
@@ -181,9 +181,7 @@ export namespace SessionProcessor {
...match,
tool: value.toolName,
state: { status: "running", input: value.input, time: { start: Date.now() } },
metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata,
metadata: value.providerMetadata,
} satisfies MessageV2.ToolPart)
const parts = MessageV2.parts(ctx.assistantMessage.id)
@@ -431,6 +429,19 @@ export namespace SessionProcessor {
yield* status.set(ctx.sessionID, { type: "idle" })
})
const abort = Effect.fn("SessionProcessor.abort")(() =>
Effect.gen(function* () {
if (!ctx.assistantMessage.error) {
yield* halt(new DOMException("Aborted", "AbortError"))
}
if (!ctx.assistantMessage.time.completed) {
yield* cleanup()
return
}
yield* session.updateMessage(ctx.assistantMessage)
}),
)
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
log.info("process")
ctx.needsCompaction = false
@@ -448,14 +459,7 @@ export namespace SessionProcessor {
Stream.runDrain,
)
}).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
aborted = true
if (!ctx.assistantMessage.error) {
yield* halt(new DOMException("Aborted", "AbortError"))
}
}),
),
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.fail(Cause.squash(cause)),
@@ -476,10 +480,13 @@ export namespace SessionProcessor {
Effect.ensuring(cleanup()),
)
if (aborted && !ctx.assistantMessage.error) {
yield* abort()
}
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
return "continue"
})
}).pipe(Effect.onInterrupt(() => abort().pipe(Effect.asVoid)))
})
return {
@@ -489,6 +496,7 @@ export namespace SessionProcessor {
partFromToolCall(toolCallID: string) {
return ctx.toolcalls[toolCallID]
},
abort,
process,
} satisfies Handle
})

View File

@@ -559,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const { task: taskTool } = yield* registry.named()
const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -600,11 +600,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
subagent_type: task.agent,
command: task.command,
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: TaskTool.id, sessionID, callID: part.id },
{ args: taskArgs },
)
yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs })
const taskAgent = yield* agents.get(task.agent)
if (!taskAgent) {
@@ -683,7 +679,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger(
"tool.execute.after",
{ tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
result,
)
@@ -964,7 +960,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
const full =
!input.variant && ag.variant && same
? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
? yield* provider
.getModel(model.providerID, model.modelID)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
@@ -984,7 +982,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
format: input.format,
}
yield* Effect.addFinalizer(() => instruction.clear(info.id))
yield* Effect.addFinalizer(() =>
InstanceState.withALS(() => instruction.clear(info.id)).pipe(Effect.flatMap((x) => x)),
)
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
@@ -1076,21 +1076,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const filepath = fileURLToPath(part.url)
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
Effect.promise((signal: AbortSignal) =>
read.execute(args, {
sessionID: input.sessionID,
abort: signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, ...extra },
messages: [],
metadata: async () => {},
ask: async () => {},
}),
)
if (part.mime === "text/plain") {
let offset: number | undefined
let limit: number | undefined
@@ -1127,12 +1112,29 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) => execRead(args, { model: mdl })),
const read = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
Effect.promise(() =>
t.execute(args, {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model: mdl },
messages: [],
metadata: async () => {},
ask: async () => {},
}),
),
),
),
),
Effect.exit,
)
if (Exit.isSuccess(exit)) {
const result = exit.value
if (Exit.isSuccess(read)) {
const result = read.value
pieces.push({
messageID: info.id,
sessionID: input.sessionID,
@@ -1154,7 +1156,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
}
} else {
const error = Cause.squash(exit.cause)
const error = Cause.squash(read.cause)
log.error("failed to read file", { error })
const message = error instanceof Error ? error.message : String(error)
yield* bus.publish(Session.Event.Error, {
@@ -1174,25 +1176,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
log.error("failed to read directory", { error })
const message = error instanceof Error ? error.message : String(error)
yield* bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({ message }).toObject(),
})
return [
{
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `Read tool failed to read ${filepath} with the following error: ${message}`,
},
]
}
const result = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
messages: [],
metadata: async () => {},
ask: async () => {},
}),
),
),
)
return [
{
messageID: info.id,
@@ -1206,7 +1205,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: exit.value.output,
text: result.output,
},
{ ...part, messageID: info.id, sessionID: input.sessionID },
]
@@ -1371,10 +1370,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
)
// Some providers return "stop" even when the assistant message contains tool calls.
// Keep the loop running so tool results can be sent back to the model.
// Skip provider-executed tool parts — those were fully handled within the
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
const hasToolCalls =
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false
if (
lastAssistant?.finish &&
@@ -1458,104 +1454,110 @@ NOTE: At any point in time through this workflow you should feel free to ask the
model,
})
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const outcome: "break" | "continue" = yield* Effect.onExit(
Effect.gen(function* () {
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const tools = yield* resolveTools({
agent,
session,
model,
tools: lastUser.tools,
processor: handle,
bypassAgentCheck,
messages: msgs,
})
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: lastUser.format.schema,
onSuccess(output) {
structured = output
},
const tools = yield* resolveTools({
agent,
session,
model,
tools: lastUser.tools,
processor: handle,
bypassAgentCheck,
messages: msgs,
})
}
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: lastUser.format.schema,
onSuccess(output) {
structured = output
},
})
}
if (step > 1 && lastFinished) {
for (const m of msgs) {
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
for (const p of m.parts) {
if (p.type !== "text" || p.ignored || p.synthetic) continue
if (!p.text.trim()) continue
p.text = [
"<system-reminder>",
"The user sent the following message:",
p.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
if (step > 1 && lastFinished) {
for (const m of msgs) {
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
for (const p of m.parts) {
if (p.type !== "text" || p.ignored || p.synthetic) continue
if (!p.text.trim()) continue
p.text = [
"<system-reminder>",
"The user sent the following message:",
p.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")
}
}
}
}
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* handle.process({
user: lastUser,
agent,
permission: session.permission,
sessionID,
parentSessionID: session.parentID,
system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* handle.process({
user: lastUser,
agent,
permission: session.permission,
sessionID,
parentSessionID: session.parentID,
system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})
if (structured !== undefined) {
handle.message.structured = structured
handle.message.finish = handle.message.finish ?? "stop"
yield* sessions.updateMessage(handle.message)
return "break" as const
}
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
if (finished && !handle.message.error) {
if (format.type === "json_schema") {
handle.message.error = new MessageV2.StructuredOutputError({
message: "Model did not produce structured output",
retries: 0,
}).toObject()
if (structured !== undefined) {
handle.message.structured = structured
handle.message.finish = handle.message.finish ?? "stop"
yield* sessions.updateMessage(handle.message)
return "break" as const
}
}
if (result === "stop") return "break" as const
if (result === "compact") {
yield* compaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
overflow: !handle.message.finish,
})
}
return "continue" as const
}).pipe(Effect.ensuring(instruction.clear(handle.message.id)))
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
if (finished && !handle.message.error) {
if (format.type === "json_schema") {
handle.message.error = new MessageV2.StructuredOutputError({
message: "Model did not produce structured output",
retries: 0,
}).toObject()
yield* sessions.updateMessage(handle.message)
return "break" as const
}
}
if (result === "stop") return "break" as const
if (result === "compact") {
yield* compaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
overflow: !handle.message.finish,
})
}
return "continue" as const
}),
Effect.fnUntraced(function* (exit) {
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
yield* InstanceState.withALS(() => instruction.clear(handle.message.id)).pipe(Effect.flatMap((x) => x))
}),
)
if (outcome === "break") break
continue
}

View File

@@ -6,10 +6,6 @@ import { iife } from "@/util/iife"
export namespace SessionRetry {
export type Err = ReturnType<NamedError["toObject"]>
// This exported message is shared with the TUI upsell detector. Matching on a
// literal error string kind of sucks, but it is the simplest for now.
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -57,7 +53,8 @@ export namespace SessionRetry {
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
if (error.data.responseBody?.includes("FreeUsageLimitError"))
return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}

View File

@@ -51,13 +51,13 @@ export namespace Shell {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
return which(shell) || shell
return Bun.which(shell) || shell
}
function pick() {
const pwsh = which("pwsh.exe")
const pwsh = Bun.which("pwsh")
if (pwsh) return pwsh
const powershell = which("powershell.exe")
const powershell = Bun.which("powershell")
if (powershell) return powershell
}

View File

@@ -1,5 +1,5 @@
import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Global } from "../global"
import { Log } from "../util/log"
import { ProjectTable } from "../project/project.sql"
@@ -23,7 +23,7 @@ export namespace JsonMigration {
progress?: (event: Progress) => void
}
export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
export async function run(sqlite: Database, options?: Options) {
const storageDir = path.join(Global.Path.data, "storage")
if (!existsSync(storageDir)) {
@@ -43,13 +43,13 @@ export namespace JsonMigration {
log.info("starting json to sqlite migration", { storageDir })
const start = performance.now()
// const db = drizzle({ client: sqlite })
const db = drizzle({ client: sqlite })
// Optimize SQLite for bulk inserts
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = OFF")
db.run("PRAGMA cache_size = 10000")
db.run("PRAGMA temp_store = MEMORY")
sqlite.exec("PRAGMA journal_mode = WAL")
sqlite.exec("PRAGMA synchronous = OFF")
sqlite.exec("PRAGMA cache_size = 10000")
sqlite.exec("PRAGMA temp_store = MEMORY")
const stats = {
projects: 0,
sessions: 0,
@@ -146,7 +146,7 @@ export namespace JsonMigration {
progress?.({ current, total, label: "starting" })
db.run("BEGIN TRANSACTION")
sqlite.exec("BEGIN TRANSACTION")
// Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content
@@ -400,7 +400,7 @@ export namespace JsonMigration {
log.warn("skipped orphaned session shares", { count: orphans.shares })
}
db.run("COMMIT")
sqlite.exec("COMMIT")
log.info("json migration complete", {
projects: stats.projects,

View File

@@ -67,7 +67,9 @@ export const ReadTool = Tool.defineEffect(
if (item.type === "directory") return item.name + "/"
if (item.type !== "symlink") return item.name
const target = yield* fs.stat(path.join(filepath, item.name)).pipe(Effect.catch(() => Effect.void))
const target = yield* fs
.stat(path.join(filepath, item.name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target?.type === "Directory") return item.name + "/"
return item.name
}),

View File

@@ -42,25 +42,20 @@ import { Agent } from "../agent/agent"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type TaskDef = Tool.InferDef<typeof TaskTool>
type ReadDef = Tool.InferDef<typeof ReadTool>
type State = {
custom: Tool.Def[]
builtin: Tool.Def[]
task: TaskDef
read: ReadDef
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -72,7 +67,6 @@ export namespace ToolRegistry {
| Plugin.Service
| Question.Service
| Todo.Service
| Agent.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
@@ -83,10 +77,8 @@ export namespace ToolRegistry {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const task = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -98,11 +90,11 @@ export namespace ToolRegistry {
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx: PluginToolContext = {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
}
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
@@ -140,52 +132,34 @@ export namespace ToolRegistry {
}
const cfg = yield* config.get()
const questionEnabled =
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool),
bash: Tool.init(BashTool),
read: Tool.init(read),
glob: Tool.init(GlobTool),
grep: Tool.init(GrepTool),
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
task: Tool.init(task),
fetch: Tool.init(WebFetchTool),
todo: Tool.init(todo),
search: Tool.init(WebSearchTool),
code: Tool.init(CodeSearchTool),
skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool),
question: Tool.init(question),
lsp: Tool.init(LspTool),
plan: Tool.init(PlanExitTool),
})
return {
custom,
builtin: [
tool.invalid,
...(questionEnabled ? [tool.question] : []),
tool.bash,
tool.read,
tool.glob,
tool.grep,
tool.edit,
tool.write,
tool.task,
tool.fetch,
tool.todo,
tool.search,
tool.code,
tool.skill,
tool.patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
],
task: tool.task,
read: tool.read,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
}),
)
@@ -195,6 +169,13 @@ export namespace ToolRegistry {
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const tools = yield* all()
const match = tools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
return (yield* all()).map((tool) => tool.id)
})
@@ -227,6 +208,7 @@ export namespace ToolRegistry {
id: tool.id,
description: [
output.description,
// TODO: remove this hack
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
@@ -241,12 +223,7 @@ export namespace ToolRegistry {
)
})
const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () {
const s = yield* InstanceState.get(state)
return { task: s.task, read: s.read }
})
return Service.of({ ids, all, named, tools })
return Service.of({ ids, tools, all, fromID })
}),
)
@@ -257,7 +234,6 @@ export namespace ToolRegistry {
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),

View File

@@ -6,101 +6,96 @@ import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
const id = "task"
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
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"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.defineEffect(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
return {
description,
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"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.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) {
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
}
const next = yield* agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
...(hasTodoWritePermission
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(hasTaskPermission
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
permission: t,
})) ?? []),
],
})
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const model = next.model ?? {
const model = agent.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
@@ -108,7 +103,7 @@ export const TaskTool = Tool.defineEffect(
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
sessionId: session.id,
model,
},
})
@@ -116,77 +111,59 @@ export const TaskTool = Tool.defineEffect(
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(nextSession.id)
SessionPrompt.cancel(session.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
...(hasTodoWritePermission ? {} : { todowrite: false }),
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
)
})
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
}
}),
)
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return {
title: params.description,
metadata: {
sessionId: session.id,
model,
},
output,
}
},
}
})
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const items = yield* Effect.promise(() =>
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
const accessibleAgents = agents.filter(
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
)
const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny")
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
return [`Available agent types and the tools they have access to:`, description].join("\n")
})

View File

@@ -7,7 +7,7 @@ import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
export interface Metadata {
interface Metadata {
[key: string]: any
}
@@ -60,13 +60,6 @@ export namespace Tool {
export type InferMetadata<T> =
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
export type InferDef<T> =
T extends Info<infer P, infer M>
? Def<P, M>
: T extends Effect.Effect<Info<infer P, infer M>, any, any>
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
@@ -105,27 +98,24 @@ export namespace Tool {
}
}
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
id: ID,
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> & { id: ID } {
): Info<Parameters, Result> {
return {
id,
init: wrap(id, init),
}
}
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
return Object.assign(
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
{ id },
)
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {

View File

@@ -17,7 +17,7 @@ export const withStatics =
Object.assign(schema, methods(schema))
declare const NewtypeBrand: unique symbol
export type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
/**
* Nominal wrapper for scalar types. The class itself is a valid schema —

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { NpmConfig } from "../../src/npm/config"
import { tmpdir } from "../fixture/fixture"
function env(next: Record<string, string | undefined>) {
const prev = Object.fromEntries(Object.keys(next).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(next)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return () => {
for (const [key, value] of Object.entries(prev)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
}
describe("NpmConfig", () => {
test("returns selected config file paths in precedence order", async () => {
await using tmp = await tmpdir()
const global = path.join(tmp.path, "global.npmrc")
const user = path.join(tmp.path, "user.npmrc")
const root = path.join(tmp.path, ".npmrc")
const child = path.join(tmp.path, "repo", ".npmrc")
const pkg = path.join(tmp.path, "repo", "package.json")
const dir = path.join(tmp.path, "repo", ".opencode")
await fs.mkdir(dir, { recursive: true })
await Bun.write(global, "registry=https://global.example/\n")
await Bun.write(user, "registry=https://user.example/\n")
await Bun.write(root, "registry=https://root.example/\n")
await Bun.write(child, "registry=https://child.example/\n")
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
const restore = env({
npm_config_globalconfig: global,
npm_config_userconfig: user,
})
try {
expect(await NpmConfig.paths(dir)).toEqual([child, user, global])
} finally {
restore()
}
})
test("merges config relative to a directory with env last", async () => {
await using tmp = await tmpdir()
const global = path.join(tmp.path, "global.npmrc")
const user = path.join(tmp.path, "user.npmrc")
const root = path.join(tmp.path, ".npmrc")
const child = path.join(tmp.path, "repo", ".npmrc")
const pkg = path.join(tmp.path, "repo", "package.json")
const dir = path.join(tmp.path, "repo", ".opencode")
await fs.mkdir(dir, { recursive: true })
await Bun.write(global, "registry=https://global.example/\nignore-scripts=false\n")
await Bun.write(user, "registry=https://user.example/\nignore-scripts=false\n")
await Bun.write(root, "registry=https://root.example/\nignore-scripts=false\n")
await Bun.write(child, "ignore-scripts=true\nbin-links=false\n@scope:registry=https://scope.example/\n")
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
const restore = env({
npm_config_globalconfig: global,
npm_config_userconfig: user,
npm_config_ignore_scripts: "false",
npm_config_registry: "https://env.example/",
})
try {
const cfg = await NpmConfig.config(dir)
expect(cfg.registry).toBe("https://env.example/")
expect(cfg.ignoreScripts).toBe(false)
expect(cfg.binLinks).toBe(false)
expect(cfg["@scope:registry"]).toBe("https://scope.example/")
} finally {
restore()
}
})
test("reloads config on each call", async () => {
await using tmp = await tmpdir()
const global = path.join(tmp.path, "global.npmrc")
const user = path.join(tmp.path, "user.npmrc")
const local = path.join(tmp.path, "repo", ".npmrc")
const pkg = path.join(tmp.path, "repo", "package.json")
const dir = path.join(tmp.path, "repo", ".opencode")
await fs.mkdir(dir, { recursive: true })
await Bun.write(global, "registry=https://global.example/\n")
await Bun.write(user, "registry=https://user.example/\n")
await Bun.write(local, "ignore-scripts=true\n")
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
const restore = env({
npm_config_globalconfig: global,
npm_config_userconfig: user,
})
try {
const first = await NpmConfig.config(dir)
await Bun.write(local, "ignore-scripts=false\n")
const second = await NpmConfig.config(dir)
expect(first.ignoreScripts).toBe(true)
expect(second.ignoreScripts).toBe(false)
} finally {
restore()
}
})
})

View File

@@ -19,7 +19,7 @@ afterEach(async () => {
describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir()
const app = Server.Default().app
const app = Server.Default()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
@@ -76,7 +76,7 @@ describe("project.initGit endpoint", () => {
test("does not reload when the project is already git", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const app = Server.Default()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)

View File

@@ -42,7 +42,7 @@ describe("session action routes", () => {
fn: async () => {
const session = await Session.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default().app
const app = Server.Default()
const res = await app.request(`/session/${session.id}/abort`, {
method: "POST",
@@ -66,7 +66,7 @@ describe("session action routes", () => {
const msg = await user(session.id, "hello")
const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
const app = Server.Default().app
const app = Server.Default()
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE",

View File

@@ -60,7 +60,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
const ids = await fill(session.id, 5)
const app = Server.Default().app
const app = Server.Default()
const a = await app.request(`/session/${session.id}/message?limit=2`)
expect(a.status).toBe(200)
@@ -89,7 +89,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
const ids = await fill(session.id, 3)
const app = Server.Default().app
const app = Server.Default()
const res = await app.request(`/session/${session.id}/message`)
expect(res.status).toBe(200)
@@ -109,7 +109,7 @@ describe("session messages endpoint", () => {
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const app = Server.Default().app
const app = Server.Default()
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
expect(bad.status).toBe(400)
@@ -131,7 +131,7 @@ describe("session messages endpoint", () => {
fn: async () => {
const session = await Session.create({})
await fill(session.id, 520)
const app = Server.Default().app
const app = Server.Default()
const res = await app.request(`/session/${session.id}/message?limit=510`)
expect(res.status).toBe(200)

View File

@@ -21,7 +21,7 @@ describe("tui.selectSession endpoint", () => {
const session = await Session.create({})
// #when
const app = Server.Default().app
const app = Server.Default()
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -47,7 +47,7 @@ describe("tui.selectSession endpoint", () => {
const nonExistentSessionID = "ses_nonexistent123"
// #when
const app = Server.Default().app
const app = Server.Default()
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -69,7 +69,7 @@ describe("tui.selectSession endpoint", () => {
const invalidSessionID = "invalid_session_id"
// #when
const app = Server.Default().app
const app = Server.Default()
const response = await app.request("/tui/select-session", {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@@ -139,6 +139,7 @@ function fake(
get message() {
return msg
},
abort: Effect.fn("TestSessionProcessor.abort")(() => Effect.void),
partFromToolCall() {
return {
id: PartID.ascending(),

View File

@@ -593,6 +593,9 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
yield* Fiber.interrupt(run)
const exit = yield* Fiber.await(run)
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
yield* handle.abort()
}
const parts = MessageV2.parts(msg.id)
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
@@ -662,6 +665,9 @@ it.live("session.processor effect tests record aborted errors and idle state", (
yield* Fiber.interrupt(run)
const exit = yield* Fiber.await(run)
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
yield* handle.abort()
}
yield* Effect.promise(() => seen.promise)
const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id })
const state = yield* sts.get(chat.id)

View File

@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { expect } from "bun:test"
import { expect, spyOn } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import z from "zod"
@@ -29,6 +29,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
@@ -626,27 +627,34 @@ it.live(
"cancel finalizes subtask tool state",
() =>
provideTmpdirInstance(
() =>
(dir) =>
Effect.gen(function* () {
const ready = defer<void>()
const aborted = defer<void>()
const registry = yield* ToolRegistry.Service
const { task } = yield* registry.named()
const original = task.execute
task.execute = async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
}
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
const init = spyOn(TaskTool, "init").mockImplementation(async () => ({
description: "task",
parameters: z.object({
description: z.string(),
prompt: z.string(),
subagent_type: z.string(),
task_id: z.string().optional(),
command: z.string().optional(),
}),
execute: async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
},
}))
yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello")
@@ -1231,109 +1239,3 @@ unix(
),
30_000,
)
// Abort signal propagation tests for inline tool execution
/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
const ready = defer<void>()
const aborted = defer<void>()
const original = tool.execute
tool.execute = async (_args: any, ctx: any) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return { title: "", metadata: {}, output: "" }
}
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
return { ready, aborted, restore }
}
it.live(
"interrupt propagates abort signal to read tool via file part (text/plain)",
() =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const { read } = yield* registry.named()
const { ready, aborted, restore } = hangUntilAborted(read)
yield* restore
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Abort Test" })
const testFile = path.join(dir, "test.txt")
yield* Effect.promise(() => Bun.write(testFile, "hello world"))
const fiber = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
parts: [
{ type: "text", text: "read this" },
{ type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
],
})
.pipe(Effect.forkChild)
yield* Effect.promise(() => ready.promise)
yield* Fiber.interrupt(fiber)
yield* Effect.promise(() =>
Promise.race([
aborted.promise,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
),
]),
)
}),
{ git: true, config: cfg },
),
30_000,
)
it.live(
"interrupt propagates abort signal to read tool via file part (directory)",
() =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const { read } = yield* registry.named()
const { ready, aborted, restore } = hangUntilAborted(read)
yield* restore
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Abort Test" })
const fiber = yield* prompt
.prompt({
sessionID: chat.id,
agent: "build",
parts: [
{ type: "text", text: "read this" },
{ type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
],
})
.pipe(Effect.forkChild)
yield* Effect.promise(() => ready.promise)
yield* Fiber.interrupt(fiber)
yield* Effect.promise(() =>
Promise.race([
aborted.promise,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
),
]),
)
}),
{ git: true, config: cfg },
),
30_000,
)

View File

@@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { Database } from "bun:sqlite"
import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import path from "path"
import fs from "fs/promises"
@@ -89,21 +89,18 @@ function createTestDb() {
name: entry.name,
}))
.sort((a, b) => a.timestamp - b.timestamp)
migrate(drizzle({ client: sqlite }), migrations)
const db = drizzle({ client: sqlite })
migrate(db, migrations)
return [sqlite, db] as const
return sqlite
}
describe("JSON to SQLite migration", () => {
let storageDir: string
let sqlite: Database
let db: SQLiteBunDatabase
beforeEach(async () => {
storageDir = await setupStorageDir()
;[sqlite, db] = createTestDb()
sqlite = createTestDb()
})
afterEach(async () => {
@@ -121,10 +118,11 @@ describe("JSON to SQLite migration", () => {
sandboxes: ["/test/sandbox"],
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -145,10 +143,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
@@ -165,10 +164,11 @@ describe("JSON to SQLite migration", () => {
commands: { start: "npm run dev" },
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
@@ -185,10 +185,11 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
@@ -215,8 +216,9 @@ describe("JSON to SQLite migration", () => {
share: { url: "https://example.com/share" },
})
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
@@ -245,11 +247,12 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ ...fixtures.part }),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -284,11 +287,12 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -325,10 +329,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
@@ -362,10 +367,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
@@ -386,7 +392,7 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(0)
})
@@ -414,10 +420,11 @@ describe("JSON to SQLite migration", () => {
time: { created: 1700000000000, updated: 1700000001000 },
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
@@ -445,10 +452,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
@@ -463,9 +471,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
await JsonMigration.run(db)
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
})
@@ -498,10 +507,11 @@ describe("JSON to SQLite migration", () => {
]),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("First todo")
@@ -530,8 +540,9 @@ describe("JSON to SQLite migration", () => {
]),
)
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(3)
@@ -559,10 +570,11 @@ describe("JSON to SQLite migration", () => {
]
await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.permissions).toBe(1)
const db = drizzle({ client: sqlite })
const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc")
@@ -588,10 +600,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.shares).toBe(1)
const db = drizzle({ client: sqlite })
const shares = db.select().from(SessionShareTable).all()
expect(shares.length).toBe(1)
expect(shares[0].session_id).toBe("ses_test456def")
@@ -603,7 +616,7 @@ describe("JSON to SQLite migration", () => {
test("returns empty stats when storage directory does not exist", async () => {
await fs.rm(storageDir, { recursive: true, force: true })
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(0)
expect(stats.sessions).toBe(0)
@@ -624,11 +637,12 @@ describe("JSON to SQLite migration", () => {
})
await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(1)
expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -652,9 +666,10 @@ describe("JSON to SQLite migration", () => {
]),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("keep-0")
@@ -699,12 +714,13 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.shares).toBe(1)
const db = drizzle({ client: sqlite })
expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1)
@@ -807,7 +823,7 @@ describe("JSON to SQLite migration", () => {
)
await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
@@ -821,6 +837,7 @@ describe("JSON to SQLite migration", () => {
expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1)

View File

@@ -98,37 +98,6 @@ describe("tool.registry", () => {
}),
)
await Bun.write(
path.join(opencodeDir, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
},
}),
)
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay")
await fs.mkdir(cowsayDir, { recursive: true })
await Bun.write(
path.join(cowsayDir, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
)
await Bun.write(
path.join(cowsayDir, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
)
await Bun.write(
path.join(toolsDir, "cowsay.ts"),
[

View File

@@ -1,412 +1,50 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Config } from "../../src/config/config"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskDescription, TaskTool } from "../../src/tool/task"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
const it = testEffect(
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
)
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
const session = yield* Session.Service
const chat = yield* session.create({ title })
const user = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: chat.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
const assistant: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: user.id,
sessionID: chat.id,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: Date.now() },
}
yield* session.updateMessage(assistant)
return { chat, assistant }
})
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
const id = MessageID.ascending()
return {
info: {
id,
role: "assistant",
parentID: input.messageID ?? MessageID.ascending(),
sessionID: input.sessionID,
mode: input.agent ?? "general",
agent: input.agent ?? "general",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: input.model?.modelID ?? ref.modelID,
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "stop",
},
parts: [
{
id: PartID.ascending(),
messageID: id,
sessionID: input.sessionID,
type: "text",
text,
},
],
}
}
describe("tool.task", () => {
it.live("description sorts subagents by name and is stable across calls", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const first = yield* TaskDescription(build)
const second = yield* TaskDescription(build)
expect(first).toBe(second)
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
),
)
})
it.live("description hides denied subagents for the caller", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const description = yield* TaskDescription(build)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(TaskDescription(agent))
const second = await Effect.runPromise(TaskDescription(agent))
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
expect(first).toBe(second)
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
},
),
)
it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "resumed")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
),
)
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
const calls: unknown[] = []
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => reply(input, "done")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
Effect.promise(() =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra,
messages: [],
metadata() {},
ask: async (input) => {
calls.push(input)
},
},
),
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
},
})
}),
),
)
it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "created")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
),
)
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "done")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "reviewer",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
expect(child.permission).toEqual([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "allow",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
])
expect(seen?.tools).toEqual({
todowrite: false,
bash: false,
read: false,
})
}),
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
},
},
experimental: {
primary_tools: ["bash", "read"],
},
},
},
),
)
})
})
})

View File

@@ -17,25 +17,58 @@ const ctx = {
ask: async () => {},
}
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
await fn(server.url)
type TimerID = ReturnType<typeof setTimeout>
async function withFetch(
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
fn: () => Promise<void>,
) {
const originalFetch = globalThis.fetch
globalThis.fetch = mockFetch as unknown as typeof fetch
try {
await fn()
} finally {
globalThis.fetch = originalFetch
}
}
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
const ids: TimerID[] = []
const cleared: TimerID[] = []
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
const id = set(...args)
ids.push(id)
return id
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: TimerID) => {
if (id !== undefined) cleared.push(id)
return clear(id)
}) as typeof clearTimeout
try {
await fn({ ids, cleared })
} finally {
ids.forEach(clear)
globalThis.setTimeout = set
globalThis.clearTimeout = clear
}
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async (url) => {
async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" },
ctx,
)
const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx)
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -54,17 +87,17 @@ describe("tool.webfetch", () => {
test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch(
() =>
async () =>
new Response(svg, {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
async (url) => {
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx)
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
@@ -75,17 +108,17 @@ describe("tool.webfetch", () => {
test("keeps text responses as text output", async () => {
await withFetch(
() =>
async () =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
async (url) => {
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
@@ -93,4 +126,29 @@ describe("tool.webfetch", () => {
},
)
})
test("clears timeout when fetch rejects", async () => {
await withTimers(async ({ ids, cleared }) => {
await withFetch(
async () => {
throw new Error("boom")
},
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
await expect(
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
).rejects.toThrow("boom")
},
})
},
)
expect(ids).toHaveLength(1)
expect(cleared).toHaveLength(1)
expect(cleared[0]).toBe(ids[0])
})
})
})

View File

@@ -18,12 +18,6 @@
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
],
"outDir": "dist/types",
"rootDir": ".",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"]
]
}
}

View File

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

View File

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

View File

@@ -1250,29 +1250,6 @@ export type ProviderConfig = {
env?: Array<string>
id?: string
npm?: string
whitelist?: Array<string>
blacklist?: Array<string>
options?: {
apiKey?: string
baseURL?: string
/**
* GitHub Enterprise URL for copilot authentication
*/
enterpriseUrl?: string
/**
* Enable promptCacheKey for this provider (default false)
*/
setCacheKey?: boolean
/**
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
*/
timeout?: number | false
/**
* Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.
*/
chunkTimeout?: number
[key: string]: unknown | string | boolean | number | false | number | undefined
}
models?: {
[key: string]: {
id?: string
@@ -1311,16 +1288,16 @@ export type ProviderConfig = {
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
provider?: {
npm?: string
api?: string
}
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm?: string
api?: string
}
/**
* Variant-specific configuration
*/
@@ -1335,6 +1312,29 @@ export type ProviderConfig = {
}
}
}
whitelist?: Array<string>
blacklist?: Array<string>
options?: {
apiKey?: string
baseURL?: string
/**
* GitHub Enterprise URL for copilot authentication
*/
enterpriseUrl?: string
/**
* Enable promptCacheKey for this provider (default false)
*/
setCacheKey?: boolean
/**
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
*/
timeout?: number | false
/**
* Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.
*/
chunkTimeout?: number
[key: string]: unknown | string | boolean | number | false | number | undefined
}
}
export type McpLocalConfig = {
@@ -3936,10 +3936,7 @@ export type SessionShellResponses = {
/**
* Created message
*/
200: {
info: Message
parts: Array<Part>
}
200: AssistantMessage
}
export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]
@@ -4215,7 +4212,68 @@ export type ProviderListResponses = {
* List of providers
*/
200: {
all: Array<Provider>
all: Array<{
api?: string
name: string
env: Array<string>
id: string
npm?: string
models: {
[key: string]: {
id: string
name: string
family?: string
release_date: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
interleaved?:
| true
| {
field: "reasoning_content" | "reasoning_details"
}
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit: {
context: number
input?: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm?: string
api?: string
}
variants?: {
[key: string]: {
[key: string]: unknown
}
}
}
}
}>
default: {
[key: string]: string
}

View File

@@ -4098,19 +4098,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/Message"
},
"parts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Part"
}
}
},
"required": ["info", "parts"]
"$ref": "#/components/schemas/AssistantMessage"
}
}
}
@@ -4802,7 +4790,211 @@
"all": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Provider"
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"interleaved": {
"anyOf": [
{
"type": "boolean",
"const": true
},
{
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": ["reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
"additionalProperties": false
}
]
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
},
"context_over_200k": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": ["input", "output"]
}
},
"required": ["input", "output"]
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": ["context", "output"]
},
"modalities": {
"type": "object",
"properties": {
"input": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
},
"output": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
}
},
"required": ["input", "output"]
},
"experimental": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated"]
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"headers": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"provider": {
"type": "object",
"properties": {
"npm": {
"type": "string"
},
"api": {
"type": "string"
}
}
},
"variants": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
"required": [
"id",
"name",
"release_date",
"attachment",
"reasoning",
"temperature",
"tool_call",
"limit",
"options"
]
}
}
},
"required": ["name", "env", "id", "models"]
}
},
"default": {
@@ -10596,60 +10788,6 @@
"npm": {
"type": "string"
},
"whitelist": {
"type": "array",
"items": {
"type": "string"
}
},
"blacklist": {
"type": "array",
"items": {
"type": "string"
}
},
"options": {
"type": "object",
"properties": {
"apiKey": {
"type": "string"
},
"baseURL": {
"type": "string"
},
"enterpriseUrl": {
"description": "GitHub Enterprise URL for copilot authentication",
"type": "string"
},
"setCacheKey": {
"description": "Enable promptCacheKey for this provider (default false)",
"type": "boolean"
},
"timeout": {
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"anyOf": [
{
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
{
"description": "Disable timeout for this provider entirely.",
"type": "boolean",
"const": false
}
]
},
"chunkTimeout": {
"description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": {}
},
"models": {
"type": "object",
"propertyNames": {
@@ -10779,17 +10917,6 @@
"type": "string",
"enum": ["alpha", "beta", "deprecated"]
},
"provider": {
"type": "object",
"properties": {
"npm": {
"type": "string"
},
"api": {
"type": "string"
}
}
},
"options": {
"type": "object",
"propertyNames": {
@@ -10806,6 +10933,17 @@
"type": "string"
}
},
"provider": {
"type": "object",
"properties": {
"npm": {
"type": "string"
},
"api": {
"type": "string"
}
}
},
"variants": {
"description": "Variant-specific configuration",
"type": "object",
@@ -10825,6 +10963,60 @@
}
}
}
},
"whitelist": {
"type": "array",
"items": {
"type": "string"
}
},
"blacklist": {
"type": "array",
"items": {
"type": "string"
}
},
"options": {
"type": "object",
"properties": {
"apiKey": {
"type": "string"
},
"baseURL": {
"type": "string"
},
"enterpriseUrl": {
"description": "GitHub Enterprise URL for copilot authentication",
"type": "string"
},
"setCacheKey": {
"description": "Enable promptCacheKey for this provider (default false)",
"type": "boolean"
},
"timeout": {
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"anyOf": [
{
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
{
"description": "Disable timeout for this provider entirely.",
"type": "boolean",
"const": false
}
]
},
"chunkTimeout": {
"description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": {}
}
},
"additionalProperties": false

View File

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

View File

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

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