mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 21:00:29 +08:00
Merge branch 'dev' into openai-compaction
This commit is contained in:
44
bun.lock
44
bun.lock
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -225,7 +225,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -268,7 +268,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/shared": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -328,7 +328,7 @@
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.102",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
@@ -458,7 +458,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -493,7 +493,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@opencode-ai/shared",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -532,7 +532,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -567,7 +567,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -616,7 +616,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -693,7 +693,7 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "6.0.158",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
@@ -761,7 +761,7 @@
|
||||
|
||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
||||
|
||||
@@ -2457,7 +2457,7 @@
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
@@ -2517,7 +2517,7 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
|
||||
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||
|
||||
@@ -5703,8 +5703,6 @@
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=",
|
||||
"aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=",
|
||||
"aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=",
|
||||
"x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc="
|
||||
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
|
||||
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
|
||||
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
|
||||
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"ai": "6.0.158",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.4.9"
|
||||
version = "1.4.10"
|
||||
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.9/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/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.9/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -85,7 +85,7 @@
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.102",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
|
||||
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
|
||||
|
||||
SDK shape rule:
|
||||
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte
|
||||
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
|
||||
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
|
||||
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
|
||||
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
|
||||
### Schema.Class vs Schema.Struct
|
||||
|
||||
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
|
||||
|
||||
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
```
|
||||
|
||||
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({ ... }).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
```
|
||||
|
||||
When to use each:
|
||||
|
||||
- Use **Schema.Class** when:
|
||||
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
|
||||
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
|
||||
- Use **Schema.Struct** when:
|
||||
- the type is only used as a nested field inside another named schema
|
||||
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
|
||||
|
||||
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
|
||||
|
||||
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
|
||||
|
||||
```ts
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
|
||||
```
|
||||
|
||||
Temporary exception:
|
||||
|
||||
@@ -365,17 +401,16 @@ Current instance route inventory:
|
||||
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
||||
- `permission` - `bridged`
|
||||
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `bridged` (partial)
|
||||
bridged endpoint: `GET /provider/auth`
|
||||
not yet ported: `GET /provider`, OAuth mutations
|
||||
- `config` - `next`
|
||||
best next endpoint: `GET /config/providers`
|
||||
- `provider` - `bridged`
|
||||
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
|
||||
- `config` - `bridged` (partial)
|
||||
bridged endpoint: `GET /config/providers`
|
||||
later endpoint: `GET /config`
|
||||
defer `PATCH /config` for now
|
||||
- `project` - `later`
|
||||
best small reads: `GET /project`, `GET /project/current`
|
||||
- `project` - `bridged` (partial)
|
||||
bridged endpoints: `GET /project`, `GET /project/current`
|
||||
defer git-init mutation first
|
||||
- `workspace` - `later`
|
||||
- `workspace` - `next`
|
||||
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
||||
defer create/remove mutations first
|
||||
- `file` - `later`
|
||||
@@ -393,12 +428,12 @@ Current instance route inventory:
|
||||
- `tui` - `defer`
|
||||
queue-style UI bridge, weak early `HttpApi` fit
|
||||
|
||||
Recommended near-term sequence after the first spike:
|
||||
Recommended near-term sequence:
|
||||
|
||||
1. `provider` auth read endpoint
|
||||
2. `config` providers read endpoint
|
||||
3. `project` read endpoints
|
||||
4. `workspace` read endpoints
|
||||
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
|
||||
2. `config` full read endpoint (`GET /config`)
|
||||
3. `file` JSON read endpoints
|
||||
4. `mcp` JSON read endpoints
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
|
||||
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- [x] verify OTEL spans and HTTP logs flow to motel
|
||||
- [x] bridge question, permission, and provider auth routes
|
||||
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [ ] port `config` read endpoints
|
||||
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [x] port `config` providers read endpoint
|
||||
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
|
||||
- [ ] port `workspace` read endpoints
|
||||
- [ ] port `GET /config` full read endpoint
|
||||
- [ ] port `file` JSON read endpoints
|
||||
- [ ] decide when to remove the flag and make Effect routes the default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
@@ -139,15 +139,10 @@ export function DialogSessionList() {
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
workspaceStatus === "error"
|
||||
? theme.error
|
||||
: workspaceStatus === "disconnected"
|
||||
? theme.textMuted
|
||||
: theme.success,
|
||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||
}}
|
||||
>
|
||||
■
|
||||
●
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
|
||||
total: result.data.total,
|
||||
})
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
try {
|
||||
await input.sync.bootstrap({ fatal: false })
|
||||
} catch (e) {}
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
|
||||
log.error("session restore refresh failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "restore" as "cancel" | "restore",
|
||||
})
|
||||
|
||||
const options = ["cancel", "restore"] as const
|
||||
|
||||
async function confirm() {
|
||||
if (store.active === "cancel") {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const result = await props.onRestore?.()
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
void confirm()
|
||||
return
|
||||
}
|
||||
if (evt.name === "left") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "cancel")
|
||||
return
|
||||
}
|
||||
if (evt.name === "right") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "restore")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Workspace Unavailable
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
This session is attached to a workspace that is no longer available.
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Would you like to restore this session into a new workspace?
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
|
||||
export type PromptProps = {
|
||||
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
|
||||
const args = useArgs()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
@@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
|
||||
keybind: "input_submit",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
onSelect: async (dialog) => {
|
||||
if (!input.focused) return
|
||||
void submit()
|
||||
const handled = await submit()
|
||||
if (!handled) return
|
||||
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "input", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return
|
||||
if (autocomplete?.visible) return
|
||||
if (!store.prompt.input) return
|
||||
if (props.disabled) return false
|
||||
if (autocomplete?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return
|
||||
if (!agent) return false
|
||||
const trimmed = store.prompt.input.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return
|
||||
return true
|
||||
}
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
void promptModelWarning()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
|
||||
const workspaceID = workspaceSession?.workspaceID
|
||||
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
|
||||
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(nextWorkspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: nextWorkspaceID,
|
||||
sessionID: props.sessionID!,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return false
|
||||
}
|
||||
|
||||
let sessionID = props.sessionID
|
||||
@@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) {
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
@@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
}, 50)
|
||||
input.clear()
|
||||
return true
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TuiInfo = z
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
plugin: ConfigPlugin.Spec.array().optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
|
||||
@@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||
import { makeRuntime } from "@/effect/runtime"
|
||||
import { Filesystem, Log } from "@/util"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Layer } from "effect"
|
||||
import { TuiConfig } from "./config/tui"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
import { Observability } from "@/effect/observability"
|
||||
|
||||
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
@@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspaceStatus = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "error"
|
||||
return project.workspace.status(workspaceID) ?? "error"
|
||||
}
|
||||
const workspaceLabel = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "unknown"
|
||||
const info = project.workspace.get(workspaceID)
|
||||
if (!info) return "unknown"
|
||||
return `${info.type}: ${info.name}`
|
||||
}
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
return (
|
||||
@@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
{workspaceLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||
</Show>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { TuiConfig } from "./config/tui"
|
||||
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const env = sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
})
|
||||
|
||||
const worker = new Worker(file, {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
env,
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@/util/opencode-process"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
|
||||
@@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
model: ConfigModelID.optional(),
|
||||
model: ConfigModelID.zod.optional(),
|
||||
variant: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Log } from "../util"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import { InvalidError } from "./error"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
export const Info = z.object({
|
||||
template: z.string(),
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: ConfigModelID.optional(),
|
||||
subtask: z.boolean().optional(),
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
template: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(ConfigModelID),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export async function load(dir: string) {
|
||||
const result: Record<string, Info> = {}
|
||||
@@ -49,7 +51,7 @@ export async function load(dir: string) {
|
||||
...md.data,
|
||||
template: md.content.trim(),
|
||||
}
|
||||
const parsed = Info.safeParse(config)
|
||||
const parsed = Info.zod.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result[config.name] = parsed.data
|
||||
continue
|
||||
|
||||
@@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
|
||||
import { ConfigFormatter } from "./formatter"
|
||||
import { ConfigLSP } from "./lsp"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -97,10 +97,10 @@ export const Info = z
|
||||
logLevel: Log.Level.optional().describe("Log level"),
|
||||
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
||||
command: z
|
||||
.record(z.string(), ConfigCommand.Info)
|
||||
.record(z.string(), ConfigCommand.Info.zod)
|
||||
.optional()
|
||||
.describe("Command configuration, see https://opencode.ai/docs/commands"),
|
||||
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
|
||||
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
|
||||
watcher: z
|
||||
.object({
|
||||
ignore: z.array(z.string()).optional(),
|
||||
@@ -113,7 +113,7 @@ export const Info = z
|
||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
),
|
||||
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
|
||||
plugin: ConfigPlugin.Spec.array().optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
.optional()
|
||||
@@ -135,10 +135,10 @@ export const Info = z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
|
||||
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
small_model: ConfigModelID.describe(
|
||||
"Small model to use for tasks like title generation in the format of provider/model",
|
||||
).optional(),
|
||||
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
small_model: ConfigModelID.zod
|
||||
.describe("Small model to use for tasks like title generation in the format of provider/model")
|
||||
.optional(),
|
||||
default_agent: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -178,7 +178,7 @@ export const Info = z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([
|
||||
ConfigMCP.Info,
|
||||
ConfigMCP.Info.zod,
|
||||
z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -188,8 +188,8 @@ export const Info = z
|
||||
)
|
||||
.optional()
|
||||
.describe("MCP (Model Context Protocol) server configurations"),
|
||||
formatter: ConfigFormatter.Info.optional(),
|
||||
lsp: ConfigLSP.Info.optional(),
|
||||
formatter: ConfigFormatter.Info.zod.optional(),
|
||||
lsp: ConfigLSP.Info.zod.optional(),
|
||||
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
||||
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
||||
permission: ConfigPermission.Info.optional(),
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export const ConsoleState = z.object({
|
||||
consoleManagedProviders: z.array(z.string()),
|
||||
activeOrgName: z.string().optional(),
|
||||
switchableOrgCount: z.number().int().nonnegative(),
|
||||
})
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export type ConsoleState = z.infer<typeof ConsoleState>
|
||||
|
||||
export const emptyConsoleState: ConsoleState = {
|
||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
||||
consoleManagedProviders: [],
|
||||
activeOrgName: undefined,
|
||||
switchableOrgCount: 0,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Entry = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
extensions: z.array(z.string()).optional(),
|
||||
})
|
||||
export const Entry = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import * as LSPServer from "../lsp/server"
|
||||
|
||||
export const Disabled = z.object({
|
||||
disabled: z.literal(true),
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Entry = Schema.Union([
|
||||
Disabled,
|
||||
Schema.Struct({
|
||||
command: Schema.mutable(Schema.Array(Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
/**
|
||||
* For custom (non-builtin) LSP server entries, `extensions` is required so the
|
||||
* client knows which files the server should attach to. Builtin server IDs and
|
||||
* explicitly disabled entries are exempt.
|
||||
*/
|
||||
export const requiresExtensionsForCustomServers = Schema.makeFilter<
|
||||
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
|
||||
>((data) => {
|
||||
if (typeof data === "boolean") return undefined
|
||||
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
|
||||
const ok = Object.entries(data).every(([id, config]) => {
|
||||
if ("disabled" in config && config.disabled) return true
|
||||
if (serverIds.has(id)) return true
|
||||
return "extensions" in config && Boolean(config.extensions)
|
||||
})
|
||||
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
|
||||
})
|
||||
|
||||
export const Entry = z.union([
|
||||
Disabled,
|
||||
z.object({
|
||||
command: z.array(z.string()),
|
||||
extensions: z.array(z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
initialization: z.record(z.string(), z.any()).optional(),
|
||||
}),
|
||||
])
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
.check(requiresExtensionsForCustomServers)
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
|
||||
(data) => {
|
||||
if (typeof data === "boolean") return true
|
||||
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
|
||||
|
||||
return Object.entries(data).every(([id, config]) => {
|
||||
if (config.disabled) return true
|
||||
if (serverIds.has(id)) return true
|
||||
return Boolean(config.extensions)
|
||||
})
|
||||
},
|
||||
{
|
||||
error: "For custom LSP servers, 'extensions' array is required.",
|
||||
},
|
||||
)
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -1,68 +1,62 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Local = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
command: z.string().array().describe("Command and arguments to run the MCP server"),
|
||||
environment: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables to set when running the MCP server"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
timeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpLocalConfig",
|
||||
})
|
||||
export class Local extends Schema.Class<Local>("McpLocalConfig")({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
|
||||
description: "Command and arguments to run the MCP server",
|
||||
}),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Environment variables to set when running the MCP server",
|
||||
}),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const OAuth = z
|
||||
.object({
|
||||
clientId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||
redirectUri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpOAuthConfig",
|
||||
})
|
||||
export type OAuth = z.infer<typeof OAuth>
|
||||
export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
|
||||
clientId: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
|
||||
}),
|
||||
clientSecret: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client secret (if required by the authorization server)",
|
||||
}),
|
||||
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
|
||||
redirectUri: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Remote = z
|
||||
.object({
|
||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||
url: z.string().describe("URL of the remote MCP server"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
|
||||
oauth: z
|
||||
.union([OAuth, z.literal(false)])
|
||||
.optional()
|
||||
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
|
||||
timeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpRemoteConfig",
|
||||
})
|
||||
export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
|
||||
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
|
||||
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Headers to send with the request",
|
||||
}),
|
||||
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
|
||||
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Local, Remote])
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Union([Local, Remote])
|
||||
.annotate({ discriminator: "type" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as ConfigMCP from "./mcp"
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
// literal pointer to an outside schema — so the walker cannot re-derive it
|
||||
// from AST metadata. Preserve the exact original Zod via ZodOverride.
|
||||
export const ConfigModelID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * as ConfigPermission from "./permission"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const permissionPreprocess = (val: unknown) => {
|
||||
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
||||
@@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => {
|
||||
return val
|
||||
}
|
||||
|
||||
export const Action = z.enum(["ask", "allow", "deny"]).meta({
|
||||
ref: "PermissionActionConfig",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
||||
.annotate({ identifier: "PermissionActionConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Action = Schema.Schema.Type<typeof Action>
|
||||
|
||||
export const Object = z.record(z.string(), Action).meta({
|
||||
ref: "PermissionObjectConfig",
|
||||
})
|
||||
export type Object = z.infer<typeof Object>
|
||||
export const Object = Schema.Record(Schema.String, Action)
|
||||
.annotate({ identifier: "PermissionObjectConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Object = Schema.Schema.Type<typeof Object>
|
||||
|
||||
export const Rule = z.union([Action, Object]).meta({
|
||||
ref: "PermissionRuleConfig",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
export const Rule = Schema.Union([Action, Object])
|
||||
.annotate({ identifier: "PermissionRuleConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
const transform = (x: unknown): Record<string, Rule> => {
|
||||
if (typeof x === "string") return { "*": x as Action }
|
||||
@@ -41,25 +44,25 @@ export const Info = z
|
||||
z
|
||||
.object({
|
||||
__originalKeys: z.string().array().optional(),
|
||||
read: Rule.optional(),
|
||||
edit: Rule.optional(),
|
||||
glob: Rule.optional(),
|
||||
grep: Rule.optional(),
|
||||
list: Rule.optional(),
|
||||
bash: Rule.optional(),
|
||||
task: Rule.optional(),
|
||||
external_directory: Rule.optional(),
|
||||
todowrite: Action.optional(),
|
||||
question: Action.optional(),
|
||||
webfetch: Action.optional(),
|
||||
websearch: Action.optional(),
|
||||
codesearch: Action.optional(),
|
||||
lsp: Rule.optional(),
|
||||
doom_loop: Action.optional(),
|
||||
skill: Rule.optional(),
|
||||
read: Rule.zod.optional(),
|
||||
edit: Rule.zod.optional(),
|
||||
glob: Rule.zod.optional(),
|
||||
grep: Rule.zod.optional(),
|
||||
list: Rule.zod.optional(),
|
||||
bash: Rule.zod.optional(),
|
||||
task: Rule.zod.optional(),
|
||||
external_directory: Rule.zod.optional(),
|
||||
todowrite: Action.zod.optional(),
|
||||
question: Action.zod.optional(),
|
||||
webfetch: Action.zod.optional(),
|
||||
websearch: Action.zod.optional(),
|
||||
codesearch: Action.zod.optional(),
|
||||
lsp: Rule.zod.optional(),
|
||||
doom_loop: Action.zod.optional(),
|
||||
skill: Rule.zod.optional(),
|
||||
})
|
||||
.catchall(Rule)
|
||||
.or(Action),
|
||||
.catchall(Rule.zod)
|
||||
.or(Action.zod),
|
||||
)
|
||||
.transform(transform)
|
||||
.meta({
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import path from "path"
|
||||
|
||||
const Options = z.record(z.string(), z.unknown())
|
||||
export type Options = z.infer<typeof Options>
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Options = Schema.Schema.Type<typeof Options>
|
||||
|
||||
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
||||
// It answers "what should we load?" but says nothing about where that value came from.
|
||||
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
||||
export type Spec = z.infer<typeof Spec>
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Info = z.object({
|
||||
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
||||
urls: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "Additional paths to skill folders",
|
||||
}),
|
||||
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
|
||||
}),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as ConfigSkills from "./skills"
|
||||
|
||||
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
|
||||
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => {
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
|
||||
OPENCODE_WORKSPACE_ID: config.id,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
}
|
||||
await adaptor.create(config, env)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share"
|
||||
import { SessionShare } from "@/share"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
import { memoMap } from "./memo-map"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
|
||||
@@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
|
||||
import * as EffectLogger from "./logger"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||
import { ensureProcessMetadata } from "@/util/opencode-process"
|
||||
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
export const enabled = !!base
|
||||
const processID = crypto.randomUUID()
|
||||
|
||||
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
@@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
)
|
||||
: undefined
|
||||
|
||||
const resource = {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: InstallationVersion,
|
||||
attributes: {
|
||||
"deployment.environment.name": InstallationChannel,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
function resource() {
|
||||
const processMetadata = ensureProcessMetadata("main")
|
||||
return {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: InstallationVersion,
|
||||
attributes: {
|
||||
"deployment.environment.name": InstallationChannel,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
"opencode.process_role": processMetadata.processRole,
|
||||
"opencode.run_id": processMetadata.runID,
|
||||
"service.instance.id": processID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const logs = Logger.layer(
|
||||
[
|
||||
EffectLogger.logger,
|
||||
OtlpLogger.make({
|
||||
url: `${base}/v1/logs`,
|
||||
resource,
|
||||
headers,
|
||||
}),
|
||||
],
|
||||
{ mergeWithExisting: false },
|
||||
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
|
||||
function logs() {
|
||||
return Logger.layer(
|
||||
[
|
||||
EffectLogger.logger,
|
||||
OtlpLogger.make({
|
||||
url: `${base}/v1/logs`,
|
||||
resource: resource(),
|
||||
headers,
|
||||
}),
|
||||
],
|
||||
{ mergeWithExisting: false },
|
||||
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
|
||||
const traces = async () => {
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
@@ -58,7 +68,7 @@ const traces = async () => {
|
||||
context.setGlobalContextManager(mgr)
|
||||
|
||||
return NodeSdk.layer(() => ({
|
||||
resource,
|
||||
resource: resource(),
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${base}/v1/traces`,
|
||||
@@ -73,7 +83,7 @@ export const layer = !base
|
||||
: Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const trace = yield* Effect.promise(traces)
|
||||
return Layer.mergeAll(trace, logs)
|
||||
return Layer.mergeAll(trace, logs())
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep"
|
||||
|
||||
import { Filesystem } from "@/util"
|
||||
import { Log } from "@/util"
|
||||
import { sanitizedProcessEnv } from "@/util/opencode-process"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
|
||||
@@ -157,9 +158,7 @@ type WorkerError = {
|
||||
}
|
||||
|
||||
function env() {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
|
||||
)
|
||||
const env = sanitizedProcessEnv()
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ripgrep } from "ripgrep"
|
||||
import { sanitizedProcessEnv } from "@/util/opencode-process"
|
||||
|
||||
function env() {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
|
||||
)
|
||||
const env = sanitizedProcessEnv()
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
|
||||
import { PluginCommand } from "./cli/cmd/plug"
|
||||
import { Heap } from "./cli/heap"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { ensureProcessMetadata } from "./util/opencode-process"
|
||||
|
||||
const processMetadata = ensureProcessMetadata("main")
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
@@ -108,6 +111,8 @@ const cli = yargs(args)
|
||||
Log.Default.info("opencode", {
|
||||
version: InstallationVersion,
|
||||
args: process.argv.slice(2),
|
||||
process_role: processMetadata.processRole,
|
||||
run_id: processMetadata.runID,
|
||||
})
|
||||
|
||||
const marker = path.join(Global.Path.data, "opencode.db")
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
export * as Npm from "./effect"
|
||||
|
||||
import path from "path"
|
||||
import semver from "semver"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Global } from "@opencode-ai/shared/global"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
|
||||
import { makeRuntime } from "../effect/runtime"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
dir: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint: Option.Option<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: { add: string[] },
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: Option.Option<string>
|
||||
try {
|
||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
entrypoint = Option.some(resolved)
|
||||
} catch {
|
||||
entrypoint = Option.none()
|
||||
}
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
}
|
||||
}
|
||||
|
||||
interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ArboristTree {
|
||||
edgesOut: Map<string, { to?: ArboristNode }>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||
const arborist = new Arborist({
|
||||
path: input.dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
})
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
arborist.reify({
|
||||
add: input?.add || [],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
}),
|
||||
catch: (cause) =>
|
||||
new InstallFailedError({
|
||||
cause,
|
||||
add: input?.add,
|
||||
dir: input.dir,
|
||||
}),
|
||||
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
||||
}).pipe(
|
||||
Effect.withSpan("Npm.reify", {
|
||||
attributes: input,
|
||||
}),
|
||||
)
|
||||
|
||||
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
})
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}, Effect.scoped)
|
||||
|
||||
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
|
||||
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!canWrite) return
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||
if (!nodeModulesExists) {
|
||||
yield* reify({ add: input?.add, dir })
|
||||
return
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
|
||||
const pkgAny = pkg as any
|
||||
const lockAny = lock as any
|
||||
const declared = new Set([
|
||||
...Object.keys(pkgAny?.dependencies || {}),
|
||||
...Object.keys(pkgAny?.devDependencies || {}),
|
||||
...Object.keys(pkgAny?.peerDependencies || {}),
|
||||
...Object.keys(pkgAny?.optionalDependencies || {}),
|
||||
...(input?.add || []),
|
||||
])
|
||||
|
||||
const root = lockAny?.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root?.dependencies || {}),
|
||||
...Object.keys(root?.devDependencies || {}),
|
||||
...Object.keys(root?.peerDependencies || {}),
|
||||
...Object.keys(root?.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
yield* reify({ dir, add: input?.add })
|
||||
return
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||
|
||||
return
|
||||
}, Effect.scoped)
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = Effect.fnUntraced(function* () {
|
||||
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
|
||||
if (files.length === 0) return Option.none<string>()
|
||||
if (files.length === 1) return Option.some(files[0])
|
||||
|
||||
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
|
||||
|
||||
if (Option.isSome(pkgJson)) {
|
||||
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
|
||||
if (parsed?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = parsed.bin
|
||||
if (typeof bin === "string") return Option.some(unscoped)
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return Option.some(keys[0])
|
||||
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
install,
|
||||
outdated,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function install(...args: Parameters<Interface["install"]>) {
|
||||
return runPromise((svc) => svc.install(...args))
|
||||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
@@ -1,198 +1,271 @@
|
||||
import semver from "semver"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util"
|
||||
export * as Npm from "."
|
||||
|
||||
import path from "path"
|
||||
import { readdir, rm } from "fs/promises"
|
||||
import { Filesystem } from "@/util"
|
||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||
import semver from "semver"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Global } from "@opencode-ai/shared/global"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
|
||||
import { makeRuntime } from "../effect/runtime"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
dir: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint: Option.Option<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: { add: string[] },
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
||||
const log = Log.create({ service: "npm" })
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
||||
}
|
||||
|
||||
function resolveEntryPoint(name: string, dir: string) {
|
||||
let entrypoint: string | undefined
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: Option.Option<string>
|
||||
try {
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {}
|
||||
const result = {
|
||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
entrypoint = Option.some(resolved)
|
||||
} catch {
|
||||
entrypoint = Option.none()
|
||||
}
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
const { Arborist } = await import("@npmcli/arborist")
|
||||
const dir = directory(pkg)
|
||||
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
interface ArboristTree {
|
||||
edgesOut: Map<string, { to?: ArboristNode }>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||
const arborist = new Arborist({
|
||||
path: input.dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
})
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
arborist.reify({
|
||||
add: input?.add || [],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
}),
|
||||
catch: (cause) =>
|
||||
new InstallFailedError({
|
||||
cause,
|
||||
add: input?.add,
|
||||
dir: input.dir,
|
||||
}),
|
||||
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
||||
}).pipe(
|
||||
Effect.withSpan("Npm.reify", {
|
||||
attributes: input,
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await arborist
|
||||
.reify({
|
||||
add: [pkg],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
})
|
||||
.catch((cause) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}, Effect.scoped)
|
||||
|
||||
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
|
||||
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!canWrite) return
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||
if (!nodeModulesExists) {
|
||||
yield* reify({ add: input?.add, dir })
|
||||
return
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
|
||||
const pkgAny = pkg as any
|
||||
const lockAny = lock as any
|
||||
const declared = new Set([
|
||||
...Object.keys(pkgAny?.dependencies || {}),
|
||||
...Object.keys(pkgAny?.devDependencies || {}),
|
||||
...Object.keys(pkgAny?.peerDependencies || {}),
|
||||
...Object.keys(pkgAny?.optionalDependencies || {}),
|
||||
...(input?.add || []),
|
||||
])
|
||||
|
||||
const root = lockAny?.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root?.dependencies || {}),
|
||||
...Object.keys(root?.devDependencies || {}),
|
||||
...Object.keys(root?.peerDependencies || {}),
|
||||
...Object.keys(root?.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
yield* reify({ dir, add: input?.add })
|
||||
return
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||
|
||||
return
|
||||
}, Effect.scoped)
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = Effect.fnUntraced(function* () {
|
||||
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
|
||||
if (files.length === 0) return Option.none<string>()
|
||||
if (files.length === 1) return Option.some(files[0])
|
||||
|
||||
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
|
||||
|
||||
if (Option.isSome(pkgJson)) {
|
||||
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
|
||||
if (parsed?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = parsed.bin
|
||||
if (typeof bin === "string") return Option.some(unscoped)
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return Option.some(keys[0])
|
||||
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
)
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) throw new InstallFailedError({ pkg })
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}
|
||||
|
||||
export async function install(dir: string) {
|
||||
await using _ = await Flock.acquire(`npm-install:${dir}`)
|
||||
log.info("checking dependencies", { dir })
|
||||
|
||||
const reify = async () => {
|
||||
const { Arborist } = await import("@npmcli/arborist")
|
||||
const arb = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
return Service.of({
|
||||
add,
|
||||
install,
|
||||
outdated,
|
||||
which,
|
||||
})
|
||||
await arb.reify().catch(() => {})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
type PackageDeps = Record<string, string>
|
||||
type PackageJson = {
|
||||
dependencies?: PackageDeps
|
||||
devDependencies?: PackageDeps
|
||||
peerDependencies?: PackageDeps
|
||||
optionalDependencies?: PackageDeps
|
||||
}
|
||||
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
|
||||
packages?: Record<string, PackageJson>
|
||||
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
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 install(...args: Parameters<Interface["install"]>) {
|
||||
return runPromise((svc) => svc.install(...args))
|
||||
}
|
||||
|
||||
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]
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
const entry = await runPromise((svc) => svc.add(...args))
|
||||
return {
|
||||
directory: entry.directory,
|
||||
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||
}
|
||||
|
||||
const bin = await pick()
|
||||
if (bin) return path.join(binDir, bin)
|
||||
|
||||
await rm(path.join(dir, "package-lock.json"), { force: true })
|
||||
await add(pkg)
|
||||
const resolved = await pick()
|
||||
if (!resolved) return
|
||||
return path.join(binDir, resolved)
|
||||
}
|
||||
|
||||
export * as Npm from "."
|
||||
export async function outdated(...args: Parameters<Interface["outdated"]>) {
|
||||
return runPromise((svc) => svc.outdated(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
const resolved = await runPromise((svc) => svc.which(...args))
|
||||
return Option.getOrUndefined(resolved)
|
||||
}
|
||||
|
||||
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
|
||||
export namespace PluginLoader {
|
||||
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
|
||||
export type Plan = {
|
||||
spec: string
|
||||
options: ConfigPlugin.Options | undefined
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
// A plugin that has been resolved to a concrete target and entrypoint on disk.
|
||||
export type Resolved = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
entry: string
|
||||
pkg?: PluginPackage
|
||||
}
|
||||
|
||||
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
|
||||
export type Missing = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
pkg?: PluginPackage
|
||||
message: string
|
||||
}
|
||||
|
||||
// A resolved plugin whose module has been imported successfully.
|
||||
export type Loaded = Resolved & {
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
||||
type Report = {
|
||||
// Called before each attempt so callers can log initial load attempts and retries uniformly.
|
||||
start?: (candidate: Candidate, retry: boolean) => void
|
||||
// Called when the package exists but does not provide the requested entrypoint.
|
||||
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||
// Called for operational failures such as install, compatibility, or dynamic import errors.
|
||||
error?: (
|
||||
candidate: Candidate,
|
||||
retry: boolean,
|
||||
@@ -46,11 +56,16 @@ export namespace PluginLoader {
|
||||
) => void
|
||||
}
|
||||
|
||||
// Normalize a config item into the loader's internal representation.
|
||||
function plan(item: ConfigPlugin.Spec): Plan {
|
||||
const spec = ConfigPlugin.pluginSpecifier(item)
|
||||
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||
}
|
||||
|
||||
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
|
||||
//
|
||||
// The stages here intentionally separate install/target resolution, entrypoint detection,
|
||||
// and compatibility checks so callers can report the exact reason a plugin was skipped.
|
||||
export async function resolve(
|
||||
plan: Plan,
|
||||
kind: PluginKind,
|
||||
@@ -59,6 +74,7 @@ export namespace PluginLoader {
|
||||
| { ok: false; stage: "missing"; value: Missing }
|
||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||
> {
|
||||
// First make sure the plugin exists locally, installing npm plugins on demand.
|
||||
let target = ""
|
||||
try {
|
||||
target = await resolvePluginTarget(plan.spec)
|
||||
@@ -67,6 +83,7 @@ export namespace PluginLoader {
|
||||
}
|
||||
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
|
||||
|
||||
// Then inspect the target for the requested server/tui entrypoint.
|
||||
let base
|
||||
try {
|
||||
base = await createPluginEntry(plan.spec, target, kind)
|
||||
@@ -86,6 +103,8 @@ export namespace PluginLoader {
|
||||
},
|
||||
}
|
||||
|
||||
// npm plugins can declare which opencode versions they support; file plugins are treated
|
||||
// as local development code and skip this compatibility gate.
|
||||
if (base.source === "npm") {
|
||||
try {
|
||||
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
||||
@@ -96,6 +115,7 @@ export namespace PluginLoader {
|
||||
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
|
||||
}
|
||||
|
||||
// Import the resolved module only after all earlier validation has succeeded.
|
||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||
let mod
|
||||
try {
|
||||
@@ -107,6 +127,8 @@ export namespace PluginLoader {
|
||||
return { ok: true, value: { ...row, mod } }
|
||||
}
|
||||
|
||||
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
|
||||
// import the module, and finally let the caller transform the loaded plugin into any result type.
|
||||
async function attempt<R>(
|
||||
candidate: Candidate,
|
||||
kind: PluginKind,
|
||||
@@ -116,11 +138,17 @@ export namespace PluginLoader {
|
||||
report: Report | undefined,
|
||||
): Promise<R | undefined> {
|
||||
const plan = candidate.plan
|
||||
|
||||
// Deprecated plugin packages are silently ignored because they are now built in.
|
||||
if (plan.deprecated) return
|
||||
|
||||
report?.start?.(candidate, retry)
|
||||
|
||||
const resolved = await resolve(plan, kind)
|
||||
if (!resolved.ok) {
|
||||
if (resolved.stage === "missing") {
|
||||
// Missing entrypoints are handled separately so callers can still inspect package metadata,
|
||||
// for example to load theme files from a tui plugin package that has no code entrypoint.
|
||||
if (missing) {
|
||||
const value = await missing(resolved.value, candidate.origin, retry)
|
||||
if (value !== undefined) return value
|
||||
@@ -131,11 +159,15 @@ export namespace PluginLoader {
|
||||
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = await load(resolved.value)
|
||||
if (!loaded.ok) {
|
||||
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
||||
return
|
||||
}
|
||||
|
||||
// The default behavior is to return the successfully loaded plugin as-is, but callers can
|
||||
// provide a finisher to adapt the result into a more specific runtime shape.
|
||||
if (!finish) return loaded.value as R
|
||||
return finish(loaded.value, candidate.origin, retry)
|
||||
}
|
||||
@@ -149,6 +181,11 @@ export namespace PluginLoader {
|
||||
report?: Report
|
||||
}
|
||||
|
||||
// Resolve and load all configured plugins in parallel.
|
||||
//
|
||||
// If `wait` is provided, file-based plugins that initially failed are retried once after the
|
||||
// caller finishes preparing dependencies. This supports local plugins that depend on an install
|
||||
// step happening elsewhere before their entrypoint becomes loadable.
|
||||
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
||||
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
||||
const list: Array<Promise<R | undefined>> = []
|
||||
@@ -160,6 +197,9 @@ export namespace PluginLoader {
|
||||
let deps: Promise<void> | undefined
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (out[i] !== undefined) continue
|
||||
|
||||
// Only local file plugins are retried. npm plugins already attempted installation during
|
||||
// the first pass, while file plugins may need the caller's dependency preparation to finish.
|
||||
const candidate = candidates[i]
|
||||
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
||||
deps ??= input.wait()
|
||||
@@ -167,6 +207,8 @@ export namespace PluginLoader {
|
||||
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop skipped/failed entries while preserving the successful result order.
|
||||
const ready: R[] = []
|
||||
for (const item of out) if (item !== undefined) ready.push(item)
|
||||
return ready
|
||||
|
||||
@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
import { Filesystem } from "@/util"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
// Old npm package names for plugins that are now built-in
|
||||
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
|
||||
@@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
||||
family: model.family,
|
||||
api: {
|
||||
id: model.id,
|
||||
url: model.provider?.api ?? provider.api!,
|
||||
url: model.provider?.api ?? provider.api ?? "",
|
||||
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||
},
|
||||
status: model.status ?? "active",
|
||||
@@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
||||
output: model.limit.output,
|
||||
},
|
||||
capabilities: {
|
||||
temperature: model.temperature,
|
||||
reasoning: model.reasoning,
|
||||
attachment: model.attachment,
|
||||
toolcall: model.tool_call,
|
||||
temperature: model.temperature ?? false,
|
||||
reasoning: model.reasoning ?? false,
|
||||
attachment: model.attachment ?? false,
|
||||
toolcall: model.tool_call ?? true,
|
||||
input: {
|
||||
text: model.modalities?.input?.includes("text") ?? false,
|
||||
audio: model.modalities?.input?.includes("audio") ?? false,
|
||||
@@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
||||
},
|
||||
interleaved: model.interleaved ?? false,
|
||||
},
|
||||
release_date: model.release_date,
|
||||
release_date: model.release_date ?? "",
|
||||
variants: {},
|
||||
}
|
||||
|
||||
@@ -1143,7 +1143,7 @@ const layer: Layer.Layer<
|
||||
existingModel?.api.npm ??
|
||||
modelsDev[providerID]?.npm ??
|
||||
"@ai-sdk/openai-compatible",
|
||||
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
|
||||
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
|
||||
},
|
||||
status: model.status ?? existingModel?.status ?? "active",
|
||||
name,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
@@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/experimental/workspace", WorkspaceRoutes())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Config } from "@/config"
|
||||
import { Provider } from "@/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||
return c.json(config)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.update", c, function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
|
||||
@@ -12,11 +12,11 @@ import { Config } from "@/config"
|
||||
import { ConsoleState } from "@/config/console-state"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
@@ -49,28 +49,24 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
description: "Active Console provider metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleState),
|
||||
schema: resolver(ConsoleState.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/console/orgs",
|
||||
@@ -89,28 +85,25 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const orgs = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
return groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return c.json({ orgs })
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
const orgs = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
return { orgs }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/console/switch",
|
||||
@@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", ConsoleSwitchBody),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool/ids",
|
||||
@@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
return c.json(ids)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool",
|
||||
@@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await AppRuntime.runPromise(
|
||||
const tools = await runRequest(
|
||||
"ExperimentalRoutes.tool.list",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
|
||||
return c.json(worktree)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/worktree",
|
||||
@@ -272,10 +261,11 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
|
||||
return c.json(sandboxes)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.sandboxes(Instance.project.id)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/worktree",
|
||||
@@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = yield* Worktree.Service
|
||||
const project = yield* Project.Service
|
||||
yield* worktree.remove(body)
|
||||
yield* project.removeSandbox(Instance.project.id, body.directory)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/worktree/reset",
|
||||
@@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
yield* svc.reset(body)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
@@ -406,15 +399,10 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { LSP } from "@/lsp"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const FileRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
|
||||
pattern: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const result = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||
)
|
||||
return c.json(result.items)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findText", c, function* () {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const svc = yield* Ripgrep.Service
|
||||
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
|
||||
return result.items
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/file",
|
||||
@@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query").query
|
||||
const dirs = c.req.valid("query").dirs
|
||||
const type = c.req.valid("query").type
|
||||
const limit = c.req.valid("query").limit
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) =>
|
||||
svc.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return c.json(results)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findFile", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.search({
|
||||
query: query.query,
|
||||
limit: query.limit ?? 10,
|
||||
dirs: query.dirs !== "false",
|
||||
type: query.type,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/symbol",
|
||||
@@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.list(path))
|
||||
}),
|
||||
)
|
||||
return c.json(content)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.list", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.list(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/content",
|
||||
@@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.read(path))
|
||||
}),
|
||||
)
|
||||
return c.json(content)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.read", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.read(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/status",
|
||||
@@ -198,13 +181,10 @@ export const FileRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.status())
|
||||
}),
|
||||
)
|
||||
return c.json(content)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.status", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,8 @@ import { ExperimentalRoutes } from "./experimental"
|
||||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { SyncRoutes } from "./sync"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceMiddleware } from "./middleware"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
const app = new Hono()
|
||||
@@ -140,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch, default_branch }
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.get", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch, default_branch }
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
@@ -177,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
mode: Vcs.Mode,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
@@ -205,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
|
||||
return c.json(commands)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.command.list", c, function* () {
|
||||
const svc = yield* Command.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/agent",
|
||||
@@ -227,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
return c.json(modes)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.agent.list", c, function* () {
|
||||
const svc = yield* Agent.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/skill",
|
||||
@@ -249,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
return c.json(skills)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.skill.list", c, function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/lsp",
|
||||
@@ -276,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
|
||||
return c.json(items)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.lsp.status", c, function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
return yield* lsp.status()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/formatter",
|
||||
@@ -298,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.formatter.status", c, function* () {
|
||||
const svc = yield* Format.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Config } from "@/config"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -28,9 +27,11 @@ export const McpRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.status", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.status()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
@@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: ConfigMCP.Info,
|
||||
config: ConfigMCP.Info.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
|
||||
return c.json(result.status)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.add", c, function* () {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
const result = yield* mcp.add(name, config)
|
||||
return result.status
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth",
|
||||
@@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.start",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
@@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
|
||||
code: z.string().describe("Authorization code from OAuth callback"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
|
||||
return c.json(status)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.callback", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.finishAuth(name, code)
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/authenticate",
|
||||
@@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
const result = await runRequest(
|
||||
"McpRoutes.auth.authenticate",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
@@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
|
||||
return c.json({ success: true as const })
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.auth.remove", c, function* () {
|
||||
const name = c.req.param("name")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.removeAuth(name)
|
||||
return { success: true as const }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/connect",
|
||||
@@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.connect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.connect(name)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:name/disconnect",
|
||||
@@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("McpRoutes.disconnect", c, function* () {
|
||||
const { name } = c.req.valid("param")
|
||||
const mcp = yield* MCP.Service
|
||||
yield* mcp.disconnect(name)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
35
packages/opencode/src/server/routes/instance/middleware.ts
Normal file
35
packages/opencode/src/server/routes/instance/middleware.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = AppFileSystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const PermissionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
@@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||
return c.json(permissions)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.list", c, function* () {
|
||||
const svc = yield* Permission.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await AppRuntime.runPromise(
|
||||
const next = await runRequest(
|
||||
"ProjectRoutes.initGit",
|
||||
c,
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
@@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
|
||||
return c.json(project)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProjectRoutes.update", c, function* () {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.update({ ...body, projectID })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Provider } from "@/provider"
|
||||
import { ModelsDev } from "@/provider"
|
||||
import { ProviderAuth } from "@/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.list", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return c.json({
|
||||
all: result.all,
|
||||
default: result.default,
|
||||
connected: result.connected,
|
||||
})
|
||||
},
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/auth",
|
||||
@@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.auth", c, function* () {
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.methods()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/authorize",
|
||||
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.AuthorizeInput.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(
|
||||
ProviderAuth.Service.use((svc) =>
|
||||
svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/callback",
|
||||
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.CallbackInput.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
ProviderAuth.Service.use((svc) =>
|
||||
svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
yield* svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "@/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
@@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.list", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
return c.json(info)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.create", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID",
|
||||
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
const info = await runRequest(
|
||||
"PtyRoutes.get",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(c.req.valid("param").ptyID)
|
||||
@@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
return c.json(info)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.update", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:ptyID",
|
||||
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.remove", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
if (
|
||||
!(await AppRuntime.runPromise(
|
||||
!(await runRequest(
|
||||
"PtyRoutes.connect",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(id)
|
||||
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, socket, cursor)
|
||||
}),
|
||||
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
|
||||
)
|
||||
ready = true
|
||||
for (const msg of pending) handler?.onMessage(msg)
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Question } from "@/question"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
const Reply = z.object({
|
||||
answers: Question.Answer.zod
|
||||
@@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
return c.json(questions)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.list", c, function* () {
|
||||
const svc = yield* Question.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Question.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reject", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reject(params.requestID)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Command } from "@/command"
|
||||
@@ -26,7 +25,7 @@ import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Bus } from "@/bus"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { jsonRequest } from "./trace"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Session.CreateInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.create", c, function* () {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const svc = yield* SessionShare.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID",
|
||||
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: Session.RemoveInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.delete", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const svc = yield* Session.Service
|
||||
yield* svc.remove(sessionID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID",
|
||||
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.update", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
|
||||
.post(
|
||||
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.init", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
yield* svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/fork",
|
||||
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", Session.ForkInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.fork", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Session.Service
|
||||
return yield* svc.fork({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/abort",
|
||||
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.abort", c, function* () {
|
||||
const svc = yield* SessionPrompt.Service
|
||||
yield* svc.cancel(c.req.valid("param").sessionID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/share",
|
||||
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.share", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/diff",
|
||||
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: SessionSummary.DiffInput.shape.messageID,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const params = c.req.valid("param")
|
||||
const result = await AppRuntime.runPromise(
|
||||
SessionSummary.Service.use((summary) =>
|
||||
summary.diff({
|
||||
sessionID: params.sessionID,
|
||||
messageID: query.messageID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.diff", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const params = c.req.valid("param")
|
||||
const summary = yield* SessionSummary.Service
|
||||
return yield* summary.diff({
|
||||
sessionID: params.sessionID,
|
||||
messageID: query.messageID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/share",
|
||||
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.unshare", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/summarize",
|
||||
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
|
||||
auto: z.boolean().optional().default(false),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const agent = yield* Agent.Service
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.summarize", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
yield* revert.cleanup(yield* session.get(sessionID))
|
||||
const msgs = yield* session.messages({ sessionID })
|
||||
const defaultAgent = yield* agent.defaultAgent()
|
||||
let currentAgent = defaultAgent
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const info = msgs[i].info
|
||||
if (info.role === "user") {
|
||||
currentAgent = info.agent || defaultAgent
|
||||
break
|
||||
}
|
||||
yield* revert.cleanup(yield* session.get(sessionID))
|
||||
const msgs = yield* session.messages({ sessionID })
|
||||
const defaultAgent = yield* agent.defaultAgent()
|
||||
let currentAgent = defaultAgent
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const info = msgs[i].info
|
||||
if (info.role === "user") {
|
||||
currentAgent = info.agent || defaultAgent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield* compact.create({
|
||||
sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: body.providerID,
|
||||
modelID: body.modelID,
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
yield* prompt.loop({ sessionID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
yield* compact.create({
|
||||
sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: body.providerID,
|
||||
modelID: body.modelID,
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
yield* prompt.loop({ sessionID })
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/message",
|
||||
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
|
||||
const query = c.req.valid("query")
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
if (query.limit === undefined || query.limit === 0) {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
const messages = await runRequest(
|
||||
"SessionRoutes.messages",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
yield* session.get(sessionID)
|
||||
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(params.sessionID)
|
||||
yield* session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.deleteMessage", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(params.sessionID)
|
||||
yield* session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.deletePart", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Session.Service
|
||||
yield* svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
@@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
|
||||
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
|
||||
return c.json(part)
|
||||
return jsonRequest("SessionRoutes.updatePart", c, function* () {
|
||||
const svc = yield* Session.Service
|
||||
return yield* svc.updatePart(body)
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async (stream) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(
|
||||
const msg = await runRequest(
|
||||
"SessionRoutes.prompt",
|
||||
c,
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
)
|
||||
void stream.write(JSON.stringify(msg))
|
||||
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
|
||||
(err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
void Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
},
|
||||
)
|
||||
void runRequest(
|
||||
"SessionRoutes.prompt_async",
|
||||
c,
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
void Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.command", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.command({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/shell",
|
||||
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.shell", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.shell({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/revert",
|
||||
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("revert", c.req.valid("json"))
|
||||
const session = await AppRuntime.runPromise(
|
||||
SessionRevert.Service.use((svc) =>
|
||||
svc.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(session)
|
||||
return jsonRequest("SessionRoutes.revert", c, function* () {
|
||||
const svc = yield* SessionRevert.Service
|
||||
return yield* svc.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.unrevert", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const svc = yield* SessionRevert.Service
|
||||
return yield* svc.unrevert({ sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/permissions/:permissionID",
|
||||
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ response: Permission.Reply.zod })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.permissionRespond", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
|
||||
const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
await runRequest(
|
||||
"TuiRoutes.sessionSelect",
|
||||
c,
|
||||
Session.Service.use((svc) => svc.get(sessionID)),
|
||||
)
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { generateSpecs } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { adapter } from "#hono"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Log } from "@/util"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { MDNS } from "./mdns"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { FenceMiddleware } from "./fence"
|
||||
@@ -20,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control"
|
||||
import { UIRoutes } from "./routes/ui"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { WorkspaceRouterMiddleware } from "./workspace"
|
||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -48,34 +44,6 @@ function create(opts: { cors?: string[] }) {
|
||||
|
||||
const runtime = adapter.create(app)
|
||||
|
||||
function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = AppFileSystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_WORKSPACE_ID) {
|
||||
return {
|
||||
app: app
|
||||
@@ -88,9 +56,14 @@ function create(opts: { cors?: string[] }) {
|
||||
|
||||
return {
|
||||
app: app
|
||||
.use(InstanceMiddleware())
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
|
||||
.route(
|
||||
"/",
|
||||
new Hono()
|
||||
.use(InstanceMiddleware())
|
||||
.route("/experimental/workspace", WorkspaceRoutes())
|
||||
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
|
||||
)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util"
|
||||
import { ServerProxy } from "./proxy"
|
||||
|
||||
@@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
|
||||
).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export const layer: Layer.Layer<
|
||||
return true
|
||||
})
|
||||
|
||||
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
|
||||
@@ -649,7 +649,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
|
||||
return input.partID
|
||||
})
|
||||
|
||||
const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
|
||||
const updatePartDelta = Effect.fnUntraced(function* (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
|
||||
@@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
|
||||
export type Share = typeof ShareSchema.Type
|
||||
|
||||
type State = {
|
||||
queue: Map<string, { data: Map<string, Data> }>
|
||||
queue: Map<SessionID, Map<string, Data>>
|
||||
scope: Scope.Closeable
|
||||
shared: Map<SessionID, Share | null>
|
||||
}
|
||||
|
||||
type Data =
|
||||
@@ -118,17 +119,20 @@ export const layer = Layer.effect(
|
||||
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
|
||||
return Effect.gen(function* () {
|
||||
if (disabled) return
|
||||
const share = yield* getCached(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
const existing = s.queue.get(sessionID)
|
||||
if (existing) {
|
||||
for (const item of data) {
|
||||
existing.data.set(key(item), item)
|
||||
existing.set(key(item), item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const next = new Map(data.map((item) => [key(item), item]))
|
||||
s.queue.set(sessionID, { data: next })
|
||||
s.queue.set(sessionID, next)
|
||||
yield* flush(sessionID).pipe(
|
||||
Effect.delay(1000),
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -143,13 +147,14 @@ export const layer = Layer.effect(
|
||||
|
||||
const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
|
||||
Effect.fn("ShareNext.state")(function* (_ctx) {
|
||||
const cache: State = { queue: new Map(), scope: yield* Scope.make() }
|
||||
const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() }
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Scope.close(cache.scope, Exit.void).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
cache.queue.clear()
|
||||
cache.shared.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -227,6 +232,18 @@ export const layer = Layer.effect(
|
||||
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
|
||||
})
|
||||
|
||||
const getCached = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (s.shared.has(sessionID)) {
|
||||
const cached = s.shared.get(sessionID)
|
||||
return cached === null ? undefined : cached
|
||||
}
|
||||
|
||||
const share = yield* get(sessionID)
|
||||
s.shared.set(sessionID, share ?? null)
|
||||
return share
|
||||
})
|
||||
|
||||
const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
|
||||
if (disabled) return
|
||||
const s = yield* InstanceState.get(state)
|
||||
@@ -235,13 +252,13 @@ export const layer = Layer.effect(
|
||||
|
||||
s.queue.delete(sessionID)
|
||||
|
||||
const share = yield* get(sessionID)
|
||||
const share = yield* getCached(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const req = yield* request()
|
||||
const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
|
||||
HttpClientRequest.setHeaders(req.headers),
|
||||
HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }),
|
||||
HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }),
|
||||
Effect.flatMap((r) => http.execute(r)),
|
||||
)
|
||||
|
||||
@@ -307,6 +324,7 @@ export const layer = Layer.effect(
|
||||
.run(),
|
||||
)
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.shared.set(sessionID, result)
|
||||
yield* full(sessionID).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
@@ -321,8 +339,13 @@ export const layer = Layer.effect(
|
||||
const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
|
||||
if (disabled) return
|
||||
log.info("removing share", { sessionID })
|
||||
const share = yield* get(sessionID)
|
||||
if (!share) return
|
||||
const s = yield* InstanceState.get(state)
|
||||
const share = yield* getCached(sessionID)
|
||||
if (!share) {
|
||||
s.shared.delete(sessionID)
|
||||
s.queue.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const req = yield* request()
|
||||
yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
|
||||
@@ -332,6 +355,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
|
||||
s.shared.delete(sessionID)
|
||||
s.queue.delete(sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ init, url, request, create, remove })
|
||||
|
||||
@@ -16,13 +16,34 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||
if (override) return override
|
||||
|
||||
const out = body(ast)
|
||||
let out = body(ast)
|
||||
for (const check of ast.checks ?? []) {
|
||||
out = applyCheck(out, check, ast)
|
||||
}
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const next = desc ? out.describe(desc) : out
|
||||
return ref ? next.meta({ ref }) : next
|
||||
}
|
||||
|
||||
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (check._tag === "FilterGroup") {
|
||||
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
|
||||
}
|
||||
return out.superRefine((value, ctx) => {
|
||||
const issue = check.run(value, ast, {} as any)
|
||||
if (!issue) return
|
||||
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
|
||||
ctx.addIssue({ code: "custom", message })
|
||||
})
|
||||
}
|
||||
|
||||
function issueMessage(issue: any): string | undefined {
|
||||
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
|
||||
if (typeof issue?.message === "string") return issue.message
|
||||
return undefined
|
||||
}
|
||||
|
||||
function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast)
|
||||
|
||||
@@ -98,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||
}
|
||||
|
||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||
if (ast.elements.length > 0) return fail(ast)
|
||||
if (ast.rest.length !== 1) return fail(ast)
|
||||
return z.array(walk(ast.rest[0]))
|
||||
// Pure variadic arrays: { elements: [], rest: [item] }
|
||||
if (ast.elements.length === 0) {
|
||||
if (ast.rest.length !== 1) return fail(ast)
|
||||
return z.array(walk(ast.rest[0]))
|
||||
}
|
||||
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
|
||||
// Tuples with a variadic tail (...rest) are not yet supported.
|
||||
if (ast.rest.length > 0) return fail(ast)
|
||||
const items = ast.elements.map(walk)
|
||||
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
|
||||
|
||||
24
packages/opencode/src/util/opencode-process.ts
Normal file
24
packages/opencode/src/util/opencode-process.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
|
||||
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
|
||||
|
||||
export function ensureRunID() {
|
||||
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
|
||||
}
|
||||
|
||||
export function ensureProcessRole(fallback: "main" | "worker") {
|
||||
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
|
||||
}
|
||||
|
||||
export function ensureProcessMetadata(fallback: "main" | "worker") {
|
||||
return {
|
||||
runID: ensureRunID(),
|
||||
processRole: ensureProcessRole(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
return overrides ? Object.assign(env, overrides) : env
|
||||
}
|
||||
@@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
@@ -117,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
@@ -179,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
@@ -241,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
@@ -299,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
@@ -468,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Npm } from "@/npm/effect"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
|
||||
87
packages/opencode/test/config/lsp.test.ts
Normal file
87
packages/opencode/test/config/lsp.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigLSP } from "../../src/config/lsp"
|
||||
|
||||
// The LSP config refinement enforces: any custom (non-builtin) LSP server
|
||||
// entry must declare an `extensions` array so the client knows which files
|
||||
// the server should attach to. Builtin server IDs and explicitly disabled
|
||||
// entries are exempt.
|
||||
//
|
||||
// Both validation paths must honor this rule:
|
||||
// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
|
||||
// - `ConfigLSP.Info.zod.parse(...)` (derived Zod)
|
||||
//
|
||||
// `typescript` is a builtin server id (see src/lsp/server.ts).
|
||||
describe("ConfigLSP.Info refinement", () => {
|
||||
const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
|
||||
|
||||
describe("accepted inputs", () => {
|
||||
test("true and false pass (top-level toggle)", () => {
|
||||
expect(decodeEffect(true)).toBe(true)
|
||||
expect(decodeEffect(false)).toBe(false)
|
||||
expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
|
||||
expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
|
||||
})
|
||||
|
||||
test("builtin server with no extensions passes", () => {
|
||||
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("custom server WITH extensions passes", () => {
|
||||
const input = {
|
||||
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
|
||||
}
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("disabled custom server passes (no extensions needed)", () => {
|
||||
const input = { "my-lsp": { disabled: true as const } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("mix of builtin and custom with extensions passes", () => {
|
||||
const input = {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
|
||||
}
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("rejected inputs", () => {
|
||||
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
|
||||
|
||||
test("custom server WITHOUT extensions fails via Effect decode", () => {
|
||||
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
|
||||
})
|
||||
|
||||
test("custom server WITHOUT extensions fails via derived Zod", () => {
|
||||
const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
|
||||
})
|
||||
|
||||
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
|
||||
// Boolean(['']) is true, so a non-empty array of strings is fine.
|
||||
// Boolean([]) is also true in JS, so empty arrays are accepted by the
|
||||
// refinement. This test documents current behavior.
|
||||
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("custom server without extensions mixed with a valid builtin still fails", () => {
|
||||
const input = {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
"my-lsp": { command: ["my-lsp-bin"] },
|
||||
}
|
||||
expect(() => decodeEffect(input)).toThrow(expectedMessage)
|
||||
expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
|
||||
})
|
||||
|
||||
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
||||
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
|
||||
return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
|
||||
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
|
||||
return { directory: tmp.extra.scope, entrypoint: undefined }
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
@@ -927,7 +927,7 @@ export default {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
const missing: string[] = []
|
||||
|
||||
try {
|
||||
@@ -996,7 +996,7 @@ export default {
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
|
||||
@@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ModelsDev.Provider
|
||||
} as unknown as ModelsDev.Provider
|
||||
|
||||
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
|
||||
expect(model.cost.input).toEqual(5)
|
||||
@@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("models.dev normalization fills required response fields", () => {
|
||||
const provider = {
|
||||
id: "gateway",
|
||||
name: "Gateway",
|
||||
env: [],
|
||||
models: {
|
||||
"gpt-5.4": {
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
family: "gpt",
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 15,
|
||||
},
|
||||
limit: {
|
||||
context: 1_050_000,
|
||||
input: 922_000,
|
||||
output: 128_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ModelsDev.Provider
|
||||
|
||||
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
|
||||
expect(model.api.url).toBe("")
|
||||
expect(model.capabilities.temperature).toBe(false)
|
||||
expect(model.capabilities.reasoning).toBe(false)
|
||||
expect(model.capabilities.attachment).toBe(false)
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
expect(model.release_date).toBe("")
|
||||
})
|
||||
|
||||
test("model variants are generated for reasoning models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("throws for unsupported tuple schemas", () => {
|
||||
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
|
||||
describe("Tuples", () => {
|
||||
test("fixed-length tuple parses matching array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
|
||||
expect(out.parse(["a", 1])).toEqual(["a", 1])
|
||||
expect(out.safeParse(["a"]).success).toBe(false)
|
||||
expect(out.safeParse(["a", "b"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("single-element tuple parses a one-element array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.Boolean]))
|
||||
expect(out.parse([true])).toEqual([true])
|
||||
expect(out.safeParse([true, false]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("tuple inside a union picks the right branch", () => {
|
||||
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
|
||||
expect(out.parse("hello")).toBe("hello")
|
||||
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
|
||||
expect(out.safeParse(["foo"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("plain arrays still work (no element positions)", () => {
|
||||
const out = zod(Schema.Array(Schema.String))
|
||||
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
|
||||
expect(out.parse([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("string literal unions produce z.enum with enum in JSON Schema", () => {
|
||||
@@ -186,4 +210,57 @@ describe("util.effect-zod", () => {
|
||||
const schema = json(zod(Parent)) as any
|
||||
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||
})
|
||||
|
||||
describe("Schema.check translation", () => {
|
||||
test("filter returning string triggers refinement with that message", () => {
|
||||
const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
|
||||
const schema = zod(Schema.Number.check(isEven))
|
||||
|
||||
expect(schema.parse(4)).toBe(4)
|
||||
const result = schema.safeParse(3)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("expected an even number")
|
||||
})
|
||||
|
||||
test("filter returning false triggers refinement with fallback message", () => {
|
||||
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
|
||||
const schema = zod(Schema.String.check(nonEmpty))
|
||||
|
||||
expect(schema.parse("hi")).toBe("hi")
|
||||
const result = schema.safeParse("")
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toMatch(/./)
|
||||
})
|
||||
|
||||
test("filter returning undefined passes validation", () => {
|
||||
const alwaysOk = Schema.makeFilter(() => undefined)
|
||||
const schema = zod(Schema.Number.check(alwaysOk))
|
||||
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("annotations.message on the filter is used when filter returns false", () => {
|
||||
const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
|
||||
const schema = zod(Schema.Number.check(positive))
|
||||
|
||||
const result = schema.safeParse(-1)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("must be positive")
|
||||
})
|
||||
|
||||
test("cross-field check on a record flags missing key", () => {
|
||||
const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
|
||||
"required" in data ? undefined : "missing 'required' key",
|
||||
)
|
||||
const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
|
||||
|
||||
expect(schema.parse({ required: { enabled: true } })).toEqual({
|
||||
required: { enabled: true },
|
||||
})
|
||||
|
||||
const result = schema.safeParse({ other: { enabled: true } })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1807,6 +1807,12 @@ export type Provider = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsoleState = {
|
||||
consoleManagedProviders: Array<string>
|
||||
activeOrgName?: string
|
||||
switchableOrgCount: number
|
||||
}
|
||||
|
||||
export type ToolIds = Array<string>
|
||||
|
||||
export type ToolListItem = {
|
||||
@@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
|
||||
/**
|
||||
* Active Console provider metadata
|
||||
*/
|
||||
200: {
|
||||
consoleManagedProviders: Array<string>
|
||||
activeOrgName?: string
|
||||
switchableOrgCount: number
|
||||
}
|
||||
200: ConsoleState
|
||||
}
|
||||
|
||||
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
|
||||
|
||||
@@ -1607,24 +1607,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"consoleManagedProviders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"activeOrgName": {
|
||||
"type": "string"
|
||||
},
|
||||
"switchableOrgCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": ["consoleManagedProviders", "switchableOrgCount"]
|
||||
"$ref": "#/components/schemas/ConsoleState"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11428,13 +11411,10 @@
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"additionalProperties": false
|
||||
"required": ["type", "command"]
|
||||
},
|
||||
"McpOAuthConfig": {
|
||||
"type": "object",
|
||||
@@ -11455,8 +11435,7 @@
|
||||
"description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"McpRemoteConfig": {
|
||||
"type": "object",
|
||||
@@ -11498,13 +11477,10 @@
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
"required": ["type", "url"]
|
||||
},
|
||||
"LayoutConfig": {
|
||||
"description": "@deprecated Always uses stretch layout.",
|
||||
@@ -12366,6 +12342,24 @@
|
||||
},
|
||||
"required": ["id", "name", "source", "env", "options", "models"]
|
||||
},
|
||||
"ConsoleState": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"consoleManagedProviders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"activeOrgName": {
|
||||
"type": "string"
|
||||
},
|
||||
"switchableOrgCount": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["consoleManagedProviders", "switchableOrgCount"]
|
||||
},
|
||||
"ToolIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"name": "@opencode-ai/shared",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -165,55 +165,60 @@ export namespace EffectFlock {
|
||||
|
||||
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
|
||||
|
||||
const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) {
|
||||
const token = randomUUID()
|
||||
const metaPath = path.join(lockDir, "meta.json")
|
||||
const heartbeatPath = path.join(lockDir, "heartbeat")
|
||||
const tryAcquireLockDir = (lockDir: string, key: string) =>
|
||||
Effect.gen(function* () {
|
||||
const token = randomUUID()
|
||||
const metaPath = path.join(lockDir, "meta.json")
|
||||
const heartbeatPath = path.join(lockDir, "heartbeat")
|
||||
|
||||
// Atomic mkdir — the POSIX lock primitive
|
||||
const created = yield* atomicMkdir(lockDir)
|
||||
// Atomic mkdir — the POSIX lock primitive
|
||||
const created = yield* atomicMkdir(lockDir)
|
||||
|
||||
if (!created) {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
||||
if (!created) {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
||||
|
||||
// Stale — race for breaker ownership
|
||||
const breakerPath = lockDir + ".breaker"
|
||||
// Stale — race for breaker ownership
|
||||
const breakerPath = lockDir + ".breaker"
|
||||
|
||||
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "AlreadyExists",
|
||||
() => cleanStaleBreaker(breakerPath),
|
||||
),
|
||||
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
||||
Effect.orDie,
|
||||
)
|
||||
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "AlreadyExists",
|
||||
() => cleanStaleBreaker(breakerPath),
|
||||
),
|
||||
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
if (!claimed) return yield* new NotAcquired()
|
||||
if (!claimed) return yield* new NotAcquired()
|
||||
|
||||
// We own the breaker — double-check staleness, nuke, recreate
|
||||
const recreated = yield* Effect.gen(function* () {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
||||
yield* forceRemove(lockDir)
|
||||
return yield* atomicMkdir(lockDir)
|
||||
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
||||
// We own the breaker — double-check staleness, nuke, recreate
|
||||
const recreated = yield* Effect.gen(function* () {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
||||
yield* forceRemove(lockDir)
|
||||
return yield* atomicMkdir(lockDir)
|
||||
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
||||
|
||||
if (!recreated) return yield* new NotAcquired()
|
||||
}
|
||||
if (!recreated) return yield* new NotAcquired()
|
||||
}
|
||||
|
||||
// We own the lock dir — write heartbeat + meta with exclusive create
|
||||
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
|
||||
// We own the lock dir — write heartbeat + meta with exclusive create
|
||||
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
|
||||
|
||||
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
|
||||
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
|
||||
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
|
||||
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
|
||||
|
||||
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
|
||||
})
|
||||
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
|
||||
}).pipe(
|
||||
Effect.withSpan("EffectFlock.tryAcquire", {
|
||||
attributes: { key },
|
||||
}),
|
||||
)
|
||||
|
||||
// -- retry wrapper (preserves Handle type) --
|
||||
|
||||
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
|
||||
tryAcquireLockDir(lockfile).pipe(
|
||||
tryAcquireLockDir(lockfile, key).pipe(
|
||||
Effect.retry({
|
||||
while: (err) => err._tag === "NotAcquired",
|
||||
schedule: retrySchedule,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user