Compare commits

..
Author SHA1 Message Date
Brendan Allan c2f6135daf Merge branch 'dev' into brendan/effect-env 2026-04-14 09:24:51 +08:00
Kit Langton a2cb4909da refactor(plugin): remove async facade exports (#22367) 2026-04-13 21:24:20 -04:00
Brendan Allan 3c74c0db30 Merge branch 'dev' into brendan/effect-env 2026-04-14 09:24:18 +08:00
Kit Langton 7a05ba47d1 refactor(session): remove compaction async facade exports (#22366) 2026-04-13 21:23:34 -04:00
Kit Langton 36745caa2a refactor(worktree): remove async facade exports (#22369) 2026-04-13 21:23:15 -04:00
Nazar H.andNazar Hnatyshen c2403d0f15 fix(provider): guard reasoningSummary injection for @ai-sdk/openai-compatible providers (#22352)
Co-authored-by: Nazar Hnatyshen <nazar.hnatyshen@atolls.com>
2026-04-13 20:20:06 -05:00
Aiden Cline 34e2429c49 feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) 2026-04-13 20:14:53 -05:00
Brendan Allan 1fa55dcca7 Merge branch 'dev' into brendan/effect-env 2026-04-14 08:25:37 +08:00
Brendan Allan ad0ff9b651 formatting 2026-04-14 08:25:11 +08:00
opencode-agent[bot] 10ba68c772 chore: update nix node_modules hashes 2026-04-14 00:23:25 +00:00
Brendan Allan 9ef9fac6db formatting 2026-04-14 08:10:18 +08:00
Brendan Allan ef2faaac4c convert wrappers to use sync runtime 2026-04-14 08:07:25 +08:00
Brendan Allan e74c99320f core: effectify Env service 2026-04-14 08:00:58 +08:00
Kit Langton e8471256f2 refactor(session): move llm stream into layer (#22358) 2026-04-13 19:53:30 -04:00
Kit Langton 43b37346b6 feat: add interactive burst to the TUI logo (#22098) 2026-04-13 19:36:28 -04:00
Kit Langton d199648aeb refactor(permission): remove async facade exports (#22342) 2026-04-13 19:33:58 -04:00
Kit Langton a06f40297b fix grep exact file path searches (#22356) 2026-04-13 19:26:50 -04:00
Dax Raad 59c0fc28ee ignore: v2 thoughts 2026-04-13 17:33:34 -04:00
James Long b22add292c refactor(core): publish sync events to global event stream (#22347) 2026-04-13 16:51:59 -04:00
Kit Langton 67aaecacac refactor(session): remove revert async facade exports (#22339) 2026-04-13 16:16:13 -04:00
Kit Langton 29c202e6ab refactor(mcp): remove mcp auth async facade exports (#22338) 2026-04-13 15:36:12 -04:00
Kit Langton dcbf11f41a refactor(session): remove summary async facades (#22337) 2026-04-13 15:35:38 -04:00
Kit Langton 14ccff4037 refactor(agent): remove async facade exports (#22341) 2026-04-13 14:54:01 -04:00
Kit Langton 5b8b874732 update effect docs (#22340) 2026-04-13 14:07:59 -04:00
opencode-agent[bot] 1d81c0266c chore: generate 2026-04-13 18:02:12 +00:00
Dax Raad 913120759a session entry 2026-04-13 14:00:49 -04:00
Dax 7a6ce05d09 2.0 exploration (#22335) 2026-04-13 13:47:33 -04:00
Kit Langton 1dc69359d5 refactor(mcp): remove async facade exports (#22324) 2026-04-13 13:45:34 -04:00
opencode-agent[bot] 329fcb040b chore: generate 2026-04-13 17:37:41 +00:00
James Long bf50d1c028 feat(core): expose workspace adaptors to plugins (#21927) 2026-04-13 13:33:13 -04:00
Kit Langton b8801dbd22 refactor(file): remove async facade exports (#22322) 2026-04-13 13:12:02 -04:00
Kit Langton f7c6943817 refactor(config): remove async facade exports (#22325) 2026-04-13 13:11:05 -04:00
github-actions[bot] 91fe4db27c Update VOUCHED list
https://github.com/anomalyco/opencode/issues/22239#issuecomment-4238224546
2026-04-13 17:06:03 +00:00
Kit Langton 21d7a85e76 refactor(lsp): remove async facade exports (#22321) 2026-04-13 12:47:52 -04:00
Kit Langton 663e798e76 refactor(provider): remove async facade exports (#22320) 2026-04-13 12:40:00 -04:00
Aiden Cline 5bc2d2498d test: ensure project and global instructions are loaded (#22317) 2026-04-13 11:34:38 -05:00
Kit Langton c22e34853d refactor(auth): remove async auth facade exports (#22306) 2026-04-13 12:31:43 -04:00
Kit Langton 6825b0bbc7 refactor(pty): remove async facade exports (#22305) 2026-04-13 11:47:05 -04:00
Kit Langton 3644581b55 refactor(file): stream ripgrep search parsing (#22303) 2026-04-13 11:39:37 -04:00
Kit Langton 79cc15335e fix: dispose e2e app runtime (#22316) 2026-04-13 11:36:56 -04:00
Kit Langton ca6200121b refactor: remove vcs async facade exports (#22304) 2026-04-13 11:22:20 -04:00
Kit Langton 7239b38b7f refactor(skill): remove async facade exports (#22308) 2026-04-13 11:18:10 -04:00
Kit Langton 9ae8dc2d01 refactor: remove ToolRegistry runtime facade (#22307) 2026-04-13 11:09:32 -04:00
opencode-agent[bot] 7164662be2 chore: generate 2026-04-13 14:18:05 +00:00
Brendan AllanandKit Langton 94f71f59a3 core: make InstanceBootstrap into an effect (#22274)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-04-13 10:16:40 -04:00
Kit Langton 3eb6508a64 refactor: share TUI terminal background detection (#22297) 2026-04-13 10:05:37 -04:00
Kit Langton 6fdb8ab90d refactor(file): add ripgrep search service (#22295) 2026-04-13 10:04:32 -04:00
Kit Langton 321bf1f8e1 refactor: finish small effect service adoption cleanups (#22094) 2026-04-13 09:17:13 -04:00
Brendan Allan 62bd023086 app: replace parsePatchFiles with parseDiffFromFile (#22270) 2026-04-13 17:19:14 +08:00
Brendan Allan cb1a50055c fix(electron): wait until ready before showing the main window (#22262) 2026-04-13 15:17:09 +08:00
opencode-agent[bot] 65e3348232 chore: update nix node_modules hashes 2026-04-13 06:02:50 +00:00
Brendan Allan a6b9f0dac1 app: align workspace load more button (#22251) 2026-04-13 13:58:35 +08:00
Brendan Allan 34f5bdbc99 app: fix scroll to bottom light mode style (#22250) 2026-04-13 13:55:33 +08:00
Aiden Cline 0b4fe14b0a fix: forgot to put alibaba case in last commit (#22249) 2026-04-13 00:39:12 -05:00
Aiden Cline 7230cd2683 feat: add alibaba pkg and cache support (#22248) 2026-04-13 00:08:07 -05:00
Aiden Cline a915fe74be tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) 2026-04-12 21:39:06 -05:00
Brendan Allan 26d35583c5 sdk: throw error if response has text/html content type (#21289) 2026-04-13 09:39:53 +08:00
Goni ZahavyandAiden Cline ae17b416b8 fix(cli): auth login now asks for api key in handlePluginAuth (#21641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-12 20:37:57 -05:00
Aiden Cline 8ffadde85c chore: rm git ignored files (#22200) 2026-04-12 15:52:55 -05:00
Dax Raad 3c0ad70653 ci: enable beta branch releases with auto-update support 2026-04-12 14:40:24 -04:00
Dax 264418c0cd fix(snapshot): complete gitignore respect for previously tracked files (#22172) 2026-04-12 14:05:46 -04:00
shafdev fa2c69f09c fix(opencode): remove spurious scripts and randomField from package.json (#22160) 2026-04-12 13:49:24 -04:00
Dax 113304a058 fix(snapshot): respect gitignore for previously tracked files (#22171) 2026-04-12 13:41:50 -04:00
173 changed files with 12221 additions and 72519 deletions
+1
View File
@@ -25,6 +25,7 @@ kommander
-opencodeengineer bot that spams issues -opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-ricardo-m-l
-robinmordasiewicz -robinmordasiewicz
simonklee simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
+2 -3
View File
@@ -213,7 +213,6 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -390,7 +389,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -596,7 +595,7 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta' if: needs.version.outputs.release
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
+12
View File
@@ -319,6 +319,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
@@ -370,6 +371,7 @@
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
@@ -707,6 +709,8 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.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-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-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -2665,6 +2669,8 @@
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
@@ -3089,6 +3095,8 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
@@ -4409,6 +4417,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
@@ -5003,6 +5013,8 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=", "x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=", "aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=", "aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE=" "x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
} }
} }
+6 -1
View File
@@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
resetHeartbeat() resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
const directory = event.directory ?? "global" const directory = event.directory ?? "global"
const payload = event.payload if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload) const k = key(directory, payload)
if (k) { if (k) {
const i = coalesced.get(k) const i = coalesced.get(k)
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
size="large" size="large"
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
props.loadMore() props.loadMore()
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
onClick={props.onResumeScroll} onClick={props.onResumeScroll}
> >
<div <div
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]" class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
style={{ style={{
"box-shadow": "box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)", "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
}} }}
> >
<Icon name="arrow-down-to-line" size="small" /> <Icon name="arrow-down-to-line" size="small" />
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y, y: state.y,
width: state.width, width: state.width,
height: state.height, height: state.height,
show: true, show: false,
title: "OpenCode", title: "OpenCode",
icon: iconPath(), icon: iconPath(),
backgroundColor, backgroundColor,
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
wireZoom(win) wireZoom(win)
injectGlobals(win, globals) injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
})
return win return win
} }
+2 -2
View File
@@ -13,7 +13,7 @@
Use these rules when writing or migrating Effect code. Use these rules when writing or migrating Effect code.
See `specs/effect-migration.md` for the compact pattern reference and examples. See `specs/effect/migration.md` for the compact pattern reference and examples.
## Core ## Core
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
## Effect.cached for deduplication ## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern. Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks ## Instance.bind — ALS for native callbacks
@@ -0,0 +1,16 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_workspace` (
`id` text PRIMARY KEY,
`type` text NOT NULL,
`name` text DEFAULT '' NOT NULL,
`branch` text,
`directory` text,
`extra` text,
`project_id` text NOT NULL,
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
DROP TABLE `workspace`;--> statement-breakpoint
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
CREATE TABLE `session_entry` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`type` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_session_entry_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `session_entry_session_idx` ON `session_entry` (`session_id`);--> statement-breakpoint
CREATE INDEX `session_entry_session_type_idx` ON `session_entry` (`session_id`,`type`);--> statement-breakpoint
CREATE INDEX `session_entry_time_created_idx` ON `session_entry` (`time_created`);
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -14,18 +14,11 @@
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
"db": "bun drizzle-kit" "db": "bun drizzle-kit"
}, },
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"randomField": "this-is-a-random-value-12345",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@@ -83,6 +76,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
@@ -134,6 +128,7 @@
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
+12 -3
View File
@@ -1,3 +1,5 @@
import { AppRuntime } from "@/effect/app-runtime"
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
@@ -16,14 +18,20 @@ const seed = async () => {
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema") const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry") const { ToolRegistry } = await import("../src/tool/registry")
const { Effect } = await import("effect")
try { try {
await Instance.provide({ await Instance.provide({
directory: dir, directory: dir,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
await Config.waitForDependencies() await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
await ToolRegistry.ids() await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.ids()
}),
)
const session = await Session.create({ title }) const session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
@@ -54,6 +62,7 @@ const seed = async () => {
}) })
} finally { } finally {
await Instance.disposeAll().catch(() => {}) await Instance.disposeAll().catch(() => {})
await AppRuntime.dispose().catch(() => {})
} }
} }
+238
View File
@@ -0,0 +1,238 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
Recent progress:
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
## Completed Batches
Low-risk batch, all merged:
1. `src/pty/index.ts`
2. `src/skill/index.ts`
3. `src/project/vcs.ts`
4. `src/tool/registry.ts`
5. `src/auth/index.ts`
Caller-heavy batch, all merged:
1. `src/config/config.ts`
2. `src/provider/provider.ts`
3. `src/file/index.ts`
4. `src/lsp/index.ts`
5. `src/mcp/index.ts`
Shared pattern:
- one service file still exports `makeRuntime(...)` + async facades
- one or two route or CLI entrypoints call those facades directly
- tests call the facade directly and need to switch to `yield* svc.method(...)`
- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
## Done means
For each service in the low-risk batch, the work is complete only when all of these are true:
1. all production callers stop using `Namespace.method(...)` facade calls
2. all direct test callers stop using the facade and instead yield the service from context
3. the service file no longer has `makeRuntime(...)`
4. the service file no longer exports runtime-backed facade helpers
5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
## Caller templates
### Route handlers
Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
```ts
const value = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
)
```
If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
### Plain async CLI or script entrypoints
If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
```ts
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const skill = yield* Skill.Service
yield* auth.set(key, info)
return yield* skill.all()
}),
)
```
Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
### Bootstrap or fire-and-forget startup code
If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
```ts
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
```
Do not reintroduce a dedicated runtime in the service just for bootstrap.
### Tests
Convert facade tests to full effect style.
```ts
it.effect("does the thing", () =>
Effect.gen(function* () {
const svc = yield* Pty.Service
const info = yield* svc.create({ command: "cat", title: "a" })
yield* svc.remove(info.id)
}).pipe(Effect.provide(Pty.defaultLayer)),
)
```
If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
```ts
const infra = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
```
Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
## Questions already answered
### Do we need to effectify the whole caller first?
No.
- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
- bootstrap: use the existing bootstrap runtime
Facade removal does not require a bigger refactor than that.
### Should tests keep calling the namespace from async test bodies?
No. Convert them now.
The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
### Should we keep `runPromise` exported for convenience?
No. For this batch the goal is to delete the service-local runtime entirely.
### What if a route has websocket callbacks or nested async handlers?
Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
### Should we use one `runPromise` per service call?
No.
Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
### Should we wrap a single service expression in `Effect.gen(...)`?
Usually no.
Prefer the direct form when there is only one expression:
```ts
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
```
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
## Learnings
These were the recurring mistakes and useful corrections from the first two batches:
1. Tests should usually provide the specific service layer, not `AppRuntime`.
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
Recommended next five, in order:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
## Excluded `makeRuntime(...)` sites
- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.
+137
View File
@@ -0,0 +1,137 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
## Goal
Use Effect `HttpApi` where it gives us a better typed contract for:
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
## Core model
`HttpApi` is definition-first.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
## Why it is relevant here
The current route-effectification work is already pushing handlers toward:
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
## What HttpApi gives us
### Contracts
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
### Validation and decoding
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
### OpenAPI
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
### Typed errors
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
## Likely fit for opencode
Best fit first:
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Harder / later fit:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
## Current blockers and gaps
### Schema split
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### Existing Hono integration
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
## Recommended strategy
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
### 2. Start with one parallel group
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Run in parallel before replacing
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
- handler ergonomics
- OpenAPI output
- auth and middleware integration
- test ergonomics
### 5. Migrate JSON route groups gradually
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Proposed first steps
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [ ] use Effect Schema request / response types for that slice
- [ ] keep the underlying service calls identical to the current handlers
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist ## Migration checklist
Fully migrated (single namespace, InstanceState where needed, flattened facade): Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
- [x] `Account``account/index.ts` - [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts` - [x] `Agent``agent/agent.ts`
@@ -221,59 +223,16 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts` - [x] `ShareNext``share/share-next.ts`
Still open:
- [x] `SessionTodo``session/todo.ts` - [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect Still open at the service-shape level:
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is: - [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
1. Migrate each tool body to return Effects ## Tool migration
2. Keep `Tool.define()` inputs Effect-native
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
### Tool migration details Tool-specific migration guidance and checklist live in `tools.md`.
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `question.ts` — LOW: prompt wrapper
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `todo.ts` — LOW: todo persistence wrapper
- [ ] `invalid.ts` — LOW: invalid-tool fallback
- [ ] `plan.ts` — LOW: plan file operations
## Effect service adoption in already-migrated code ## Effect service adoption in already-migrated code
@@ -281,27 +240,19 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
### `Filesystem.*``AppFileSystem.Service` (yield in layer) ### `Filesystem.*``AppFileSystem.Service` (yield in layer)
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling - [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
- [ ] `config/config.ts` 5 remaining `Filesystem.*` calls in `installDependencies()` - [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
### `Process.spawn``ChildProcessSpawner` (yield in layer) ### `Process.spawn``ChildProcessSpawner` (yield in layer)
- [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`) - [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers - [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation ## Filesystem consolidation
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort. `util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched. Tool-specific filesystem cleanup notes live in `tools.md`.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
## Primitives & utilities ## Primitives & utilities
@@ -312,7 +263,9 @@ Current raw fs users that will convert during tool migration:
## Destroying the facades ## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process ### Process
@@ -341,47 +294,14 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime. - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration. - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed. - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed. - `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed. - `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed. - `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification ## Route handler effectification
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally. Route-handler migration guidance and checklist live in `routes.md`.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
+66
View File
@@ -0,0 +1,66 @@
# Route handler effectification
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
## Goal
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before - one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After - one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
## Rules
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
- Yield services from context instead of calling async facades repeatedly.
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
The main migration targets are:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
Additional route files that still participate in the migration:
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
- [ ] `server/instance/permission.ts` — Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
+99
View File
@@ -0,0 +1,99 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
## Preferred shapes
### Data objects
Use `Schema.Class` for structured data.
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
```ts
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
```
### Errors
Use `Schema.TaggedErrorClass` for domain errors.
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
## Ordering
Migrate in this order:
1. Shared leaf models and `schema.ts` files
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
## Checklist
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
+96
View File
@@ -0,0 +1,96 @@
# Tool migration
Practical reference for the current tool-migration state in `packages/opencode`.
## Status
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
1. remove Promise and raw platform bridges inside individual tool bodies
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
## Current shape
`Tool.define(...)` is already the Effect-native helper here.
- `init` is an `Effect`
- `info.init()` returns an `Effect`
- `execute(...)` returns an `Effect`
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
## Tests
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
## Follow-up cleanup
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`
+4 -2
View File
@@ -40,6 +40,7 @@ import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent" import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config" import { Config } from "@/config/config"
@@ -1166,7 +1167,7 @@ export namespace ACP {
this.sessionManager.get(sessionId).modeId || this.sessionManager.get(sessionId).modeId ||
(await (async () => { (await (async () => {
if (!availableModes.length) return undefined if (!availableModes.length) return undefined
const defaultAgentName = await AgentModule.defaultAgent() const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = const resolvedModeId =
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId) this.sessionManager.setMode(sessionId, resolvedModeId)
@@ -1367,7 +1368,8 @@ export namespace ACP {
if (!current) { if (!current) {
this.sessionManager.setModel(session.id, model) this.sessionManager.setModel(session.id, model)
} }
const agent = session.modeId ?? (await AgentModule.defaultAgent()) const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
const parts: Array< const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+3 -22
View File
@@ -21,7 +21,6 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect" import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@@ -74,6 +73,7 @@ export namespace Agent {
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const auth = yield* Auth.Service const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const provider = yield* Provider.Service const provider = yield* Provider.Service
@@ -336,9 +336,7 @@ export namespace Agent {
const language = yield* provider.getLanguage(resolved) const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE] const system = [PROMPT_GENERATE]
yield* Effect.promise(() => yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list()) const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over // TODO: clean this up so provider specific logic doesnt bleed over
@@ -399,27 +397,10 @@ export namespace Agent {
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(agent: string) {
return runPromise((svc) => svc.get(agent))
}
export async function list() {
return runPromise((svc) => svc.list())
}
export async function defaultAgent() {
return runPromise((svc) => svc.defaultAgent())
}
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
return runPromise((svc) => svc.generate(input))
}
} }
+4
View File
@@ -0,0 +1,4 @@
declare module "*.wav" {
const file: string
export default file
}
-19
View File
@@ -1,6 +1,5 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
@@ -89,22 +88,4 @@ export namespace Auth {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))
}
export async function all(): Promise<Record<string, Info>> {
return runPromise((service) => service.all())
}
export async function set(key: string, info: Info) {
return runPromise((service) => service.set(key, info))
}
export async function remove(key: string) {
return runPromise((service) => service.remove(key))
}
} }
+2 -9
View File
@@ -16,10 +16,7 @@ export namespace BusEvent {
} }
export function payloads() { export function payloads() {
return z return registry
.discriminatedUnion(
"type",
registry
.entries() .entries()
.map(([type, def]) => { .map(([type, def]) => {
return z return z
@@ -31,10 +28,6 @@ export namespace BusEvent {
ref: "Event" + "." + def.type, ref: "Event" + "." + def.type,
}) })
}) })
.toArray() as any, .toArray()
)
.meta({
ref: "Event",
})
} }
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) { export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
try { try {
const result = await cb() const result = await cb()
+5 -2
View File
@@ -1,5 +1,6 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui" import { UI } from "../ui"
import { Global } from "../../global" import { Global } from "../../global"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
@@ -110,7 +111,9 @@ const AgentCreateCommand = cmd({
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Generating agent configuration...") spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Agent.generate({ description, model }).catch((error) => { const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1) if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError() throw new UI.CancelledError()
@@ -220,7 +223,7 @@ const AgentListCommand = cmd({
await Instance.provide({ await Instance.provide({
directory: process.cwd(), directory: process.cwd(),
async fn() { async fn() {
const agents = await Agent.list() const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const sortedAgents = agents.sort((a, b) => { const sortedAgents = agents.sort((a, b) => {
if (a.native !== b.native) { if (a.native !== b.native) {
return a.native ? -1 : 1 return a.native ? -1 : 1
+18 -4
View File
@@ -12,6 +12,7 @@ import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { AppRuntime } from "@/effect/app-runtime"
export const AgentCommand = cmd({ export const AgentCommand = cmd({
command: "agent <name>", command: "agent <name>",
@@ -34,7 +35,7 @@ export const AgentCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const agentName = args.name as string const agentName = args.name as string
const agent = await Agent.get(agentName) const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
if (!agent) { if (!agent) {
process.stderr.write( process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
@@ -71,11 +72,17 @@ export const AgentCommand = cmd({
}) })
async function getAvailableTools(agent: Agent.Info) { async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel()) return AppRuntime.runPromise(
return ToolRegistry.tools({ Effect.gen(function* () {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({
...model, ...model,
agent, agent,
}) })
}),
)
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
@@ -118,7 +125,14 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) { async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const model = agent.model ?? (await Provider.defaultModel()) const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now() const now = Date.now()
const message: MessageV2.Assistant = { const message: MessageV2.Assistant = {
id: messageID, id: messageID,
@@ -1,5 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Config } from "../../../config/config" import { Config } from "../../../config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const config = await Config.get() const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
process.stdout.write(JSON.stringify(config, null, 2) + EOL) process.stdout.write(JSON.stringify(config, null, 2) + EOL)
}) })
}, },
+22 -4
View File
@@ -1,4 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file" import { File } from "../../../file"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const results = await File.search({ query: args.query }) const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
process.stdout.write(results.join(EOL) + EOL) process.stdout.write(results.join(EOL) + EOL)
}) })
}, },
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const content = await File.read(args.path) const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL) process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}) })
}, },
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const status = await File.status() const status = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
process.stdout.write(JSON.stringify(status, null, 2) + EOL) process.stdout.write(JSON.stringify(status, null, 2) + EOL)
}) })
}, },
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const files = await File.list(args.path) const files = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL) process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}) })
}, },
+14 -5
View File
@@ -1,4 +1,6 @@
import { LSP } from "../../../lsp" import { LSP } from "../../../lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { Log } from "../../../util/log" import { Log } from "../../../util/log"
@@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true) const out = await AppRuntime.runPromise(
await sleep(1000) LSP.Service.use((lsp) =>
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) Effect.gen(function* () {
yield* lsp.touchFile(args.file, true)
yield* Effect.sleep(1000)
return yield* lsp.diagnostics()
}),
),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
}) })
}, },
}) })
@@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols") using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query) const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
@@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols") using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri) const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
+10 -3
View File
@@ -1,4 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep" import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
@@ -76,12 +77,18 @@ const SearchCommand = cmd({
description: "Limit number of results", description: "Limit number of results",
}), }),
async handler(args) { async handler(args) {
const results = await Ripgrep.search({ await bootstrap(process.cwd(), async () => {
cwd: process.cwd(), const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern, pattern: args.pattern,
glob: args.glob as string[] | undefined, glob: args.glob as string[] | undefined,
limit: args.limit, limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
}) })
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}, },
}) })
+8 -1
View File
@@ -1,4 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill" import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -9,7 +11,12 @@ export const SkillCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const skills = await Skill.all() const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
process.stdout.write(JSON.stringify(skills, null, 2) + EOL) process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
}) })
}, },
+80 -39
View File
@@ -15,6 +15,8 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser" import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string { function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) { switch (status) {
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote" return isMcpConfigured(config) && config.type === "remote"
} }
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
async function listState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
}),
)
}
async function authState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
}),
)
}
export const McpCommand = cmd({ export const McpCommand = cmd({
command: "mcp", command: "mcp",
describe: "manage MCP (Model Context Protocol) servers", describe: "manage MCP (Model Context Protocol) servers",
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP Servers") prompts.intro("MCP Servers")
const config = await Config.get() const { config, statuses, stored } = await listState()
const mcpServers = config.mcp ?? {} const servers = configuredServers(config)
const statuses = await MCP.status()
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
if (servers.length === 0) { if (servers.length === 0) {
prompts.log.warn("No MCP servers configured") prompts.log.warn("No MCP servers configured")
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
for (const [name, serverConfig] of servers) { for (const [name, serverConfig] of servers) {
const status = statuses[name] const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = await MCP.hasStoredTokens(name) const hasStoredTokens = stored[name]
let statusIcon: string let statusIcon: string
let statusText: string let statusText: string
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Authentication") prompts.intro("MCP OAuth Authentication")
const config = await Config.get() const { config, auth } = await authState()
const mcpServers = config.mcp ?? {} const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled) if (servers.length === 0) {
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(` prompts.log.info(`
@@ -177,9 +211,8 @@ export const McpAuthCommand = cmd({
let serverName = args.name let serverName = args.name
if (!serverName) { if (!serverName) {
// Build options with auth status // Build options with auth status
const options = await Promise.all( const options = servers.map(([name, cfg]) => {
oauthServers.map(async ([name, cfg]) => { const authStatus = auth[name]
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus) const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus) const statusText = getAuthStatusText(authStatus)
const url = cfg.url const url = cfg.url
@@ -188,8 +221,7 @@ export const McpAuthCommand = cmd({
value: name, value: name,
hint: url, hint: url,
} }
}), })
)
const selected = await prompts.select({ const selected = await prompts.select({
message: "Select MCP server to authenticate", message: "Select MCP server to authenticate",
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
} }
// Check if already authenticated // Check if already authenticated
const authStatus = await MCP.getAuthStatus(serverName) const authStatus =
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
if (authStatus === "authenticated") { if (authStatus === "authenticated") {
const confirm = await prompts.confirm({ const confirm = await prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`, message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
}) })
try { try {
const status = await MCP.authenticate(serverName) const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
if (status.status === "connected") { if (status.status === "connected") {
spinner.stop("Authentication successful!") spinner.stop("Authentication successful!")
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Status") prompts.intro("MCP OAuth Status")
const config = await Config.get() const { config, auth } = await authState()
const mcpServers = config.mcp ?? {} const servers = oauthServers(config)
// Get OAuth-capable servers if (servers.length === 0) {
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done") prompts.outro("Done")
return return
} }
for (const [name, serverConfig] of oauthServers) { for (const [name, serverConfig] of servers) {
const authStatus = await MCP.getAuthStatus(name) const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus) const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus) const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url const url = serverConfig.url
@@ -312,7 +340,7 @@ export const McpAuthListCommand = cmd({
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
} }
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`) prompts.outro(`${servers.length} OAuth-capable server(s)`)
}, },
}) })
}, },
@@ -333,8 +361,7 @@ export const McpLogoutCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Logout") prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json") const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
const credentials = await McpAuth.all()
const serverNames = Object.keys(credentials) const serverNames = Object.keys(credentials)
if (serverNames.length === 0) { if (serverNames.length === 0) {
@@ -372,7 +399,7 @@ export const McpLogoutCommand = cmd({
return return
} }
await MCP.removeAuth(serverName) await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
prompts.log.success(`Removed OAuth credentials for ${serverName}`) prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done") prompts.outro("Done")
}, },
@@ -595,7 +622,7 @@ export const McpDebugCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Debug") prompts.intro("MCP OAuth Debug")
const config = await Config.get() const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {} const mcpServers = config.mcp ?? {}
const serverName = args.name const serverName = args.name
@@ -622,10 +649,18 @@ export const McpDebugCommand = cmd({
prompts.log.info(`URL: ${serverConfig.url}`) prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status // Check stored auth status
const authStatus = await MCP.getAuthStatus(serverName) const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
const entry = await McpAuth.get(serverName)
if (entry?.tokens) { if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) { if (entry.tokens.expiresAt) {
@@ -681,6 +716,11 @@ export const McpDebugCommand = cmd({
// Try to discover OAuth metadata // Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const auth = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}),
)
const authProvider = new McpOAuthProvider( const authProvider = new McpOAuthProvider(
serverName, serverName,
serverConfig.url, serverConfig.url,
@@ -693,6 +733,7 @@ export const McpDebugCommand = cmd({
{ {
onRedirect: async () => {}, onRedirect: async () => {},
}, },
auth,
) )
prompts.log.info("Testing OAuth flow (without completing authorization)...") prompts.log.info("Testing OAuth flow (without completing authorization)...")
+20 -10
View File
@@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "../ui" import { UI } from "../ui"
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({ export const ModelsCommand = cmd({
command: "models [provider]", command: "models [provider]",
@@ -35,12 +37,15 @@ export const ModelsCommand = cmd({
await Instance.provide({ await Instance.provide({
directory: process.cwd(), directory: process.cwd(),
async fn() { async fn() {
const providers = await Provider.list() await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
function printModels(providerID: ProviderID, verbose?: boolean) { const print = (providerID: ProviderID, verbose?: boolean) => {
const provider = providers[providerID] const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) { for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`) process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL) process.stdout.write(EOL)
if (verbose) { if (verbose) {
@@ -51,17 +56,18 @@ export const ModelsCommand = cmd({
} }
if (args.provider) { if (args.provider) {
const provider = providers[ProviderID.make(args.provider)] const providerID = ProviderID.make(args.provider)
const provider = providers[providerID]
if (!provider) { if (!provider) {
UI.error(`Provider not found: ${args.provider}`) yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
return return
} }
printModels(ProviderID.make(args.provider), args.verbose) yield* Effect.sync(() => print(providerID, args.verbose))
return return
} }
const providerIDs = Object.keys(providers).sort((a, b) => { const ids = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode") const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode") const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1 if (aIsOpencode && !bIsOpencode) return -1
@@ -69,9 +75,13 @@ export const ModelsCommand = cmd({
return a.localeCompare(b) return a.localeCompare(b)
}) })
for (const providerID of providerIDs) { yield* Effect.sync(() => {
printModels(ProviderID.make(providerID), args.verbose) for (const providerID of ids) {
print(ProviderID.make(providerID), args.verbose)
} }
})
}),
)
}, },
}) })
}, },
+55 -17
View File
@@ -1,4 +1,5 @@
import { Auth } from "../../auth" import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
@@ -13,9 +14,18 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process" import { Process } from "../../util/process"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Effect } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]> type PluginAuth = NonNullable<Hooks["auth"]>
const put = (key: string, info: Auth.Info) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(key, info)
}),
)
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> { async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0 let index = 0
if (methodName) { if (methodName) {
@@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, { await put(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, { await put(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -148,6 +158,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (method.type === "api") { if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
if (method.authorize) { if (method.authorize) {
const result = await method.authorize(inputs) const result = await method.authorize(inputs)
if (result.type === "failed") { if (result.type === "failed") {
@@ -155,9 +171,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (result.type === "success") { if (result.type === "success") {
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key ?? key,
}) })
prompts.log.success("Login successful") prompts.log.success("Login successful")
} }
@@ -215,7 +231,12 @@ export const ProvidersListCommand = cmd({
const homedir = os.homedir() const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Auth.all()) const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await ModelsDev.get() const database = await ModelsDev.get()
for (const [providerID, result] of results) { for (const [providerID, result] of results) {
@@ -294,7 +315,7 @@ export const ProvidersLoginCommand = cmd({
prompts.outro("Done") prompts.outro("Done")
return return
} }
await Auth.set(url, { await put(url, {
type: "wellknown", type: "wellknown",
key: wellknown.auth.env, key: wellknown.auth.env,
token: token.trim(), token: token.trim(),
@@ -305,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
} }
await ModelsDev.refresh(true).catch(() => {}) await ModelsDev.refresh(true).catch(() => {})
const config = await Config.get() const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? []) const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
@@ -319,6 +340,12 @@ export const ProvidersLoginCommand = cmd({
} }
return filtered return filtered
}) })
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = { const priority: Record<string, number> = {
opencode: 0, opencode: 0,
@@ -330,7 +357,7 @@ export const ProvidersLoginCommand = cmd({
vercel: 6, vercel: 6,
} }
const pluginProviders = resolvePluginProviders({ const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(), hooks,
existingProviders: providers, existingProviders: providers,
disabled, disabled,
enabled, enabled,
@@ -387,7 +414,7 @@ export const ProvidersLoginCommand = cmd({
provider = selected as string provider = selected as string
} }
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) { if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return if (handled) return
@@ -401,7 +428,7 @@ export const ProvidersLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError() if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "") provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) { if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return if (handled) return
@@ -441,7 +468,7 @@ export const ProvidersLoginCommand = cmd({
validate: (x) => (x && x.length > 0 ? undefined : "Required"), validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}) })
if (prompts.isCancel(key)) throw new UI.CancelledError() if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, { await put(provider, {
type: "api", type: "api",
key, key,
}) })
@@ -457,22 +484,33 @@ export const ProvidersLogoutCommand = cmd({
describe: "log out from a configured provider", describe: "log out from a configured provider",
async handler(_args) { async handler(_args) {
UI.empty() UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x)) const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential") prompts.intro("Remove credential")
if (credentials.length === 0) { if (credentials.length === 0) {
prompts.log.error("No credentials found") prompts.log.error("No credentials found")
return return
} }
const database = await ModelsDev.get() const database = await ModelsDev.get()
const providerID = await prompts.select({ const selected = await prompts.select({
message: "Select provider", message: "Select provider",
options: credentials.map(([key, value]) => ({ options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key, value: key,
})), })),
}) })
if (prompts.isCancel(providerID)) throw new UI.CancelledError() if (prompts.isCancel(selected)) throw new UI.CancelledError()
await Auth.remove(providerID) const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful") prompts.outro("Logout successful")
}, },
}) })
+10 -8
View File
@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash" import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo" import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = { type ToolProps<T> = {
input: Tool.InferParameters<T> input: Tool.InferParameters<T>
@@ -573,6 +574,7 @@ export const RunCommand = cmd({
// Validate agent if specified // Validate agent if specified
const agent = await (async () => { const agent = await (async () => {
if (!args.agent) return undefined if (!args.agent) return undefined
const name = args.agent
// When attaching, validate against the running server instead of local Instance state. // When attaching, validate against the running server instead of local Instance state.
if (args.attach) { if (args.attach) {
@@ -590,12 +592,12 @@ export const RunCommand = cmd({
return undefined return undefined
} }
const agent = modes.find((a) => a.name === args.agent) const agent = modes.find((a) => a.name === name)
if (!agent) { if (!agent) {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`, `agent "${name}" not found. Falling back to default agent`,
) )
return undefined return undefined
} }
@@ -604,20 +606,20 @@ export const RunCommand = cmd({
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
) )
return undefined return undefined
} }
return args.agent return name
} }
const entry = await Agent.get(args.agent) const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
if (!entry) { if (!entry) {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`, `agent "${name}" not found. Falling back to default agent`,
) )
return undefined return undefined
} }
@@ -625,11 +627,11 @@ export const RunCommand = cmd({
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
) )
return undefined return undefined
} }
return args.agent return name
})() })()
const sessionID = await session(sdk) const sessionID = await session(sdk)
+2 -61
View File
@@ -1,6 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { import {
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
@@ -178,7 +119,7 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput() win32DisableProcessedInput()
const mode = await getTerminalBackgroundColor() const mode = await Terminal.getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT. // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
type Adaptor = {
type: string
name: string
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
@@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const [creating, setCreating] = createSignal<string>() const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
}) })
const options = createMemo(() => { const options = createMemo(() => {
@@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
}, },
] ]
} }
const list = adaptors()
if (!list) {
return [ return [
{ {
title: "Worktree", title: "Loading workspaces...",
value: "worktree" as const, value: "loading" as const,
description: "Create a local git worktree", description: "Fetching available workspace adaptors",
}, },
] ]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
}) })
const create = async (type: string) => { const create = async (type: string) => {
@@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
skipFilter={true} skipFilter={true}
options={options()} options={options()}
onSelect={(option) => { onSelect={(option) => {
if (option.value === "creating") return if (option.value === "creating" || option.value === "loading") return
void create(option.value) void create(option.value)
}} }}
/> />
@@ -1,82 +1,630 @@
import { TextAttributes, RGBA } from "@opentui/core" import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js" import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme, tint } from "@tui/context/theme"
import { logo, marks } from "@/cli/logo" import { Sound } from "@tui/util/sound"
import { logo } from "@/cli/logo"
// Shadow markers (rendered chars in parens): // Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow) // _ = full shadow cell (space with bg=shadow)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
// ~ = shadow top only (▀ with fg=shadow) // ~ = shadow top only (▀ with fg=shadow)
const SHADOW_MARKER = new RegExp(`[${marks}]`) const GAP = 1
const WIDTH = 0.76
const GAIN = 2.3
const FLASH = 2.15
const TRAIL = 0.28
const SWELL = 0.24
const WIDE = 1.85
const DRIFT = 1.45
const EXPAND = 1.62
const LIFE = 1020
const CHARGE = 3000
const HOLD = 90
const SINK = 40
const ARC = 2.2
const FORK = 1.2
const DIM = 1.04
const KICK = 0.86
const LAG = 60
const SUCK = 0.34
const SHIMMER_IN = 60
const SHIMMER_OUT = 2.8
const TRACE = 0.033
const TAIL = 1.8
const TRACE_IN = 200
const GLOW_OUT = 1600
const PEAK = RGBA.fromInts(255, 255, 255)
type Ring = {
x: number
y: number
at: number
force: number
kick: number
}
type Hold = {
x: number
y: number
at: number
glyph: number | undefined
}
type Release = {
x: number
y: number
at: number
glyph: number | undefined
level: number
rise: number
}
type Glow = {
glyph: number
at: number
force: number
}
type Frame = {
t: number
list: Ring[]
hold: Hold | undefined
release: Release | undefined
glow: Glow | undefined
spark: number
}
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
] as const
type Trace = {
glyph: number
i: number
l: number
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * clamp(t)
}
function ease(t: number) {
const p = clamp(t)
return p * p * (3 - 2 * p)
}
function push(t: number) {
const p = clamp(t)
return ease(p * p)
}
function ramp(t: number, start: number, end: number) {
if (end <= start) return ease(t >= end ? 1 : 0)
return ease((t - start) / (end - start))
}
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
const mid = tint(base, theme.primary, 0.84)
const top = tint(theme.primary, PEAK, 0.96)
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
}
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
if (n >= 0) return glow(base, theme, n)
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
}
function ghost(n: number, scale: number) {
if (n < 0) return n
return n * scale
}
function noise(x: number, y: number, t: number) {
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
return n - Math.floor(n)
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~"
}
function key(x: number, y: number) {
return `${x},${y}`
}
function route(list: Array<{ x: number; y: number }>) {
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
const path: Array<{ x: number; y: number }> = []
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
let dir = { x: 1, y: 0 }
while (cur) {
path.push(cur)
left.delete(key(cur.x, cur.y))
if (!left.size) return path
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
.filter((item): item is { x: number; y: number } => !!item)
.sort((a, b) => {
const ax = a.x - cur.x
const ay = a.y - cur.y
const bx = b.x - cur.x
const by = b.y - cur.y
const adot = ax * dir.x + ay * dir.y
const bdot = bx * dir.x + by * dir.y
if (adot !== bdot) return bdot - adot
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
})[0]
if (!next) {
cur = [...left.values()].sort((a, b) => {
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
return da - db
})[0]
dir = { x: 1, y: 0 }
continue
}
dir = { x: next.x - cur.x, y: next.y - cur.y }
cur = next
}
return path
}
function mapGlyphs() {
const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
}
}
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
const seen = new Set<string>()
const glyph = new Map<string, number>()
const trace = new Map<string, Trace>()
const center = new Map<number, { x: number; y: number }>()
let id = 0
for (const item of cells) {
const start = key(item.x, item.y)
if (seen.has(start)) continue
const stack = [item]
const part = [] as Array<{ x: number; y: number }>
seen.add(start)
while (stack.length) {
const cur = stack.pop()!
part.push(cur)
glyph.set(key(cur.x, cur.y), id)
for (const [dx, dy] of NEAR) {
const next = all.get(key(cur.x + dx, cur.y + dy))
if (!next) continue
const mark = key(next.x, next.y)
if (seen.has(mark)) continue
seen.add(mark)
stack.push(next)
}
}
const path = route(part)
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
center.set(id, {
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
})
id++
}
return { glyph, trace, center }
}
const MAP = mapGlyphs()
function shimmer(x: number, y: number, frame: Frame) {
return frame.list.reduce((best, item) => {
const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
const n = band * wobble * (1 - p) ** 1.45
if (n > best) return n
return best
}, 0)
}
function remain(x: number, y: number, item: Release, t: number) {
const age = t - item.at
if (age < 0 || age > LIFE) return 0
const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
}
function wave(x: number, y: number, frame: Frame, live: boolean) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
const p = age / LIFE
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
return sum + edge + swell + trail + flash + wake - kick - suck
}, 0)
}
function field(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
const level = held ? push(rise) : rest!.level
const body = rise
const storm = level * level
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const spin = frame.t * lerp(0.008, 0.018, storm)
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
const shell =
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
const ember =
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
lerp(0.02, 0.78, body)
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
const flicker =
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
function select(x: number, y: number) {
const direct = MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined,
)
return near
}
function trace(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
const appear = held ? ramp(age, 0, TRACE_IN) : 1
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
const head = (age * speed) % step.l
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade
}
function bloom(x: number, y: number, frame: Frame) {
const item = frame.glow
if (!item) return 0
const glyph = MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0
const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT
const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
}
export function Logo() { export function Logo() {
const { theme } = useTheme() const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
const [glow, setGlow] = createSignal<Glow>()
const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined
let hum = false
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { const stop = () => {
const shadow = tint(theme.background, fg, 0.25) if (!timer) return
clearInterval(timer)
timer = undefined
}
const tick = () => {
const t = performance.now()
setNow(t)
const item = hold()
if (item && !hum && t - item.at >= HOLD) {
hum = true
Sound.start()
}
if (item && t - item.at >= CHARGE) {
burst(item.x, item.y)
}
let live = false
setRings((list) => {
const next = list.filter((item) => t - item.at < LIFE)
live = next.length > 0
return next
})
const flash = glow()
if (flash && t - flash.at >= GLOW_OUT) {
setGlow(undefined)
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
stop()
}
const start = () => {
if (timer) return
timer = setInterval(tick, 16)
}
const hit = (x: number, y: number) => {
const char = FULL[y]?.[x]
return char !== undefined && char !== " "
}
const press = (x: number, y: number, t: number) => {
const last = hold()
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) })
hum = false
start()
}
const burst = (x: number, y: number) => {
const item = hold()
if (!item) return
hum = false
const t = performance.now()
const age = t - item.at
const rise = ramp(age, HOLD, CHARGE)
const level = push(rise)
setHold(undefined)
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
if (item.glyph !== undefined) {
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
}
setRings((list) => [
...list,
{
x: x + 0.5,
y: y * 2 + 1,
at: t,
force: lerp(0.82, 2.55, level),
kick: lerp(0.32, 0.32 + KICK, level),
},
])
setNow(t)
start()
Sound.pulse(lerp(0.8, 1, level))
}
const frame = createMemo(() => {
const t = now()
const item = hold()
return {
t,
list: rings(),
hold: item,
release: release(),
glow: glow(),
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const dusk = createMemo(() => {
const base = frame()
const t = base.t - LAG
const item = base.hold
return {
t,
list: base.list,
hold: item,
release: base.release,
glow: base.glow,
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const renderLine = (
line: string,
y: number,
ink: RGBA,
bold: boolean,
off: number,
frame: Frame,
dusk: Frame,
): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined const attrs = bold ? TextAttributes.BOLD : undefined
const elements: JSX.Element[] = []
let i = 0
while (i < line.length) { return [...line].map((char, i) => {
const rest = line.slice(i) const h = field(off + i, y, frame)
const markerIndex = rest.search(SHADOW_MARKER) const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h
const p = lit(char) ? pick(off + i, y, frame) : 0
const e = lit(char) ? trace(off + i, y, frame) : 0
const b = lit(char) ? bloom(off + i, y, frame) : 0
const q = shimmer(off + i, y, frame)
if (markerIndex === -1) { if (char === "_") {
elements.push( return (
<text fg={fg} attributes={attrs} selectable={false}> <text
{rest} fg={shade(ink, theme, s * 0.08)}
</text>, bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
) attributes={attrs}
break selectable={false}
} >
if (markerIndex > 0) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest.slice(0, markerIndex)}
</text>,
)
}
const marker = rest[markerIndex]
switch (marker) {
case "_":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "} {" "}
</text>, </text>
) )
break
case "^":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
case "~":
elements.push(
<text fg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
} }
i += markerIndex + 1 if (char === "^") {
return (
<text
fg={shade(ink, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs}
selectable={false}
>
</text>
)
} }
return elements if (char === "~") {
return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === " ") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
</text>
)
} }
return ( return (
<box> <text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
const x = evt.x - box.x
const y = evt.y - box.y
if (!hit(x, y)) return
if (evt.type === "drag" && hold()) return
evt.preventDefault()
evt.stopPropagation()
const t = performance.now()
press(x, y, t)
return
}
if (!hold()) return
if (evt.type === "up") {
const item = hold()
if (!item) return
burst(item.x, item.y)
}
}
return (
<box ref={(item: BoxRenderable) => (box = item)}>
<box
position="absolute"
top={0}
left={0}
width={FULL[0]?.length ?? 0}
height={FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={logo.left}> <For each={logo.left}>
{(line, index) => ( {(line, index) => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box> <box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box> <box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
</box>
</box> </box>
)} )}
</For> </For>
@@ -8,6 +8,10 @@ export function useEvent() {
function subscribe(handler: (event: Event) => void) { function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => { return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") {
return
}
// Special hack for truly global events // Special hack for truly global events
if (event.directory === "global") { if (event.directory === "global") {
handler(event.payload) handler(event.payload)
@@ -31,16 +31,6 @@ import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
type SessionDiffSummary = Pick<Snapshot.FileDiff, "file" | "additions" | "deletions">
function summarizeDiff(diff?: Snapshot.FileDiff[]): SessionDiffSummary[] {
return (diff ?? []).map((item) => ({
file: item.file,
additions: item.additions,
deletions: item.deletions,
}))
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
init: () => { init: () => {
@@ -65,7 +55,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: SessionDiffSummary[] [sessionID: string]: Snapshot.FileDiff[]
} }
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
@@ -203,7 +193,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break break
case "session.diff": case "session.diff":
setStore("session_diff", event.properties.sessionID, summarizeDiff(event.properties.diff)) setStore("session_diff", event.properties.sessionID, event.properties.diff)
break break
case "session.deleted": { case "session.deleted": {
@@ -513,7 +503,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
for (const message of messages.data!) { for (const message of messages.data!) {
draft.part[message.info.id] = message.parts draft.part[message.info.id] = message.parts
} }
draft.session_diff[sessionID] = summarizeDiff(diff.data) draft.session_diff[sessionID] = diff.data ?? []
}), }),
) )
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
@@ -0,0 +1,156 @@
import { Player } from "cli-sound"
import { mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, join } from "node:path"
import { Process } from "@/util/process"
import { which } from "@/util/which"
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
import charge from "../asset/charge.wav" with { type: "file" }
const FILE = [pulseA, pulseB, pulseC]
const HUM = charge
const DIR = join(tmpdir(), "opencode-sfx")
const LIST = [
"ffplay",
"mpv",
"mpg123",
"mpg321",
"mplayer",
"afplay",
"play",
"omxplayer",
"aplay",
"cmdmp3",
"cvlc",
"powershell.exe",
] as const
type Kind = (typeof LIST)[number]
function args(kind: Kind, file: string, volume: number) {
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
if (kind === "mpv")
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
if (kind === "play") return [kind, "-v", String(volume), file]
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
}
export namespace Sound {
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
}
}
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
export namespace Terminal { export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>> export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
/** /**
* Query terminal colors including background, foreground, and palette (0-15). * Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values. * Uses OSC escape sequences to retrieve actual terminal color values.
@@ -31,46 +53,26 @@ export namespace Terminal {
clearTimeout(timeout) clearTimeout(timeout)
} }
const parseColor = (colorStr: string): RGBA | null => {
if (colorStr.startsWith("rgb:")) {
const parts = colorStr.substring(4).split("/")
return RGBA.fromInts(
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
parseInt(parts[1], 16) >> 8,
parseInt(parts[2], 16) >> 8,
255,
)
}
if (colorStr.startsWith("#")) {
return RGBA.fromHex(colorStr)
}
if (colorStr.startsWith("rgb(")) {
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
const handler = (data: Buffer) => { const handler = (data: Buffer) => {
const str = data.toString() const str = data.toString()
// Match OSC 11 (background color) // Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) { if (bgMatch) {
background = parseColor(bgMatch[1]) background = parse(bgMatch[1])
} }
// Match OSC 10 (foreground color) // Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) { if (fgMatch) {
foreground = parseColor(fgMatch[1]) foreground = parse(fgMatch[1])
} }
// Match OSC 4 (palette colors) // Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) { for (const match of paletteMatches) {
const index = parseInt(match[1]) const index = parseInt(match[1])
const color = parseColor(match[2]) const color = parse(match[2])
if (color) paletteColors[index] = color if (color) paletteColors[index] = color
} }
@@ -100,15 +102,36 @@ export namespace Terminal {
}) })
} }
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
const result = await colors() if (!process.stdin.isTTY) return "dark"
if (!result.background) return "dark"
const { r, g, b } = result.background return new Promise((resolve) => {
// Calculate luminance using relative luminance formula let timeout: NodeJS.Timeout
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold const cleanup = () => {
return luminance > 0.5 ? "light" : "dark" process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
} }
} }
+3 -3
View File
@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -74,14 +74,14 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
await Instance.provide({ await Instance.provide({
directory: input.directory, directory: input.directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
await upgrade().catch(() => {}) await upgrade().catch(() => {})
}, },
}) })
}, },
async reload() { async reload() {
await Config.invalidate(true) await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
}, },
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Argv, InferredOptionTypes } from "yargs" import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config" import { Config } from "../config/config"
import { AppRuntime } from "@/effect/app-runtime"
const options = { const options = {
port: { port: {
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
} }
export async function resolveNetworkOptions(args: NetworkOptions) { export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.getGlobal() const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const portExplicitlySet = process.argv.includes("--port") const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
+1 -1
View File
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
export async function upgrade() { export async function upgrade() {
const config = await Config.getGlobal() const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return if (!latest) return
+100 -103
View File
@@ -22,21 +22,18 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs" import { existsSync } from "fs"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm" import { Npm } from "@/npm"
@@ -140,53 +137,11 @@ export namespace Config {
} }
export type InstallInput = { export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void> waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
} }
export async function installDependencies(dir: string, input?: InstallInput) { type Package = {
if (!(await isWritable(dir))) return dependencies?: Record<string, string>
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
await Npm.install(dir)
}
async function isWritable(dir: string) {
try {
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
}
} }
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
@@ -1111,7 +1066,7 @@ export namespace Config {
type State = { type State = {
config: Info config: Info
directories: string[] directories: string[]
deps: Promise<void>[] deps: Fiber.Fiber<void, never>[]
consoleState: ConsoleState consoleState: ConsoleState
} }
@@ -1119,6 +1074,7 @@ export namespace Config {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info> readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState> readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void> readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info> readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void> readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1205,13 +1161,17 @@ export namespace Config {
}), }),
) )
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> = export const layer: Layer.Layer<
Layer.effect( Service,
never,
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service const accountSvc = yield* Account.Service
const env = yield* Env.Service
const readConfigFile = Effect.fnUntraced(function* (filepath: string) { const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe( return yield* fs.readFileString(filepath).pipe(
@@ -1231,10 +1191,7 @@ export namespace Config {
const source = "path" in options ? options.path : options.source const source = "path" in options ? options.path : options.source
const isFile = "path" in options const isFile = "path" in options
const data = yield* Effect.promise(() => const data = yield* Effect.promise(() =>
ConfigPaths.parseText( ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
),
) )
const normalized = (() => { const normalized = (() => {
@@ -1320,6 +1277,74 @@ export namespace Config {
return yield* cachedGlobal return yield* cachedGlobal
}) })
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1334,11 +1359,7 @@ export namespace Config {
return "global" return "global"
}) })
const track = Effect.fnUntraced(function* ( const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return if (!list?.length) return
const hit = kind ?? (yield* scope(source)) const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([ const plugins = deduplicatePluginOrigins([
@@ -1402,7 +1423,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
} }
const deps: Promise<void>[] = [] const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1416,12 +1437,18 @@ export namespace Config {
} }
} }
const dep = iife(async () => { const dep = yield* installDependencies(dir).pipe(
await installDependencies(dir) Effect.exit,
}) Effect.tap((exit) =>
void dep.catch((err) => { Exit.isFailure(exit)
log.warn("background dependency install failed", { dir, error: err }) ? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
}) })
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep) deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1452,7 +1479,7 @@ export namespace Config {
) )
if (Option.isSome(tokenOpt)) { if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
} }
activeOrgName = activeOrg.org.name activeOrgName = activeOrg.org.name
@@ -1558,7 +1585,9 @@ export namespace Config {
}) })
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined))) yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
}) })
const update = Effect.fn("Config.update")(function* (config: Info) { const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1613,6 +1642,7 @@ export namespace Config {
get, get,
getGlobal, getGlobal,
getConsoleState, getConsoleState,
installDependencies,
update, update,
updateGlobal, updateGlobal,
invalidate, invalidate,
@@ -1626,39 +1656,6 @@ export namespace Config {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer), Layer.provide(Account.defaultLayer),
Layer.provide(Env.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function getGlobal() {
return runPromise((svc) => svc.getGlobal())
}
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
export async function updateGlobal(config: Info) {
return runPromise((svc) => svc.updateGlobal(config))
}
export async function invalidate(wait = false) {
return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
return runPromise((svc) => svc.directories())
}
export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
}
} }
+2 -1
View File
@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
@@ -51,7 +52,7 @@ export namespace TuiConfig {
} }
function installDeps(dir: string): Promise<void> { function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir) return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
} }
async function mergeFile(acc: Acc, file: string) { async function mergeFile(acc: Acc, file: string) {
@@ -1,20 +1,52 @@
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types" import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = { export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
} }
export function getAdaptor(type: string): Promise<Adaptor> { const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
return ADAPTORS[type]()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
} }
export function installAdaptor(type: string, adaptor: Adaptor) { export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
// This is experimental: mostly used for testing right now, but we const builtin = await Promise.all(
// will likely allow this in the future. Need to figure out the Object.entries(BUILTIN).map(async ([type, init]) => {
// TypeScript story const adaptor = await init()
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// @ts-expect-error we force the builtin types right now, but we // Plugins can be loaded per-project so we need to scope them. If you
// will implement a way to extend the types for custom adaptors // want to install a global one pass `ProjectID.global`
ADAPTORS[type] = () => adaptor export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
} }
@@ -1,18 +1,19 @@
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const Config = WorkspaceInfo.extend({ const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name.unwrap(), name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(), branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(), directory: WorkspaceInfo.shape.directory.unwrap(),
}) })
type Config = z.infer<typeof Config> export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
export const WorktreeAdaptor: Adaptor = { description: "Create a git worktree",
async configure(info) { async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined) const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return { return {
...info, ...info,
name: worktree.name, name: worktree.name,
@@ -21,19 +22,23 @@ export const WorktreeAdaptor: Adaptor = {
} }
}, },
async create(info) { async create(info) {
const config = Config.parse(info) const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({ await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name, name: config.name,
directory: config.directory, directory: config.directory,
branch: config.branch, branch: config.branch,
}) }),
),
)
}, },
async remove(info) { async remove(info) {
const config = Config.parse(info) const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory }) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
}, },
target(info) { target(info) {
const config = Config.parse(info) const config = WorktreeConfig.parse(info)
return { return {
type: "local", type: "local",
directory: config.directory, directory: config.directory,
+8 -6
View File
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({ export const WorkspaceInfo = z.object({
id: WorkspaceID.zod, id: WorkspaceID.zod,
type: z.string(), type: z.string(),
name: z.string(),
branch: z.string().nullable(), branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(), directory: z.string().nullable(),
extra: z.unknown().nullable(), extra: z.unknown().nullable(),
projectID: ProjectID.zod, projectID: ProjectID.zod,
@@ -24,9 +24,11 @@ export type Target =
headers?: HeadersInit headers?: HeadersInit
} }
export type Adaptor = { export type WorkspaceAdaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> name: string
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void> description: string
remove(config: WorkspaceInfo): Promise<void> configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
target(config: WorkspaceInfo): Target | Promise<Target> create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
} }
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", { export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(), id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(), type: text().notNull(),
name: text().notNull().default(""),
branch: text(), branch: text(),
name: text(),
directory: text(), directory: text(),
extra: text({ mode: "json" }), extra: text({ mode: "json" }),
project_id: text() project_id: text()
@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/util/slug"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types" import { WorkspaceInfo } from "./types"
@@ -66,9 +67,9 @@ export namespace Workspace {
export const create = fn(CreateInput, async (input) => { export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type) const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null }) const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = { const info: Info = {
id, id,
@@ -124,7 +125,7 @@ export namespace Workspace {
stopSync(id) stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(row.type) const adaptor = await getAdaptor(info.projectID, row.type)
adaptor.remove(info) adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info return info
@@ -162,7 +163,7 @@ export namespace Workspace {
log.info("connecting to sync: " + space.id) log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting") setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space) const target = await adaptor.target(space)
if (target.type === "local") return if (target.type === "local") return
+2 -2
View File
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll( export const AppLayer = Layer.mergeAll(
Observability.layer, // Observability.layer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Bus.defaultLayer, Bus.defaultLayer,
Auth.defaultLayer, Auth.defaultLayer,
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer, Installation.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
SessionShare.defaultLayer, SessionShare.defaultLayer,
) ).pipe(Layer.provide(Observability.layer))
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap }) export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -1,10 +1,27 @@
import { Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service" import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format" import { Format } from "@/format"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Observability } from "./oltp"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer) export const BootstrapLayer = Layer.mergeAll(
Plugin.defaultLayer,
ShareNext.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+43 -17
View File
@@ -1,28 +1,54 @@
import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Context, Effect, Layer } from "effect"
export namespace Env { export namespace Env {
const state = Instance.state(() => { type State = Record<string, string | undefined>
export interface Interface {
readonly get: (key: string) => Effect.Effect<string | undefined>
readonly all: () => Effect.Effect<State>
readonly set: (key: string, value: string) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Env.state")(() =>
Effect.succeed(
// Create a shallow copy to isolate environment per instance // Create a shallow copy to isolate environment per instance
// Prevents parallel tests from interfering with each other's env vars // Prevents parallel tests from interfering with each other's env vars
return { ...process.env } as Record<string, string | undefined> { ...process.env } as State,
),
),
)
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
const env = yield* InstanceState.get(state)
env[key] = value
}) })
export function get(key: string) { const remove = Effect.fn("Env.remove")(function* (key: string) {
const env = state() const env = yield* InstanceState.get(state)
return env[key] delete env[key]
} })
export function all() { return Service.of({ get, all, set, remove })
return state() }),
} )
export const defaultLayer = layer
const rt = makeRuntime(Service, layer)
export function set(key: string, value: string) { export function set(key: string, value: string) {
const env = state() return rt.runSync((svc) => svc.set(key, value))
env[key] = value
}
export function remove(key: string) {
const env = state()
delete env[key]
} }
} }
-23
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git" import { Git } from "@/git"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
@@ -644,26 +643,4 @@ export namespace File {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
} }
+138 -54
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
@@ -94,8 +94,43 @@ export namespace Ripgrep {
const Result = z.union([Begin, Match, End, Summary]) const Result = z.union([Begin, Match, End, Summary])
const Hit = Schema.Struct({
type: Schema.Literal("match"),
data: Schema.Struct({
path: Schema.Struct({
text: Schema.String,
}),
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.mutable(
Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
}),
),
),
}),
})
const Row = Schema.Union([
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
Hit,
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
])
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
export type Result = z.infer<typeof Result> export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match> export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin> export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End> export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary> export type Summary = z.infer<typeof Summary>
@@ -289,6 +324,14 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) => Stream.Stream<string, PlatformError> }) => Stream.Stream<string, PlatformError>
readonly search: (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
@@ -298,6 +341,33 @@ export namespace Ripgrep {
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service const afs = yield* AppFileSystem.Service
const bin = Effect.fn("Ripgrep.path")(function* () {
return yield* Effect.promise(() => filepath())
})
const args = Effect.fn("Ripgrep.args")(function* (input: {
mode: "files" | "search"
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
limit?: number
pattern?: string
file?: string[]
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
if (input.hidden !== false) out.push("--hidden")
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
out.push(`--glob=${g}`)
}
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
return out
})
const files = Effect.fn("Ripgrep.files")(function* (input: { const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string cwd: string
@@ -306,7 +376,7 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) { }) {
const rgPath = yield* Effect.promise(() => filepath()) const rgPath = yield* bin()
const isDir = yield* afs.isDir(input.cwd) const isDir = yield* afs.isDir(input.cwd)
if (!isDir) { if (!isDir) {
return yield* Effect.die( return yield* Effect.die(
@@ -318,23 +388,79 @@ export namespace Ripgrep {
) )
} }
const args = [rgPath, "--files", "--glob=!.git/*"] const cmd = yield* args({
if (input.follow) args.push("--follow") mode: "files",
if (input.hidden !== false) args.push("--hidden") glob: input.glob,
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) hidden: input.hidden,
if (input.glob) { follow: input.follow,
for (const g of input.glob) { maxDepth: input.maxDepth,
args.push(`--glob=${g}`) })
}
}
return spawner return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd })) .streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0)) .pipe(Stream.filter((line: string) => line.length > 0))
}) })
const search = Effect.fn("Ripgrep.search")(function* (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
const cmd = yield* args({
mode: "search",
glob: input.glob,
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
file: input.file,
})
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: input.cwd,
stdin: "ignore",
}),
)
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect((line) =>
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
),
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
Stream.map((row): Item => row.data),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
}
return {
items,
partial: code === 2,
}
}),
)
})
return Service.of({ return Service.of({
files: (input) => Stream.unwrap(files(input)), files: (input) => Stream.unwrap(files(input)),
search,
}) })
}), }),
) )
@@ -401,46 +527,4 @@ export namespace Ripgrep {
return lines.join("\n") return lines.join("\n")
} }
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
} }
+9 -21
View File
@@ -1,4 +1,3 @@
import { text } from "node:stream/consumers"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -217,26 +216,16 @@ export const rlang: Info = {
name: "air", name: "air",
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const airPath = which("air") const air = which("air")
if (airPath == null) return false if (air == null) return false
try { const output = await Process.text([air, "--help"], { nothrow: true })
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
if (!proc.stdout) return false
const output = await text(proc.stdout)
// Check for "Air: An R language server and formatter" // Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0] const firstLine = output.text.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) return ["air", "format", "$FILE"] if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
} catch {
return false
}
return false return false
}, },
} }
@@ -246,11 +235,10 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
if (which("uv") !== null) { const uv = which("uv")
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) if (uv == null) return false
const code = await proc.exited const output = await Process.run([uv, "format", "--help"], { nothrow: true })
if (code === 0) return ["uv", "format", "--", "$FILE"] if (output.code === 0) return [uv, "format", "--", "$FILE"]
}
return false return false
}, },
} }
+1
View File
@@ -13,6 +13,7 @@ export namespace Identifier {
pty: "pty", pty: "pty",
tool: "tool", tool: "tool",
workspace: "wrk", workspace: "wrk",
entry: "ent",
} as const } as const
export function schema(prefix: keyof typeof prefixes) { export function schema(prefix: keyof typeof prefixes) {
-32
View File
@@ -13,7 +13,6 @@ import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch" import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP { export namespace LSP {
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
@@ -508,37 +507,6 @@ export namespace LSP {
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const init = async () => runPromise((svc) => svc.init())
export const status = async () => runPromise((svc) => svc.status())
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic { export namespace Diagnostic {
const MAX_PER_FILE = 20 const MAX_PER_FILE = 20
-29
View File
@@ -3,7 +3,6 @@ import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
export namespace McpAuth { export namespace McpAuth {
export const Tokens = z.object({ export const Tokens = z.object({
@@ -142,32 +141,4 @@ export namespace McpAuth {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facades for backward compat (used by McpOAuthProvider, CLI)
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
export const getForUrl = async (mcpName: string, serverUrl: string) =>
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
export const all = async () => runPromise((svc) => svc.all())
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
} }
+2 -34
View File
@@ -27,7 +27,6 @@ import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -294,6 +293,7 @@ export namespace MCP {
log.info("oauth redirect requested", { key, url: url.toString() }) log.info("oauth redirect requested", { key, url: url.toString() })
}, },
}, },
auth,
) )
} }
@@ -745,6 +745,7 @@ export namespace MCP {
capturedUrl = url capturedUrl = url
}, },
}, },
auth,
) )
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
@@ -890,37 +891,4 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
// --- Async facade functions ---
export const status = async () => runPromise((svc) => svc.status())
export const tools = async () => runPromise((svc) => svc.tools())
export const prompts = async () => runPromise((svc) => svc.prompts())
export const resources = async () => runPromise((svc) => svc.resources())
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
} }
+19 -13
View File
@@ -5,6 +5,7 @@ import type {
OAuthClientInformation, OAuthClientInformation,
OAuthClientInformationFull, OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js" } from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect"
import { McpAuth } from "./auth" import { McpAuth } from "./auth"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -30,6 +31,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
private serverUrl: string, private serverUrl: string,
private config: McpOAuthConfig, private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks, private callbacks: McpOAuthCallbacks,
private auth: McpAuth.Interface,
) {} ) {}
get redirectUrl(): string { get redirectUrl(): string {
@@ -61,7 +63,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
// Check stored client info (from dynamic registration) // Check stored client info (from dynamic registration)
// Use getForUrl to validate credentials are for the current server URL // Use getForUrl to validate credentials are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl) const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (entry?.clientInfo) { if (entry?.clientInfo) {
// Check if client secret has expired // Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
@@ -79,7 +81,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> { async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await McpAuth.updateClientInfo( await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName, this.mcpName,
{ {
clientId: info.client_id, clientId: info.client_id,
@@ -88,6 +91,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
clientSecretExpiresAt: info.client_secret_expires_at, clientSecretExpiresAt: info.client_secret_expires_at,
}, },
this.serverUrl, this.serverUrl,
),
) )
log.info("saved dynamically registered client", { log.info("saved dynamically registered client", {
mcpName: this.mcpName, mcpName: this.mcpName,
@@ -97,7 +101,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
async tokens(): Promise<OAuthTokens | undefined> { async tokens(): Promise<OAuthTokens | undefined> {
// Use getForUrl to validate tokens are for the current server URL // Use getForUrl to validate tokens are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl) const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (!entry?.tokens) return undefined if (!entry?.tokens) return undefined
return { return {
@@ -112,7 +116,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
async saveTokens(tokens: OAuthTokens): Promise<void> { async saveTokens(tokens: OAuthTokens): Promise<void> {
await McpAuth.updateTokens( await Effect.runPromise(
this.auth.updateTokens(
this.mcpName, this.mcpName,
{ {
accessToken: tokens.access_token, accessToken: tokens.access_token,
@@ -121,6 +126,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
scope: tokens.scope, scope: tokens.scope,
}, },
this.serverUrl, this.serverUrl,
),
) )
log.info("saved oauth tokens", { mcpName: this.mcpName }) log.info("saved oauth tokens", { mcpName: this.mcpName })
} }
@@ -131,11 +137,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
async saveCodeVerifier(codeVerifier: string): Promise<void> { async saveCodeVerifier(codeVerifier: string): Promise<void> {
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier) await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
} }
async codeVerifier(): Promise<string> { async codeVerifier(): Promise<string> {
const entry = await McpAuth.get(this.mcpName) const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry?.codeVerifier) { if (!entry?.codeVerifier) {
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`) throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
} }
@@ -143,11 +149,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
async saveState(state: string): Promise<void> { async saveState(state: string): Promise<void> {
await McpAuth.updateOAuthState(this.mcpName, state) await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
} }
async state(): Promise<string> { async state(): Promise<string> {
const entry = await McpAuth.get(this.mcpName) const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (entry?.oauthState) { if (entry?.oauthState) {
return entry.oauthState return entry.oauthState
} }
@@ -159,28 +165,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32))) const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
.join("") .join("")
await McpAuth.updateOAuthState(this.mcpName, newState) await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
return newState return newState
} }
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> { async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
log.info("invalidating credentials", { mcpName: this.mcpName, type }) log.info("invalidating credentials", { mcpName: this.mcpName, type })
const entry = await McpAuth.get(this.mcpName) const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) { if (!entry) {
return return
} }
switch (type) { switch (type) {
case "all": case "all":
await McpAuth.remove(this.mcpName) await Effect.runPromise(this.auth.remove(this.mcpName))
break break
case "client": case "client":
delete entry.clientInfo delete entry.clientInfo
await McpAuth.set(this.mcpName, entry) await Effect.runPromise(this.auth.set(this.mcpName, entry))
break break
case "tokens": case "tokens":
delete entry.tokens delete entry.tokens
await McpAuth.set(this.mcpName, entry) await Effect.runPromise(this.auth.set(this.mcpName, entry))
break break
} }
} }
-15
View File
@@ -2,7 +2,6 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
@@ -308,18 +307,4 @@ export namespace Permission {
} }
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))
}
export async function reply(input: z.infer<typeof ReplyInput>) {
return runPromise((s) => s.reply(input))
}
export async function list() {
return runPromise((s) => s.list())
}
} }
+14 -19
View File
@@ -1,4 +1,10 @@
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" import type {
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -14,10 +20,11 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader" import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -132,6 +139,11 @@ export namespace Plugin {
project: ctx.project, project: ctx.project,
worktree: ctx.worktree, worktree: ctx.worktree,
directory: ctx.directory, directory: ctx.directory,
experimental_workspace: {
register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
},
},
get serverUrl(): URL { get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096") return Server.url ?? new URL("http://localhost:4096")
}, },
@@ -277,21 +289,4 @@ export namespace Plugin {
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger<
Name extends TriggerName,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
return runPromise((svc) => svc.trigger(name, input, output))
}
export async function list(): Promise<Hooks[]> {
return runPromise((svc) => svc.list())
}
export async function init() {
return runPromise((svc) => svc.init())
}
} }
+15 -13
View File
@@ -9,24 +9,26 @@ import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import * as Effect from "effect/Effect"
export async function InstanceBootstrap() { export const InstanceBootstrap = Effect.gen(function* () {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() yield* Plugin.Service.use((svc) => svc.init())
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init())) yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
await LSP.init() yield* LSP.Service.use((svc) => svc.init())
File.init() yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Vcs.init() yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Snapshot.init() yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Bus.subscribe(Command.Event.Executed, async (payload) => { yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id) Project.setInitialized(Instance.project.id)
} }
}) }),
} )
}).pipe(Effect.withSpan("InstanceBootstrap"))
@@ -21,8 +21,6 @@ const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
} }
function emitDisposed(directory: string) {}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) { function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => { return iife(async () => {
const ctx = const ctx =
-19
View File
@@ -4,7 +4,6 @@ import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git" import { Git } from "@/git"
@@ -231,22 +230,4 @@ export namespace Vcs {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function branch() {
return runPromise((svc) => svc.branch())
}
export async function defaultBranch() {
return runPromise((svc) => svc.defaultBranch())
}
export async function diff(mode: Mode) {
return runPromise((svc) => svc.diff(mode))
}
} }
-2
View File
@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
+84 -99
View File
@@ -18,11 +18,11 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Global } from "../global" import { Global } from "../global"
import path from "path" import path from "path"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem"
import { isRecord } from "@/util/record"
// Direct imports for bundled providers // Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -46,6 +46,7 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity" import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel" import { createVercel } from "@ai-sdk/vercel"
import { createVenice } from "venice-ai-sdk-provider" import { createVenice } from "venice-ai-sdk-provider"
import { createAlibaba } from "@ai-sdk/alibaba"
import { import {
createGitLab, createGitLab,
VERSION as GITLAB_PROVIDER_VERSION, VERSION as GITLAB_PROVIDER_VERSION,
@@ -115,8 +116,8 @@ export namespace Provider {
}) })
} }
function e2eURL() { function e2eURL(env: Record<string, string | undefined>) {
const url = Env.get("OPENCODE_E2E_LLM_URL") const url = env["OPENCODE_E2E_LLM_URL"]
if (typeof url !== "string" || url === "") return if (typeof url !== "string" || url === "") return
return url return url
} }
@@ -145,6 +146,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity, "@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel, "@ai-sdk/vercel": createVercel,
"@ai-sdk/alibaba": createAlibaba,
"gitlab-ai-provider": createGitLab, "gitlab-ai-provider": createGitLab,
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
"venice-ai-sdk-provider": createVenice, "venice-ai-sdk-provider": createVenice,
@@ -170,7 +172,7 @@ export namespace Provider {
return sdk.responses === undefined && sdk.chat === undefined return sdk.responses === undefined && sdk.chat === undefined
} }
function custom(dep: CustomDep): Record<string, CustomLoader> { function custom(dep: CustomDep, env: Env.Interface): Record<string, CustomLoader> {
return { return {
anthropic: () => anthropic: () =>
Effect.succeed({ Effect.succeed({
@@ -182,9 +184,9 @@ export namespace Provider {
}, },
}), }),
opencode: Effect.fnUntraced(function* (input: Info) { opencode: Effect.fnUntraced(function* (input: Info) {
const env = Env.all() const vals = yield* env.all()
const hasKey = iife(() => { const hasKey = iife(() => {
if (input.env.some((item) => env[item])) return true if (input.env.some((item) => vals[item])) return true
return false return false
}) })
const ok = const ok =
@@ -229,14 +231,13 @@ export namespace Provider {
}, },
options: {}, options: {},
}), }),
azure: (provider) => { azure: Effect.fnUntraced(function* (provider: Info) {
const resource = iife(() => { const resource =
const name = provider.options?.resourceName typeof provider.options?.resourceName === "string" && provider.options.resourceName.trim() !== ""
if (typeof name === "string" && name.trim() !== "") return name ? provider.options.resourceName
return Env.get("AZURE_RESOURCE_NAME") : yield* env.get("AZURE_RESOURCE_NAME")
})
return Effect.succeed({ return {
autoload: false, autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) { async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -252,11 +253,11 @@ export namespace Provider {
...(resource && { AZURE_RESOURCE_NAME: resource }), ...(resource && { AZURE_RESOURCE_NAME: resource }),
} }
}, },
}) }
}, }),
"azure-cognitive-services": () => { "azure-cognitive-services": Effect.fnUntraced(function* () {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") const resource = yield* env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return Effect.succeed({ return {
autoload: false, autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) { async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -267,25 +268,25 @@ export namespace Provider {
} }
}, },
options: { options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, baseURL: resource ? `https://${resource}.cognitiveservices.azure.com/openai` : undefined,
},
})
}, },
}
}),
"amazon-bedrock": Effect.fnUntraced(function* () { "amazon-bedrock": Effect.fnUntraced(function* () {
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"] const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
const auth = yield* dep.auth("amazon-bedrock") const auth = yield* dep.auth("amazon-bedrock")
// Region precedence: 1) config file, 2) env var, 3) default // Region precedence: 1) config file, 2) env var, 3) default
const configRegion = providerConfig?.options?.region const configRegion = providerConfig?.options?.region
const envRegion = Env.get("AWS_REGION") const envRegion = yield* env.get("AWS_REGION")
const defaultRegion = configRegion ?? envRegion ?? "us-east-1" const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
// Profile: config file takes precedence over env var // Profile: config file takes precedence over env var
const configProfile = providerConfig?.options?.profile const configProfile = providerConfig?.options?.profile
const envProfile = Env.get("AWS_PROFILE") const envProfile = yield* env.get("AWS_PROFILE")
const profile = configProfile ?? envProfile const profile = configProfile ?? envProfile
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") const awsAccessKeyId = yield* env.get("AWS_ACCESS_KEY_ID")
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy, // TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
// until the scope of the Env API is clarified (test only or runtime?) // until the scope of the Env API is clarified (test only or runtime?)
@@ -299,7 +300,7 @@ export namespace Provider {
return undefined return undefined
}) })
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE") const awsWebIdentityTokenFile = yield* env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
const containerCreds = Boolean( const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
@@ -437,24 +438,24 @@ export namespace Provider {
}, },
}, },
}), }),
"google-vertex": (provider) => { "google-vertex": Effect.fnUntraced(function* (provider: Info) {
const project = const project =
provider.options?.project ?? provider.options?.project ??
Env.get("GOOGLE_CLOUD_PROJECT") ?? (yield* env.get("GOOGLE_CLOUD_PROJECT")) ??
Env.get("GCP_PROJECT") ?? (yield* env.get("GCP_PROJECT")) ??
Env.get("GCLOUD_PROJECT") (yield* env.get("GCLOUD_PROJECT"))
const location = String( const location = String(
provider.options?.location ?? provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ?? (yield* env.get("GOOGLE_VERTEX_LOCATION")) ??
Env.get("GOOGLE_CLOUD_LOCATION") ?? (yield* env.get("GOOGLE_CLOUD_LOCATION")) ??
Env.get("VERTEX_LOCATION") ?? (yield* env.get("VERTEX_LOCATION")) ??
"us-central1", "us-central1",
) )
const autoload = Boolean(project) const autoload = Boolean(project)
if (!autoload) return Effect.succeed({ autoload: false }) if (!autoload) return { autoload: false }
return Effect.succeed({ return {
autoload: true, autoload: true,
vars(_options: Record<string, any>) { vars(_options: Record<string, any>) {
const endpoint = const endpoint =
@@ -483,14 +484,17 @@ export namespace Provider {
const id = String(modelID).trim() const id = String(modelID).trim()
return sdk.languageModel(id) return sdk.languageModel(id)
}, },
}) }
}, }),
"google-vertex-anthropic": () => { "google-vertex-anthropic": Effect.fnUntraced(function* () {
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT") const project =
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global" (yield* env.get("GOOGLE_CLOUD_PROJECT")) ??
(yield* env.get("GCP_PROJECT")) ??
(yield* env.get("GCLOUD_PROJECT"))
const location = (yield* env.get("GOOGLE_CLOUD_LOCATION")) ?? (yield* env.get("VERTEX_LOCATION")) ?? "global"
const autoload = Boolean(project) const autoload = Boolean(project)
if (!autoload) return Effect.succeed({ autoload: false }) if (!autoload) return { autoload: false }
return Effect.succeed({ return {
autoload: true, autoload: true,
options: { options: {
project, project,
@@ -500,8 +504,8 @@ export namespace Provider {
const id = String(modelID).trim() const id = String(modelID).trim()
return sdk.languageModel(id) return sdk.languageModel(id)
}, },
}) }
}, }),
"sap-ai-core": Effect.fnUntraced(function* () { "sap-ai-core": Effect.fnUntraced(function* () {
const auth = yield* dep.auth("sap-ai-core") const auth = yield* dep.auth("sap-ai-core")
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
@@ -537,14 +541,11 @@ export namespace Provider {
}, },
}), }),
gitlab: Effect.fnUntraced(function* (input: Info) { gitlab: Effect.fnUntraced(function* (input: Info) {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com" const instanceUrl = (yield* env.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
const auth = yield* dep.auth(input.id) const auth = yield* dep.auth(input.id)
const apiKey = yield* Effect.sync(() => { const apiKey =
if (auth?.type === "oauth") return auth.access auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : yield* env.get("GITLAB_TOKEN")
if (auth?.type === "api") return auth.key
return Env.get("GITLAB_TOKEN")
})
const providerConfig = (yield* dep.config()).provider?.["gitlab"] const providerConfig = (yield* dep.config()).provider?.["gitlab"]
@@ -680,7 +681,7 @@ export namespace Provider {
const auth = yield* dep.auth(input.id) const auth = yield* dep.auth(input.id)
const accountId = const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined) (yield* env.get("CLOUDFLARE_ACCOUNT_ID")) || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
if (!accountId) if (!accountId)
return { return {
autoload: false, autoload: false,
@@ -692,7 +693,7 @@ export namespace Provider {
} }
const apiKey = yield* Effect.gen(function* () { const apiKey = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_KEY") const envToken = yield* env.get("CLOUDFLARE_API_KEY")
if (envToken) return envToken if (envToken) return envToken
if (auth?.type === "api") return auth.key if (auth?.type === "api") return auth.key
return undefined return undefined
@@ -722,9 +723,9 @@ export namespace Provider {
const auth = yield* dep.auth(input.id) const auth = yield* dep.auth(input.id)
const accountId = const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined) (yield* env.get("CLOUDFLARE_ACCOUNT_ID")) || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
const gateway = const gateway =
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) (yield* env.get("CLOUDFLARE_GATEWAY_ID")) || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
if (!accountId || !gateway) { if (!accountId || !gateway) {
const missing = [ const missing = [
@@ -743,7 +744,7 @@ export namespace Provider {
// Get API token from env or auth - required for authenticated gateways // Get API token from env or auth - required for authenticated gateways
const apiToken = yield* Effect.gen(function* () { const apiToken = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN") const envToken = (yield* env.get("CLOUDFLARE_API_TOKEN")) || (yield* env.get("CF_AIG_TOKEN"))
if (envToken) return envToken if (envToken) return envToken
if (auth?.type === "api") return auth.key if (auth?.type === "api") return auth.key
return undefined return undefined
@@ -1028,12 +1029,18 @@ export namespace Provider {
} }
} }
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect( const layer: Layer.Layer<
Service,
never,
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const config = yield* Config.Service const config = yield* Config.Service
const auth = yield* Auth.Service const auth = yield* Auth.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const env = yield* Env.Service
const state = yield* InstanceState.make<State>(() => const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -1179,11 +1186,11 @@ export namespace Provider {
} }
// load env // load env
const env = Env.all() const vals = yield* env.all()
for (const [id, provider] of Object.entries(database)) { for (const [id, provider] of Object.entries(database)) {
const providerID = ProviderID.make(id) const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => env[item]).find(Boolean) const apiKey = provider.env.map((item) => vals[item]).find(Boolean)
if (!apiKey) continue if (!apiKey) continue
mergeProvider(providerID, { mergeProvider(providerID, {
source: "env", source: "env",
@@ -1226,7 +1233,7 @@ export namespace Provider {
mergeProvider(providerID, patch) mergeProvider(providerID, patch)
} }
for (const [id, fn] of Object.entries(custom(dep))) { for (const [id, fn] of Object.entries(custom(dep, env))) {
const providerID = ProviderID.make(id) const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const data = database[providerID] const data = database[providerID]
@@ -1356,7 +1363,7 @@ export namespace Provider {
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
async function resolveSDK(model: Model, s: State) { async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try { try {
using _ = log.time("getSDK", { using _ = log.time("getSDK", {
providerID: model.providerID, providerID: model.providerID,
@@ -1387,7 +1394,7 @@ export namespace Provider {
} }
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = Env.get(String(key)) const val = envs[String(key)]
return val ?? item return val ?? item
}) })
return url return url
@@ -1519,9 +1526,10 @@ export namespace Provider {
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const key = `${model.providerID}/${model.id}` const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)! if (s.models.has(key)) return s.models.get(key)!
const vals = yield* env.all()
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const url = e2eURL() const url = e2eURL(vals)
if (url) { if (url) {
const language = createOpenAICompatible({ const language = createOpenAICompatible({
name: model.providerID, name: model.providerID,
@@ -1533,7 +1541,7 @@ export namespace Provider {
} }
const provider = s.providers[model.providerID] const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s) const sdk = await resolveSDK(model, s, vals)
try { try {
const language = s.modelLoaders[model.providerID] const language = s.modelLoaders[model.providerID]
@@ -1631,12 +1639,17 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model) if (cfg.model) return parseModel(cfg.model)
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const recent = yield* Effect.promise(() => const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Filesystem.readJson<{ Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
recent?: { providerID: ProviderID; modelID: ModelID }[] if (!isRecord(x) || !Array.isArray(x.recent)) return []
}>(path.join(Global.Path.state, "model.json")) return x.recent.flatMap((item) => {
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : [])) if (!isRecord(item)) return []
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []), if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
) )
for (const entry of recent) { for (const entry of recent) {
const provider = s.providers[entry.providerID] const provider = s.providers[entry.providerID]
@@ -1663,42 +1676,14 @@ export namespace Provider {
export const defaultLayer = Layer.suspend(() => export const defaultLayer = Layer.suspend(() =>
layer.pipe( layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function getProvider(providerID: ProviderID) {
return runPromise((svc) => svc.getProvider(providerID))
}
export async function getModel(providerID: ProviderID, modelID: ModelID) {
return runPromise((svc) => svc.getModel(providerID, modelID))
}
export async function getLanguage(model: Model) {
return runPromise((svc) => svc.getLanguage(model))
}
export async function closest(providerID: ProviderID, query: string[]) {
return runPromise((svc) => svc.closest(providerID, query))
}
export async function getSmallModel(providerID: ProviderID) {
return runPromise((svc) => svc.getSmallModel(providerID))
}
export async function defaultModel() {
return runPromise((svc) => svc.defaultModel())
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort<T extends { id: string }>(models: T[]) { export function sort<T extends { id: string }>(models: T[]) {
return sortBy( return sortBy(
+14 -1
View File
@@ -209,6 +209,9 @@ export namespace ProviderTransform {
copilot: { copilot: {
copilot_cache_control: { type: "ephemeral" }, copilot_cache_control: { type: "ephemeral" },
}, },
alibaba: {
cacheControl: { type: "ephemeral" },
},
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
model.api.id.includes("claude") || model.api.id.includes("claude") ||
model.id.includes("anthropic") || model.id.includes("anthropic") ||
model.id.includes("claude") || model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic") && model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm !== "@ai-sdk/gateway" model.api.npm !== "@ai-sdk/gateway"
) { ) {
msgs = applyCaching(msgs, model) msgs = applyCaching(msgs, model)
@@ -828,8 +832,17 @@ export namespace ProviderTransform {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) { if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium" result["reasoningEffort"] = "medium"
// Only inject reasoningSummary for providers that support it natively.
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
// parameter and return "Unknown parameter: 'reasoningSummary'".
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["reasoningSummary"] = "auto" result["reasoningSummary"] = "auto"
} }
}
// Only set textVerbosity for non-chat gpt-5.x models // Only set textVerbosity for non-chat gpt-5.x models
// Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity
-31
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import type { Proc } from "#pty" import type { Proc } from "#pty"
import z from "zod" import z from "zod"
@@ -361,34 +360,4 @@ export namespace Pty {
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function get(id: PtyID) {
return runPromise((svc) => svc.get(id))
}
export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data))
}
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
return runPromise((svc) => svc.connect(id, ws, cursor))
}
export async function create(input: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function update(id: PtyID, input: UpdateInput) {
return runPromise((svc) => svc.update(id, input))
}
export async function remove(id: PtyID) {
return runPromise((svc) => svc.remove(id))
}
} }
+14 -2
View File
@@ -1,5 +1,7 @@
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Effect } from "effect"
import { ProviderID } from "@/provider/schema" import { ProviderID } from "@/provider/schema"
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
@@ -39,7 +41,12 @@ export function ControlPlaneRoutes(): Hono {
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
const info = c.req.valid("json") const info = c.req.valid("json")
await Auth.set(providerID, info) await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(providerID, info)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -69,7 +76,12 @@ export function ControlPlaneRoutes(): Hono {
), ),
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
await Auth.remove(providerID) await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -7,6 +7,8 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -30,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Config.get()) return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
}, },
) )
.patch( .patch(
@@ -54,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") const config = c.req.valid("json")
await Config.update(config) await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config) return c.json(config)
}, },
) )
@@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
using _ = log.time("providers") using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) const providers = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
return mapValues(yield* svc.list(), (item) => item)
}),
)
return c.json({ return c.json({
providers: Object.values(providers), providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
@@ -1,8 +1,10 @@
import z from "zod"
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi" import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming" import { streamSSE } from "hono/streaming"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { AsyncQueue } from "../../util/queue" import { AsyncQueue } from "../../util/queue"
@@ -20,7 +22,11 @@ export const EventRoutes = () =>
description: "Event stream", description: "Event stream",
content: { content: {
"text/event-stream": { "text/event-stream": {
schema: resolver(BusEvent.payloads()), schema: resolver(
z.union(BusEvent.payloads()).meta({
ref: "Event",
}),
),
}, },
}, },
}, },
@@ -162,7 +162,13 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await ToolRegistry.ids()) const ids = await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
}, },
) )
.get( .get(
@@ -205,11 +211,17 @@ export const ExperimentalRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { provider, model } = c.req.valid("query") const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ const tools = await AppRuntime.runPromise(
Effect.gen(function* () {
const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service
return yield* registry.tools({
providerID: ProviderID.make(provider), providerID: ProviderID.make(provider),
modelID: ModelID.make(model), modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()), agent: yield* agents.get(yield* agents.defaultAgent()),
}) })
}),
)
return c.json( return c.json(
tools.map((t) => ({ tools.map((t) => ({
id: t.id, id: t.id,
@@ -242,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.CreateInput.optional()), validator("json", Worktree.CreateInput.optional()),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
const worktree = await Worktree.create(body) const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
return c.json(worktree) return c.json(worktree)
}, },
) )
@@ -289,7 +301,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.RemoveInput), validator("json", Worktree.RemoveInput),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Worktree.remove(body) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await Project.removeSandbox(Instance.project.id, body.directory) await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true) return c.json(true)
}, },
@@ -315,7 +327,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.ResetInput), validator("json", Worktree.ResetInput),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Worktree.reset(body) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true) return c.json(true)
}, },
) )
@@ -396,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await MCP.resources()) return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
}, },
), ),
) )
+29 -16
View File
@@ -1,6 +1,8 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file" import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep" import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp" import { LSP } from "../../lsp"
@@ -34,12 +36,10 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const pattern = c.req.valid("query").pattern const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({ const result = await AppRuntime.runPromise(
cwd: Instance.directory, Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
pattern, )
limit: 10, return c.json(result.items)
})
return c.json(result)
}, },
) )
.get( .get(
@@ -73,12 +73,18 @@ export const FileRoutes = lazy(() =>
const dirs = c.req.valid("query").dirs const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type const type = c.req.valid("query").type
const limit = c.req.valid("query").limit const limit = c.req.valid("query").limit
const results = await File.search({ const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) =>
svc.search({
query, query,
limit: limit ?? 10, limit: limit ?? 10,
dirs: dirs !== "false", dirs: dirs !== "false",
type, type,
}) }),
)
}),
)
return c.json(results) return c.json(results)
}, },
) )
@@ -106,11 +112,6 @@ export const FileRoutes = lazy(() =>
}), }),
), ),
async (c) => { async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([]) return c.json([])
}, },
) )
@@ -139,7 +140,11 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const path = c.req.valid("query").path const path = c.req.valid("query").path
const content = await File.list(path) const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content) return c.json(content)
}, },
) )
@@ -168,7 +173,11 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const path = c.req.valid("query").path const path = c.req.valid("query").path
const content = await File.read(path) const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content) return c.json(content)
}, },
) )
@@ -190,7 +199,11 @@ export const FileRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const content = await File.status() const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content) return c.json(content)
}, },
), ),
@@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() =>
directory: z.string(), directory: z.string(),
project: z.string().optional(), project: z.string().optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
payload: BusEvent.payloads(), payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
}) })
.meta({ .meta({
ref: "GlobalEvent", ref: "GlobalEvent",
@@ -135,52 +135,6 @@ export const GlobalRoutes = lazy(() =>
}) })
}, },
) )
.get(
"/sync-event",
describeRoute({
summary: "Subscribe to global sync events",
description: "Get global sync events",
operationId: "global.sync-event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
payload: SyncEvent.payloads(),
})
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("Cache-Control", "no-cache, no-transform")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
})
},
)
.get( .get(
"/config", "/config",
describeRoute({ describeRoute({
@@ -199,7 +153,7 @@ export const GlobalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Config.getGlobal()) return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
}, },
) )
.patch( .patch(
@@ -223,7 +177,7 @@ export const GlobalRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") const config = c.req.valid("json")
const next = await Config.updateGlobal(config) const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
return c.json(next) return c.json(next)
}, },
) )
+28 -8
View File
@@ -1,6 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { Format } from "../../format" import { Format } from "../../format"
import { TuiRoutes } from "./tui" import { TuiRoutes } from "./tui"
@@ -119,11 +120,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) return c.json(
return c.json({ await AppRuntime.runPromise(
branch, Effect.gen(function* () {
default_branch, const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
}) })
return { branch, default_branch }
}),
),
)
}, },
) )
.get( .get(
@@ -150,7 +157,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}), }),
), ),
async (c) => { async (c) => {
return c.json(await Vcs.diff(c.req.valid("query").mode)) return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
}, },
) )
.get( .get(
@@ -193,7 +207,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const modes = await Agent.list() const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
return c.json(modes) return c.json(modes)
}, },
) )
@@ -215,7 +229,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const skills = await Skill.all() const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
return c.json(skills) return c.json(skills)
}, },
) )
@@ -237,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await LSP.status()) const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
}, },
) )
.get( .get(
+34 -14
View File
@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { Config } from "../../config/config" import { Config } from "../../config/config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Effect } from "effect"
export const McpRoutes = lazy(() => export const McpRoutes = lazy(() =>
new Hono() new Hono()
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await MCP.status()) return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
}, },
) )
.post( .post(
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { name, config } = c.req.valid("json") const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config) const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status) return c.json(result.status)
}, },
) )
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name) const result = await AppRuntime.runPromise(
if (!supportsOAuth) { Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
auth: yield* mcp.startAuth(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
} }
const result = await MCP.startAuth(name) return c.json(result.auth)
return c.json(result)
}, },
) )
.post( .post(
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const { code } = c.req.valid("json") const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code) const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status) return c.json(status)
}, },
) )
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name) const result = await AppRuntime.runPromise(
if (!supportsOAuth) { Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
status: yield* mcp.authenticate(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
} }
const status = await MCP.authenticate(name) return c.json(result.status)
return c.json(status)
}, },
) )
.delete( .delete(
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
await MCP.removeAuth(name) await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const }) return c.json({ success: true as const })
}, },
) )
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) => {
const { name } = c.req.valid("param") const { name } = c.req.valid("param")
await MCP.connect(name) await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true) return c.json(true)
}, },
) )
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) => {
const { name } = c.req.valid("param") const { name } = c.req.valid("param")
await MCP.disconnect(name) await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true) return c.json(true)
}, },
), ),
@@ -10,6 +10,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -66,7 +67,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (!workspaceID) { if (!workspaceID) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() { async fn() {
return next() return next()
}, },
@@ -94,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
const adaptor = await getAdaptor(workspace.type) const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace) const target = await adaptor.target(workspace)
if (target.type === "local") { if (target.type === "local") {
@@ -103,7 +104,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () => fn: () =>
Instance.provide({ Instance.provide({
directory: target.directory, directory: target.directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() { async fn() {
return next() return next()
}, },
@@ -1,6 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../error" import { errors } from "../error"
@@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() =>
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
const json = c.req.valid("json") const json = c.req.valid("json")
await Permission.reply({ await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.requestID, requestID: params.requestID,
reply: json.reply, reply: json.reply,
message: json.message, message: json.message,
}) }),
),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const permissions = await Permission.list() const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
return c.json(permissions) return c.json(permissions)
}, },
), ),
@@ -8,6 +8,7 @@ import { ProjectID } from "../../project/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
export const ProjectRoutes = lazy(() => export const ProjectRoutes = lazy(() =>
new Hono() new Hono()
@@ -83,7 +84,7 @@ export const ProjectRoutes = lazy(() =>
directory: dir, directory: dir,
worktree: dir, worktree: dir,
project: next, project: next,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
}) })
return c.json(next) return c.json(next)
}, },
@@ -11,6 +11,7 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -40,27 +41,36 @@ export const ProviderRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const config = await Config.get() 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 disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
const allProviders = await ModelsDev.get() for (const [key, value] of Object.entries(all)) {
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value filtered[key] = value
} }
} }
const connected = yield* svc.list()
const connected = await Provider.list()
const providers = Object.assign( const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected, connected,
) )
return c.json({ return {
all: Object.values(providers), all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected), connected: Object.keys(connected),
}
}),
)
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
}) })
}, },
) )
+56 -8
View File
@@ -1,7 +1,9 @@
import { Hono, type MiddlewareHandler } from "hono" import { Hono, type MiddlewareHandler } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema" import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db" import { NotFoundError } from "../../storage/db"
@@ -27,7 +29,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Pty.list()) return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
}, },
) )
.post( .post(
@@ -50,7 +59,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("json", Pty.CreateInput), validator("json", Pty.CreateInput),
async (c) => { async (c) => {
const info = await Pty.create(c.req.valid("json")) 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) return c.json(info)
}, },
) )
@@ -74,7 +88,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
const info = await Pty.get(c.req.valid("param").ptyID) const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
}),
)
if (!info) { if (!info) {
throw new NotFoundError({ message: "Session not found" }) throw new NotFoundError({ message: "Session not found" })
} }
@@ -102,7 +121,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput), validator("json", Pty.UpdateInput),
async (c) => { async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) 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) return c.json(info)
}, },
) )
@@ -126,7 +150,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
await Pty.remove(c.req.valid("param").ptyID) await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -150,6 +179,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket(async (c) => { upgradeWebSocket(async (c) => {
type Handler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
const id = PtyID.zod.parse(c.req.param("ptyID")) const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => { const cursor = (() => {
const value = c.req.query("cursor") const value = c.req.query("cursor")
@@ -158,8 +192,17 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
if (!Number.isSafeInteger(parsed) || parsed < -1) return if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed return parsed
})() })()
let handler: Awaited<ReturnType<typeof Pty.connect>> let handler: Handler | undefined
if (!(await Pty.get(id))) throw new Error("Session not found") if (
!(await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
}),
))
) {
throw new Error("Session not found")
}
type Socket = { type Socket = {
readyState: number readyState: number
@@ -185,7 +228,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
ws.close() ws.close()
return return
} }
handler = await Pty.connect(id, socket, cursor) handler = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}),
)
ready = true ready = true
for (const msg of pending) handler?.onMessage(msg) for (const msg of pending) handler?.onMessage(msg)
pending.length = 0 pending.length = 0
@@ -474,10 +474,14 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const query = c.req.valid("query") const query = c.req.valid("query")
const params = c.req.valid("param") const params = c.req.valid("param")
const result = await SessionSummary.diff({ const result = await AppRuntime.runPromise(
SessionSummary.Service.use((summary) =>
summary.diff({
sessionID: params.sessionID, sessionID: params.sessionID,
messageID: query.messageID, messageID: query.messageID,
}) }),
),
)
return c.json(result) return c.json(result)
}, },
) )
@@ -547,18 +551,27 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const session = await Session.get(sessionID) await AppRuntime.runPromise(
await SessionRevert.cleanup(session) Effect.gen(function* () {
const msgs = await Session.messages({ sessionID }) const session = yield* Session.Service
let currentAgent = await Agent.defaultAgent() 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--) { for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info const info = msgs[i].info
if (info.role === "user") { if (info.role === "user") {
currentAgent = info.agent || (await Agent.defaultAgent()) currentAgent = info.agent || defaultAgent
break break
} }
} }
await SessionCompaction.create({
yield* compact.create({
sessionID, sessionID,
agent: currentAgent, agent: currentAgent,
model: { model: {
@@ -567,7 +580,9 @@ export const SessionRoutes = lazy(() =>
}, },
auto: body.auto, auto: body.auto,
}) })
await SessionPrompt.loop({ sessionID }) yield* prompt.loop({ sessionID })
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -985,10 +1000,14 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json")) log.info("revert", c.req.valid("json"))
const session = await SessionRevert.revert({ const session = await AppRuntime.runPromise(
SessionRevert.Service.use((svc) =>
svc.revert({
sessionID, sessionID,
...c.req.valid("json"), ...c.req.valid("json"),
}) }),
),
)
return c.json(session) return c.json(session)
}, },
) )
@@ -1018,7 +1037,7 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const session = await SessionRevert.unrevert({ sessionID }) const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
return c.json(session) return c.json(session)
}, },
) )
@@ -1051,10 +1070,14 @@ export const SessionRoutes = lazy(() =>
validator("json", z.object({ response: Permission.Reply })), validator("json", z.object({ response: Permission.Reply })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
Permission.reply({ await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.permissionID, requestID: params.permissionID,
reply: c.req.valid("json").response, reply: c.req.valid("json").response,
}) }),
),
)
return c.json(true) return c.json(true)
}, },
), ),
@@ -1,13 +1,41 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod" import z from "zod"
import { listAdaptors } from "../../control-plane/adaptors"
import { Workspace } from "../../control-plane/workspace" import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
const WorkspaceAdaptor = z.object({
type: z.string(),
name: z.string(),
description: z.string(),
})
export const WorkspaceRoutes = lazy(() => export const WorkspaceRoutes = lazy(() =>
new Hono() new Hono()
.get(
"/adaptor",
describeRoute({
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adaptor.list",
responses: {
200: {
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(z.array(WorkspaceAdaptor)),
},
},
},
},
}),
async (c) => {
return c.json(await listAdaptors(Instance.project.id))
},
)
.post( .post(
"/", "/",
describeRoute({ describeRoute({
+20 -23
View File
@@ -9,14 +9,12 @@ import z from "zod"
import { Token } from "../util/token" import { Token } from "../util/token"
import { Log } from "../util/log" import { Log } from "../util/log"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db" import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow" import { isOverflow as overflow } from "./overflow"
@@ -310,6 +308,25 @@ When constructing the summary, try to stick to this template:
} }
if (!replay) { if (!replay) {
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({ const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
@@ -337,6 +354,7 @@ When constructing the summary, try to stick to this template:
}) })
} }
} }
}
if (processor.message.error) return "stop" if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.create(input)),
)
} }
+9 -7
View File
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import z from "zod" import z from "zod"
import { type ProviderMetadata } from "ai" import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Installation } from "../installation" import { Installation } from "../installation"
@@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Option, Context } from "effect" import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -240,7 +239,7 @@ export namespace Session {
export const getUsage = (input: { export const getUsage = (input: {
model: Provider.Model model: Provider.Model
usage: LanguageModelV2Usage usage: LanguageModelUsage
metadata?: ProviderMetadata metadata?: ProviderMetadata
}) => { }) => {
const safe = (value: number) => { const safe = (value: number) => {
@@ -249,11 +248,14 @@ export namespace Session {
} }
const inputTokens = safe(input.usage.inputTokens ?? 0) const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0) const cacheReadInputTokens = safe(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe( const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? (input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key // google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
@@ -274,7 +276,7 @@ export namespace Session {
const tokens = { const tokens = {
total, total,
input: adjustedInputTokens, input: adjustedInputTokens,
output: outputTokens - reasoningTokens, output: safe(outputTokens - reasoningTokens),
reasoning: reasoningTokens, reasoning: reasoningTokens,
cache: { cache: {
write: cacheWriteInputTokens, write: cacheWriteInputTokens,
+72 -48
View File
@@ -1,7 +1,6 @@
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Cause, Effect, Layer, Record, Context } from "effect" import { Context, Effect, Layer, Record } from "effect"
import * as Queue from "effect/Queue"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda" import { mergeDeep, pipe } from "remeda"
@@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { makeRuntime } from "@/effect/run-service"
export namespace LLM { export namespace LLM {
const log = Log.create({ service: "llm" }) const log = Log.create({ service: "llm" })
const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
export type StreamInput = { export type StreamInput = {
user: MessageV2.User user: MessageV2.User
@@ -45,7 +47,7 @@ export namespace LLM {
abort: AbortSignal abort: AbortSignal
} }
export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface { export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown> readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
@@ -53,35 +55,16 @@ export namespace LLM {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export const layer = Layer.effect( export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> =
Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
return Service.of({ const auth = yield* Auth.Service
stream(input) { const config = yield* Config.Service
return Stream.scoped( const provider = yield* Provider.Service
Stream.unwrap( const plugin = yield* Plugin.Service
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal })) const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
return Stream.fromAsyncIterable(result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
)
}),
),
)
},
})
}),
)
export const defaultLayer = layer
export async function stream(input: StreamRequest) {
const l = log const l = log
.clone() .clone()
.tag("providerID", input.model.providerID) .tag("providerID", input.model.providerID)
@@ -94,14 +77,19 @@ export namespace LLM {
modelID: input.model.id, modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
}) })
const [language, cfg, provider, auth] = await Promise.all([
Provider.getLanguage(input.model), const [language, cfg, item, info] = yield* Effect.all(
Config.get(), [
Provider.getProvider(input.model.providerID), provider.getLanguage(input.model),
Auth.get(input.model.providerID), config.get(),
]) provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
// TODO: move this to a proper hook // TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
const system: string[] = [] const system: string[] = []
system.push( system.push(
@@ -118,7 +106,7 @@ export namespace LLM {
) )
const header = system[0] const header = system[0]
await Plugin.trigger( yield* plugin.trigger(
"experimental.chat.system.transform", "experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model }, { sessionID: input.sessionID, model: input.model },
{ system }, { system },
@@ -139,7 +127,7 @@ export namespace LLM {
: ProviderTransform.options({ : ProviderTransform.options({
model: input.model, model: input.model,
sessionID: input.sessionID, sessionID: input.sessionID,
providerOptions: provider.options, providerOptions: item.options,
}) })
const options: Record<string, any> = pipe( const options: Record<string, any> = pipe(
base, base,
@@ -166,13 +154,13 @@ export namespace LLM {
...input.messages, ...input.messages,
] ]
const params = await Plugin.trigger( const params = yield* plugin.trigger(
"chat.params", "chat.params",
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent.name, agent: input.agent.name,
model: input.model, model: input.model,
provider, provider: item,
message: input.user, message: input.user,
}, },
{ {
@@ -186,13 +174,13 @@ export namespace LLM {
}, },
) )
const { headers } = await Plugin.trigger( const { headers } = yield* plugin.trigger(
"chat.headers", "chat.headers",
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent.name, agent: input.agent.name,
model: input.model, model: input.model,
provider, provider: item,
message: input.user, message: input.user,
}, },
{ {
@@ -200,7 +188,7 @@ export namespace LLM {
}, },
) )
const tools = await resolveTools(input) const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present // LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used. // when message history contains tool calls, even if no tools are being used.
@@ -209,7 +197,7 @@ export namespace LLM {
// 1. Providers with "litellm" in their ID or API ID (auto-detected) // 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy = const isLiteLLMProxy =
provider.options?.["litellmProxy"] === true || item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") || input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm") input.model.api.id.toLowerCase().includes("litellm")
@@ -295,7 +283,8 @@ export namespace LLM {
} }
}) })
const uniquePatterns = [...new Set(toolPatterns)] as string[] const uniquePatterns = [...new Set(toolPatterns)] as string[]
await Permission.ask({ await perms.runPromise((svc) =>
svc.ask({
id, id,
sessionID: SessionID.make(input.sessionID), sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval", permission: "workflow_tool_approval",
@@ -303,9 +292,13 @@ export namespace LLM {
metadata: { tools: approvalTools }, metadata: { tools: approvalTools },
always: uniquePatterns, always: uniquePatterns,
ruleset: [], ruleset: [],
}) }),
)
for (const name of uniqueNames) approvedToolsForSession.add(name) for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames] workflowModel.sessionPreapprovedTools = [
...(workflowModel.sessionPreapprovedTools ?? []),
...uniqueNames,
]
return { approved: true } return { approved: true }
} catch { } catch {
return { approved: false } return { approved: false }
@@ -392,7 +385,38 @@ export namespace LLM {
}, },
}, },
}) })
} })
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
)
}),
),
)
return Service.of({ stream })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) { function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled( const disabled = Permission.disabled(
+8 -2
View File
@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Layer, Context } from "effect" import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
@@ -89,6 +89,7 @@ export namespace SessionProcessor {
| LLM.Service | LLM.Service
| Permission.Service | Permission.Service
| Plugin.Service | Plugin.Service
| SessionSummary.Service
| SessionStatus.Service | SessionStatus.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
@@ -101,6 +102,8 @@ export namespace SessionProcessor {
const llm = yield* LLM.Service const llm = yield* LLM.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const summary = yield* SessionSummary.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service const status = yield* SessionStatus.Service
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
@@ -385,10 +388,12 @@ export namespace SessionProcessor {
} }
ctx.snapshot = undefined ctx.snapshot = undefined
} }
SessionSummary.summarize({ yield* summary
.summarize({
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.parentID, messageID: ctx.assistantMessage.parentID,
}) })
.pipe(Effect.ignore, Effect.forkIn(scope))
if ( if (
!ctx.assistantMessage.summary && !ctx.assistantMessage.summary &&
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model }) isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
@@ -603,6 +608,7 @@ export namespace SessionProcessor {
Layer.provide(LLM.defaultLayer), Layer.provide(LLM.defaultLayer),
Layer.provide(Permission.defaultLayer), Layer.provide(Permission.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SessionStatus.defaultLayer), Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
+4 -3
View File
@@ -1,10 +1,11 @@
import { NotFoundError, eq, and } from "../storage/db" import { NotFoundError, eq, and, sql } from "../storage/db"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { Session } from "./index" import { Session } from "./index"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql" import { SessionTable, MessageTable, PartTable, SessionEntryTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { DateTime } from "effect"
import { SessionEntry } from "@/v2/session-entry"
const log = Log.create({ service: "session.projector" }) const log = Log.create({ service: "session.projector" })

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