mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-14 18:04:48 +00:00
Compare commits
135 Commits
kit/tool-e
...
2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6ce05d09 | ||
|
|
1dc69359d5 | ||
|
|
329fcb040b | ||
|
|
bf50d1c028 | ||
|
|
b8801dbd22 | ||
|
|
f7c6943817 | ||
|
|
91fe4db27c | ||
|
|
21d7a85e76 | ||
|
|
663e798e76 | ||
|
|
5bc2d2498d | ||
|
|
c22e34853d | ||
|
|
6825b0bbc7 | ||
|
|
3644581b55 | ||
|
|
79cc15335e | ||
|
|
ca6200121b | ||
|
|
7239b38b7f | ||
|
|
9ae8dc2d01 | ||
|
|
7164662be2 | ||
|
|
94f71f59a3 | ||
|
|
3eb6508a64 | ||
|
|
6fdb8ab90d | ||
|
|
321bf1f8e1 | ||
|
|
62bd023086 | ||
|
|
cb1a50055c | ||
|
|
65e3348232 | ||
|
|
a6b9f0dac1 | ||
|
|
34f5bdbc99 | ||
|
|
0b4fe14b0a | ||
|
|
7230cd2683 | ||
|
|
a915fe74be | ||
|
|
26d35583c5 | ||
|
|
ae17b416b8 | ||
|
|
8ffadde85c | ||
|
|
3c0ad70653 | ||
|
|
264418c0cd | ||
|
|
fa2c69f09c | ||
|
|
113304a058 | ||
|
|
8c4d49c2bc | ||
|
|
2aa6110c6e | ||
|
|
8b9b9ad31e | ||
|
|
3729fd5706 | ||
|
|
74b14a2d4e | ||
|
|
cdb951ec2f | ||
|
|
fc01cad2b8 | ||
|
|
c1ddc0ea2d | ||
|
|
319b7655b7 | ||
|
|
824c12c01a | ||
|
|
17b2900884 | ||
|
|
003010bdb6 | ||
|
|
82a4292934 | ||
|
|
eea4253d67 | ||
|
|
1eacc3c339 | ||
|
|
1a509d62a0 | ||
|
|
4c4eef46f1 | ||
|
|
d62ec7776e | ||
|
|
cb1e5d9e41 | ||
|
|
ca5f086759 | ||
|
|
57c40eb7c2 | ||
|
|
63035f977f | ||
|
|
514d2a36bc | ||
|
|
0b6fd5f612 | ||
|
|
029e7135b7 | ||
|
|
c43591f8a2 | ||
|
|
a2c22714cb | ||
|
|
312f10f797 | ||
|
|
d1f05b0f3a | ||
|
|
ccb0b320e1 | ||
|
|
5ee7edaf9e | ||
|
|
27190635ea | ||
|
|
2e340d976f | ||
|
|
fe4dfb9f6f | ||
|
|
5e3dc80999 | ||
|
|
d84cc33742 | ||
|
|
c92c462148 | ||
|
|
9ca06e0336 | ||
|
|
3b523b32f5 | ||
|
|
ba3600a515 | ||
|
|
03ce2e5288 | ||
|
|
87e23abb10 | ||
|
|
2868000c20 | ||
|
|
f38f415bf0 | ||
|
|
4341ab838e | ||
|
|
cd004cf0b2 | ||
|
|
19ae8c88b0 | ||
|
|
3dd09147c2 | ||
|
|
9581bf0670 | ||
|
|
af8aff3788 | ||
|
|
2a8a59ded9 | ||
|
|
5917ac2162 | ||
|
|
b6af4d0dc6 | ||
|
|
577139c626 | ||
|
|
c5fb6281f0 | ||
|
|
f99812443c | ||
|
|
b898c6d0ea | ||
|
|
9e7045eaec | ||
|
|
a17ac02061 | ||
|
|
57f9397677 | ||
|
|
a4c686025c | ||
|
|
face879100 | ||
|
|
605559b165 | ||
|
|
5cd4c6eb22 | ||
|
|
40358d60a0 | ||
|
|
96c1c0363d | ||
|
|
33819932ec | ||
|
|
5d6fe01465 | ||
|
|
cf27a73397 | ||
|
|
f2c492a8e6 | ||
|
|
0556774097 | ||
|
|
d9d5a0615e | ||
|
|
d72ddd71fa | ||
|
|
fb26308bc7 | ||
|
|
b41fa8e318 | ||
|
|
57b2e64345 | ||
|
|
346b3e1b8d | ||
|
|
b139bc2ef3 | ||
|
|
378b8ca241 | ||
|
|
f63bdc8e08 | ||
|
|
ce26120205 | ||
|
|
d2d5d84d1e | ||
|
|
847f1d99c9 | ||
|
|
59d08683ea | ||
|
|
f7514d9eca | ||
|
|
180ded6a27 | ||
|
|
bf601628db | ||
|
|
00e39d2114 | ||
|
|
46b74e0873 | ||
|
|
aedc4e964f | ||
|
|
e83404367c | ||
|
|
42206da1f8 | ||
|
|
44f38193c0 | ||
|
|
9a6b455bfe | ||
|
|
8063e0b5c6 | ||
|
|
157c5d77f8 | ||
|
|
ce19c051be | ||
|
|
91786d2fc1 |
2
.github/VOUCHED.td
vendored
2
.github/VOUCHED.td
vendored
@@ -25,7 +25,9 @@ kommander
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-toastythebot
|
||||
|
||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -213,7 +213,6 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -390,7 +389,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -591,13 +590,12 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
67
bun.lock
67
bun.lock
@@ -319,7 +319,8 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||
"@ai-sdk/anthropic": "3.0.67",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
@@ -331,7 +332,7 @@
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
@@ -341,7 +342,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
@@ -357,7 +357,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -413,7 +413,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.79.0",
|
||||
"@effect/language-service": "0.84.2",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -450,6 +450,7 @@
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -641,7 +642,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@effect/platform-node": "4.0.0-beta.46",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
@@ -662,13 +663,13 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "6.0.149",
|
||||
"ai": "6.0.158",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"effect": "4.0.0-beta.46",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -707,7 +708,9 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@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-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||
"@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/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=="],
|
||||
|
||||
@@ -1025,11 +1028,11 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
"@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.46", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.46", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -1161,8 +1164,6 @@
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
@@ -1539,7 +1540,7 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -2385,7 +2386,7 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@6.0.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="],
|
||||
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||
|
||||
@@ -2889,7 +2890,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
||||
"effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -5005,7 +5006,13 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
"@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/@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/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -5281,10 +5288,6 @@
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
@@ -5509,6 +5512,10 @@
|
||||
|
||||
"@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
@@ -5549,7 +5556,9 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="],
|
||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@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-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||
|
||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
||||
|
||||
@@ -5765,6 +5774,8 @@
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
||||
|
||||
"opencode/@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=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
@@ -5947,8 +5958,6 @@
|
||||
|
||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -6435,12 +6444,18 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -6813,6 +6828,8 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
|
||||
"aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
|
||||
"aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
|
||||
"x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
|
||||
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
|
||||
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
|
||||
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
|
||||
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@effect/platform-node": "4.0.0-beta.46",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -47,8 +47,8 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"ai": "6.0.149",
|
||||
"effect": "4.0.0-beta.46",
|
||||
"ai": "6.0.158",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
||||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
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"
|
||||
onClick={(e: MouseEvent) => {
|
||||
props.loadMore()
|
||||
|
||||
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<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={{
|
||||
"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" />
|
||||
|
||||
@@ -316,7 +316,8 @@
|
||||
|
||||
/* Download Hero Section */
|
||||
[data-component="download-hero"] {
|
||||
display: grid;
|
||||
/* display: grid; */
|
||||
display: none;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 4rem;
|
||||
padding-bottom: 2rem;
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: true,
|
||||
show: false,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
|
||||
wireZoom(win)
|
||||
injectGlobals(win, globals)
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
@@ -14,18 +14,11 @@
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.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"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"randomField": "this-is-a-random-value-12345",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -39,11 +32,16 @@
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#hono": {
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.79.0",
|
||||
"@effect/language-service": "0.84.2",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -78,7 +76,8 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||
"@ai-sdk/anthropic": "3.0.67",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
@@ -90,7 +89,7 @@
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
@@ -100,7 +99,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
@@ -116,7 +114,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
||||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||
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 { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||
const { ToolRegistry } = await import("../src/tool/registry")
|
||||
const { Effect } = await import("effect")
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
@@ -54,6 +62,7 @@ const seed = async () => {
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await AppRuntime.dispose().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
137
packages/opencode/specs/effect/http-api.md
Normal file
137
packages/opencode/specs/effect/http-api.md
Normal 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.
|
||||
@@ -23,7 +23,7 @@ export namespace Foo {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
|
||||
## 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 [Destroying the facades](#destroying-the-facades).
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
@@ -217,62 +219,20 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
- [x] `SessionSummary` — `session/summary.ts`
|
||||
- [x] `SessionRevert` — `session/revert.ts`
|
||||
- [x] `Instruction` — `session/instruction.ts`
|
||||
- [x] `SystemPrompt` — `session/system.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
- [x] `SessionTodo` — `session/todo.ts`
|
||||
|
||||
Still open:
|
||||
Still open at the service-shape level:
|
||||
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `ShareNext` — `share/share-next.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||
|
||||
## Tool interface → Effect
|
||||
## Tool migration
|
||||
|
||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
||||
|
||||
1. Migrate each tool to return Effects
|
||||
2. Update `Tool.define()` factory to work with Effects
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
||||
|
||||
### Tool migration details
|
||||
|
||||
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
|
||||
|
||||
- `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. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
|
||||
- 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* Effect.promise(() => 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
|
||||
Tool-specific migration guidance and checklist live in `tools.md`.
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
@@ -280,27 +240,19 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
||||
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||
|
||||
### `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
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
@@ -308,3 +260,48 @@ Current raw fs users that will convert during tool migration:
|
||||
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
|
||||
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
|
||||
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
|
||||
|
||||
## Destroying the facades
|
||||
|
||||
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
|
||||
|
||||
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
|
||||
|
||||
For each service, the migration is roughly:
|
||||
|
||||
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
|
||||
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
|
||||
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
|
||||
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
|
||||
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
|
||||
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
|
||||
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
|
||||
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
|
||||
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
|
||||
- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
|
||||
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
|
||||
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
|
||||
|
||||
### Migration log
|
||||
|
||||
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — 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.
|
||||
- `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.
|
||||
|
||||
## Route handler effectification
|
||||
|
||||
Route-handler migration guidance and checklist live in `routes.md`.
|
||||
66
packages/opencode/specs/effect/routes.md
Normal file
66
packages/opencode/specs/effect/routes.md
Normal 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
packages/opencode/specs/effect/schema.md
Normal file
99
packages/opencode/specs/effect/schema.md
Normal 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
packages/opencode/specs/effect/tools.md
Normal file
96
packages/opencode/specs/effect/tools.md
Normal 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`
|
||||
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
- `api.state`
|
||||
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
|
||||
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
|
||||
- `api.client`
|
||||
- `api.event.on(type, handler)`
|
||||
- `api.renderer`
|
||||
- `api.slots.register(plugin)`
|
||||
@@ -270,7 +270,6 @@ Command behavior:
|
||||
- `provider`
|
||||
- `path.{state,config,worktree,directory}`
|
||||
- `vcs?.branch`
|
||||
- `workspace.list()` / `workspace.get(workspaceID)`
|
||||
- `session.count()`
|
||||
- `session.diff(sessionID)`
|
||||
- `session.todo(sessionID)`
|
||||
@@ -282,8 +281,6 @@ Command behavior:
|
||||
- `lsp()`
|
||||
- `mcp()`
|
||||
- `api.client` always reflects the current runtime client.
|
||||
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
|
||||
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
|
||||
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
|
||||
- `api.renderer` exposes the raw `CliRenderer`.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
@@ -181,7 +180,7 @@ export namespace Account {
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
@@ -454,18 +453,4 @@ export namespace Account {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
||||
return runPromise((service) => service.orgsByAccount())
|
||||
}
|
||||
|
||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
@@ -38,7 +38,7 @@ export namespace AccountRepo {
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
|
||||
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||
AccountRepo,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
import { Schema } from "effect"
|
||||
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
|
||||
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const AccountID = Schema.String.pipe(
|
||||
Schema.brand("AccountID"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export const AccountID = Schema.String.pipe(Schema.brand("AccountID"))
|
||||
export type AccountID = Schema.Schema.Type<typeof AccountID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(
|
||||
Schema.brand("OrgID"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(
|
||||
Schema.brand("AccessToken"),
|
||||
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||
)
|
||||
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(
|
||||
Schema.brand("RefreshToken"),
|
||||
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||
)
|
||||
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(
|
||||
Schema.brand("DeviceCode"),
|
||||
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||
)
|
||||
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(
|
||||
Schema.brand("UserCode"),
|
||||
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||
)
|
||||
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, ServiceMap, Layer } from "effect"
|
||||
import { Effect, Context, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -67,7 +67,7 @@ export namespace Agent {
|
||||
|
||||
type State = Omit<Interface, "generate">
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -398,13 +398,11 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
),
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
@@ -49,7 +48,7 @@ export namespace Auth {
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -89,22 +88,4 @@ export namespace Auth {
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: any
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -41,7 +42,7 @@ export namespace Bus {
|
||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -91,8 +92,13 @@ export namespace Bus {
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
const context = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: dir,
|
||||
project: context.project.id,
|
||||
workspace,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
@@ -141,7 +147,7 @@ export namespace Bus {
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
Effect.runFork(Scope.close(scope, Exit.void))
|
||||
Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer)))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -164,6 +170,8 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||
await AppRuntime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => switchEffect())
|
||||
await AppRuntime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => orgsEffect())
|
||||
await AppRuntime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => openEffect())
|
||||
await AppRuntime.runPromise(openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "../../../provider/provider"
|
||||
import { Session } from "../../../session"
|
||||
@@ -11,6 +12,7 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
@@ -70,11 +72,17 @@ export const AgentCommand = cmd({
|
||||
})
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
return AppRuntime.runPromise(
|
||||
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,
|
||||
agent,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
@@ -117,7 +125,14 @@ function parseToolParams(input?: string) {
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
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 message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
@@ -157,14 +172,16 @@ async function createToolContext(agent: Agent.Info) {
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
metadata: () => Effect.void,
|
||||
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
return Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
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)
|
||||
})
|
||||
},
|
||||
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
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)
|
||||
})
|
||||
},
|
||||
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
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)
|
||||
})
|
||||
},
|
||||
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { LSP } from "../../../lsp"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
@@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
await sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
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) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
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)
|
||||
})
|
||||
},
|
||||
@@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
@@ -76,12 +77,18 @@ const SearchCommand = cmd({
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
async handler(args) {
|
||||
const results = await Ripgrep.search({
|
||||
cwd: process.cwd(),
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: Instance.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
})
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Skill } from "../../../skill"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -9,7 +11,12 @@ export const SkillCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -258,7 +259,9 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -497,20 +500,21 @@ export const GithubRunCommand = cmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
||||
const gitStatus = (args: string[]) =>
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Instance } from "../../project/instance"
|
||||
import { ShareNext } from "../../share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
@@ -100,7 +101,7 @@ export const ImportCommand = cmd({
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(args.file)
|
||||
if (!slug) {
|
||||
const baseUrl = await ShareNext.url()
|
||||
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
@@ -108,7 +109,7 @@ export const ImportCommand = cmd({
|
||||
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await ShareNext.request()
|
||||
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Global } from "../../global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
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({
|
||||
command: "mcp",
|
||||
describe: "manage MCP (Model Context Protocol) servers",
|
||||
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const statuses = await MCP.status()
|
||||
|
||||
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
|
||||
isMcpConfigured(entry[1]),
|
||||
)
|
||||
const { config, statuses, stored } = await listState()
|
||||
const servers = configuredServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = await MCP.hasStoredTokens(name)
|
||||
const hasStoredTokens = stored[name]
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const config = await Config.get()
|
||||
const { config, auth } = await authState()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
if (servers.length === 0) {
|
||||
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(`
|
||||
@@ -177,19 +211,17 @@ export const McpAuthCommand = cmd({
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = await Promise.all(
|
||||
oauthServers.map(async ([name, cfg]) => {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
})
|
||||
|
||||
const selected = await prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
|
||||
}
|
||||
|
||||
// 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") {
|
||||
const confirm = await prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
|
||||
})
|
||||
|
||||
try {
|
||||
const status = await MCP.authenticate(serverName)
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
|
||||
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const { config, auth } = await authState()
|
||||
const servers = oauthServers(config)
|
||||
|
||||
// Get OAuth-capable servers
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of oauthServers) {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
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.outro(`${oauthServers.length} OAuth-capable server(s)`)
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -334,7 +362,7 @@ export const McpLogoutCommand = cmd({
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const authPath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
const credentials = await McpAuth.all()
|
||||
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
@@ -372,7 +400,7 @@ export const McpLogoutCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
await MCP.removeAuth(serverName)
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
},
|
||||
@@ -595,7 +623,7 @@ export const McpDebugCommand = cmd({
|
||||
UI.empty()
|
||||
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 serverName = args.name
|
||||
|
||||
@@ -622,10 +650,18 @@ export const McpDebugCommand = cmd({
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// 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)}`)
|
||||
|
||||
const entry = await McpAuth.get(serverName)
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "../ui"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ModelsCommand = cmd({
|
||||
command: "models [provider]",
|
||||
@@ -35,43 +37,51 @@ export const ModelsCommand = cmd({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
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 provider = providers[providerID]
|
||||
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sortedModels) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const provider = providers[providerID]
|
||||
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const provider = providers[ProviderID.make(args.provider)]
|
||||
if (!provider) {
|
||||
UI.error(`Provider not found: ${args.provider}`)
|
||||
return
|
||||
}
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
const provider = providers[providerID]
|
||||
if (!provider) {
|
||||
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||
return
|
||||
}
|
||||
|
||||
printModels(ProviderID.make(args.provider), args.verbose)
|
||||
return
|
||||
}
|
||||
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||
return
|
||||
}
|
||||
|
||||
const providerIDs = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
for (const providerID of providerIDs) {
|
||||
printModels(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
for (const providerID of ids) {
|
||||
print(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -67,19 +68,29 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
const remotes = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
}
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
@@ -13,9 +14,18 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect } from "effect"
|
||||
|
||||
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> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
@@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await Auth.set(saveProvider, {
|
||||
await put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await Auth.set(saveProvider, {
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await Auth.set(saveProvider, {
|
||||
await put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await Auth.set(saveProvider, {
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -148,6 +158,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
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) {
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
@@ -155,9 +171,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await Auth.set(saveProvider, {
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
key: result.key ?? key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
@@ -215,7 +231,12 @@ export const ProvidersListCommand = cmd({
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
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()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
@@ -294,7 +315,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await Auth.set(url, {
|
||||
await put(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
@@ -305,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
}
|
||||
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 enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
@@ -441,7 +462,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await Auth.set(provider, {
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
@@ -457,22 +478,33 @@ export const ProvidersLogoutCommand = cmd({
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
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")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const providerID = await prompts.select({
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
||||
await Auth.remove(providerID)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { Terminal } from "@tui/util/terminal"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -23,6 +23,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider, useProject } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
@@ -35,7 +37,6 @@ import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
@@ -54,73 +55,12 @@ import { KVProvider, useKV } from "./context/kv"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
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 { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
@@ -179,7 +119,7 @@ export function tui(input: {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const mode = await getTerminalBackgroundColor()
|
||||
const mode = await Terminal.getTerminalBackgroundColor()
|
||||
|
||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||
@@ -216,27 +156,29 @@ export function tui(input: {
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
@@ -260,6 +202,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
@@ -283,6 +226,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
event,
|
||||
sdk,
|
||||
sync,
|
||||
theme: themeState,
|
||||
@@ -461,22 +405,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
? [
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
value: "workspace.list",
|
||||
category: "Workspace",
|
||||
suggested: true,
|
||||
slash: {
|
||||
name: "workspaces",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
@@ -491,12 +419,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const current = promptRef.current
|
||||
// Don't require focus - if there's any text, preserve it
|
||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||
const workspaceID =
|
||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
||||
route.navigate({
|
||||
type: "home",
|
||||
initialPrompt: currentPrompt,
|
||||
workspaceID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -806,11 +731,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
])
|
||||
|
||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
@@ -819,14 +744,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: evt.properties.sessionID,
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on("session.deleted", (evt) => {
|
||||
event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -836,7 +761,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
})
|
||||
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error)
|
||||
@@ -848,7 +773,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on("installation.update-available", async (evt) => {
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.properties.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
|
||||
@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { useKV } from "../context/kv"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
@@ -31,15 +37,68 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function createWorkspace() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let workspaceStatus: WorkspaceStatus | null = null
|
||||
if (x.workspaceID) {
|
||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
||||
}
|
||||
|
||||
let footer = ""
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
let desc = "unknown"
|
||||
if (workspace) {
|
||||
desc = `${workspace.type}: ${workspace.name}`
|
||||
}
|
||||
|
||||
footer = (
|
||||
<>
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
workspaceStatus === "error"
|
||||
? theme.error
|
||||
: workspaceStatus === "disconnected"
|
||||
? theme.textMuted
|
||||
: theme.success,
|
||||
}}
|
||||
>
|
||||
■
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
footer = Locale.time(x.time.updated)
|
||||
}
|
||||
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
@@ -53,7 +112,7 @@ export function DialogSessionList() {
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
footer,
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
@@ -102,6 +161,15 @@ export function DialogSessionList() {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
onTrigger: () => {
|
||||
createWorkspace()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { useSDK } from "../context/sdk"
|
||||
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) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
}) {
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
while (true) {
|
||||
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: result.data.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||
|
||||
onMount(() => {
|
||||
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 type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
const list = adaptors()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adaptors",
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
void create(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
forceCreate?: boolean
|
||||
}) {
|
||||
const cacheSession = (session: Session) => {
|
||||
input.sync.set(
|
||||
"session",
|
||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
cacheSession(session)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: session.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
let created: Session | undefined
|
||||
while (!created) {
|
||||
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
created = result.data
|
||||
}
|
||||
cacheSession(created)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: created.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
}
|
||||
|
||||
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const createWorkspace = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
console.log(err)
|
||||
return undefined
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void createWorkspace(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const keybind = useKeybind()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
||||
|
||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
||||
openWorkspace({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string) {
|
||||
if (workspaceID === "__local__") {
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const count = counts()[workspaceID]
|
||||
if (count && count > 0) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = scoped(sdk, sync, workspaceID)
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => {
|
||||
if (route.data.type === "session") {
|
||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||
}
|
||||
return "__local__"
|
||||
})
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
)
|
||||
|
||||
let run = 0
|
||||
createEffect(() => {
|
||||
const workspaces = sync.data.workspaceList
|
||||
const next = ++run
|
||||
if (!workspaces.length) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = scoped(sdk, sync, workspace.id)
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (run !== next) return
|
||||
setCounts(Object.fromEntries(entries))
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: "__local__",
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
},
|
||||
...sync.data.workspaceList.map((workspace) => {
|
||||
const count = counts()[workspace.id]
|
||||
return {
|
||||
title:
|
||||
toDelete() === workspace.id
|
||||
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
|
||||
: workspace.id,
|
||||
value: workspace.id,
|
||||
category: workspace.type,
|
||||
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
|
||||
footer:
|
||||
count === undefined
|
||||
? "Loading sessions..."
|
||||
: count === null
|
||||
? "Sessions unavailable"
|
||||
: `${count} session${count === 1 ? "" : "s"}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New workspace",
|
||||
value: "__create__",
|
||||
category: "Actions",
|
||||
description: "Create a new workspace",
|
||||
},
|
||||
])
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sync.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
current={currentWorkspaceID()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
setToDelete(undefined)
|
||||
if (option.value === "__create__") {
|
||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
||||
return
|
||||
}
|
||||
void selectWorkspace(option.value)
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === "__local__") return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
||||
setToDelete(undefined)
|
||||
if (result?.error) {
|
||||
toast.show({
|
||||
message: "Failed to delete workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const fullPath = `${baseDir}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId = 0
|
||||
const event = useEvent()
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.insertText(evt.properties.text)
|
||||
setTimeout(() => {
|
||||
@@ -587,6 +589,13 @@ export function Prompt(props: PromptProps) {
|
||||
])
|
||||
|
||||
async function submit() {
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
||||
setStore("prompt", "input", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return
|
||||
if (autocomplete?.visible) return
|
||||
if (!store.prompt.input) return
|
||||
@@ -992,7 +1001,11 @@ export function Prompt(props: PromptProps) {
|
||||
input.cursorOffset = input.plainText.length
|
||||
}
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onSubmit={() => {
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => props.workspaceID,
|
||||
async (workspaceID) => {
|
||||
if (!workspaceID) return undefined
|
||||
const result = await sdk.client.session.list({ roots: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query || props.localOnly) return undefined
|
||||
const result = await sdk.client.session.list({
|
||||
search: query,
|
||||
limit: 30,
|
||||
...(props.workspaceID ? { roots: true } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
if (searchResults()) return searchResults()!
|
||||
if (props.workspaceID) return listed() ?? []
|
||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
||||
return sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => {
|
||||
if (x.parentID !== undefined) return false
|
||||
if (props.workspaceID && listed()) return true
|
||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
||||
if (props.localOnly) return !x.workspaceID
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
||||
options={options()}
|
||||
skipFilter={!props.localOnly}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
setToDelete(undefined)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (props.workspaceID) {
|
||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
||||
return
|
||||
}
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((session) => session.id !== option.value),
|
||||
)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useProject } from "./project"
|
||||
import { useSync } from "./sync"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export function useDirectory() {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
return createMemo(() => {
|
||||
const directory = sync.data.path.directory || process.cwd()
|
||||
const directory = project.instance.path().directory || process.cwd()
|
||||
const result = directory.replace(Global.Path.home, "~")
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
|
||||
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { useProject } from "./project"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export function useEvent() {
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
function subscribe(handler: (event: Event) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.directory === project.instance.directory()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||
return subscribe((event) => {
|
||||
if (event.type !== type) return
|
||||
handler(event as Extract<Event, { type: T }>)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
on,
|
||||
}
|
||||
}
|
||||
106
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
106
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { batch } from "solid-js"
|
||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = createStore({
|
||||
project: {
|
||||
id: undefined as string | undefined,
|
||||
},
|
||||
instance: {
|
||||
path: {
|
||||
home: "",
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path,
|
||||
},
|
||||
workspace: {
|
||||
current: undefined as string | undefined,
|
||||
list: [] as Workspace[],
|
||||
status: {} as Record<string, WorkspaceStatus>,
|
||||
},
|
||||
})
|
||||
|
||||
async function sync() {
|
||||
const workspace = store.workspace.current
|
||||
const [path, project] = await Promise.all([
|
||||
sdk.client.path.get({ workspace }),
|
||||
sdk.client.project.current({ workspace }),
|
||||
])
|
||||
|
||||
batch(() => {
|
||||
setStore("instance", "path", reconcile(path.data!))
|
||||
setStore("project", "id", project.data?.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function syncWorkspace() {
|
||||
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!listed?.data) return
|
||||
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
|
||||
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
|
||||
|
||||
batch(() => {
|
||||
setStore("workspace", "list", reconcile(listed.data))
|
||||
setStore("workspace", "status", reconcile(next))
|
||||
if (!listed.data.some((item) => item.id === store.workspace.current)) {
|
||||
setStore("workspace", "current", undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "workspace.status") {
|
||||
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
data: store,
|
||||
project() {
|
||||
return store.project.id
|
||||
},
|
||||
instance: {
|
||||
path() {
|
||||
return store.instance.path
|
||||
},
|
||||
directory() {
|
||||
return store.instance.path.directory
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
current() {
|
||||
return store.workspace.current
|
||||
},
|
||||
set(next?: string | null) {
|
||||
const workspace = next ?? undefined
|
||||
if (store.workspace.current === workspace) return
|
||||
setStore("workspace", "current", workspace)
|
||||
},
|
||||
list() {
|
||||
return store.workspace.list
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspace.list.find((item) => item.id === workspaceID)
|
||||
},
|
||||
status(workspaceID: string) {
|
||||
return store.workspace.status[workspaceID]
|
||||
},
|
||||
statuses() {
|
||||
return store.workspace.status
|
||||
},
|
||||
sync: syncWorkspace,
|
||||
},
|
||||
sync,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
initialPrompt?: PromptInfo
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
|
||||
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
event: GlobalEvent
|
||||
}>()
|
||||
|
||||
let queue: Event[] = []
|
||||
let queue: GlobalEvent[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
||||
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit(event.type, event)
|
||||
emitter.emit("event", event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEvent = (event: Event) => {
|
||||
const handleEvent = (event: GlobalEvent) => {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
|
||||
onMount(async () => {
|
||||
if (props.events) {
|
||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
||||
const unsub = await props.events.subscribe(handleEvent)
|
||||
onCleanup(unsub)
|
||||
} else {
|
||||
startSSE()
|
||||
|
||||
@@ -19,16 +19,16 @@ import type {
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { batch, createEffect, on } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
@@ -75,8 +75,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -104,20 +102,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
}
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
bootstrap()
|
||||
@@ -344,7 +335,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
case "lsp.updated": {
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
||||
const workspace = project.workspace.current()
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -360,25 +352,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
console.log("bootstrapping")
|
||||
const workspace = project.workspace.current()
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
||||
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||
const consoleStatePromise = sdk.client.experimental.console
|
||||
.get({}, { throwOnError: true })
|
||||
.get({ workspace }, { throwOnError: true })
|
||||
.then((x) => ConsoleState.parse(x.data))
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const projectPromise = project.sync()
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
@@ -423,18 +418,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
Promise.all([
|
||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status().then((x) => {
|
||||
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status({ workspace }).then((x) => {
|
||||
setStore("session_status", reconcile(x.data!))
|
||||
}),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
syncWorkspaces(),
|
||||
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
project.workspace.sync(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -449,11 +445,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bootstrap()
|
||||
})
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => project.workspace.current(),
|
||||
() => {
|
||||
fullSyncedSessions.clear()
|
||||
void bootstrap()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
set: setStore,
|
||||
@@ -463,6 +465,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
@@ -481,11 +486,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
if (fullSyncedSessions.has(sessionID)) return
|
||||
const workspace = project.workspace.current()
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||
sdk.client.session.todo({ sessionID }),
|
||||
sdk.client.session.diff({ sessionID }),
|
||||
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||
sdk.client.session.todo({ sessionID, workspace }),
|
||||
sdk.client.session.diff({ sessionID, workspace }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -503,12 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useEvent } from "@tui/context/event"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
@@ -36,6 +37,7 @@ type Input = {
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
event: ReturnType<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return sync.data.path
|
||||
return sync.path
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
@@ -144,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
branch: sync.data.vcs.branch,
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return sync.data.workspaceList
|
||||
},
|
||||
get(workspaceID) {
|
||||
return sync.workspace.get(workspaceID)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return sync.data.session.length
|
||||
@@ -342,7 +336,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
event: input.sdk.event,
|
||||
event: input.event,
|
||||
renderer: input.renderer,
|
||||
slots: {
|
||||
register() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
@@ -18,6 +19,7 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -63,11 +65,16 @@ export function Home() {
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="home_prompt"
|
||||
mode="replace"
|
||||
workspace_id={project.workspace.current()}
|
||||
ref={bind}
|
||||
>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
workspaceID={route.workspaceID}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
||||
workspaceID={project.workspace.current()}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "path"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
@@ -116,6 +118,8 @@ export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
@@ -172,10 +176,16 @@ export function Session() {
|
||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
await sdk.client.session
|
||||
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||
.then((x) => {
|
||||
project.workspace.set(x.data?.workspaceID)
|
||||
})
|
||||
.then(() => sync.session.sync(route.sessionID))
|
||||
.then(() => {
|
||||
if (scroll) scroll.scrollBy(100_000)
|
||||
})
|
||||
@@ -189,13 +199,10 @@ export function Session() {
|
||||
})
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
// Handle initial prompt from fork
|
||||
let seeded = false
|
||||
let lastSwitch: string | undefined = undefined
|
||||
sdk.event.on("message.part.updated", (evt) => {
|
||||
event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
if (part.type !== "tool") return
|
||||
if (part.sessionID !== route.sessionID) return
|
||||
@@ -224,7 +231,7 @@ export function Session() {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== route.sessionID) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
|
||||
const base = sync.data.path.directory
|
||||
const base = sync.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
subscribe: async (directory, handler) => {
|
||||
const id = await client.call("subscribe", { directory })
|
||||
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
|
||||
if (e.id === id) {
|
||||
handler(e.event)
|
||||
}
|
||||
subscribe: async (handler) => {
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
handler(e)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
client.call("unsubscribe", { id })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -145,12 +137,18 @@ export const TuiThreadCommand = cmd({
|
||||
),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error(e)
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error(e)
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
}
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
margin?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
props.ref?.(ref)
|
||||
|
||||
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
|
||||
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
|
||||
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
|
||||
<For each={keybinds()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<box
|
||||
paddingRight={2}
|
||||
paddingLeft={4}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<For each={left()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<For each={right()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
|
||||
|
||||
export namespace Terminal {
|
||||
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).
|
||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||
@@ -31,46 +53,26 @@ export namespace Terminal {
|
||||
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 str = data.toString()
|
||||
|
||||
// Match OSC 11 (background color)
|
||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (bgMatch) {
|
||||
background = parseColor(bgMatch[1])
|
||||
background = parse(bgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 10 (foreground color)
|
||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||
if (fgMatch) {
|
||||
foreground = parseColor(fgMatch[1])
|
||||
foreground = parse(fgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 4 (palette colors)
|
||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||
for (const match of paletteMatches) {
|
||||
const index = parseInt(match[1])
|
||||
const color = parseColor(match[2])
|
||||
const color = parse(match[2])
|
||||
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"> {
|
||||
const result = await colors()
|
||||
if (!result.background) return "dark"
|
||||
if (!process.stdin.isTTY) return "dark"
|
||||
|
||||
const { r, g, b } = result.background
|
||||
// Calculate luminance using relative luminance formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
// Determine if dark or light based on luminance threshold
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
const cleanup = () => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
|
||||
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
const eventStreams = new Map<string, AbortController>()
|
||||
|
||||
function startEventStream(directory: string) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const abort = new AbortController()
|
||||
const signal = abort.signal
|
||||
|
||||
eventStreams.set(id, abort)
|
||||
|
||||
async function run() {
|
||||
while (!signal.aborted) {
|
||||
const shouldReconnect = await Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
Rpc.emit("event", {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
} satisfies Event)
|
||||
|
||||
let settled = false
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
Rpc.emit("event", {
|
||||
id,
|
||||
event: event as Event,
|
||||
})
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
settle(false)
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
}),
|
||||
}).catch((error) => {
|
||||
Log.Default.error("event stream subscribe error", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!shouldReconnect || signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
Log.Default.error("event stream error", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function stopEventStream(id: string) {
|
||||
const abortController = eventStreams.get(id)
|
||||
if (!abortController) return
|
||||
|
||||
abortController.abort()
|
||||
eventStreams.delete(id)
|
||||
}
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
const headers = { ...input.headers }
|
||||
@@ -158,28 +74,18 @@ export const rpc = {
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: InstanceBootstrap,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async subscribe(input: { directory: string | undefined }) {
|
||||
return startEventStream(input.directory || process.cwd())
|
||||
},
|
||||
async unsubscribe(input: { id: string }) {
|
||||
stopEventStream(input.id)
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
for (const id of [...eventStreams.keys()]) {
|
||||
stopEventStream(id)
|
||||
}
|
||||
|
||||
await Instance.disposeAll()
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "../../installation"
|
||||
import { Global } from "../../global"
|
||||
import fs from "fs/promises"
|
||||
@@ -57,7 +58,7 @@ export const UninstallCommand = {
|
||||
UI.empty()
|
||||
prompts.intro("Uninstall OpenCode")
|
||||
|
||||
const method = await Installation.method()
|
||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
prompts.log.info(`Installation method: ${method}`)
|
||||
|
||||
const targets = await collectRemovalTargets(args, method)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const UpgradeCommand = {
|
||||
@@ -24,7 +25,7 @@ export const UpgradeCommand = {
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Upgrade")
|
||||
const detectedMethod = await Installation.method()
|
||||
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
|
||||
@@ -42,7 +43,9 @@ export const UpgradeCommand = {
|
||||
}
|
||||
}
|
||||
prompts.log.info("Using method: " + method)
|
||||
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
|
||||
const target = args.target
|
||||
? args.target.replace(/^v/, "")
|
||||
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
||||
|
||||
if (Installation.VERSION === target) {
|
||||
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
||||
@@ -53,7 +56,9 @@ export const UpgradeCommand = {
|
||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Upgrading...")
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
||||
(err) => err,
|
||||
)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed", 1)
|
||||
if (err instanceof Installation.UpgradeFailedError) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Argv, InferredOptionTypes } from "yargs"
|
||||
import { Config } from "../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const options = {
|
||||
port: {
|
||||
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||
}
|
||||
|
||||
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 hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await Config.getGlobal()
|
||||
const method = await Installation.method()
|
||||
const latest = await Installation.latest(method).catch(() => {})
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
||||
if (!latest) return
|
||||
|
||||
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
||||
@@ -25,7 +26,7 @@ export async function upgrade() {
|
||||
}
|
||||
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
|
||||
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
@@ -70,7 +71,7 @@ export namespace Command {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -79,7 +80,7 @@ export namespace Command {
|
||||
const mcp = yield* MCP.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
||||
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
||||
const cfg = yield* config.get()
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
@@ -140,6 +141,7 @@ export namespace Command {
|
||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||
.join("\n") || "",
|
||||
),
|
||||
Effect.provide(EffectLogger.layer),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -186,10 +188,4 @@ export namespace Command {
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
import { Process } from "../util/process"
|
||||
import z from "zod"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe, unique } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fsNode from "fs/promises"
|
||||
@@ -23,24 +22,22 @@ import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
|
||||
import { Flock } from "@/util/flock"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { Npm } from "@/npm"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -140,53 +137,11 @@ export namespace Config {
|
||||
}
|
||||
|
||||
export type InstallInput = {
|
||||
signal?: AbortSignal
|
||||
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string, input?: InstallInput) {
|
||||
if (!(await isWritable(dir))) return
|
||||
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
|
||||
}
|
||||
type Package = {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
@@ -1111,7 +1066,7 @@ export namespace Config {
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Promise<void>[]
|
||||
deps: Fiber.Fiber<void, never>[]
|
||||
consoleState: ConsoleState
|
||||
}
|
||||
|
||||
@@ -1119,6 +1074,7 @@ export namespace Config {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
|
||||
readonly update: (config: Info) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||
@@ -1126,7 +1082,7 @@ export namespace Config {
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
|
||||
|
||||
function globalConfigFile() {
|
||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||
@@ -1320,6 +1276,74 @@ export namespace Config {
|
||||
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 auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
@@ -1327,27 +1351,31 @@ export namespace Config {
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
const scope = (source: string): PluginScope => {
|
||||
const scope = Effect.fnUntraced(function* (source: string) {
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||
if (Instance.containsPath(source)) return "local"
|
||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||
return "global"
|
||||
}
|
||||
})
|
||||
|
||||
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
|
||||
const track = Effect.fnUntraced(function* (
|
||||
source: string,
|
||||
list: PluginSpec[] | undefined,
|
||||
kind?: PluginScope,
|
||||
) {
|
||||
if (!list?.length) return
|
||||
const hit = kind ?? scope(source)
|
||||
const hit = kind ?? (yield* scope(source))
|
||||
const plugins = deduplicatePluginOrigins([
|
||||
...(result.plugin_origins ?? []),
|
||||
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||
])
|
||||
result.plugin = plugins.map((item) => item.spec)
|
||||
result.plugin_origins = plugins
|
||||
}
|
||||
})
|
||||
|
||||
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
||||
result = mergeConfigConcatArrays(result, next)
|
||||
track(source, next.plugin, kind)
|
||||
return track(source, next.plugin, kind)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
@@ -1367,16 +1395,16 @@ export namespace Config {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
merge(source, next, "global")
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
merge(Global.Path.config, global, "global")
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
@@ -1384,7 +1412,7 @@ export namespace Config {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
merge(file, yield* loadFile(file), "local")
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1398,33 +1426,39 @@ export namespace Config {
|
||||
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)) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
merge(source, yield* loadFile(source))
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
const dep = iife(async () => {
|
||||
await installDependencies(dir)
|
||||
})
|
||||
void dep.catch((err) => {
|
||||
log.warn("background dependency install failed", { dir, error: err })
|
||||
})
|
||||
const dep = yield* installDependencies(dir).pipe(
|
||||
Effect.exit,
|
||||
Effect.tap((exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.sync(() => {
|
||||
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
deps.push(dep)
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||
const list = yield* Effect.promise(() => loadPlugin(dir))
|
||||
track(dir, list)
|
||||
yield* track(dir, list)
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
@@ -1433,7 +1467,7 @@ export namespace Config {
|
||||
dir: ctx.directory,
|
||||
source,
|
||||
})
|
||||
merge(source, next, "local")
|
||||
yield* merge(source, next, "local")
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
@@ -1462,7 +1496,7 @@ export namespace Config {
|
||||
for (const providerID of Object.keys(next.provider ?? {})) {
|
||||
consoleManagedProviders.add(providerID)
|
||||
}
|
||||
merge(source, next, "global")
|
||||
yield* merge(source, next, "global")
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catch((err) => {
|
||||
@@ -1477,7 +1511,7 @@ export namespace Config {
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(managedDir, file)
|
||||
merge(source, yield* loadFile(source), "global")
|
||||
yield* merge(source, yield* loadFile(source), "global")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,7 +1588,9 @@ export namespace Config {
|
||||
})
|
||||
|
||||
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) {
|
||||
@@ -1609,6 +1645,7 @@ export namespace Config {
|
||||
get,
|
||||
getGlobal,
|
||||
getConsoleState,
|
||||
installDependencies,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
@@ -1623,38 +1660,4 @@ export namespace Config {
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
@@ -51,7 +52,7 @@ export namespace TuiConfig {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
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),
|
||||
}
|
||||
|
||||
export function getAdaptor(type: string): Promise<Adaptor> {
|
||||
return ADAPTORS[type]()
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||
|
||||
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) {
|
||||
// This is experimental: mostly used for testing right now, but we
|
||||
// will likely allow this in the future. Need to figure out the
|
||||
// TypeScript story
|
||||
|
||||
// @ts-expect-error we force the builtin types right now, but we
|
||||
// will implement a way to extend the types for custom adaptors
|
||||
ADAPTORS[type] = () => adaptor
|
||||
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, init]) => {
|
||||
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]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
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,18 @@
|
||||
import z from "zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { type Adaptor, WorkspaceInfo } from "../types"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
|
||||
const Config = WorkspaceInfo.extend({
|
||||
name: WorkspaceInfo.shape.name.unwrap(),
|
||||
const WorktreeConfig = z.object({
|
||||
name: WorkspaceInfo.shape.name,
|
||||
branch: WorkspaceInfo.shape.branch.unwrap(),
|
||||
directory: WorkspaceInfo.shape.directory.unwrap(),
|
||||
})
|
||||
|
||||
type Config = z.infer<typeof Config>
|
||||
|
||||
export const WorktreeAdaptor: Adaptor = {
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info) {
|
||||
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
|
||||
const worktree = await Worktree.makeWorktreeInfo(undefined)
|
||||
return {
|
||||
...info,
|
||||
name: worktree.name,
|
||||
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
}
|
||||
},
|
||||
async create(info) {
|
||||
const config = Config.parse(info)
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
})
|
||||
},
|
||||
async remove(info) {
|
||||
const config = Config.parse(info)
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
target(info) {
|
||||
const config = Config.parse(info)
|
||||
const config = WorktreeConfig.parse(info)
|
||||
return {
|
||||
type: "local",
|
||||
directory: config.directory,
|
||||
|
||||
@@ -10,8 +10,7 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||
|
||||
export const WorkspaceID = workspaceIdSchema.pipe(
|
||||
withStatics((schema: typeof workspaceIdSchema) => ({
|
||||
make: (id: string) => schema.makeUnsafe(id),
|
||||
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
|
||||
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
|
||||
export const WorkspaceInfo = z.object({
|
||||
id: WorkspaceID.zod,
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
branch: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
directory: z.string().nullable(),
|
||||
extra: z.unknown().nullable(),
|
||||
projectID: ProjectID.zod,
|
||||
@@ -24,9 +24,11 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type Adaptor = {
|
||||
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(config: WorkspaceInfo): Promise<void>
|
||||
target(config: WorkspaceInfo): Target | Promise<Target>
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { LocalContext } from "../util/local-context"
|
||||
import type { WorkspaceID } from "../control-plane/schema"
|
||||
|
||||
export interface WorkspaceContext {
|
||||
workspaceID: string
|
||||
}
|
||||
|
||||
const context = LocalContext.create<WorkspaceContext>("instance")
|
||||
|
||||
export const WorkspaceContext = {
|
||||
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
|
||||
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||
},
|
||||
|
||||
get workspaceID() {
|
||||
try {
|
||||
return context.use().workspaceID
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<WorkspaceID>().primaryKey(),
|
||||
type: text().notNull(),
|
||||
name: text().notNull().default(""),
|
||||
branch: text(),
|
||||
name: text(),
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
|
||||
@@ -5,8 +5,11 @@ import { Database, eq } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { WorkspaceInfo } from "./types"
|
||||
@@ -14,6 +17,18 @@ import { WorkspaceID } from "./schema"
|
||||
import { parseSSE } from "./sse"
|
||||
|
||||
export namespace Workspace {
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const ConnectionStatus = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
status: z.enum(["connected", "connecting", "disconnected", "error"]),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
@@ -27,13 +42,9 @@ export namespace Workspace {
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||
}
|
||||
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -56,9 +67,9 @@ export namespace Workspace {
|
||||
|
||||
export const create = fn(CreateInput, async (input) => {
|
||||
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 = {
|
||||
id,
|
||||
@@ -85,6 +96,9 @@ export namespace Workspace {
|
||||
})
|
||||
|
||||
await adaptor.create(config)
|
||||
|
||||
startSync(info)
|
||||
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -92,77 +106,125 @@ export namespace Workspace {
|
||||
const rows = Database.use((db) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
|
||||
)
|
||||
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const space of spaces) startSync(space)
|
||||
return spaces
|
||||
}
|
||||
|
||||
export const get = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (!row) return
|
||||
return fromRow(row)
|
||||
const space = fromRow(row)
|
||||
startSync(space)
|
||||
return space
|
||||
})
|
||||
|
||||
export const remove = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (row) {
|
||||
stopSync(id)
|
||||
|
||||
const info = fromRow(row)
|
||||
const adaptor = await getAdaptor(row.type)
|
||||
const adaptor = await getAdaptor(info.projectID, row.type)
|
||||
adaptor.remove(info)
|
||||
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||
return info
|
||||
}
|
||||
})
|
||||
|
||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||
const aborts = new Map<WorkspaceID, AbortController>()
|
||||
|
||||
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
|
||||
const prev = connections.get(id)
|
||||
if (prev?.status === status && prev?.error === error) return
|
||||
const next = { workspaceID: id, status, error }
|
||||
connections.set(id, next)
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
workspace: id,
|
||||
payload: {
|
||||
type: Event.Status.type,
|
||||
properties: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function status(): ConnectionStatus[] {
|
||||
return [...connections.values()]
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "workspace-sync" })
|
||||
|
||||
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
|
||||
while (!stop.aborted) {
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const target = await Promise.resolve(adaptor.target(space))
|
||||
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
|
||||
log.info("starting sync: " + space.id)
|
||||
|
||||
if (target.type === "local") {
|
||||
return
|
||||
}
|
||||
while (!signal.aborted) {
|
||||
log.info("connecting to sync: " + space.id)
|
||||
|
||||
const baseURL = String(target.url).replace(/\/?$/, "/")
|
||||
setStatus(space.id, "connecting")
|
||||
const adaptor = await getAdaptor(space.projectID, space.type)
|
||||
const target = await adaptor.target(space)
|
||||
|
||||
const res = await fetch(new URL(baseURL + "/event"), {
|
||||
method: "GET",
|
||||
signal: stop,
|
||||
if (target.type === "local") return
|
||||
|
||||
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
|
||||
setStatus(space.id, "error", String(err))
|
||||
return undefined
|
||||
})
|
||||
if (!res || !res.ok || !res.body) {
|
||||
log.info("failed to connect to sync: " + res?.status)
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
setStatus(space.id, "connected")
|
||||
await parseSSE(res.body, signal, (evt) => {
|
||||
const event = evt as SyncEvent.SerializedEvent
|
||||
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
})
|
||||
try {
|
||||
if (!event.type.startsWith("server.")) {
|
||||
SyncEvent.replay(event)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("failed to replay sync event", {
|
||||
workspaceID: space.id,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
setStatus(space.id, "disconnected")
|
||||
log.info("disconnected to sync: " + space.id)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
export function startSyncing(project: Project.Info) {
|
||||
const stop = new AbortController()
|
||||
const spaces = list(project).filter((space) => space.type !== "worktree")
|
||||
function startSync(space: Info) {
|
||||
if (space.type === "worktree") {
|
||||
void Filesystem.exists(space.directory!).then((exists) => {
|
||||
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
spaces.forEach((space) => {
|
||||
void workspaceEventLoop(space, stop.signal).catch((error) => {
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
if (aborts.has(space.id)) return
|
||||
const abort = new AbortController()
|
||||
aborts.set(space.id, abort)
|
||||
setStatus(space.id, "disconnected")
|
||||
|
||||
void workspaceEventLoop(space, abort.signal).catch((error) => {
|
||||
setStatus(space.id, "error", String(error))
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
stop.abort()
|
||||
},
|
||||
}
|
||||
function stopSync(id: WorkspaceID) {
|
||||
aborts.get(id)?.abort()
|
||||
aborts.delete(id)
|
||||
connections.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
100
packages/opencode/src/effect/app-runtime.ts
Normal file
100
packages/opencode/src/effect/app-runtime.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
import { Observability } from "./oltp"
|
||||
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Bus } from "@/bus"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Skill } from "@/skill"
|
||||
import { Discovery } from "@/skill/discovery"
|
||||
import { Question } from "@/question"
|
||||
import { Permission } from "@/permission"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Session } from "@/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionProcessor } from "@/session/processor"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { LSP } from "@/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
// Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Storage.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Discovery.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionProcessor.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
McpAuth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
27
packages/opencode/src/effect/bootstrap-runtime.ts
Normal file
27
packages/opencode/src/effect/bootstrap-runtime.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
|
||||
import { Plugin } from "@/plugin"
|
||||
import { LSP } from "@/lsp"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
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(
|
||||
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 })
|
||||
@@ -402,6 +402,7 @@ export const make = Effect.gen(function* () {
|
||||
|
||||
const fd = yield* setupFds(command, proc, extra)
|
||||
const out = setupOutput(command, proc, sout, serr)
|
||||
let ref = true
|
||||
return makeHandle({
|
||||
pid: ProcessId(proc.pid!),
|
||||
stdin: yield* setupStdin(command, proc, sin),
|
||||
@@ -432,6 +433,18 @@ export const make = Effect.gen(function* () {
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
},
|
||||
unref: Effect.sync(() => {
|
||||
if (ref) {
|
||||
proc.unref()
|
||||
ref = false
|
||||
}
|
||||
return Effect.sync(() => {
|
||||
if (!ref) {
|
||||
proc.ref()
|
||||
ref = true
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ServiceMap } from "effect"
|
||||
import { Context } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
@@ -16,10 +18,10 @@ export namespace InstanceState {
|
||||
try {
|
||||
return Instance.bind(fn)
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
const fiber = Fiber.getCurrent()
|
||||
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
|
||||
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
|
||||
if (!ctx) return fn
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
@@ -28,6 +30,10 @@ export namespace InstanceState {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})
|
||||
|
||||
export const workspaceID = Effect.gen(function* () {
|
||||
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
})
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
export const make = <A, E = never, R = never>(
|
||||
@@ -42,7 +48,9 @@ export namespace InstanceState {
|
||||
}),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
const off = registerDisposer((directory) =>
|
||||
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
return {
|
||||
|
||||
67
packages/opencode/src/effect/logger.ts
Normal file
67
packages/opencode/src/effect/logger.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Cause, Effect, Logger, References } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export namespace EffectLogger {
|
||||
type Fields = Record<string, unknown>
|
||||
|
||||
export interface Handle {
|
||||
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly with: (extra: Fields) => Handle
|
||||
}
|
||||
|
||||
const clean = (input?: Fields): Fields =>
|
||||
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
|
||||
|
||||
const text = (input: unknown): string => {
|
||||
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
|
||||
return input === undefined ? "" : String(input)
|
||||
}
|
||||
|
||||
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
|
||||
const ann = clean({ ...base, ...extra })
|
||||
const fx = run(msg)
|
||||
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
|
||||
}
|
||||
|
||||
export const logger = Logger.make((opts) => {
|
||||
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
|
||||
const now = opts.date.getTime()
|
||||
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
|
||||
extra[`logSpan.${key}`] = `${now - start}ms`
|
||||
}
|
||||
if (opts.cause.reasons.length > 0) {
|
||||
extra.cause = Cause.pretty(opts.cause)
|
||||
}
|
||||
|
||||
const svc = typeof extra.service === "string" ? extra.service : undefined
|
||||
if (svc) delete extra.service
|
||||
const log = svc ? Log.create({ service: svc }) : Log.Default
|
||||
const msg = text(opts.message)
|
||||
|
||||
switch (opts.logLevel) {
|
||||
case "Trace":
|
||||
case "Debug":
|
||||
return log.debug(msg, extra)
|
||||
case "Warn":
|
||||
return log.warn(msg, extra)
|
||||
case "Error":
|
||||
case "Fatal":
|
||||
return log.error(msg, extra)
|
||||
default:
|
||||
return log.info(msg, extra)
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Logger.layer([logger], { mergeWithExisting: false })
|
||||
|
||||
export const create = (base: Fields = {}): Handle => ({
|
||||
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
|
||||
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
|
||||
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
|
||||
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
|
||||
with: (extra) => create({ ...base, ...extra }),
|
||||
})
|
||||
}
|
||||
@@ -1,34 +1,41 @@
|
||||
import { Layer } from "effect"
|
||||
import { Duration, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Otlp } from "effect/unstable/observability"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { CHANNEL, VERSION } from "@/installation/meta"
|
||||
|
||||
export namespace Observability {
|
||||
export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
export const enabled = !!base
|
||||
|
||||
export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
? Layer.empty
|
||||
: Otlp.layerJson({
|
||||
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
loggerMergeWithExisting: false,
|
||||
resource: {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: VERSION,
|
||||
attributes: {
|
||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
const resource = {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: VERSION,
|
||||
attributes: {
|
||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
}
|
||||
|
||||
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
(acc, x) => {
|
||||
const [key, value] = x.split("=")
|
||||
acc[key] = value
|
||||
return acc
|
||||
},
|
||||
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
(acc, x) => {
|
||||
const [key, value] = x.split("=")
|
||||
acc[key] = value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined,
|
||||
}).pipe(Layer.provide(FetchHttpClient.layer))
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined
|
||||
|
||||
export const layer = !base
|
||||
? EffectLogger.layer
|
||||
: Otlp.layerJson({
|
||||
baseUrl: base,
|
||||
loggerExportInterval: Duration.seconds(1),
|
||||
loggerMergeWithExisting: true,
|
||||
resource,
|
||||
headers,
|
||||
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
import * as Context from "effect/Context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { Observability } from "./oltp"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
const ctx = Instance.current
|
||||
return Effect.provideService(effect, InstanceRef, ctx)
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
return effect
|
||||
}
|
||||
|
||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
@@ -11,7 +10,6 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
@@ -338,12 +336,13 @@ export namespace File {
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
@@ -410,6 +409,10 @@ export namespace File {
|
||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
})
|
||||
|
||||
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
||||
return (yield* git.run(args, { cwd: Instance.directory })).text()
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure()
|
||||
})
|
||||
@@ -417,100 +420,87 @@ export namespace File {
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
const diffOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--numstat",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
).text()
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const untrackedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
])
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
const content = yield* appFs
|
||||
.readFileString(path.join(Instance.directory, file))
|
||||
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
||||
if (content === undefined) continue
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const deletedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
@@ -558,27 +548,19 @@ export namespace File {
|
||||
)
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
let diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text", content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text", content }
|
||||
})
|
||||
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
||||
if (!diff.trim()) {
|
||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = yield* git.show(Instance.directory, "HEAD", file)
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text" as const, content }
|
||||
}
|
||||
|
||||
return { type: "text" as const, content }
|
||||
@@ -660,27 +642,5 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.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))
|
||||
}
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
@@ -87,8 +94,43 @@ export namespace Ripgrep {
|
||||
|
||||
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 Match = z.infer<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = z.infer<typeof Begin>
|
||||
export type End = z.infer<typeof End>
|
||||
export type Summary = z.infer<typeof Summary>
|
||||
@@ -274,6 +316,156 @@ export namespace Ripgrep {
|
||||
input.signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
}) => Stream.Stream<string, PlatformError>
|
||||
readonly search: (input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
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
|
||||
}) {
|
||||
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)
|
||||
return out
|
||||
})
|
||||
|
||||
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
}) {
|
||||
const rgPath = yield* bin()
|
||||
const isDir = yield* afs.isDir(input.cwd)
|
||||
if (!isDir) {
|
||||
return yield* Effect.die(
|
||||
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||
code: "ENOENT" as const,
|
||||
errno: -2,
|
||||
path: input.cwd,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cmd = yield* args({
|
||||
mode: "files",
|
||||
glob: input.glob,
|
||||
hidden: input.hidden,
|
||||
follow: input.follow,
|
||||
maxDepth: input.maxDepth,
|
||||
})
|
||||
|
||||
return spawner
|
||||
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
|
||||
.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
|
||||
}) {
|
||||
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,
|
||||
})
|
||||
|
||||
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({
|
||||
files: (input) => Stream.unwrap(files(input)),
|
||||
search,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||
log.info("tree", input)
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||
@@ -331,46 +523,4 @@ export namespace Ripgrep {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
|
||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
@@ -34,10 +33,10 @@ export namespace FileTime {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -103,8 +102,8 @@ export namespace FileTime {
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
|
||||
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
@@ -112,22 +111,4 @@ export namespace FileTime {
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.get(sessionID, file))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromise((s) => s.assert(sessionID, filepath))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromise((s) => s.withLock(filepath, fn))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
import { Cause, Effect, Layer, Scope, Context } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
@@ -8,7 +8,6 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
@@ -65,12 +64,13 @@ export namespace FileWatcher {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
@@ -131,11 +131,9 @@ export namespace FileWatcher {
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
})
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
@@ -161,11 +159,5 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
@@ -36,7 +36,7 @@ export namespace AppFileSystem {
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Npm } from "@/npm"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -217,26 +216,16 @@ export const rlang: Info = {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
const air = which("air")
|
||||
if (air == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (!proc.stdout) return false
|
||||
const output = await text(proc.stdout)
|
||||
const output = await Process.text([air, "--help"], { nothrow: true })
|
||||
|
||||
// Check for "Air: An R language server and formatter"
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// Check for "Air: An R language server and formatter"
|
||||
const firstLine = output.text.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
|
||||
return false
|
||||
},
|
||||
}
|
||||
@@ -246,11 +235,10 @@ export const uvformat: Info = {
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
if (code === 0) return ["uv", "format", "--", "$FILE"]
|
||||
}
|
||||
const uv = which("uv")
|
||||
if (uv == null) return false
|
||||
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
|
||||
if (output.code === 0) return [uv, "format", "--", "$FILE"]
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
@@ -31,7 +30,7 @@ export namespace Format {
|
||||
readonly file: (filepath: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -51,6 +50,13 @@ export namespace Format {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
// Ruff and uv are both the same formatter, so disabling either should disable both.
|
||||
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
|
||||
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
|
||||
delete formatters.ruff
|
||||
delete formatters.uv
|
||||
continue
|
||||
}
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
@@ -186,18 +192,4 @@ export namespace Format {
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromise((s) => s.status())
|
||||
}
|
||||
|
||||
export async function file(filepath: string) {
|
||||
return runPromise((s) => s.file(filepath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Git {
|
||||
const cfg = [
|
||||
@@ -80,7 +79,7 @@ export namespace Git {
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -258,14 +257,4 @@ export namespace Git {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function run(args: string[], opts: Options) {
|
||||
return runPromise((git) => git.run(args, opts))
|
||||
}
|
||||
|
||||
export async function defaultBranch(cwd: string) {
|
||||
return runPromise((git) => git.defaultBranch(cwd))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export namespace Identifier {
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
entry: "ent",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -91,7 +90,7 @@ export namespace Installation {
|
||||
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
|
||||
Layer.effect(
|
||||
@@ -338,18 +337,4 @@ export namespace Installation {
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function method(): Promise<Method> {
|
||||
return runPromise((svc) => svc.method())
|
||||
}
|
||||
|
||||
export async function latest(installMethod?: Method): Promise<string> {
|
||||
return runPromise((svc) => svc.latest(installMethod))
|
||||
}
|
||||
|
||||
export async function upgrade(m: Method, target: string): Promise<void> {
|
||||
return runPromise((svc) => svc.upgrade(m, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Process } from "../util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -156,7 +155,7 @@ export namespace LSP {
|
||||
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -508,38 +507,9 @@ export namespace LSP {
|
||||
|
||||
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 {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
@@ -554,5 +524,14 @@ export namespace LSP {
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
|
||||
export function report(file: string, issues: LSPClient.Diagnostic[]) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) return ""
|
||||
const limited = errors.slice(0, MAX_PER_FILE)
|
||||
const more = errors.length - MAX_PER_FILE
|
||||
const suffix = more > 0 ? `\n... and ${more} more` : ""
|
||||
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,17 +105,7 @@ export namespace LSPServer {
|
||||
if (!tsserver) return
|
||||
const bin = await Npm.which("typescript-language-server")
|
||||
if (!bin) return
|
||||
|
||||
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
|
||||
|
||||
if (
|
||||
!(await pathExists(path.join(root, "tsconfig.json"))) &&
|
||||
!(await pathExists(path.join(root, "jsconfig.json")))
|
||||
) {
|
||||
args.push("--ignore-node-modules")
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
const proc = spawn(bin, ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -49,7 +49,7 @@ export namespace McpAuth {
|
||||
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -141,7 +141,7 @@ export namespace McpAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import { BusEvent } from "../bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -239,7 +239,7 @@ export namespace MCP {
|
||||
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -469,12 +469,14 @@ export namespace MCP {
|
||||
log.info("tools list changed notification received", { server: name })
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
const listed = await Effect.runPromise(defs(name, client, timeout))
|
||||
const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer)))
|
||||
if (!listed) return
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
s.defs[name] = listed
|
||||
await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
await Effect.runPromise(
|
||||
bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -887,37 +889,4 @@ export namespace MCP {
|
||||
Layer.provide(CrossSpawnSpawner.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))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
@@ -135,7 +135,7 @@ export namespace Permission {
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
|
||||
@@ -5,12 +5,8 @@ import { Identifier } from "@/id/id"
|
||||
import { Newtype } from "@/util/schema"
|
||||
|
||||
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
|
||||
static make(id: string): PermissionID {
|
||||
return this.makeUnsafe(id)
|
||||
}
|
||||
|
||||
static ascending(id?: string): PermissionID {
|
||||
return this.makeUnsafe(Identifier.ascending("permission", id))
|
||||
return this.make(Identifier.ascending("permission", id))
|
||||
}
|
||||
|
||||
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
||||
import { Log } from "../../util/log"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { CopilotModels } from "./models"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
|
||||
const log = Log.create({ service: "plugin.copilot" })
|
||||
|
||||
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
|
||||
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
||||
}
|
||||
|
||||
function fix(model: Model): Model {
|
||||
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
||||
function imgMsg(msg: any): boolean {
|
||||
if (msg?.role !== "user") return false
|
||||
|
||||
// Handle the 3 api formats
|
||||
|
||||
const content = msg.content
|
||||
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(
|
||||
(part: any) =>
|
||||
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
||||
)
|
||||
}
|
||||
|
||||
function fix(model: Model, url: string): Model {
|
||||
return {
|
||||
...model,
|
||||
api: {
|
||||
...model.api,
|
||||
url,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
}
|
||||
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
id: "github-copilot",
|
||||
async models(provider, ctx) {
|
||||
if (ctx.auth?.type !== "oauth") {
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
||||
}
|
||||
|
||||
const auth = ctx.auth
|
||||
|
||||
return CopilotModels.get(
|
||||
base(ctx.auth.enterpriseUrl),
|
||||
base(auth.enterpriseUrl),
|
||||
{
|
||||
Authorization: `Bearer ${ctx.auth.refresh}`,
|
||||
Authorization: `Bearer ${auth.refresh}`,
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
},
|
||||
provider.models,
|
||||
).catch((error) => {
|
||||
log.error("failed to fetch copilot models", { error })
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
||||
return Object.fromEntries(
|
||||
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const info = await getAuth()
|
||||
if (!info || info.type !== "oauth") return {}
|
||||
|
||||
const baseURL = base(info.enterpriseUrl)
|
||||
|
||||
return {
|
||||
baseURL,
|
||||
apiKey: "",
|
||||
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||
const info = await getAuth()
|
||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(msg: any) =>
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(item: any) =>
|
||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
part.content.some((nested: any) => nested?.type === "image")),
|
||||
),
|
||||
),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user