Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
591e197c46 | ||
|
|
aef81b3cea | ||
|
|
21d7a85e76 | ||
|
|
663e798e76 | ||
|
|
5bc2d2498d | ||
|
|
c22e34853d | ||
|
|
80566f0def | ||
|
|
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 |
@@ -26,6 +26,7 @@ kommander
|
|||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-robinmordasiewicz
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-toastythebot
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
@@ -213,7 +213,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.ref_name != 'beta'
|
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -390,7 +389,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -591,13 +590,12 @@ jobs:
|
|||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
if: github.ref_name != 'beta'
|
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-signed-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
pattern: latest-yml-*
|
pattern: latest-yml-*
|
||||||
path: /tmp/latest-yml
|
path: /tmp/latest-yml
|
||||||
|
|||||||
@@ -319,7 +319,8 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/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/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -331,7 +332,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@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/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -341,7 +342,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "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/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
@@ -708,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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
@@ -1162,8 +1164,6 @@
|
|||||||
|
|
||||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -1540,7 +1540,7 @@
|
|||||||
|
|
||||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
"@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=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -2386,7 +2386,7 @@
|
|||||||
|
|
||||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -5006,7 +5006,13 @@
|
|||||||
|
|
||||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||||
|
|
||||||
"@ai-sdk/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=="],
|
"@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=="],
|
||||||
|
|
||||||
@@ -5282,10 +5288,6 @@
|
|||||||
|
|
||||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -5554,7 +5556,9 @@
|
|||||||
|
|
||||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -5770,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/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/@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=="],
|
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||||
@@ -5952,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=="],
|
"@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/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=="],
|
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
@@ -6450,6 +6454,8 @@
|
|||||||
|
|
||||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"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=="],
|
"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=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -6822,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=="],
|
"@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=="],
|
"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=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
|
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
|
||||||
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
|
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
|
||||||
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
|
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
|
||||||
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
|
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.46",
|
"effect": "4.0.0-beta.46",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
<div class="relative w-full py-1">
|
<div class="relative w-full py-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={(e: MouseEvent) => {
|
onClick={(e: MouseEvent) => {
|
||||||
props.loadMore()
|
props.loadMore()
|
||||||
|
|||||||
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
|
|||||||
onClick={props.onResumeScroll}
|
onClick={props.onResumeScroll}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
|
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||||
style={{
|
style={{
|
||||||
"box-shadow":
|
"box-shadow":
|
||||||
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
|
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="arrow-down-to-line" size="small" />
|
<Icon name="arrow-down-to-line" size="small" />
|
||||||
|
|||||||
@@ -316,7 +316,8 @@
|
|||||||
|
|
||||||
/* Download Hero Section */
|
/* Download Hero Section */
|
||||||
[data-component="download-hero"] {
|
[data-component="download-hero"] {
|
||||||
display: grid;
|
/* display: grid; */
|
||||||
|
display: none;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
|
|||||||
y: state.y,
|
y: state.y,
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
show: true,
|
show: false,
|
||||||
title: "OpenCode",
|
title: "OpenCode",
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
|
|||||||
wireZoom(win)
|
wireZoom(win)
|
||||||
injectGlobals(win, globals)
|
injectGlobals(win, globals)
|
||||||
|
|
||||||
|
win.once("ready-to-show", () => {
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
Use these rules when writing or migrating Effect code.
|
Use these rules when writing or migrating Effect code.
|
||||||
|
|
||||||
See `specs/effect-migration.md` for the compact pattern reference and examples.
|
See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
|||||||
|
|
||||||
## Effect.cached for deduplication
|
## Effect.cached for deduplication
|
||||||
|
|
||||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
|
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||||
|
|
||||||
## Instance.bind — ALS for native callbacks
|
## Instance.bind — ALS for native callbacks
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,11 @@
|
|||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
|
||||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
|
||||||
"lint": "echo 'Running lint checks...' && bun test --coverage",
|
|
||||||
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
|
|
||||||
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
|
|
||||||
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
|
|
||||||
"db": "bun drizzle-kit"
|
"db": "bun drizzle-kit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"randomField": "this-is-a-random-value-12345",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
@@ -39,6 +32,11 @@
|
|||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.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": {
|
"devDependencies": {
|
||||||
@@ -78,7 +76,8 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/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/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@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/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -116,7 +114,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "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/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@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 dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
||||||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||||
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
||||||
@@ -16,14 +18,20 @@ const seed = async () => {
|
|||||||
const { Project } = await import("../src/project/project")
|
const { Project } = await import("../src/project/project")
|
||||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||||
const { ToolRegistry } = await import("../src/tool/registry")
|
const { ToolRegistry } = await import("../src/tool/registry")
|
||||||
|
const { Effect } = await import("effect")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await Config.waitForDependencies()
|
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
|
||||||
await ToolRegistry.ids()
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
yield* registry.ids()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const session = await Session.create({ title })
|
const session = await Session.create({ title })
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
@@ -54,6 +62,7 @@ const seed = async () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await Instance.disposeAll().catch(() => {})
|
await Instance.disposeAll().catch(() => {})
|
||||||
|
await AppRuntime.dispose().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+28
-63
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||||||
|
|
||||||
## Migration checklist
|
## Migration checklist
|
||||||
|
|
||||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
|
||||||
|
|
||||||
|
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
|
||||||
|
|
||||||
- [x] `Account` — `account/index.ts`
|
- [x] `Account` — `account/index.ts`
|
||||||
- [x] `Agent` — `agent/agent.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] `SessionSummary` — `session/summary.ts`
|
||||||
- [x] `SessionRevert` — `session/revert.ts`
|
- [x] `SessionRevert` — `session/revert.ts`
|
||||||
- [x] `Instruction` — `session/instruction.ts`
|
- [x] `Instruction` — `session/instruction.ts`
|
||||||
|
- [x] `SystemPrompt` — `session/system.ts`
|
||||||
- [x] `Provider` — `provider/provider.ts`
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
- [x] `Storage` — `storage/storage.ts`
|
- [x] `Storage` — `storage/storage.ts`
|
||||||
- [x] `ShareNext` — `share/share-next.ts`
|
- [x] `ShareNext` — `share/share-next.ts`
|
||||||
|
|
||||||
Still open:
|
|
||||||
|
|
||||||
- [x] `SessionTodo` — `session/todo.ts`
|
- [x] `SessionTodo` — `session/todo.ts`
|
||||||
- [ ] `SyncEvent` — `sync/index.ts`
|
|
||||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
|
||||||
|
|
||||||
## Tool interface → Effect
|
Still open at the service-shape level:
|
||||||
|
|
||||||
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:
|
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||||
|
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||||
|
|
||||||
1. Migrate each tool to return Effects
|
## Tool migration
|
||||||
2. Update `Tool.define()` factory to work with Effects
|
|
||||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
|
||||||
|
|
||||||
### Tool migration details
|
Tool-specific migration guidance and checklist live in `tools.md`.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Effect service adoption in already-migrated code
|
## 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)
|
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||||
|
|
||||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
|
||||||
|
|
||||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||||
|
|
||||||
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
|
||||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||||
|
|
||||||
## Filesystem consolidation
|
## Filesystem consolidation
|
||||||
|
|
||||||
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
|
||||||
|
|
||||||
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||||
|
|
||||||
Current raw fs users that will convert during tool migration:
|
|
||||||
|
|
||||||
- `tool/read.ts` — fs.createReadStream, readline
|
|
||||||
- `tool/apply_patch.ts` — fs/promises
|
|
||||||
- `file/ripgrep.ts` — fs/promises
|
|
||||||
- `patch/index.ts` — fs, fs/promises
|
|
||||||
|
|
||||||
## Primitives & utilities
|
## Primitives & utilities
|
||||||
|
|
||||||
@@ -311,7 +263,9 @@ Current raw fs users that will convert during tool migration:
|
|||||||
|
|
||||||
## Destroying the facades
|
## Destroying the facades
|
||||||
|
|
||||||
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
This phase is still broadly open. As of 2026-04-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
|
### Process
|
||||||
|
|
||||||
@@ -340,3 +294,14 @@ For each service, the migration is roughly:
|
|||||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||||
|
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
import { normalizeServerUrl } from "./url"
|
import { normalizeServerUrl } from "./url"
|
||||||
@@ -454,18 +453,4 @@ export namespace Account {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,13 +398,11 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { AppFileSystem } from "../filesystem"
|
import { AppFileSystem } from "../filesystem"
|
||||||
@@ -89,22 +88,4 @@ export namespace Auth {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
|
||||||
return runPromise((service) => service.get(providerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function all(): Promise<Record<string, Info>> {
|
|
||||||
return runPromise((service) => service.all())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function set(key: string, info: Info) {
|
|
||||||
return runPromise((service) => service.set(key, info))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(key: string) {
|
|
||||||
return runPromise((service) => service.remove(key))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await cb()
|
const result = await cb()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
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) {
|
async handler(args) {
|
||||||
UI.empty()
|
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,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => switchEffect())
|
await AppRuntime.runPromise(switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => orgsEffect())
|
await AppRuntime.runPromise(orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => openEffect())
|
await AppRuntime.runPromise(openEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Permission } from "../../../permission"
|
|||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export const AgentCommand = cmd({
|
export const AgentCommand = cmd({
|
||||||
command: "agent <name>",
|
command: "agent <name>",
|
||||||
@@ -71,11 +72,17 @@ export const AgentCommand = cmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function getAvailableTools(agent: Agent.Info) {
|
async function getAvailableTools(agent: Agent.Info) {
|
||||||
const model = agent.model ?? (await Provider.defaultModel())
|
return AppRuntime.runPromise(
|
||||||
return ToolRegistry.tools({
|
Effect.gen(function* () {
|
||||||
...model,
|
const provider = yield* Provider.Service
|
||||||
agent,
|
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>>) {
|
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||||
@@ -118,7 +125,14 @@ function parseToolParams(input?: string) {
|
|||||||
async function createToolContext(agent: Agent.Info) {
|
async function createToolContext(agent: Agent.Info) {
|
||||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
const model = agent.model ?? (await Provider.defaultModel())
|
const model =
|
||||||
|
agent.model ??
|
||||||
|
(await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
return yield* provider.defaultModel()
|
||||||
|
}),
|
||||||
|
))
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const message: MessageV2.Assistant = {
|
const message: MessageV2.Assistant = {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Config } from "../../../config/config"
|
import { Config } from "../../../config/config"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
|
|||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { LSP } from "../../../lsp"
|
import { LSP } from "../../../lsp"
|
||||||
|
import { AppRuntime } from "../../../effect/app-runtime"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { Log } from "../../../util/log"
|
import { Log } from "../../../util/log"
|
||||||
@@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({
|
|||||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
await LSP.touchFile(args.file, true)
|
const out = await AppRuntime.runPromise(
|
||||||
await sleep(1000)
|
LSP.Service.use((lsp) =>
|
||||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
Effect.gen(function* () {
|
||||||
|
yield* lsp.touchFile(args.file, true)
|
||||||
|
yield* Effect.sleep(1000)
|
||||||
|
return yield* lsp.diagnostics()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({
|
|||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
using _ = Log.Default.time("symbols")
|
using _ = Log.Default.time("symbols")
|
||||||
const results = await LSP.workspaceSymbol(args.query)
|
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
|
||||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({
|
|||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
using _ = Log.Default.time("document-symbols")
|
using _ = Log.Default.time("document-symbols")
|
||||||
const results = await LSP.documentSymbol(args.uri)
|
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
|
||||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import { AppRuntime } from "../../../effect/app-runtime"
|
||||||
import { Ripgrep } from "../../../file/ripgrep"
|
import { Ripgrep } from "../../../file/ripgrep"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
@@ -76,12 +77,18 @@ const SearchCommand = cmd({
|
|||||||
description: "Limit number of results",
|
description: "Limit number of results",
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
const results = await Ripgrep.search({
|
await bootstrap(process.cwd(), async () => {
|
||||||
cwd: process.cwd(),
|
const results = await AppRuntime.runPromise(
|
||||||
pattern: args.pattern,
|
Ripgrep.Service.use((svc) =>
|
||||||
glob: args.glob as string[] | undefined,
|
svc.search({
|
||||||
limit: args.limit,
|
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 { EOL } from "os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Skill } from "../../../skill"
|
import { Skill } from "../../../skill"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -9,7 +11,12 @@ export const SkillCommand = cmd({
|
|||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const skills = await Skill.all()
|
const skills = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
return yield* skill.all()
|
||||||
|
}),
|
||||||
|
)
|
||||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Global } from "../../global"
|
|||||||
import { modify, applyEdits } from "jsonc-parser"
|
import { modify, applyEdits } from "jsonc-parser"
|
||||||
import { Filesystem } from "../../util/filesystem"
|
import { Filesystem } from "../../util/filesystem"
|
||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -75,7 +76,7 @@ export const McpListCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP Servers")
|
prompts.intro("MCP Servers")
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
const statuses = await MCP.status()
|
const statuses = await MCP.status()
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ export const McpAuthCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Authentication")
|
prompts.intro("MCP OAuth Authentication")
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
|
|
||||||
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
||||||
@@ -289,7 +290,7 @@ export const McpAuthListCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Status")
|
prompts.intro("MCP OAuth Status")
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
|
|
||||||
// Get OAuth-capable servers
|
// Get OAuth-capable servers
|
||||||
@@ -595,7 +596,7 @@ export const McpDebugCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Debug")
|
prompts.intro("MCP OAuth Debug")
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
const serverName = args.name
|
const serverName = args.name
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models"
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
export const ModelsCommand = cmd({
|
export const ModelsCommand = cmd({
|
||||||
command: "models [provider]",
|
command: "models [provider]",
|
||||||
@@ -35,43 +37,51 @@ export const ModelsCommand = cmd({
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
async fn() {
|
async fn() {
|
||||||
const providers = await Provider.list()
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Provider.Service
|
||||||
|
const providers = yield* svc.list()
|
||||||
|
|
||||||
function printModels(providerID: ProviderID, verbose?: boolean) {
|
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||||
const provider = providers[providerID]
|
const provider = providers[providerID]
|
||||||
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||||
for (const [modelID, model] of sortedModels) {
|
for (const [modelID, model] of sorted) {
|
||||||
process.stdout.write(`${providerID}/${modelID}`)
|
process.stdout.write(`${providerID}/${modelID}`)
|
||||||
process.stdout.write(EOL)
|
process.stdout.write(EOL)
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
process.stdout.write(JSON.stringify(model, null, 2))
|
process.stdout.write(JSON.stringify(model, null, 2))
|
||||||
process.stdout.write(EOL)
|
process.stdout.write(EOL)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.provider) {
|
if (args.provider) {
|
||||||
const provider = providers[ProviderID.make(args.provider)]
|
const providerID = ProviderID.make(args.provider)
|
||||||
if (!provider) {
|
const provider = providers[providerID]
|
||||||
UI.error(`Provider not found: ${args.provider}`)
|
if (!provider) {
|
||||||
return
|
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
printModels(ProviderID.make(args.provider), args.verbose)
|
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerIDs = Object.keys(providers).sort((a, b) => {
|
const ids = Object.keys(providers).sort((a, b) => {
|
||||||
const aIsOpencode = a.startsWith("opencode")
|
const aIsOpencode = a.startsWith("opencode")
|
||||||
const bIsOpencode = b.startsWith("opencode")
|
const bIsOpencode = b.startsWith("opencode")
|
||||||
if (aIsOpencode && !bIsOpencode) return -1
|
if (aIsOpencode && !bIsOpencode) return -1
|
||||||
if (!aIsOpencode && bIsOpencode) return 1
|
if (!aIsOpencode && bIsOpencode) return 1
|
||||||
return a.localeCompare(b)
|
return a.localeCompare(b)
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const providerID of providerIDs) {
|
yield* Effect.sync(() => {
|
||||||
printModels(ProviderID.make(providerID), args.verbose)
|
for (const providerID of ids) {
|
||||||
}
|
print(ProviderID.make(providerID), args.verbose)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
@@ -13,9 +14,18 @@ import { Instance } from "../../project/instance"
|
|||||||
import type { Hooks } from "@opencode-ai/plugin"
|
import type { Hooks } from "@opencode-ai/plugin"
|
||||||
import { Process } from "../../util/process"
|
import { Process } from "../../util/process"
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||||
|
|
||||||
|
const put = (key: string, info: Auth.Info) =>
|
||||||
|
AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.set(key, info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
||||||
let index = 0
|
let index = 0
|
||||||
if (methodName) {
|
if (methodName) {
|
||||||
@@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
const saveProvider = result.provider ?? provider
|
const saveProvider = result.provider ?? provider
|
||||||
if ("refresh" in result) {
|
if ("refresh" in result) {
|
||||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||||
await Auth.set(saveProvider, {
|
await put(saveProvider, {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
refresh,
|
refresh,
|
||||||
access,
|
access,
|
||||||
@@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if ("key" in result) {
|
if ("key" in result) {
|
||||||
await Auth.set(saveProvider, {
|
await put(saveProvider, {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
})
|
})
|
||||||
@@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
const saveProvider = result.provider ?? provider
|
const saveProvider = result.provider ?? provider
|
||||||
if ("refresh" in result) {
|
if ("refresh" in result) {
|
||||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||||
await Auth.set(saveProvider, {
|
await put(saveProvider, {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
refresh,
|
refresh,
|
||||||
access,
|
access,
|
||||||
@@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if ("key" in result) {
|
if ("key" in result) {
|
||||||
await Auth.set(saveProvider, {
|
await put(saveProvider, {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
})
|
})
|
||||||
@@ -148,6 +158,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method.type === "api") {
|
if (method.type === "api") {
|
||||||
|
const key = await prompts.password({
|
||||||
|
message: "Enter your API key",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
|
|
||||||
if (method.authorize) {
|
if (method.authorize) {
|
||||||
const result = await method.authorize(inputs)
|
const result = await method.authorize(inputs)
|
||||||
if (result.type === "failed") {
|
if (result.type === "failed") {
|
||||||
@@ -155,9 +171,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
}
|
}
|
||||||
if (result.type === "success") {
|
if (result.type === "success") {
|
||||||
const saveProvider = result.provider ?? provider
|
const saveProvider = result.provider ?? provider
|
||||||
await Auth.set(saveProvider, {
|
await put(saveProvider, {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key ?? key,
|
||||||
})
|
})
|
||||||
prompts.log.success("Login successful")
|
prompts.log.success("Login successful")
|
||||||
}
|
}
|
||||||
@@ -215,7 +231,12 @@ export const ProvidersListCommand = cmd({
|
|||||||
const homedir = os.homedir()
|
const homedir = os.homedir()
|
||||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||||
const results = Object.entries(await Auth.all())
|
const results = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
return Object.entries(yield* auth.all())
|
||||||
|
}),
|
||||||
|
)
|
||||||
const database = await ModelsDev.get()
|
const database = await ModelsDev.get()
|
||||||
|
|
||||||
for (const [providerID, result] of results) {
|
for (const [providerID, result] of results) {
|
||||||
@@ -294,7 +315,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Auth.set(url, {
|
await put(url, {
|
||||||
type: "wellknown",
|
type: "wellknown",
|
||||||
key: wellknown.auth.env,
|
key: wellknown.auth.env,
|
||||||
token: token.trim(),
|
token: token.trim(),
|
||||||
@@ -305,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
}
|
}
|
||||||
await ModelsDev.refresh(true).catch(() => {})
|
await ModelsDev.refresh(true).catch(() => {})
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
|
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
@@ -441,7 +462,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
await Auth.set(provider, {
|
await put(provider, {
|
||||||
type: "api",
|
type: "api",
|
||||||
key,
|
key,
|
||||||
})
|
})
|
||||||
@@ -457,22 +478,33 @@ export const ProvidersLogoutCommand = cmd({
|
|||||||
describe: "log out from a configured provider",
|
describe: "log out from a configured provider",
|
||||||
async handler(_args) {
|
async handler(_args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
return Object.entries(yield* auth.all())
|
||||||
|
}),
|
||||||
|
)
|
||||||
prompts.intro("Remove credential")
|
prompts.intro("Remove credential")
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
prompts.log.error("No credentials found")
|
prompts.log.error("No credentials found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const database = await ModelsDev.get()
|
const database = await ModelsDev.get()
|
||||||
const providerID = await prompts.select({
|
const selected = await prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
options: credentials.map(([key, value]) => ({
|
options: credentials.map(([key, value]) => ({
|
||||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||||
value: key,
|
value: key,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||||
await Auth.remove(providerID)
|
const providerID = selected as string
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.remove(providerID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
prompts.outro("Logout successful")
|
prompts.outro("Logout successful")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
import { Selection } from "@tui/util/selection"
|
||||||
|
import { Terminal } from "@tui/util/terminal"
|
||||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import {
|
import {
|
||||||
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
|
|||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|
||||||
// can't set raw mode if not a TTY
|
|
||||||
if (!process.stdin.isTTY) return "dark"
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let timeout: NodeJS.Timeout
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
process.stdin.setRawMode(false)
|
|
||||||
process.stdin.removeListener("data", handler)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
|
||||||
const str = data.toString()
|
|
||||||
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
|
||||||
if (match) {
|
|
||||||
cleanup()
|
|
||||||
const color = match[1]
|
|
||||||
// Parse RGB values from color string
|
|
||||||
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
|
||||||
let r = 0,
|
|
||||||
g = 0,
|
|
||||||
b = 0
|
|
||||||
|
|
||||||
if (color.startsWith("rgb:")) {
|
|
||||||
const parts = color.substring(4).split("/")
|
|
||||||
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
} else if (color.startsWith("#")) {
|
|
||||||
r = parseInt(color.substring(1, 3), 16)
|
|
||||||
g = parseInt(color.substring(3, 5), 16)
|
|
||||||
b = parseInt(color.substring(5, 7), 16)
|
|
||||||
} else if (color.startsWith("rgb(")) {
|
|
||||||
const parts = color.substring(4, color.length - 1).split(",")
|
|
||||||
r = parseInt(parts[0])
|
|
||||||
g = parseInt(parts[1])
|
|
||||||
b = parseInt(parts[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate luminance using relative luminance formula
|
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
||||||
|
|
||||||
// Determine if dark or light based on luminance threshold
|
|
||||||
resolve(luminance > 0.5 ? "light" : "dark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
|
||||||
process.stdin.on("data", handler)
|
|
||||||
process.stdout.write("\x1b]11;?\x07")
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
cleanup()
|
|
||||||
resolve("dark")
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
|
|
||||||
@@ -178,7 +119,7 @@ export function tui(input: {
|
|||||||
const unguard = win32InstallCtrlCGuard()
|
const unguard = win32InstallCtrlCGuard()
|
||||||
win32DisableProcessedInput()
|
win32DisableProcessedInput()
|
||||||
|
|
||||||
const mode = await getTerminalBackgroundColor()
|
const mode = await Terminal.getTerminalBackgroundColor()
|
||||||
|
|
||||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||||
|
|||||||
@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
async function submit() {
|
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 (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return
|
||||||
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
input.cursorOffset = input.plainText.length
|
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) => {
|
onPaste={async (event: PasteEvent) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
worker.onerror = (e) => {
|
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 client = Rpc.client<typeof rpc>(worker)
|
||||||
const error = (e: unknown) => {
|
const error = (e: unknown) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("process error", { error: errorMessage(e) })
|
||||||
}
|
}
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
client.call("reload", undefined).catch((err) => {
|
client.call("reload", undefined).catch((err) => {
|
||||||
|
|||||||
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
|
|||||||
|
|
||||||
export namespace Terminal {
|
export namespace Terminal {
|
||||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||||
|
|
||||||
|
function parse(color: string): RGBA | null {
|
||||||
|
if (color.startsWith("rgb:")) {
|
||||||
|
const parts = color.substring(4).split("/")
|
||||||
|
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||||
|
}
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return RGBA.fromHex(color)
|
||||||
|
}
|
||||||
|
if (color.startsWith("rgb(")) {
|
||||||
|
const parts = color.substring(4, color.length - 1).split(",")
|
||||||
|
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mode(bg: RGBA | null): "dark" | "light" {
|
||||||
|
if (!bg) return "dark"
|
||||||
|
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||||
|
return luminance > 0.5 ? "light" : "dark"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query terminal colors including background, foreground, and palette (0-15).
|
* Query terminal colors including background, foreground, and palette (0-15).
|
||||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||||
@@ -31,46 +53,26 @@ export namespace Terminal {
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseColor = (colorStr: string): RGBA | null => {
|
|
||||||
if (colorStr.startsWith("rgb:")) {
|
|
||||||
const parts = colorStr.substring(4).split("/")
|
|
||||||
return RGBA.fromInts(
|
|
||||||
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
|
|
||||||
parseInt(parts[1], 16) >> 8,
|
|
||||||
parseInt(parts[2], 16) >> 8,
|
|
||||||
255,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (colorStr.startsWith("#")) {
|
|
||||||
return RGBA.fromHex(colorStr)
|
|
||||||
}
|
|
||||||
if (colorStr.startsWith("rgb(")) {
|
|
||||||
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
|
|
||||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
const handler = (data: Buffer) => {
|
||||||
const str = data.toString()
|
const str = data.toString()
|
||||||
|
|
||||||
// Match OSC 11 (background color)
|
// Match OSC 11 (background color)
|
||||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
if (bgMatch) {
|
if (bgMatch) {
|
||||||
background = parseColor(bgMatch[1])
|
background = parse(bgMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match OSC 10 (foreground color)
|
// Match OSC 10 (foreground color)
|
||||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||||
if (fgMatch) {
|
if (fgMatch) {
|
||||||
foreground = parseColor(fgMatch[1])
|
foreground = parse(fgMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match OSC 4 (palette colors)
|
// Match OSC 4 (palette colors)
|
||||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||||
for (const match of paletteMatches) {
|
for (const match of paletteMatches) {
|
||||||
const index = parseInt(match[1])
|
const index = parseInt(match[1])
|
||||||
const color = parseColor(match[2])
|
const color = parse(match[2])
|
||||||
if (color) paletteColors[index] = color
|
if (color) paletteColors[index] = color
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +102,36 @@ export namespace Terminal {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||||
|
// needs OSC 11 and should resolve on the first background response instead of
|
||||||
|
// waiting on the full palette query used by system theme generation.
|
||||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
const result = await colors()
|
if (!process.stdin.isTTY) return "dark"
|
||||||
if (!result.background) return "dark"
|
|
||||||
|
|
||||||
const { r, g, b } = result.background
|
return new Promise((resolve) => {
|
||||||
// Calculate luminance using relative luminance formula
|
let timeout: NodeJS.Timeout
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
||||||
|
|
||||||
// Determine if dark or light based on luminance threshold
|
const cleanup = () => {
|
||||||
return luminance > 0.5 ? "light" : "dark"
|
process.stdin.setRawMode(false)
|
||||||
|
process.stdin.removeListener("data", handler)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (data: Buffer) => {
|
||||||
|
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
|
if (!match) return
|
||||||
|
cleanup()
|
||||||
|
resolve(mode(parse(match[1])))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.setRawMode(true)
|
||||||
|
process.stdin.on("data", handler)
|
||||||
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
resolve("dark")
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
|
|||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { writeHeapSnapshot } from "node:v8"
|
import { writeHeapSnapshot } from "node:v8"
|
||||||
import { Heap } from "@/cli/heap"
|
import { Heap } from "@/cli/heap"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
print: process.argv.includes("--print-logs"),
|
print: process.argv.includes("--print-logs"),
|
||||||
@@ -74,14 +74,14 @@ export const rpc = {
|
|||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await upgrade().catch(() => {})
|
await upgrade().catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async reload() {
|
async reload() {
|
||||||
await Config.invalidate(true)
|
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||||
},
|
},
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Argv, InferredOptionTypes } from "yargs"
|
import type { Argv, InferredOptionTypes } from "yargs"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
port: {
|
port: {
|
||||||
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||||
const config = await Config.getGlobal()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
const portExplicitlySet = process.argv.includes("--port")
|
const portExplicitlySet = process.argv.includes("--port")
|
||||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|
||||||
export async function upgrade() {
|
export async function upgrade() {
|
||||||
const config = await Config.getGlobal()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
||||||
if (!latest) return
|
if (!latest) return
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
@@ -23,21 +22,18 @@ import { Instance, type InstanceContext } from "../project/instance"
|
|||||||
import { LSPServer } from "../lsp/server"
|
import { LSPServer } from "../lsp/server"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ConfigMarkdown } from "./markdown"
|
import { ConfigMarkdown } from "./markdown"
|
||||||
import { constants, existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
import { iife } from "@/util/iife"
|
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "./paths"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import type { ConsoleState } from "./console-state"
|
import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
|
||||||
import { Duration, Effect, Layer, Option, Context } from "effect"
|
|
||||||
import { Flock } from "@/util/flock"
|
import { Flock } from "@/util/flock"
|
||||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||||
import { Npm } from "@/npm"
|
import { Npm } from "@/npm"
|
||||||
@@ -141,53 +137,11 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InstallInput = {
|
export type InstallInput = {
|
||||||
signal?: AbortSignal
|
|
||||||
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
|
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDependencies(dir: string, input?: InstallInput) {
|
type Package = {
|
||||||
if (!(await isWritable(dir))) return
|
dependencies?: Record<string, string>
|
||||||
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
|
|
||||||
signal: input?.signal,
|
|
||||||
onWait: (tick) =>
|
|
||||||
input?.waitTick?.({
|
|
||||||
dir,
|
|
||||||
attempt: tick.attempt,
|
|
||||||
delay: tick.delay,
|
|
||||||
waited: tick.waited,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
input?.signal?.throwIfAborted()
|
|
||||||
|
|
||||||
const pkg = path.join(dir, "package.json")
|
|
||||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
|
||||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
|
||||||
dependencies: {},
|
|
||||||
}))
|
|
||||||
json.dependencies = {
|
|
||||||
...json.dependencies,
|
|
||||||
"@opencode-ai/plugin": target,
|
|
||||||
}
|
|
||||||
await Filesystem.writeJson(pkg, json)
|
|
||||||
|
|
||||||
const gitignore = path.join(dir, ".gitignore")
|
|
||||||
const ignore = await Filesystem.exists(gitignore)
|
|
||||||
if (!ignore) {
|
|
||||||
await Filesystem.write(
|
|
||||||
gitignore,
|
|
||||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await Npm.install(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isWritable(dir: string) {
|
|
||||||
try {
|
|
||||||
await fsNode.access(dir, constants.W_OK)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rel(item: string, patterns: string[]) {
|
function rel(item: string, patterns: string[]) {
|
||||||
@@ -1112,7 +1066,7 @@ export namespace Config {
|
|||||||
type State = {
|
type State = {
|
||||||
config: Info
|
config: Info
|
||||||
directories: string[]
|
directories: string[]
|
||||||
deps: Promise<void>[]
|
deps: Fiber.Fiber<void, never>[]
|
||||||
consoleState: ConsoleState
|
consoleState: ConsoleState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,6 +1074,7 @@ export namespace Config {
|
|||||||
readonly get: () => Effect.Effect<Info>
|
readonly get: () => Effect.Effect<Info>
|
||||||
readonly getGlobal: () => Effect.Effect<Info>
|
readonly getGlobal: () => Effect.Effect<Info>
|
||||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||||
|
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
readonly update: (config: Info) => Effect.Effect<void>
|
readonly update: (config: Info) => Effect.Effect<void>
|
||||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||||
@@ -1321,6 +1276,74 @@ export namespace Config {
|
|||||||
return yield* cachedGlobal
|
return yield* cachedGlobal
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const install = Effect.fnUntraced(function* (dir: string) {
|
||||||
|
const pkg = path.join(dir, "package.json")
|
||||||
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
|
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
||||||
|
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
const json = yield* fs.readJson(pkg).pipe(
|
||||||
|
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
||||||
|
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
||||||
|
)
|
||||||
|
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
|
||||||
|
const hasIgnore = yield* fs.existsSafe(gitignore)
|
||||||
|
const hasPkg = yield* fs.existsSafe(plugin)
|
||||||
|
|
||||||
|
if (!hasDep) {
|
||||||
|
yield* fs.writeJson(pkg, {
|
||||||
|
...json,
|
||||||
|
dependencies: {
|
||||||
|
...json.dependencies,
|
||||||
|
"@opencode-ai/plugin": target,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIgnore) {
|
||||||
|
yield* fs.writeFileString(
|
||||||
|
gitignore,
|
||||||
|
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDep && hasIgnore && hasPkg) return
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Npm.install(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
const installDependencies = Effect.fn("Config.installDependencies")(function* (
|
||||||
|
dir: string,
|
||||||
|
input?: InstallInput,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.orElseSucceed(() => false),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const key =
|
||||||
|
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
|
||||||
|
|
||||||
|
yield* Effect.acquireUseRelease(
|
||||||
|
Effect.promise((signal) =>
|
||||||
|
Flock.acquire(key, {
|
||||||
|
signal,
|
||||||
|
onWait: (tick) =>
|
||||||
|
input?.waitTick?.({
|
||||||
|
dir,
|
||||||
|
attempt: tick.attempt,
|
||||||
|
delay: tick.delay,
|
||||||
|
waited: tick.waited,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => install(dir),
|
||||||
|
(lease) => Effect.promise(() => lease.release()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -1403,7 +1426,7 @@ export namespace Config {
|
|||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deps: Promise<void>[] = []
|
const deps: Fiber.Fiber<void, never>[] = []
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
for (const dir of unique(directories)) {
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
@@ -1417,12 +1440,18 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dep = iife(async () => {
|
const dep = yield* installDependencies(dir).pipe(
|
||||||
await installDependencies(dir)
|
Effect.exit,
|
||||||
})
|
Effect.tap((exit) =>
|
||||||
void dep.catch((err) => {
|
Exit.isFailure(exit)
|
||||||
log.warn("background dependency install failed", { dir, error: err })
|
? Effect.sync(() => {
|
||||||
})
|
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
deps.push(dep)
|
deps.push(dep)
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||||
@@ -1559,7 +1588,9 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||||
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
yield* InstanceState.useEffect(state, (s) =>
|
||||||
|
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||||
@@ -1614,6 +1645,7 @@ export namespace Config {
|
|||||||
get,
|
get,
|
||||||
getGlobal,
|
getGlobal,
|
||||||
getConsoleState,
|
getConsoleState,
|
||||||
|
installDependencies,
|
||||||
update,
|
update,
|
||||||
updateGlobal,
|
updateGlobal,
|
||||||
invalidate,
|
invalidate,
|
||||||
@@ -1628,38 +1660,4 @@ export namespace Config {
|
|||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.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 { Log } from "@/util/log"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
export namespace TuiConfig {
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
@@ -51,7 +52,7 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installDeps(dir: string): Promise<void> {
|
function installDeps(dir: string): Promise<void> {
|
||||||
return Config.installDependencies(dir)
|
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string) {
|
async function mergeFile(acc: Acc, file: string) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
|
|||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
|
|
||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
Observability.layer,
|
// Observability.layer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Bus.defaultLayer,
|
Bus.defaultLayer,
|
||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
SessionShare.defaultLayer,
|
SessionShare.defaultLayer,
|
||||||
)
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|
||||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { memoMap } from "./run-service"
|
import { memoMap } from "./run-service"
|
||||||
|
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import { LSP } from "@/lsp"
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import { File } from "@/file"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Observability } from "./oltp"
|
||||||
|
|
||||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
|
export const BootstrapLayer = Layer.mergeAll(
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
ShareNext.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
LSP.defaultLayer,
|
||||||
|
File.defaultLayer,
|
||||||
|
FileWatcher.defaultLayer,
|
||||||
|
Vcs.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
|
Bus.defaultLayer,
|
||||||
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|
||||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
@@ -94,8 +94,43 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
const Result = z.union([Begin, Match, End, Summary])
|
const Result = z.union([Begin, Match, End, Summary])
|
||||||
|
|
||||||
|
const Hit = Schema.Struct({
|
||||||
|
type: Schema.Literal("match"),
|
||||||
|
data: Schema.Struct({
|
||||||
|
path: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
lines: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
line_number: Schema.Number,
|
||||||
|
absolute_offset: Schema.Number,
|
||||||
|
submatches: Schema.mutable(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
match: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
start: Schema.Number,
|
||||||
|
end: Schema.Number,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Row = Schema.Union([
|
||||||
|
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
|
||||||
|
Hit,
|
||||||
|
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
|
||||||
|
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
|
||||||
|
|
||||||
export type Result = z.infer<typeof Result>
|
export type Result = z.infer<typeof Result>
|
||||||
export type Match = z.infer<typeof Match>
|
export type Match = z.infer<typeof Match>
|
||||||
|
export type Item = Match["data"]
|
||||||
export type Begin = z.infer<typeof Begin>
|
export type Begin = z.infer<typeof Begin>
|
||||||
export type End = z.infer<typeof End>
|
export type End = z.infer<typeof End>
|
||||||
export type Summary = z.infer<typeof Summary>
|
export type Summary = z.infer<typeof Summary>
|
||||||
@@ -289,6 +324,13 @@ export namespace Ripgrep {
|
|||||||
follow?: boolean
|
follow?: boolean
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
}) => Stream.Stream<string, PlatformError>
|
}) => Stream.Stream<string, PlatformError>
|
||||||
|
readonly search: (input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
follow?: boolean
|
||||||
|
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||||
@@ -298,6 +340,32 @@ export namespace Ripgrep {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* AppFileSystem.Service
|
||||||
|
const bin = Effect.fn("Ripgrep.path")(function* () {
|
||||||
|
return yield* Effect.promise(() => filepath())
|
||||||
|
})
|
||||||
|
const args = Effect.fn("Ripgrep.args")(function* (input: {
|
||||||
|
mode: "files" | "search"
|
||||||
|
glob?: string[]
|
||||||
|
hidden?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
limit?: number
|
||||||
|
pattern?: string
|
||||||
|
}) {
|
||||||
|
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: {
|
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
||||||
cwd: string
|
cwd: string
|
||||||
@@ -306,7 +374,7 @@ export namespace Ripgrep {
|
|||||||
follow?: boolean
|
follow?: boolean
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
}) {
|
}) {
|
||||||
const rgPath = yield* Effect.promise(() => filepath())
|
const rgPath = yield* bin()
|
||||||
const isDir = yield* afs.isDir(input.cwd)
|
const isDir = yield* afs.isDir(input.cwd)
|
||||||
if (!isDir) {
|
if (!isDir) {
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
@@ -318,23 +386,77 @@ export namespace Ripgrep {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [rgPath, "--files", "--glob=!.git/*"]
|
const cmd = yield* args({
|
||||||
if (input.follow) args.push("--follow")
|
mode: "files",
|
||||||
if (input.hidden !== false) args.push("--hidden")
|
glob: input.glob,
|
||||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
hidden: input.hidden,
|
||||||
if (input.glob) {
|
follow: input.follow,
|
||||||
for (const g of input.glob) {
|
maxDepth: input.maxDepth,
|
||||||
args.push(`--glob=${g}`)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spawner
|
return spawner
|
||||||
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
|
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
|
||||||
.pipe(Stream.filter((line: string) => line.length > 0))
|
.pipe(Stream.filter((line: string) => line.length > 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("Ripgrep.search")(function* (input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
follow?: boolean
|
||||||
|
}) {
|
||||||
|
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({
|
return Service.of({
|
||||||
files: (input) => Stream.unwrap(files(input)),
|
files: (input) => Stream.unwrap(files(input)),
|
||||||
|
search,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -401,46 +523,4 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: {
|
|
||||||
cwd: string
|
|
||||||
pattern: string
|
|
||||||
glob?: string[]
|
|
||||||
limit?: number
|
|
||||||
follow?: boolean
|
|
||||||
}) {
|
|
||||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
|
|
||||||
if (input.glob) {
|
|
||||||
for (const g of input.glob) {
|
|
||||||
args.push(`--glob=${g}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.limit) {
|
|
||||||
args.push(`--max-count=${input.limit}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("--")
|
|
||||||
args.push(input.pattern)
|
|
||||||
|
|
||||||
const result = await Process.text(args, {
|
|
||||||
cwd: input.cwd,
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
||||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
|
||||||
// Parse JSON lines from ripgrep output
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.map((line) => JSON.parse(line))
|
|
||||||
.map((parsed) => Result.parse(parsed))
|
|
||||||
.filter((r) => r.type === "match")
|
|
||||||
.map((r) => r.data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
@@ -112,22 +111,4 @@ export namespace FileTime {
|
|||||||
).pipe(Layer.orDie)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
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, () => Effect.promise(fn)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import z from "zod"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
@@ -161,10 +160,4 @@ export namespace FileWatcher {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { text } from "node:stream/consumers"
|
|
||||||
import { Npm } from "@/npm"
|
import { Npm } from "@/npm"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
@@ -217,26 +216,16 @@ export const rlang: Info = {
|
|||||||
name: "air",
|
name: "air",
|
||||||
extensions: [".R"],
|
extensions: [".R"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
const airPath = which("air")
|
const air = which("air")
|
||||||
if (airPath == null) return false
|
if (air == null) return false
|
||||||
|
|
||||||
try {
|
const output = await Process.text([air, "--help"], { nothrow: true })
|
||||||
const proc = Process.spawn(["air", "--help"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
if (!proc.stdout) return false
|
|
||||||
const output = await text(proc.stdout)
|
|
||||||
|
|
||||||
// Check for "Air: An R language server and formatter"
|
// Check for "Air: An R language server and formatter"
|
||||||
const firstLine = output.split("\n")[0]
|
const firstLine = output.text.split("\n")[0]
|
||||||
const hasR = firstLine.includes("R language")
|
const hasR = firstLine.includes("R language")
|
||||||
const hasFormatter = firstLine.includes("formatter")
|
const hasFormatter = firstLine.includes("formatter")
|
||||||
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
|
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -246,11 +235,10 @@ export const uvformat: Info = {
|
|||||||
extensions: [".py", ".pyi"],
|
extensions: [".py", ".pyi"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
if (await ruff.enabled()) return false
|
if (await ruff.enabled()) return false
|
||||||
if (which("uv") !== null) {
|
const uv = which("uv")
|
||||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
if (uv == null) return false
|
||||||
const code = await proc.exited
|
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
|
||||||
if (code === 0) return ["uv", "format", "--", "$FILE"]
|
if (output.code === 0) return [uv, "format", "--", "$FILE"]
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Process } from "../util/process"
|
|||||||
import { spawn as lspspawn } from "./launch"
|
import { spawn as lspspawn } from "./launch"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace LSP {
|
export namespace LSP {
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
@@ -508,37 +507,6 @@ export namespace LSP {
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export const init = async () => runPromise((svc) => svc.init())
|
|
||||||
|
|
||||||
export const status = async () => runPromise((svc) => svc.status())
|
|
||||||
|
|
||||||
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
|
|
||||||
|
|
||||||
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
|
|
||||||
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
|
|
||||||
|
|
||||||
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
|
|
||||||
|
|
||||||
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
|
|
||||||
|
|
||||||
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
|
|
||||||
|
|
||||||
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
|
|
||||||
|
|
||||||
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
|
|
||||||
|
|
||||||
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
|
|
||||||
|
|
||||||
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
|
|
||||||
|
|
||||||
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
|
|
||||||
|
|
||||||
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
|
|
||||||
|
|
||||||
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
|
|
||||||
|
|
||||||
export namespace Diagnostic {
|
export namespace Diagnostic {
|
||||||
const MAX_PER_FILE = 20
|
const MAX_PER_FILE = 20
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
|||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { CopilotModels } from "./models"
|
import { CopilotModels } from "./models"
|
||||||
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.copilot" })
|
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"
|
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 {
|
return {
|
||||||
...model,
|
...model,
|
||||||
api: {
|
api: {
|
||||||
...model.api,
|
...model.api,
|
||||||
|
url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
async models(provider, ctx) {
|
async models(provider, ctx) {
|
||||||
if (ctx.auth?.type !== "oauth") {
|
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(
|
return CopilotModels.get(
|
||||||
base(ctx.auth.enterpriseUrl),
|
base(auth.enterpriseUrl),
|
||||||
{
|
{
|
||||||
Authorization: `Bearer ${ctx.auth.refresh}`,
|
Authorization: `Bearer ${auth.refresh}`,
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
},
|
},
|
||||||
provider.models,
|
provider.models,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
log.error("failed to fetch copilot models", { 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()
|
const info = await getAuth()
|
||||||
if (!info || info.type !== "oauth") return {}
|
if (!info || info.type !== "oauth") return {}
|
||||||
|
|
||||||
const baseURL = base(info.enterpriseUrl)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseURL,
|
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||||
const info = await getAuth()
|
const info = await getAuth()
|
||||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
(msg: any) =>
|
(msg: any) =>
|
||||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
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) =>
|
(item: any) =>
|
||||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
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")),
|
part.content.some((nested: any) => nested?.type === "image")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ export namespace CopilotModels {
|
|||||||
(remote.capabilities.supports.vision ?? false) ||
|
(remote.capabilities.supports.vision ?? false) ||
|
||||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||||
|
|
||||||
|
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
providerID: "github-copilot",
|
providerID: "github-copilot",
|
||||||
api: {
|
api: {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
url,
|
url: isMsgApi ? `${url}/v1` : url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
// API response wins
|
// API response wins
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Plugin {
|
|||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||||
})
|
})
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
@@ -210,13 +210,15 @@ export namespace Plugin {
|
|||||||
return message
|
return message
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((message) =>
|
Effect.catch(() => {
|
||||||
bus.publish(Session.Event.Error, {
|
// TODO: make proper events for this
|
||||||
error: new NamedError.Unknown({
|
// bus.publish(Session.Event.Error, {
|
||||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
// error: new NamedError.Unknown({
|
||||||
}).toObject(),
|
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
}),
|
// }).toObject(),
|
||||||
),
|
// })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
@@ -10,23 +9,26 @@ import { Bus } from "../bus"
|
|||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import * as Effect from "effect/Effect"
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export const InstanceBootstrap = Effect.gen(function* () {
|
||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
yield* Plugin.Service.use((svc) => svc.init())
|
||||||
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
|
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
await LSP.init()
|
yield* LSP.Service.use((svc) => svc.init())
|
||||||
File.init()
|
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
FileWatcher.init()
|
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
Vcs.init()
|
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
Snapshot.init()
|
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
yield* Bus.Service.use((svc) =>
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||||
Project.setInitialized(Instance.project.id)
|
if (payload.properties.name === Command.Default.INIT) {
|
||||||
}
|
Project.setInitialized(Instance.project.id)
|
||||||
})
|
}
|
||||||
}
|
}),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import path from "path"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
@@ -231,22 +230,4 @@ export namespace Vcs {
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function branch() {
|
|
||||||
return runPromise((svc) => svc.branch())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function defaultBranch() {
|
|
||||||
return runPromise((svc) => svc.defaultBranch())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function diff(mode: Mode) {
|
|
||||||
return runPromise((svc) => svc.diff(mode))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// Auto-generated by build.ts - do not edit
|
|
||||||
export declare const snapshot: Record<string, unknown>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -209,6 +209,9 @@ export namespace ProviderTransform {
|
|||||||
copilot: {
|
copilot: {
|
||||||
copilot_cache_control: { type: "ephemeral" },
|
copilot_cache_control: { type: "ephemeral" },
|
||||||
},
|
},
|
||||||
|
alibaba: {
|
||||||
|
cacheControl: { type: "ephemeral" },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const msg of unique([...system, ...final])) {
|
for (const msg of unique([...system, ...final])) {
|
||||||
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
|
|||||||
model.api.id.includes("claude") ||
|
model.api.id.includes("claude") ||
|
||||||
model.id.includes("anthropic") ||
|
model.id.includes("anthropic") ||
|
||||||
model.id.includes("claude") ||
|
model.id.includes("claude") ||
|
||||||
model.api.npm === "@ai-sdk/anthropic") &&
|
model.api.npm === "@ai-sdk/anthropic" ||
|
||||||
|
model.api.npm === "@ai-sdk/alibaba") &&
|
||||||
model.api.npm !== "@ai-sdk/gateway"
|
model.api.npm !== "@ai-sdk/gateway"
|
||||||
) {
|
) {
|
||||||
msgs = applyCaching(msgs, model)
|
msgs = applyCaching(msgs, model)
|
||||||
@@ -774,7 +778,10 @@ export namespace ProviderTransform {
|
|||||||
result["chat_template_args"] = { enable_thinking: true }
|
result["chat_template_args"] = { enable_thinking: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
|
if (
|
||||||
|
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
|
||||||
|
input.model.api.npm === "@ai-sdk/openai-compatible"
|
||||||
|
) {
|
||||||
result["thinking"] = {
|
result["thinking"] = {
|
||||||
type: "enabled",
|
type: "enabled",
|
||||||
clear_thinking: false,
|
clear_thinking: false,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import type { Proc } from "#pty"
|
import type { Proc } from "#pty"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -361,34 +360,4 @@ export namespace Pty {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((svc) => svc.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get(id: PtyID) {
|
|
||||||
return runPromise((svc) => svc.get(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function write(id: PtyID, data: string) {
|
|
||||||
return runPromise((svc) => svc.write(id, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
|
|
||||||
return runPromise((svc) => svc.connect(id, ws, cursor))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function create(input: CreateInput) {
|
|
||||||
return runPromise((svc) => svc.create(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update(id: PtyID, input: UpdateInput) {
|
|
||||||
return runPromise((svc) => svc.update(id, input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(id: PtyID) {
|
|
||||||
return runPromise((svc) => svc.remove(id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -199,26 +198,4 @@ export namespace Question {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function ask(input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}): Promise<Answer[]> {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID) {
|
|
||||||
return runPromise((s) => s.reject(requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((s) => s.list())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import { createBunWebSocket } from "hono/bun"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createBunWebSocket()
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const args = {
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: opts.hostname,
|
||||||
|
idleTimeout: 0,
|
||||||
|
websocket: ws.websocket,
|
||||||
|
} as const
|
||||||
|
const start = (port: number) => {
|
||||||
|
try {
|
||||||
|
return Bun.serve({ ...args, port })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||||
|
}
|
||||||
|
if (!server.port) {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
port: server.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
return Promise.resolve(server.stop(close))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createNodeWebSocket({ app })
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const start = (port: number) =>
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
const server = createAdaptorServer({ fetch: app.fetch })
|
||||||
|
ws.injectWebSocket(server)
|
||||||
|
const fail = (err: Error) => {
|
||||||
|
cleanup()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
const ready = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("error", fail)
|
||||||
|
server.off("listening", ready)
|
||||||
|
}
|
||||||
|
server.once("error", fail)
|
||||||
|
server.once("listening", ready)
|
||||||
|
server.listen(port, opts.hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
port: addr.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (close) {
|
||||||
|
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||||
|
server.closeAllConnections()
|
||||||
|
}
|
||||||
|
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||||
|
server.closeIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
|
||||||
|
export type Opts = {
|
||||||
|
port: number
|
||||||
|
hostname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Listener = {
|
||||||
|
port: number
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Runtime {
|
||||||
|
upgradeWebSocket: UpgradeWebSocket
|
||||||
|
listen(opts: Opts): Promise<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
create(app: Hono): Runtime
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { ProviderID } from "@/provider/schema"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||||
|
import z from "zod"
|
||||||
|
import { errors } from "../error"
|
||||||
|
import { GlobalRoutes } from "../instance/global"
|
||||||
|
|
||||||
|
export function ControlPlaneRoutes(): Hono {
|
||||||
|
const app = new Hono()
|
||||||
|
return app
|
||||||
|
.route("/global", GlobalRoutes())
|
||||||
|
.put(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Set auth credentials",
|
||||||
|
description: "Set authentication credentials",
|
||||||
|
operationId: "auth.set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully set authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validator("json", Auth.Info.zod),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
const info = c.req.valid("json")
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.set(providerID, info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Remove auth credentials",
|
||||||
|
description: "Remove authentication credentials",
|
||||||
|
operationId: "auth.remove",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully removed authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.remove(providerID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/doc",
|
||||||
|
openAPIRouteHandler(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "opencode",
|
||||||
|
version: "0.0.3",
|
||||||
|
description: "opencode api",
|
||||||
|
},
|
||||||
|
openapi: "3.1.1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
directory: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/log",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Write log",
|
||||||
|
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||||
|
operationId: "app.log",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Log entry written successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||||
|
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||||
|
message: z.string().meta({ description: "Log message" }),
|
||||||
|
extra: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Additional metadata for the log entry" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { service, level, message, extra } = c.req.valid("json")
|
||||||
|
const logger = Log.create({ service })
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
logger.debug(message, extra)
|
||||||
|
break
|
||||||
|
case "info":
|
||||||
|
logger.info(message, extra)
|
||||||
|
break
|
||||||
|
case "error":
|
||||||
|
logger.error(message, extra)
|
||||||
|
break
|
||||||
|
case "warn":
|
||||||
|
logger.warn(message, extra)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
-3
@@ -7,6 +7,8 @@ import { mapValues } from "remeda"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Config.get())
|
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.patch(
|
.patch(
|
||||||
@@ -54,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
validator("json", Config.Info),
|
validator("json", Config.Info),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
await Config.update(config)
|
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||||
return c.json(config)
|
return c.json(config)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
using _ = log.time("providers")
|
using _ = log.time("providers")
|
||||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
|
const providers = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Provider.Service
|
||||||
|
return mapValues(yield* svc.list(), (item) => item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json({
|
return c.json({
|
||||||
providers: Object.values(providers),
|
providers: Object.values(providers),
|
||||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||||
+58
-23
@@ -11,9 +11,11 @@ import { Session } from "../../session"
|
|||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
import { ConsoleState } from "../../config/console-state"
|
import { ConsoleState } from "../../config/console-state"
|
||||||
import { Account, AccountID, OrgID } from "../../account"
|
import { Account, AccountID, OrgID } from "../../account"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
import { WorkspaceRoutes } from "./workspace"
|
import { WorkspaceRoutes } from "./workspace"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
|
|
||||||
@@ -55,11 +57,20 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
|
const result = await AppRuntime.runPromise(
|
||||||
return c.json({
|
Effect.gen(function* () {
|
||||||
...consoleState,
|
const config = yield* Config.Service
|
||||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
const account = yield* Account.Service
|
||||||
})
|
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -80,17 +91,24 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
|
const orgs = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
const orgs = groups.flatMap((group) =>
|
const account = yield* Account.Service
|
||||||
group.orgs.map((org) => ({
|
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||||
accountID: group.account.id,
|
concurrency: "unbounded",
|
||||||
accountEmail: group.account.email,
|
})
|
||||||
accountUrl: group.account.url,
|
const info = Option.getOrUndefined(active)
|
||||||
orgID: org.id,
|
return groups.flatMap((group) =>
|
||||||
orgName: org.name,
|
group.orgs.map((org) => ({
|
||||||
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
|
accountID: group.account.id,
|
||||||
})),
|
accountEmail: group.account.email,
|
||||||
|
accountUrl: group.account.url,
|
||||||
|
orgID: org.id,
|
||||||
|
orgName: org.name,
|
||||||
|
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
return c.json({ orgs })
|
return c.json({ orgs })
|
||||||
},
|
},
|
||||||
@@ -115,7 +133,12 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", ConsoleSwitchBody),
|
validator("json", ConsoleSwitchBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const account = yield* Account.Service
|
||||||
|
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -139,7 +162,13 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await ToolRegistry.ids())
|
const ids = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
return yield* registry.ids()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(ids)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -182,11 +211,17 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { provider, model } = c.req.valid("query")
|
const { provider, model } = c.req.valid("query")
|
||||||
const tools = await ToolRegistry.tools({
|
const tools = await AppRuntime.runPromise(
|
||||||
providerID: ProviderID.make(provider),
|
Effect.gen(function* () {
|
||||||
modelID: ModelID.make(model),
|
const agents = yield* Agent.Service
|
||||||
agent: await Agent.get(await Agent.defaultAgent()),
|
const registry = yield* ToolRegistry.Service
|
||||||
})
|
return yield* registry.tools({
|
||||||
|
providerID: ProviderID.make(provider),
|
||||||
|
modelID: ModelID.make(model),
|
||||||
|
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(
|
return c.json(
|
||||||
tools.map((t) => ({
|
tools.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
+5
-11
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { File } from "../../file"
|
import { File } from "../../file"
|
||||||
import { Ripgrep } from "../../file/ripgrep"
|
import { Ripgrep } from "../../file/ripgrep"
|
||||||
import { LSP } from "../../lsp"
|
import { LSP } from "../../lsp"
|
||||||
@@ -34,12 +35,10 @@ export const FileRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const pattern = c.req.valid("query").pattern
|
const pattern = c.req.valid("query").pattern
|
||||||
const result = await Ripgrep.search({
|
const result = await AppRuntime.runPromise(
|
||||||
cwd: Instance.directory,
|
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||||
pattern,
|
)
|
||||||
limit: 10,
|
return c.json(result.items)
|
||||||
})
|
|
||||||
return c.json(result)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -106,11 +105,6 @@ export const FileRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
/*
|
|
||||||
const query = c.req.valid("query").query
|
|
||||||
const result = await LSP.workspaceSymbol(query)
|
|
||||||
return c.json(result)
|
|
||||||
*/
|
|
||||||
return c.json([])
|
return c.json([])
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
+2
-2
@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Config.getGlobal())
|
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.patch(
|
.patch(
|
||||||
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
validator("json", Config.Info),
|
validator("json", Config.Info),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
const next = await Config.updateGlobal(config)
|
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||||
return c.json(next)
|
return c.json(next)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
+52
-88
@@ -1,53 +1,34 @@
|
|||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createHash } from "node:crypto"
|
import { Format } from "../../format"
|
||||||
import * as fs from "node:fs/promises"
|
import { TuiRoutes } from "./tui"
|
||||||
import { Log } from "../util/log"
|
import { Instance } from "../../project/instance"
|
||||||
import { Format } from "../format"
|
import { Vcs } from "../../project/vcs"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Instance } from "../project/instance"
|
import { Skill } from "../../skill"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Global } from "../../global"
|
||||||
import { Agent } from "../agent/agent"
|
import { LSP } from "../../lsp"
|
||||||
import { Skill } from "../skill"
|
import { Command } from "../../command"
|
||||||
import { Global } from "../global"
|
import { QuestionRoutes } from "./question"
|
||||||
import { LSP } from "../lsp"
|
import { PermissionRoutes } from "./permission"
|
||||||
import { Command } from "../command"
|
import { ProjectRoutes } from "./project"
|
||||||
import { Flag } from "../flag/flag"
|
import { SessionRoutes } from "./session"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { PtyRoutes } from "./pty"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { McpRoutes } from "./mcp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { FileRoutes } from "./file"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ConfigRoutes } from "./config"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { ExperimentalRoutes } from "./experimental"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
import { ProviderRoutes } from "./provider"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { EventRoutes } from "./event"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
|
||||||
import { EventRoutes } from "./routes/event"
|
|
||||||
import { errorHandler } from "./middleware"
|
|
||||||
import { getMimeType } from "hono/utils/mime"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||||
|
new Hono()
|
||||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
.use(WorkspaceRouterMiddleware(upgrade))
|
||||||
? Promise.resolve(null)
|
|
||||||
: // @ts-expect-error - generated file at build time
|
|
||||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
|
||||||
|
|
||||||
const DEFAULT_CSP =
|
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
|
||||||
|
|
||||||
const csp = (hash = "") =>
|
|
||||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
|
||||||
|
|
||||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
|
||||||
app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes(upgrade))
|
.route("/pty", PtyRoutes(upgrade))
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
@@ -139,11 +120,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
return c.json(
|
||||||
return c.json({
|
await AppRuntime.runPromise(
|
||||||
branch,
|
Effect.gen(function* () {
|
||||||
default_branch,
|
const vcs = yield* Vcs.Service
|
||||||
})
|
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||||
|
concurrency: 2,
|
||||||
|
})
|
||||||
|
return { branch, default_branch }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -170,7 +157,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
return c.json(
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
return yield* vcs.diff(c.req.valid("query").mode)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -235,7 +229,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const skills = await Skill.all()
|
const skills = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
return yield* skill.all()
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(skills)
|
return c.json(skills)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -257,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await LSP.status())
|
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
|
||||||
|
return c.json(items)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -281,39 +281,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
|
||||||
const embeddedWebUI = await embeddedUIPromise
|
|
||||||
const path = c.req.path
|
|
||||||
|
|
||||||
if (embeddedWebUI) {
|
|
||||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
|
||||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
|
||||||
|
|
||||||
if (await fs.exists(match)) {
|
|
||||||
const mime = getMimeType(match) ?? "text/plain"
|
|
||||||
c.header("Content-Type", mime)
|
|
||||||
if (mime.startsWith("text/html")) {
|
|
||||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
|
||||||
}
|
|
||||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
|
||||||
} else {
|
|
||||||
return c.json({ error: "Not Found" }, 404)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
|
||||||
...c.req,
|
|
||||||
headers: {
|
|
||||||
...c.req.raw.headers,
|
|
||||||
host: "app.opencode.ai",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const match = response.headers.get("content-type")?.includes("text/html")
|
|
||||||
? (await response.clone().text()).match(
|
|
||||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
|
||||||
response.headers.set("Content-Security-Policy", csp(hash))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+9
-12
@@ -3,15 +3,14 @@ import type { UpgradeWebSocket } from "hono/ws"
|
|||||||
import { getAdaptor } from "@/control-plane/adaptors"
|
import { getAdaptor } from "@/control-plane/adaptors"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { ServerProxy } from "./proxy"
|
import { ServerProxy } from "../proxy"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||||
|
|
||||||
@@ -47,9 +46,7 @@ async function getSessionWorkspace(url: URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
return async (c, next) => {
|
||||||
|
|
||||||
return async (c) => {
|
|
||||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||||
const directory = Filesystem.resolve(
|
const directory = Filesystem.resolve(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -70,9 +67,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
if (!workspaceID) {
|
if (!workspaceID) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -87,7 +84,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||||
// made sure that it will run without an instance
|
// made sure that it will run without an instance
|
||||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
@@ -107,9 +104,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
fn: () =>
|
fn: () =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -118,7 +115,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
if (local(c.req.method, url.pathname)) {
|
if (local(c.req.method, url.pathname)) {
|
||||||
// No instance provided because we are serving cached data; there
|
// No instance provided because we are serving cached data; there
|
||||||
// is no instance to work with
|
// is no instance to work with
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||||
+2
-1
@@ -8,6 +8,7 @@ import { ProjectID } from "../../project/schema"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export const ProjectRoutes = lazy(() =>
|
export const ProjectRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -83,7 +84,7 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
directory: dir,
|
directory: dir,
|
||||||
worktree: dir,
|
worktree: dir,
|
||||||
project: next,
|
project: next,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
})
|
})
|
||||||
return c.json(next)
|
return c.json(next)
|
||||||
},
|
},
|
||||||
+29
-19
@@ -11,6 +11,7 @@ import { mapValues } from "remeda"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -40,27 +41,36 @@ export const ProviderRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = await Config.get()
|
const result = await AppRuntime.runPromise(
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
Effect.gen(function* () {
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
const svc = yield* Provider.Service
|
||||||
|
const cfg = yield* Config.Service
|
||||||
const allProviders = await ModelsDev.get()
|
const config = yield* cfg.get()
|
||||||
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
|
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||||
for (const [key, value] of Object.entries(allProviders)) {
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
filteredProviders[key] = value
|
const filtered: Record<string, (typeof all)[string]> = {}
|
||||||
}
|
for (const [key, value] of Object.entries(all)) {
|
||||||
}
|
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||||
|
filtered[key] = value
|
||||||
const connected = await Provider.list()
|
}
|
||||||
const providers = Object.assign(
|
}
|
||||||
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
|
const connected = yield* svc.list()
|
||||||
connected,
|
const providers = Object.assign(
|
||||||
|
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||||
|
connected,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
all: Object.values(providers),
|
||||||
|
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||||
|
connected: Object.keys(connected),
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
return c.json({
|
return c.json({
|
||||||
all: Object.values(providers),
|
all: result.all,
|
||||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
default: result.default,
|
||||||
connected: Object.keys(connected),
|
connected: result.connected,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
+56
-8
@@ -1,7 +1,9 @@
|
|||||||
import { Hono, type MiddlewareHandler } from "hono"
|
import { Hono, type MiddlewareHandler } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
import { NotFoundError } from "../../storage/db"
|
import { NotFoundError } from "../../storage/db"
|
||||||
@@ -27,7 +29,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Pty.list())
|
return c.json(
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.list()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -50,7 +59,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("json", Pty.CreateInput),
|
validator("json", Pty.CreateInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.create(c.req.valid("json"))
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.create(c.req.valid("json"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(info)
|
return c.json(info)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -74,7 +88,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.get(c.req.valid("param").ptyID)
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.get(c.req.valid("param").ptyID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new NotFoundError({ message: "Session not found" })
|
throw new NotFoundError({ message: "Session not found" })
|
||||||
}
|
}
|
||||||
@@ -102,7 +121,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
validator("json", Pty.UpdateInput),
|
validator("json", Pty.UpdateInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(info)
|
return c.json(info)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -126,7 +150,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
await Pty.remove(c.req.valid("param").ptyID)
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
yield* pty.remove(c.req.valid("param").ptyID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -150,6 +179,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
|
type Handler = {
|
||||||
|
onMessage: (message: string | ArrayBuffer) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
||||||
const cursor = (() => {
|
const cursor = (() => {
|
||||||
const value = c.req.query("cursor")
|
const value = c.req.query("cursor")
|
||||||
@@ -158,8 +192,17 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||||
return parsed
|
return parsed
|
||||||
})()
|
})()
|
||||||
let handler: Awaited<ReturnType<typeof Pty.connect>>
|
let handler: Handler | undefined
|
||||||
if (!(await Pty.get(id))) throw new Error("Session not found")
|
if (
|
||||||
|
!(await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.get(id)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new Error("Session not found")
|
||||||
|
}
|
||||||
|
|
||||||
type Socket = {
|
type Socket = {
|
||||||
readyState: number
|
readyState: number
|
||||||
@@ -185,7 +228,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
ws.close()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler = await Pty.connect(id, socket, cursor)
|
handler = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.connect(id, socket, cursor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
ready = true
|
ready = true
|
||||||
for (const msg of pending) handler?.onMessage(msg)
|
for (const msg of pending) handler?.onMessage(msg)
|
||||||
pending.length = 0
|
pending.length = 0
|
||||||
+11
-6
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
|
|||||||
import { resolver } from "hono-openapi"
|
import { resolver } from "hono-openapi"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { Question } from "../../question"
|
import { Question } from "../../question"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const questions = await Question.list()
|
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||||
return c.json(questions)
|
return c.json(questions)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Question.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.requestID,
|
Question.Service.use((svc) =>
|
||||||
answers: json.answers,
|
svc.reply({
|
||||||
})
|
requestID: params.requestID,
|
||||||
|
answers: json.answers,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await Question.reject(params.requestID)
|
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
+20
-5
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
|
|||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "../../session/todo"
|
import { Todo } from "../../session/todo"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { AppRuntime } from "../../effect/app-runtime"
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
permission: Permission.Ruleset.optional(),
|
||||||
time: z
|
time: z
|
||||||
.object({
|
.object({
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const updates = c.req.valid("json")
|
const updates = c.req.valid("json")
|
||||||
|
const current = await Session.get(sessionID)
|
||||||
|
|
||||||
if (updates.title !== undefined) {
|
if (updates.title !== undefined) {
|
||||||
await Session.setTitle({ sessionID, title: updates.title })
|
await Session.setTitle({ sessionID, title: updates.title })
|
||||||
}
|
}
|
||||||
|
if (updates.permission !== undefined) {
|
||||||
|
await Session.setPermission({
|
||||||
|
sessionID,
|
||||||
|
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (updates.time?.archived !== undefined) {
|
if (updates.time?.archived !== undefined) {
|
||||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||||
}
|
}
|
||||||
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await SessionRunState.assertNotBusy(params.sessionID)
|
await AppRuntime.runPromise(
|
||||||
await Session.removeMessage({
|
Effect.gen(function* () {
|
||||||
sessionID: params.sessionID,
|
const state = yield* SessionRunState.Service
|
||||||
messageID: params.messageID,
|
const session = yield* Session.Service
|
||||||
})
|
yield* state.assertNotBusy(params.sessionID)
|
||||||
|
yield* session.removeMessage({
|
||||||
|
sessionID: params.sessionID,
|
||||||
|
messageID: params.messageID,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import type { ErrorHandler } from "hono"
|
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import type { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { basicAuth } from "hono/basic-auth"
|
||||||
|
import { cors } from "hono/cors"
|
||||||
|
import { compress } from "hono/compress"
|
||||||
|
|
||||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
const log = Log.create({ service: "server" })
|
||||||
return (err, c) => {
|
|
||||||
log.error("failed", {
|
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||||
error: err,
|
log.error("failed", {
|
||||||
})
|
error: err,
|
||||||
if (err instanceof NamedError) {
|
})
|
||||||
let status: ContentfulStatusCode
|
if (err instanceof NamedError) {
|
||||||
if (err instanceof NotFoundError) status = 404
|
let status: ContentfulStatusCode
|
||||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
if (err instanceof NotFoundError) status = 404
|
||||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||||
else if (err.name.startsWith("Worktree")) status = 400
|
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||||
else status = 500
|
else if (err.name.startsWith("Worktree")) status = 400
|
||||||
return c.json(err.toObject(), { status })
|
else status = 500
|
||||||
}
|
return c.json(err.toObject(), { status })
|
||||||
if (err instanceof Session.BusyError) {
|
}
|
||||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
if (err instanceof Session.BusyError) {
|
||||||
}
|
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||||
if (err instanceof HTTPException) return err.getResponse()
|
}
|
||||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
if (err instanceof HTTPException) return err.getResponse()
|
||||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||||
status: 500,
|
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
// Allow CORS preflight requests to succeed without auth.
|
||||||
|
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||||
|
if (c.req.method === "OPTIONS") return next()
|
||||||
|
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
if (!password) return next()
|
||||||
|
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||||
|
|
||||||
|
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||||
|
|
||||||
|
return basicAuth({ username, password })(c, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
const skip = c.req.path === "/log"
|
||||||
|
if (!skip) {
|
||||||
|
log.info("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const timer = log.time("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
})
|
||||||
|
await next()
|
||||||
|
if (!skip) timer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||||
|
return cors({
|
||||||
|
maxAge: 86_400,
|
||||||
|
origin(input) {
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
if (input.startsWith("http://localhost:")) return input
|
||||||
|
if (input.startsWith("http://127.0.0.1:")) return input
|
||||||
|
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||||
|
return input
|
||||||
|
|
||||||
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||||
|
if (opts?.cors?.includes(input)) return input
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipped = compress()
|
||||||
|
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
const path = c.req.path
|
||||||
|
const method = c.req.method
|
||||||
|
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||||
|
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||||
|
return zipped(c, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { Log } from "../util/log"
|
import { generateSpecs } from "hono-openapi"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { compress } from "hono/compress"
|
import { adapter } from "#hono"
|
||||||
import { createNodeWebSocket } from "@hono/node-ws"
|
|
||||||
import { cors } from "hono/cors"
|
|
||||||
import { basicAuth } from "hono/basic-auth"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
|
||||||
import z from "zod"
|
|
||||||
import { Auth } from "../auth"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { ProviderID } from "../provider/schema"
|
|
||||||
import { WorkspaceRouterMiddleware } from "./router"
|
|
||||||
import { errors } from "./error"
|
|
||||||
import { GlobalRoutes } from "./routes/global"
|
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { errorHandler } from "./middleware"
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||||
import { InstanceRoutes } from "./instance"
|
import { InstanceRoutes } from "./instance"
|
||||||
import { initProjectors } from "./projectors"
|
import { initProjectors } from "./projectors"
|
||||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
import { Log } from "@/util/log"
|
||||||
|
import { ControlPlaneRoutes } from "./control"
|
||||||
|
import { UIRoutes } from "./ui"
|
||||||
|
|
||||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
|||||||
initProjectors()
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export type Listener = {
|
export type Listener = {
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
@@ -33,231 +25,31 @@ export namespace Server {
|
|||||||
stop: (close?: boolean) => Promise<void>
|
stop: (close?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
const zipped = compress()
|
|
||||||
|
|
||||||
const skipCompress = (path: string, method: string) => {
|
|
||||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
|
||||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = lazy(() => create({}))
|
export const Default = lazy(() => create({}))
|
||||||
|
|
||||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
|
||||||
return app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.use((c, next) => {
|
|
||||||
// Allow CORS preflight requests to succeed without auth.
|
|
||||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
|
||||||
if (c.req.method === "OPTIONS") return next()
|
|
||||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
if (!password) return next()
|
|
||||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
||||||
|
|
||||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
|
||||||
|
|
||||||
return basicAuth({ username, password })(c, next)
|
|
||||||
})
|
|
||||||
.use(async (c, next) => {
|
|
||||||
const skip = c.req.path === "/log"
|
|
||||||
if (!skip) {
|
|
||||||
log.info("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const timer = log.time("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
await next()
|
|
||||||
if (!skip) timer.stop()
|
|
||||||
})
|
|
||||||
.use(
|
|
||||||
cors({
|
|
||||||
maxAge: 86_400,
|
|
||||||
origin(input) {
|
|
||||||
if (!input) return
|
|
||||||
|
|
||||||
if (input.startsWith("http://localhost:")) return input
|
|
||||||
if (input.startsWith("http://127.0.0.1:")) return input
|
|
||||||
if (
|
|
||||||
input === "tauri://localhost" ||
|
|
||||||
input === "http://tauri.localhost" ||
|
|
||||||
input === "https://tauri.localhost"
|
|
||||||
)
|
|
||||||
return input
|
|
||||||
|
|
||||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
|
||||||
if (opts?.cors?.includes(input)) return input
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use((c, next) => {
|
|
||||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
|
||||||
return zipped(c, next)
|
|
||||||
})
|
|
||||||
.route("/global", GlobalRoutes())
|
|
||||||
.put(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Set auth credentials",
|
|
||||||
description: "Set authentication credentials",
|
|
||||||
operationId: "auth.set",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully set authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
validator("json", Auth.Info.zod),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
const info = c.req.valid("json")
|
|
||||||
await Auth.set(providerID, info)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.delete(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Remove auth credentials",
|
|
||||||
description: "Remove authentication credentials",
|
|
||||||
operationId: "auth.remove",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully removed authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
await Auth.remove(providerID)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/doc",
|
|
||||||
openAPIRouteHandler(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "opencode",
|
|
||||||
version: "0.0.3",
|
|
||||||
description: "opencode api",
|
|
||||||
},
|
|
||||||
openapi: "3.1.1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use(
|
|
||||||
validator(
|
|
||||||
"query",
|
|
||||||
z.object({
|
|
||||||
directory: z.string().optional(),
|
|
||||||
workspace: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/log",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Write log",
|
|
||||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
|
||||||
operationId: "app.log",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Log entry written successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
|
||||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
|
||||||
message: z.string().meta({ description: "Log message" }),
|
|
||||||
extra: z
|
|
||||||
.record(z.string(), z.any())
|
|
||||||
.optional()
|
|
||||||
.meta({ description: "Additional metadata for the log entry" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { service, level, message, extra } = c.req.valid("json")
|
|
||||||
const logger = Log.create({ service })
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case "debug":
|
|
||||||
logger.debug(message, extra)
|
|
||||||
break
|
|
||||||
case "info":
|
|
||||||
logger.info(message, extra)
|
|
||||||
break
|
|
||||||
case "error":
|
|
||||||
logger.error(message, extra)
|
|
||||||
break
|
|
||||||
case "warn":
|
|
||||||
logger.warn(message, extra)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.use(WorkspaceRouterMiddleware(upgrade))
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(opts: { cors?: string[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const ws = createNodeWebSocket({ app })
|
const runtime = adapter.create(app)
|
||||||
return {
|
return {
|
||||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
app: app
|
||||||
ws,
|
.onError(ErrorMiddleware)
|
||||||
|
.use(AuthMiddleware)
|
||||||
|
.use(LoggerMiddleware)
|
||||||
|
.use(CompressionMiddleware)
|
||||||
|
.use(CorsMiddleware(opts))
|
||||||
|
.route("/", ControlPlaneRoutes())
|
||||||
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||||
|
.route("/", UIRoutes()),
|
||||||
|
runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(opts: { cors?: string[] }) {
|
|
||||||
return create(opts).app
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
// Build a fresh app with all routes registered directly so
|
// Build a fresh app with all routes registered directly so
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
// strips the metadata symbol).
|
// strips the metadata symbol).
|
||||||
const { app, ws } = create({})
|
const { app } = create({})
|
||||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
|
||||||
const result = await generateSpecs(app, {
|
const result = await generateSpecs(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
@@ -281,46 +73,21 @@ export namespace Server {
|
|||||||
cors?: string[]
|
cors?: string[]
|
||||||
}): Promise<Listener> {
|
}): Promise<Listener> {
|
||||||
const built = create(opts)
|
const built = create(opts)
|
||||||
const start = (port: number) =>
|
const server = await built.runtime.listen(opts)
|
||||||
new Promise<ServerType>((resolve, reject) => {
|
|
||||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
|
||||||
built.ws.injectWebSocket(server)
|
|
||||||
const fail = (err: Error) => {
|
|
||||||
cleanup()
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
const ready = () => {
|
|
||||||
cleanup()
|
|
||||||
resolve(server)
|
|
||||||
}
|
|
||||||
const cleanup = () => {
|
|
||||||
server.off("error", fail)
|
|
||||||
server.off("listening", ready)
|
|
||||||
}
|
|
||||||
server.once("error", fail)
|
|
||||||
server.once("listening", ready)
|
|
||||||
server.listen(port, opts.hostname)
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
|
||||||
const addr = server.address()
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new URL("http://localhost")
|
const next = new URL("http://localhost")
|
||||||
next.hostname = opts.hostname
|
next.hostname = opts.hostname
|
||||||
next.port = String(addr.port)
|
next.port = String(server.port)
|
||||||
url = next
|
url = next
|
||||||
|
|
||||||
const mdns =
|
const mdns =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
addr.port &&
|
server.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (mdns) {
|
if (mdns) {
|
||||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
MDNS.publish(server.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
@@ -328,27 +95,13 @@ export namespace Server {
|
|||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
port: addr.port,
|
port: server.port,
|
||||||
url: next,
|
url: next,
|
||||||
stop(close?: boolean) {
|
stop(close?: boolean) {
|
||||||
closing ??= new Promise((resolve, reject) => {
|
closing ??= (async () => {
|
||||||
if (mdns) MDNS.unpublish()
|
if (mdns) MDNS.unpublish()
|
||||||
server.close((err) => {
|
await server.stop(close)
|
||||||
if (err) {
|
})()
|
||||||
reject(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
if (close) {
|
|
||||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
|
||||||
server.closeAllConnections()
|
|
||||||
}
|
|
||||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
|
||||||
server.closeIdleConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return closing
|
return closing
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { proxy } from "hono/proxy"
|
||||||
|
import { getMimeType } from "hono/utils/mime"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
|
||||||
|
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: // @ts-expect-error - generated file at build time
|
||||||
|
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||||
|
|
||||||
|
const DEFAULT_CSP =
|
||||||
|
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||||
|
|
||||||
|
const csp = (hash = "") =>
|
||||||
|
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||||
|
|
||||||
|
export const UIRoutes = (): Hono =>
|
||||||
|
new Hono().all("/*", async (c) => {
|
||||||
|
const embeddedWebUI = await embeddedUIPromise
|
||||||
|
const path = c.req.path
|
||||||
|
|
||||||
|
if (embeddedWebUI) {
|
||||||
|
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||||
|
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||||
|
|
||||||
|
if (await fs.exists(match)) {
|
||||||
|
const mime = getMimeType(match) ?? "text/plain"
|
||||||
|
c.header("Content-Type", mime)
|
||||||
|
if (mime.startsWith("text/html")) {
|
||||||
|
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||||
|
}
|
||||||
|
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Not Found" }, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||||
|
...c.req,
|
||||||
|
headers: {
|
||||||
|
...c.req.raw.headers,
|
||||||
|
host: "app.opencode.ai",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const match = response.headers.get("content-type")?.includes("text/html")
|
||||||
|
? (await response.clone().text()).match(
|
||||||
|
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||||
|
response.headers.set("Content-Security-Policy", csp(hash))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { type ProviderMetadata } from "ai"
|
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema"
|
|||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
|
||||||
import { Effect, Layer, Option, Context } from "effect"
|
import { Effect, Layer, Option, Context } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
@@ -240,7 +239,7 @@ export namespace Session {
|
|||||||
|
|
||||||
export const getUsage = (input: {
|
export const getUsage = (input: {
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
usage: LanguageModelV2Usage
|
usage: LanguageModelUsage
|
||||||
metadata?: ProviderMetadata
|
metadata?: ProviderMetadata
|
||||||
}) => {
|
}) => {
|
||||||
const safe = (value: number) => {
|
const safe = (value: number) => {
|
||||||
@@ -249,11 +248,14 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||||
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
|
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
||||||
|
|
||||||
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
const cacheReadInputTokens = safe(
|
||||||
|
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||||
|
)
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
(input.usage.inputTokenDetails?.cacheWriteTokens ??
|
||||||
|
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
// google-vertex-anthropic returns metadata under "vertex" key
|
// google-vertex-anthropic returns metadata under "vertex" key
|
||||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||||
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
||||||
@@ -274,7 +276,7 @@ export namespace Session {
|
|||||||
const tokens = {
|
const tokens = {
|
||||||
total,
|
total,
|
||||||
input: adjustedInputTokens,
|
input: adjustedInputTokens,
|
||||||
output: outputTokens - reasoningTokens,
|
output: safe(outputTokens - reasoningTokens),
|
||||||
reasoning: reasoningTokens,
|
reasoning: reasoningTokens,
|
||||||
cache: {
|
cache: {
|
||||||
write: cacheWriteInputTokens,
|
write: cacheWriteInputTokens,
|
||||||
@@ -366,12 +368,11 @@ export namespace Session {
|
|||||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||||
Effect.sync(() => Database.use(fn))
|
Effect.sync(() => Database.use(fn))
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service | SyncEvent.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const storage = yield* Storage.Service
|
const storage = yield* Storage.Service
|
||||||
const sync = yield* SyncEvent.Service
|
|
||||||
|
|
||||||
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
||||||
id?: SessionID
|
id?: SessionID
|
||||||
@@ -399,7 +400,7 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
log.info("created", result)
|
log.info("created", result)
|
||||||
|
|
||||||
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
|
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
|
||||||
|
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
// This only exist for backwards compatibility. We should not be
|
// This only exist for backwards compatibility. We should not be
|
||||||
@@ -447,12 +448,10 @@ export namespace Session {
|
|||||||
Effect.catchCause(() => Effect.succeed(false)),
|
Effect.catchCause(() => Effect.succeed(false)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasInstance) {
|
yield* Effect.sync(() => {
|
||||||
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: true })
|
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
|
||||||
} else {
|
SyncEvent.remove(sessionID)
|
||||||
yield* Effect.sync(() => SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: false }))
|
})
|
||||||
}
|
|
||||||
yield* sync.remove(sessionID)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(e)
|
log.error(e)
|
||||||
}
|
}
|
||||||
@@ -460,17 +459,19 @@ export namespace Session {
|
|||||||
|
|
||||||
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
|
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })
|
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
|
||||||
return msg
|
return msg
|
||||||
}).pipe(Effect.withSpan("Session.updateMessage"))
|
}).pipe(Effect.withSpan("Session.updateMessage"))
|
||||||
|
|
||||||
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
|
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* sync.run(MessageV2.Event.PartUpdated, {
|
yield* Effect.sync(() =>
|
||||||
sessionID: part.sessionID,
|
SyncEvent.run(MessageV2.Event.PartUpdated, {
|
||||||
part: structuredClone(part),
|
sessionID: part.sessionID,
|
||||||
time: Date.now(),
|
part: structuredClone(part),
|
||||||
})
|
time: Date.now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
return part
|
return part
|
||||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||||
|
|
||||||
@@ -550,7 +551,8 @@ export namespace Session {
|
|||||||
return session
|
return session
|
||||||
})
|
})
|
||||||
|
|
||||||
const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info })
|
const patch = (sessionID: SessionID, info: Patch) =>
|
||||||
|
Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
|
||||||
|
|
||||||
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
|
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
|
||||||
yield* patch(sessionID, { time: { updated: Date.now() } })
|
yield* patch(sessionID, { time: { updated: Date.now() } })
|
||||||
@@ -607,10 +609,12 @@ export namespace Session {
|
|||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
}) {
|
}) {
|
||||||
yield* sync.run(MessageV2.Event.Removed, {
|
yield* Effect.sync(() =>
|
||||||
sessionID: input.sessionID,
|
SyncEvent.run(MessageV2.Event.Removed, {
|
||||||
messageID: input.messageID,
|
sessionID: input.sessionID,
|
||||||
})
|
messageID: input.messageID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
return input.messageID
|
return input.messageID
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -619,11 +623,13 @@ export namespace Session {
|
|||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
partID: PartID
|
partID: PartID
|
||||||
}) {
|
}) {
|
||||||
yield* sync.run(MessageV2.Event.PartRemoved, {
|
yield* Effect.sync(() =>
|
||||||
sessionID: input.sessionID,
|
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||||
messageID: input.messageID,
|
sessionID: input.sessionID,
|
||||||
partID: input.partID,
|
messageID: input.messageID,
|
||||||
})
|
partID: input.partID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
return input.partID
|
return input.partID
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -674,11 +680,7 @@ export namespace Session {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
Layer.provide(Storage.defaultLayer),
|
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
@@ -708,6 +710,10 @@ export namespace Session {
|
|||||||
runPromise((svc) => svc.setArchived(input)),
|
runPromise((svc) => svc.setArchived(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||||
|
runPromise((svc) => svc.setPermission(input)),
|
||||||
|
)
|
||||||
|
|
||||||
export const setRevert = fn(
|
export const setRevert = fn(
|
||||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||||
(input) =>
|
(input) =>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
@@ -238,21 +237,7 @@ export namespace Instruction {
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function clear(messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.clear(messageID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function systemPaths() {
|
|
||||||
return runPromise((svc) => svc.systemPaths())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loaded(messages: MessageV2.WithParts[]) {
|
export function loaded(messages: MessageV2.WithParts[]) {
|
||||||
return extract(messages)
|
return extract(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,14 +94,24 @@ export namespace LLM {
|
|||||||
modelID: input.model.id,
|
modelID: input.model.id,
|
||||||
providerID: input.model.providerID,
|
providerID: input.model.providerID,
|
||||||
})
|
})
|
||||||
const [language, cfg, provider, auth] = await Promise.all([
|
const [language, cfg, provider, info] = await Effect.runPromise(
|
||||||
Provider.getLanguage(input.model),
|
Effect.gen(function* () {
|
||||||
Config.get(),
|
const auth = yield* Auth.Service
|
||||||
Provider.getProvider(input.model.providerID),
|
const cfg = yield* Config.Service
|
||||||
Auth.get(input.model.providerID),
|
const provider = yield* Provider.Service
|
||||||
])
|
return yield* Effect.all(
|
||||||
|
[
|
||||||
|
provider.getLanguage(input.model),
|
||||||
|
cfg.get(),
|
||||||
|
provider.getProvider(input.model.providerID),
|
||||||
|
auth.get(input.model.providerID),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
}).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
|
||||||
|
)
|
||||||
// TODO: move this to a proper hook
|
// TODO: move this to a proper hook
|
||||||
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
|
const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
|
||||||
|
|
||||||
const system: string[] = []
|
const system: string[] = []
|
||||||
system.push(
|
system.push(
|
||||||
@@ -200,7 +210,7 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const tools = await resolveTools(input)
|
const tools = resolveTools(input)
|
||||||
|
|
||||||
// LiteLLM and some Anthropic proxies require the tools parameter to be present
|
// LiteLLM and some Anthropic proxies require the tools parameter to be present
|
||||||
// when message history contains tool calls, even if no tools are being used.
|
// when message history contains tool calls, even if no tools are being used.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace MessageV2 {
|
export namespace MessageV2 {
|
||||||
|
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||||
|
|
||||||
export function isMedia(mime: string) {
|
export function isMedia(mime: string) {
|
||||||
return mime.startsWith("image/") || mime === "application/pdf"
|
return mime.startsWith("image/") || mime === "application/pdf"
|
||||||
}
|
}
|
||||||
@@ -808,7 +810,7 @@ export namespace MessageV2 {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: "Attached image(s) from tool result:",
|
text: SYNTHETIC_ATTACHMENT_PROMPT,
|
||||||
},
|
},
|
||||||
...media.map((attachment) => ({
|
...media.map((attachment) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
|
|||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const state = yield* SessionRunState.Service
|
const state = yield* SessionRunState.Service
|
||||||
const revert = yield* SessionRevert.Service
|
const revert = yield* SessionRevert.Service
|
||||||
|
const sys = yield* SystemPrompt.Service
|
||||||
|
const llm = yield* LLM.Service
|
||||||
|
|
||||||
const run = {
|
const run = {
|
||||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||||
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
|
|||||||
const msgs = onlySubtasks
|
const msgs = onlySubtasks
|
||||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||||
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
||||||
const text = yield* Effect.promise(async (signal) => {
|
const text = yield* llm
|
||||||
const result = await LLM.stream({
|
.stream({
|
||||||
agent: ag,
|
agent: ag,
|
||||||
user: firstInfo,
|
user: firstInfo,
|
||||||
system: [],
|
system: [],
|
||||||
small: true,
|
small: true,
|
||||||
tools: {},
|
tools: {},
|
||||||
model: mdl,
|
model: mdl,
|
||||||
abort: signal,
|
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
})
|
})
|
||||||
return result.text
|
.pipe(
|
||||||
})
|
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||||
|
Stream.map((e) => e.text),
|
||||||
|
Stream.mkString,
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
const cleaned = text
|
const cleaned = text
|
||||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
|
|
||||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
sys.skills(agent),
|
||||||
Effect.promise(() => SystemPrompt.environment(model)),
|
Effect.sync(() => sys.environment(model)),
|
||||||
instruction.system().pipe(Effect.orDie),
|
instruction.system().pipe(Effect.orDie),
|
||||||
MessageV2.toModelMessagesEffect(msgs, model),
|
MessageV2.toModelMessagesEffect(msgs, model),
|
||||||
])
|
])
|
||||||
@@ -1687,9 +1692,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(
|
||||||
Layer.provide(Bus.layer),
|
Layer.mergeAll(
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Agent.defaultLayer,
|
||||||
|
SystemPrompt.defaultLayer,
|
||||||
|
LLM.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export namespace SessionRevert {
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
const state = yield* SessionRunState.Service
|
const state = yield* SessionRunState.Service
|
||||||
const sync = yield* SyncEvent.Service
|
|
||||||
|
|
||||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||||
yield* state.assertNotBusy(input.sessionID)
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
@@ -124,7 +123,7 @@ export namespace SessionRevert {
|
|||||||
remove.push(msg)
|
remove.push(msg)
|
||||||
}
|
}
|
||||||
for (const msg of remove) {
|
for (const msg of remove) {
|
||||||
yield* sync.run(MessageV2.Event.Removed, {
|
SyncEvent.run(MessageV2.Event.Removed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: msg.info.id,
|
messageID: msg.info.id,
|
||||||
})
|
})
|
||||||
@@ -136,7 +135,7 @@ export namespace SessionRevert {
|
|||||||
const removeParts = target.parts.slice(idx)
|
const removeParts = target.parts.slice(idx)
|
||||||
target.parts = target.parts.slice(0, idx)
|
target.parts = target.parts.slice(0, idx)
|
||||||
for (const part of removeParts) {
|
for (const part of removeParts) {
|
||||||
yield* sync.run(MessageV2.Event.PartRemoved, {
|
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: target.info.id,
|
messageID: target.info.id,
|
||||||
partID: part.id,
|
partID: part.id,
|
||||||
@@ -159,7 +158,6 @@ export namespace SessionRevert {
|
|||||||
Layer.provide(Storage.defaultLayer),
|
Layer.provide(Storage.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Effect, Layer, Scope, Context } from "effect"
|
import { Effect, Layer, Scope, Context } from "effect"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -106,9 +105,4 @@ export namespace SessionRunState {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function assertNotBusy(sessionID: SessionID) {
|
|
||||||
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
|
|||||||
return [PROMPT_DEFAULT]
|
return [PROMPT_DEFAULT]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function environment(model: Provider.Model) {
|
export interface Interface {
|
||||||
const project = Instance.project
|
readonly environment: (model: Provider.Model) => string[]
|
||||||
return [
|
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||||
[
|
|
||||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
|
||||||
`Here is some useful information about the environment you are running in:`,
|
|
||||||
`<env>`,
|
|
||||||
` Working directory: ${Instance.directory}`,
|
|
||||||
` Workspace root folder: ${Instance.worktree}`,
|
|
||||||
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
|
||||||
` Platform: ${process.platform}`,
|
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
|
||||||
`</env>`,
|
|
||||||
`<directories>`,
|
|
||||||
` ${
|
|
||||||
project.vcs === "git" && false
|
|
||||||
? await Ripgrep.tree({
|
|
||||||
cwd: Instance.directory,
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
`</directories>`,
|
|
||||||
].join("\n"),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function skills(agent: Agent.Info) {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
|
||||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
|
||||||
|
|
||||||
const list = await Skill.available(agent)
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
|
||||||
return [
|
return Service.of({
|
||||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
environment(model) {
|
||||||
"Use the skill tool to load a skill when a task matches its description.",
|
const project = Instance.project
|
||||||
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
return [
|
||||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
[
|
||||||
Skill.fmt(list, { verbose: true }),
|
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||||
].join("\n")
|
`Here is some useful information about the environment you are running in:`,
|
||||||
}
|
`<env>`,
|
||||||
|
` Working directory: ${Instance.directory}`,
|
||||||
|
` Workspace root folder: ${Instance.worktree}`,
|
||||||
|
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
||||||
|
` Platform: ${process.platform}`,
|
||||||
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
|
`</env>`,
|
||||||
|
].join("\n"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||||
|
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||||
|
|
||||||
|
const list = yield* skill.available(agent)
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||||
|
"Use the skill tool to load a skill when a task matches its description.",
|
||||||
|
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
||||||
|
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||||
|
Skill.fmt(list, { verbose: true }),
|
||||||
|
].join("\n")
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,20 @@ export namespace SessionShare {
|
|||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const sync = yield* SyncEvent.Service
|
|
||||||
|
|
||||||
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
|
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
|
||||||
const conf = yield* cfg.get()
|
const conf = yield* cfg.get()
|
||||||
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
|
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
|
||||||
const result = yield* shareNext.create(sessionID)
|
const result = yield* shareNext.create(sessionID)
|
||||||
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
|
yield* Effect.sync(() =>
|
||||||
|
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
|
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
|
||||||
yield* shareNext.remove(sessionID)
|
yield* shareNext.remove(sessionID)
|
||||||
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
|
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
|
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
|
||||||
@@ -56,7 +57,6 @@ export namespace SessionShare {
|
|||||||
Layer.provide(ShareNext.defaultLayer),
|
Layer.provide(ShareNext.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
@@ -262,22 +261,4 @@ export namespace Skill {
|
|||||||
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get(name: string) {
|
|
||||||
return runPromise((skill) => skill.get(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function all() {
|
|
||||||
return runPromise((skill) => skill.all())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dirs() {
|
|
||||||
return runPromise((skill) => skill.dirs())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function available(agent?: Agent.Info) {
|
|
||||||
return runPromise((skill) => skill.available(agent))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,8 +177,39 @@ export namespace Snapshot {
|
|||||||
const all = Array.from(new Set([...tracked, ...untracked]))
|
const all = Array.from(new Set([...tracked, ...untracked]))
|
||||||
if (!all.length) return
|
if (!all.length) return
|
||||||
|
|
||||||
|
// Filter out files that are now gitignored even if previously tracked
|
||||||
|
// Files may have been tracked before being gitignored, so we need to check
|
||||||
|
// against the source project's current gitignore rules
|
||||||
|
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
|
||||||
|
const checkArgs = [
|
||||||
|
...quote,
|
||||||
|
"--git-dir",
|
||||||
|
path.join(state.worktree, ".git"),
|
||||||
|
"--work-tree",
|
||||||
|
state.worktree,
|
||||||
|
"check-ignore",
|
||||||
|
"--no-index",
|
||||||
|
"--",
|
||||||
|
...all,
|
||||||
|
]
|
||||||
|
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||||
|
const ignored =
|
||||||
|
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
|
||||||
|
const filtered = all.filter((item) => !ignored.has(item))
|
||||||
|
|
||||||
|
// Remove newly-ignored files from snapshot index to prevent re-adding
|
||||||
|
if (ignored.size > 0) {
|
||||||
|
const ignoredFiles = Array.from(ignored)
|
||||||
|
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
||||||
|
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
|
||||||
|
cwd: state.directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filtered.length) return
|
||||||
|
|
||||||
const large = (yield* Effect.all(
|
const large = (yield* Effect.all(
|
||||||
all.map((item) =>
|
filtered.map((item) =>
|
||||||
fs
|
fs
|
||||||
.stat(path.join(state.directory, item))
|
.stat(path.join(state.directory, item))
|
||||||
.pipe(Effect.catch(() => Effect.void))
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
@@ -259,14 +290,39 @@ export namespace Snapshot {
|
|||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
return { hash, files: [] }
|
return { hash, files: [] }
|
||||||
}
|
}
|
||||||
|
const files = result.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// Filter out files that are now gitignored
|
||||||
|
if (files.length > 0) {
|
||||||
|
const checkArgs = [
|
||||||
|
...quote,
|
||||||
|
"--git-dir",
|
||||||
|
path.join(state.worktree, ".git"),
|
||||||
|
"--work-tree",
|
||||||
|
state.worktree,
|
||||||
|
"check-ignore",
|
||||||
|
"--no-index",
|
||||||
|
"--",
|
||||||
|
...files,
|
||||||
|
]
|
||||||
|
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||||
|
if (check.code === 0) {
|
||||||
|
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||||
|
const filtered = files.filter((item) => !ignored.has(item))
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hash,
|
hash,
|
||||||
files: result.text
|
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -616,6 +672,30 @@ export namespace Snapshot {
|
|||||||
} satisfies Row,
|
} satisfies Row,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Filter out files that are now gitignored
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const files = rows.map((r) => r.file)
|
||||||
|
const checkArgs = [
|
||||||
|
...quote,
|
||||||
|
"--git-dir",
|
||||||
|
path.join(state.worktree, ".git"),
|
||||||
|
"--work-tree",
|
||||||
|
state.worktree,
|
||||||
|
"check-ignore",
|
||||||
|
"--no-index",
|
||||||
|
"--",
|
||||||
|
...files,
|
||||||
|
]
|
||||||
|
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||||
|
if (check.code === 0) {
|
||||||
|
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||||
|
const filtered = rows.filter((r) => !ignored.has(r.file))
|
||||||
|
rows.length = 0
|
||||||
|
rows.push(...filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const step = 100
|
const step = 100
|
||||||
const patch = (file: string, before: string, after: string) =>
|
const patch = (file: string, before: string, after: string) =>
|
||||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||||
|
|||||||
+163
-245
@@ -1,20 +1,14 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodObject } from "zod"
|
import type { ZodObject } from "zod"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
import { Bus as ProjectBus } from "@/bus"
|
import { Bus as ProjectBus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||||
import { EventID } from "./schema"
|
import { EventID } from "./schema"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
|
|
||||||
export namespace SyncEvent {
|
export namespace SyncEvent {
|
||||||
type Convert = (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
|
||||||
|
|
||||||
export type Definition = {
|
export type Definition = {
|
||||||
type: string
|
type: string
|
||||||
version: number
|
version: number
|
||||||
@@ -36,249 +30,38 @@ export namespace SyncEvent {
|
|||||||
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
||||||
|
|
||||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||||
type Init = { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: Convert }
|
|
||||||
type Payload = { def: Definition; event: Event }
|
|
||||||
|
|
||||||
export const registry = new Map<string, Definition>()
|
export const registry = new Map<string, Definition>()
|
||||||
|
let projectors: Map<Definition, ProjectorFunc> | undefined
|
||||||
const versions = new Map<string, number>()
|
const versions = new Map<string, number>()
|
||||||
let frozen = false
|
let frozen = false
|
||||||
let projectors: Map<Definition, ProjectorFunc> | undefined
|
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||||
let convert: Convert = (_, data) => data as Record<string, unknown>
|
|
||||||
const bus = new EventEmitter<{ event: [Payload] }>()
|
|
||||||
|
|
||||||
export interface Interface {
|
const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
|
||||||
readonly reset: () => Effect.Effect<void>
|
|
||||||
readonly init: (input: Init) => Effect.Effect<void>
|
|
||||||
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
|
|
||||||
readonly run: <Def extends Definition>(
|
|
||||||
def: Def,
|
|
||||||
data: Event<Def>["data"],
|
|
||||||
options?: { publish?: boolean },
|
|
||||||
) => Effect.Effect<void>
|
|
||||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
|
||||||
readonly subscribeAll: (handler: (event: Payload) => void) => Effect.Effect<() => void>
|
|
||||||
readonly payloads: () => z.ZodTypeAny
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SyncEvent") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const reset = Effect.fn("SyncEvent.reset")(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
frozen = false
|
|
||||||
projectors = undefined
|
|
||||||
convert = (_, data) => data as Record<string, unknown>
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const init = Effect.fn("SyncEvent.init")((input: Init) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
projectors = new Map(input.projectors)
|
|
||||||
|
|
||||||
// Install all the latest event defs to the bus. We only ever emit
|
|
||||||
// latest versions from code, and keep around old versions for
|
|
||||||
// replaying. Replaying does not go through the bus, and it
|
|
||||||
// simplifies the bus to only use unversioned latest events
|
|
||||||
for (const [type, version] of versions.entries()) {
|
|
||||||
const def = registry.get(versionedType(type, version))!
|
|
||||||
BusEvent.define(def.type, def.properties || def.schema)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freeze the system so it clearly errors if events are defined
|
|
||||||
// after `init` which would cause bugs
|
|
||||||
frozen = true
|
|
||||||
convert = input.convertEvent || ((_, data) => data as Record<string, unknown>)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
|
||||||
if (projectors == null) {
|
|
||||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
|
||||||
}
|
|
||||||
|
|
||||||
const projector = projectors.get(def)
|
|
||||||
if (!projector) {
|
|
||||||
throw new Error(`Projector not found for event: ${def.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent: need to ignore any events already logged
|
|
||||||
Database.transaction((tx) => {
|
|
||||||
projector(tx, event.data)
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
|
||||||
tx.insert(EventSequenceTable)
|
|
||||||
.values({
|
|
||||||
aggregate_id: event.aggregateID,
|
|
||||||
seq: event.seq,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: EventSequenceTable.aggregate_id,
|
|
||||||
set: { seq: event.seq },
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
tx.insert(EventTable)
|
|
||||||
.values({
|
|
||||||
id: event.id,
|
|
||||||
seq: event.seq,
|
|
||||||
aggregate_id: event.aggregateID,
|
|
||||||
type: versionedType(def.type, def.version),
|
|
||||||
data: event.data as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
Database.effect(
|
|
||||||
InstanceState.bind(() => {
|
|
||||||
bus.emit("event", { def, event })
|
|
||||||
|
|
||||||
if (!options.publish) return
|
|
||||||
|
|
||||||
const result = convert(def.type, event.data)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.then(
|
|
||||||
InstanceState.bind((data) => {
|
|
||||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
|
||||||
const def = registry.get(event.type)
|
|
||||||
if (!def) {
|
|
||||||
throw new Error(`Unknown event type: ${event.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
|
||||||
.get(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const latest = row?.seq ?? -1
|
|
||||||
if (event.seq <= latest) return
|
|
||||||
|
|
||||||
const expected = latest + 1
|
|
||||||
if (event.seq !== expected) {
|
|
||||||
throw new Error(
|
|
||||||
`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
process(def, event, { publish: !!options?.publish })
|
|
||||||
}
|
|
||||||
|
|
||||||
function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
|
||||||
const agg = (data as Record<string, string>)[def.aggregate]
|
|
||||||
if (agg == null) {
|
|
||||||
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (def.version !== versions.get(def.type)) {
|
|
||||||
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const publish = options?.publish ?? true
|
|
||||||
|
|
||||||
// Note that this is an "immediate" transaction which is critical.
|
|
||||||
// We need to make sure we can safely read and write with nothing
|
|
||||||
// else changing the data from under us
|
|
||||||
Database.transaction(
|
|
||||||
(tx) => {
|
|
||||||
const id = EventID.ascending()
|
|
||||||
const row = tx
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, agg))
|
|
||||||
.get()
|
|
||||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
|
||||||
|
|
||||||
const event = { id, seq, aggregateID: agg, data }
|
|
||||||
process(def, event, { publish })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
behavior: "immediate",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(aggregateID: string) {
|
|
||||||
Database.transaction((tx) => {
|
|
||||||
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
|
||||||
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeAll(handler: (event: Payload) => void) {
|
|
||||||
bus.on("event", handler)
|
|
||||||
return () => bus.off("event", handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
function payloads() {
|
|
||||||
return z
|
|
||||||
.union(
|
|
||||||
registry
|
|
||||||
.entries()
|
|
||||||
.map(([type, def]) => {
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
type: z.literal(type),
|
|
||||||
aggregate: z.literal(def.aggregate),
|
|
||||||
data: def.schema,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "SyncEvent" + "." + def.type,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toArray() as any,
|
|
||||||
)
|
|
||||||
.meta({
|
|
||||||
ref: "SyncEvent",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
reset,
|
|
||||||
init,
|
|
||||||
replay: (event, options) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
return yield* Effect.sync(() => Instance.restore(ctx, () => replay(event, options)))
|
|
||||||
}),
|
|
||||||
run: (def, data, options) =>
|
|
||||||
options?.publish === false
|
|
||||||
? Effect.sync(() => run(def, data, options))
|
|
||||||
: Effect.gen(function* () {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
return yield* Effect.sync(() => Instance.restore(ctx, () => run(def, data, options)))
|
|
||||||
}),
|
|
||||||
remove: (aggregateID) => Effect.sync(() => remove(aggregateID)),
|
|
||||||
subscribeAll: (handler) => Effect.sync(() => subscribeAll(handler)),
|
|
||||||
payloads,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
|
|
||||||
const { runSync } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function reset() {
|
export function reset() {
|
||||||
return runSync((svc) => svc.reset())
|
frozen = false
|
||||||
|
projectors = undefined
|
||||||
|
convertEvent = (_, data) => data
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init(input: Init) {
|
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
|
||||||
return runSync((svc) => svc.init(input))
|
projectors = new Map(input.projectors)
|
||||||
|
|
||||||
|
// Install all the latest event defs to the bus. We only ever emit
|
||||||
|
// latest versions from code, and keep around old versions for
|
||||||
|
// replaying. Replaying does not go through the bus, and it
|
||||||
|
// simplifies the bus to only use unversioned latest events
|
||||||
|
for (let [type, version] of versions.entries()) {
|
||||||
|
let def = registry.get(versionedType(type, version))!
|
||||||
|
|
||||||
|
BusEvent.define(def.type, def.properties || def.schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze the system so it clearly errors if events are defined
|
||||||
|
// after `init` which would cause bugs
|
||||||
|
frozen = true
|
||||||
|
convertEvent = input.convertEvent || ((_, data) => data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function versionedType<A extends string>(type: A): A
|
export function versionedType<A extends string>(type: A): A
|
||||||
@@ -319,6 +102,63 @@ export namespace SyncEvent {
|
|||||||
return [def, func as ProjectorFunc]
|
return [def, func as ProjectorFunc]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
||||||
|
if (projectors == null) {
|
||||||
|
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||||
|
}
|
||||||
|
|
||||||
|
const projector = projectors.get(def)
|
||||||
|
if (!projector) {
|
||||||
|
throw new Error(`Projector not found for event: ${def.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent: need to ignore any events already logged
|
||||||
|
|
||||||
|
Database.transaction((tx) => {
|
||||||
|
projector(tx, event.data)
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
tx.insert(EventSequenceTable)
|
||||||
|
.values({
|
||||||
|
aggregate_id: event.aggregateID,
|
||||||
|
seq: event.seq,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: EventSequenceTable.aggregate_id,
|
||||||
|
set: { seq: event.seq },
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
tx.insert(EventTable)
|
||||||
|
.values({
|
||||||
|
id: event.id,
|
||||||
|
seq: event.seq,
|
||||||
|
aggregate_id: event.aggregateID,
|
||||||
|
type: versionedType(def.type, def.version),
|
||||||
|
data: event.data as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
Database.effect(() => {
|
||||||
|
Bus.emit("event", {
|
||||||
|
def,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (options?.publish) {
|
||||||
|
const result = convertEvent(def.type, event.data)
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result.then((data) => {
|
||||||
|
ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
//
|
//
|
||||||
// * Support applying multiple events at one time. One transaction,
|
// * Support applying multiple events at one time. One transaction,
|
||||||
@@ -326,22 +166,100 @@ export namespace SyncEvent {
|
|||||||
// * when loading events from db, apply zod validation to ensure shape
|
// * when loading events from db, apply zod validation to ensure shape
|
||||||
|
|
||||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||||
return runSync((svc) => svc.replay(event, options))
|
const def = registry.get(event.type)
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`Unknown event type: ${event.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ seq: EventSequenceTable.seq })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const latest = row?.seq ?? -1
|
||||||
|
if (event.seq <= latest) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = latest + 1
|
||||||
|
if (event.seq !== expected) {
|
||||||
|
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
process(def, event, { publish: !!options?.publish })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
||||||
return runSync((svc) => svc.run(def, data, options))
|
const agg = (data as Record<string, string>)[def.aggregate]
|
||||||
|
// This should never happen: we've enforced it via typescript in
|
||||||
|
// the definition
|
||||||
|
if (agg == null) {
|
||||||
|
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.version !== versions.get(def.type)) {
|
||||||
|
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publish = true } = options || {}
|
||||||
|
|
||||||
|
// Note that this is an "immediate" transaction which is critical.
|
||||||
|
// We need to make sure we can safely read and write with nothing
|
||||||
|
// else changing the data from under us
|
||||||
|
Database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
const id = EventID.ascending()
|
||||||
|
const row = tx
|
||||||
|
.select({ seq: EventSequenceTable.seq })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, agg))
|
||||||
|
.get()
|
||||||
|
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||||
|
|
||||||
|
const event = { id, seq, aggregateID: agg, data }
|
||||||
|
process(def, event, { publish })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
behavior: "immediate",
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remove(aggregateID: string) {
|
export function remove(aggregateID: string) {
|
||||||
return runSync((svc) => svc.remove(aggregateID))
|
Database.transaction((tx) => {
|
||||||
|
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
||||||
|
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
|
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
|
||||||
return runSync((svc) => svc.subscribeAll(handler))
|
Bus.on("event", handler)
|
||||||
|
return () => Bus.off("event", handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function payloads() {
|
export function payloads() {
|
||||||
return runSync((svc) => Effect.sync(() => svc.payloads()))
|
return z
|
||||||
|
.union(
|
||||||
|
registry
|
||||||
|
.entries()
|
||||||
|
.map(([type, def]) => {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
type: z.literal(type),
|
||||||
|
aggregate: z.literal(def.aggregate),
|
||||||
|
data: def.schema,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "SyncEvent" + "." + def.type,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toArray() as any,
|
||||||
|
)
|
||||||
|
.meta({
|
||||||
|
ref: "SyncEvent",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return async () => {
|
return () =>
|
||||||
const shell = Shell.acceptable()
|
Effect.sync(() => {
|
||||||
const name = Shell.name(shell)
|
const shell = Shell.acceptable()
|
||||||
const chain =
|
const name = Shell.name(shell)
|
||||||
name === "powershell"
|
const chain =
|
||||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
name === "powershell"
|
||||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||||
log.info("bash tool using shell", { shell })
|
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||||
|
log.info("bash tool using shell", { shell })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||||
.replaceAll("${os}", process.platform)
|
.replaceAll("${os}", process.platform)
|
||||||
.replaceAll("${shell}", name)
|
.replaceAll("${shell}", name)
|
||||||
.replaceAll("${chaining}", chain)
|
.replaceAll("${chaining}", chain)
|
||||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cwd = params.workdir
|
const cwd = params.workdir
|
||||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||||
: Instance.directory
|
: Instance.directory
|
||||||
if (params.timeout !== undefined && params.timeout < 0) {
|
if (params.timeout !== undefined && params.timeout < 0) {
|
||||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||||
}
|
}
|
||||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||||
const ps = PS.has(name)
|
const ps = PS.has(name)
|
||||||
const root = yield* parse(params.command, ps)
|
const root = yield* parse(params.command, ps)
|
||||||
const scan = yield* collect(root, cwd, ps, shell)
|
const scan = yield* collect(root, cwd, ps, shell)
|
||||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||||
yield* ask(ctx, scan)
|
yield* ask(ctx, scan)
|
||||||
|
|
||||||
return yield* run(
|
return yield* run(
|
||||||
{
|
{
|
||||||
shell,
|
shell,
|
||||||
name,
|
name,
|
||||||
command: params.command,
|
command: params.command,
|
||||||
cwd,
|
cwd,
|
||||||
env: yield* shellEnv(ctx, cwd),
|
env: yield* shellEnv(ctx, cwd),
|
||||||
timeout,
|
timeout,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { AppFileSystem } from "../filesystem"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
||||||
|
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
@@ -17,7 +14,8 @@ const MAX_LINE_LENGTH = 2000
|
|||||||
export const GrepTool = Tool.define(
|
export const GrepTool = Tool.define(
|
||||||
"grep",
|
"grep",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const spawner = yield* ChildProcessSpawner
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -28,6 +26,11 @@ export const GrepTool = Tool.define(
|
|||||||
}),
|
}),
|
||||||
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const empty = {
|
||||||
|
title: params.pattern,
|
||||||
|
metadata: { matches: 0, truncated: false },
|
||||||
|
output: "No files found",
|
||||||
|
}
|
||||||
if (!params.pattern) {
|
if (!params.pattern) {
|
||||||
throw new Error("pattern is required")
|
throw new Error("pattern is required")
|
||||||
}
|
}
|
||||||
@@ -43,92 +46,58 @@ export const GrepTool = Tool.define(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let searchPath = params.path ?? Instance.directory
|
const searchPath = AppFileSystem.resolve(
|
||||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
path.isAbsolute(params.path ?? Instance.directory)
|
||||||
|
? (params.path ?? Instance.directory)
|
||||||
|
: path.join(Instance.directory, params.path ?? "."),
|
||||||
|
)
|
||||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||||
|
|
||||||
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
|
const result = yield* rg.search({
|
||||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
cwd: searchPath,
|
||||||
if (params.include) {
|
pattern: params.pattern,
|
||||||
args.push("--glob", params.include)
|
glob: params.include ? [params.include] : undefined,
|
||||||
}
|
})
|
||||||
args.push(searchPath)
|
|
||||||
|
|
||||||
const result = yield* Effect.scoped(
|
if (result.items.length === 0) return empty
|
||||||
Effect.gen(function* () {
|
|
||||||
const handle = yield* spawner.spawn(
|
|
||||||
ChildProcess.make(rgPath, args, {
|
|
||||||
stdin: "ignore",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [output, errorOutput] = yield* Effect.all(
|
const rows = result.items.map((item) => ({
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
path: AppFileSystem.resolve(
|
||||||
{ concurrency: 2 },
|
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
|
||||||
)
|
),
|
||||||
|
line: item.line_number,
|
||||||
const exitCode = yield* handle.exitCode
|
text: item.lines.text,
|
||||||
|
}))
|
||||||
return { output, errorOutput, exitCode }
|
const times = new Map(
|
||||||
}),
|
(yield* Effect.forEach(
|
||||||
|
[...new Set(rows.map((row) => row.path))],
|
||||||
|
Effect.fnUntraced(function* (file) {
|
||||||
|
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (!info || info.type === "Directory") return undefined
|
||||||
|
return [
|
||||||
|
file,
|
||||||
|
info.mtime.pipe(
|
||||||
|
Option.map((time) => time.getTime()),
|
||||||
|
Option.getOrElse(() => 0),
|
||||||
|
) ?? 0,
|
||||||
|
] as const
|
||||||
|
}),
|
||||||
|
{ concurrency: 16 },
|
||||||
|
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
|
||||||
)
|
)
|
||||||
|
const matches = rows.flatMap((row) => {
|
||||||
|
const mtime = times.get(row.path)
|
||||||
|
if (mtime === undefined) return []
|
||||||
|
return [{ ...row, mtime }]
|
||||||
|
})
|
||||||
|
|
||||||
const { output, errorOutput, exitCode } = result
|
matches.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
|
||||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
|
||||||
// Only fail if exit code is 2 AND no output was produced
|
|
||||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
|
||||||
return {
|
|
||||||
title: params.pattern,
|
|
||||||
metadata: { matches: 0, truncated: false },
|
|
||||||
output: "No files found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exitCode !== 0 && exitCode !== 2) {
|
|
||||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasErrors = exitCode === 2
|
|
||||||
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
||||||
const lines = output.trim().split(/\r?\n/)
|
|
||||||
const matches = []
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line) continue
|
|
||||||
|
|
||||||
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
|
||||||
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
|
||||||
|
|
||||||
const lineNum = parseInt(lineNumStr, 10)
|
|
||||||
const lineText = lineTextParts.join("|")
|
|
||||||
|
|
||||||
const stats = Filesystem.stat(filePath)
|
|
||||||
if (!stats) continue
|
|
||||||
|
|
||||||
matches.push({
|
|
||||||
path: filePath,
|
|
||||||
modTime: stats.mtime.getTime(),
|
|
||||||
lineNum,
|
|
||||||
lineText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.sort((a, b) => b.modTime - a.modTime)
|
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const truncated = matches.length > limit
|
const truncated = matches.length > limit
|
||||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||||
|
|
||||||
if (finalMatches.length === 0) {
|
if (finalMatches.length === 0) return empty
|
||||||
return {
|
|
||||||
title: params.pattern,
|
|
||||||
metadata: { matches: 0, truncated: false },
|
|
||||||
output: "No files found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMatches = matches.length
|
const totalMatches = matches.length
|
||||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||||
@@ -143,10 +112,8 @@ export const GrepTool = Tool.define(
|
|||||||
outputLines.push(`${match.path}:`)
|
outputLines.push(`${match.path}:`)
|
||||||
}
|
}
|
||||||
const truncatedLineText =
|
const truncatedLineText =
|
||||||
match.lineText.length > MAX_LINE_LENGTH
|
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
||||||
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
|
outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
|
||||||
: match.lineText
|
|
||||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
@@ -156,7 +123,7 @@ export const GrepTool = Tool.define(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasErrors) {
|
if (result.partial) {
|
||||||
outputLines.push("")
|
outputLines.push("")
|
||||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
|
|||||||
"multiedit",
|
"multiedit",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const editInfo = yield* EditTool
|
const editInfo = yield* EditTool
|
||||||
const edit = yield* Effect.promise(() => editInfo.init())
|
const edit = yield* editInfo.init()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { Question } from "../question"
|
import { Question } from "../question"
|
||||||
import { Todo } from "../session/todo"
|
import { Todo } from "../session/todo"
|
||||||
@@ -344,18 +343,4 @@ export namespace ToolRegistry {
|
|||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function ids() {
|
|
||||||
return runPromise((svc) => svc.ids())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tools(input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
modelID: ModelID
|
|
||||||
agent: Agent.Info
|
|
||||||
}): Promise<(Tool.Def & { id: string })[]> {
|
|
||||||
return runPromise((svc) => svc.tools(input))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
|
return () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
||||||
|
|
||||||
return async () => {
|
const description =
|
||||||
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
|
list.length === 0
|
||||||
|
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||||
const description =
|
: [
|
||||||
list.length === 0
|
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
|
||||||
: [
|
|
||||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
||||||
"",
|
|
||||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
||||||
"",
|
|
||||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
||||||
"",
|
|
||||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
||||||
"",
|
|
||||||
"The following skills provide specialized sets of instructions for particular tasks",
|
|
||||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
|
||||||
"",
|
|
||||||
Skill.fmt(list, { verbose: false }),
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
parameters: Parameters,
|
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const info = yield* skill.get(params.name)
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
const all = yield* skill.all()
|
|
||||||
const available = all.map((s) => s.name).join(", ")
|
|
||||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* ctx.ask({
|
|
||||||
permission: "skill",
|
|
||||||
patterns: [params.name],
|
|
||||||
always: [params.name],
|
|
||||||
metadata: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dir = path.dirname(info.location)
|
|
||||||
const base = pathToFileURL(dir).href
|
|
||||||
|
|
||||||
const limit = 10
|
|
||||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
|
||||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
|
||||||
Stream.map((file) => path.resolve(dir, file)),
|
|
||||||
Stream.take(limit),
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `Loaded skill: ${info.name}`,
|
|
||||||
output: [
|
|
||||||
`<skill_content name="${info.name}">`,
|
|
||||||
`# Skill: ${info.name}`,
|
|
||||||
"",
|
"",
|
||||||
info.content.trim(),
|
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||||
"",
|
"",
|
||||||
`Base directory for this skill: ${base}`,
|
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
||||||
"Note: file list is sampled.",
|
|
||||||
"",
|
"",
|
||||||
"<skill_files>",
|
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||||
files,
|
"",
|
||||||
"</skill_files>",
|
"The following skills provide specialized sets of instructions for particular tasks",
|
||||||
"</skill_content>",
|
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||||
].join("\n"),
|
"",
|
||||||
metadata: {
|
Skill.fmt(list, { verbose: false }),
|
||||||
name: info.name,
|
].join("\n")
|
||||||
dir,
|
|
||||||
},
|
return {
|
||||||
}
|
description,
|
||||||
}).pipe(Effect.orDie),
|
parameters: Parameters,
|
||||||
}
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
}
|
Effect.gen(function* () {
|
||||||
|
const info = yield* skill.get(params.name)
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
const all = yield* skill.all()
|
||||||
|
const available = all.map((s) => s.name).join(", ")
|
||||||
|
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "skill",
|
||||||
|
patterns: [params.name],
|
||||||
|
always: [params.name],
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dir = path.dirname(info.location)
|
||||||
|
const base = pathToFileURL(dir).href
|
||||||
|
|
||||||
|
const limit = 10
|
||||||
|
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||||
|
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||||
|
Stream.map((file) => path.resolve(dir, file)),
|
||||||
|
Stream.take(limit),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Loaded skill: ${info.name}`,
|
||||||
|
output: [
|
||||||
|
`<skill_content name="${info.name}">`,
|
||||||
|
`# Skill: ${info.name}`,
|
||||||
|
"",
|
||||||
|
info.content.trim(),
|
||||||
|
"",
|
||||||
|
`Base directory for this skill: ${base}`,
|
||||||
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||||
|
"Note: file list is sampled.",
|
||||||
|
"",
|
||||||
|
"<skill_files>",
|
||||||
|
files,
|
||||||
|
"</skill_files>",
|
||||||
|
"</skill_content>",
|
||||||
|
].join("\n"),
|
||||||
|
metadata: {
|
||||||
|
name: info.name,
|
||||||
|
dir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
|
}
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ export namespace Tool {
|
|||||||
|
|
||||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||||
id: string
|
id: string
|
||||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||||
|
| DefWithoutID<Parameters, M>
|
||||||
|
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||||
|
|
||||||
export type InferParameters<T> =
|
export type InferParameters<T> =
|
||||||
T extends Info<infer P, any>
|
T extends Info<infer P, any>
|
||||||
? z.infer<P>
|
? z.infer<P>
|
||||||
@@ -68,58 +72,66 @@ export namespace Tool {
|
|||||||
|
|
||||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
id: string,
|
id: string,
|
||||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
init: Init<Parameters, Result>,
|
||||||
|
truncate: Truncate.Interface,
|
||||||
|
agents: Agent.Interface,
|
||||||
) {
|
) {
|
||||||
return async () => {
|
return () =>
|
||||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
Effect.gen(function* () {
|
||||||
const execute = toolInfo.execute
|
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
||||||
toolInfo.execute = (args, ctx) =>
|
const execute = toolInfo.execute
|
||||||
Effect.gen(function* () {
|
toolInfo.execute = (args, ctx) =>
|
||||||
yield* Effect.try({
|
Effect.gen(function* () {
|
||||||
try: () => toolInfo.parameters.parse(args),
|
yield* Effect.try({
|
||||||
catch: (error) => {
|
try: () => toolInfo.parameters.parse(args),
|
||||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
catch: (error) => {
|
||||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||||
}
|
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||||
return new Error(
|
}
|
||||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
return new Error(
|
||||||
{ cause: error },
|
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||||
)
|
{ cause: error },
|
||||||
},
|
)
|
||||||
})
|
},
|
||||||
const result = yield* execute(args, ctx)
|
})
|
||||||
if (result.metadata.truncated !== undefined) {
|
const result = yield* execute(args, ctx)
|
||||||
return result
|
if (result.metadata.truncated !== undefined) {
|
||||||
}
|
return result
|
||||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
}
|
||||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
const agent = yield* agents.get(ctx.agent)
|
||||||
return {
|
const truncated = yield* truncate.output(result.output, {}, agent)
|
||||||
...result,
|
return {
|
||||||
output: truncated.content,
|
...result,
|
||||||
metadata: {
|
output: truncated.content,
|
||||||
...result.metadata,
|
metadata: {
|
||||||
truncated: truncated.truncated,
|
...result.metadata,
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
truncated: truncated.truncated,
|
||||||
},
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
}
|
},
|
||||||
}).pipe(Effect.orDie)
|
}
|
||||||
return toolInfo
|
}).pipe(Effect.orDie)
|
||||||
}
|
return toolInfo
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||||
id: ID,
|
id: ID,
|
||||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* init
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
const agents = yield* Agent.Service
|
||||||
|
return { id, init: wrap(id, resolved, truncate, agents) }
|
||||||
|
}),
|
||||||
{ id },
|
{ id },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const init = yield* Effect.promise(() => info.init())
|
const init = yield* info.init()
|
||||||
return {
|
return {
|
||||||
...init,
|
...init,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { evaluate } from "@/permission/evaluate"
|
import { evaluate } from "@/permission/evaluate"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
@@ -135,10 +134,4 @@ export namespace Truncate {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
|
||||||
return runPromise((s) => s.output(text, options, agent))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export namespace Log {
|
|||||||
WARN: 2,
|
WARN: 2,
|
||||||
ERROR: 3,
|
ERROR: 3,
|
||||||
}
|
}
|
||||||
|
const keep = 10
|
||||||
|
|
||||||
let level: Level = "INFO"
|
let level: Level = "INFO"
|
||||||
|
|
||||||
@@ -78,15 +79,19 @@ export namespace Log {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanup(dir: string) {
|
async function cleanup(dir: string) {
|
||||||
const files = await Glob.scan("????-??-??T??????.log", {
|
const files = (
|
||||||
cwd: dir,
|
await Glob.scan("????-??-??T??????.log", {
|
||||||
absolute: true,
|
cwd: dir,
|
||||||
include: "file",
|
absolute: false,
|
||||||
})
|
include: "file",
|
||||||
if (files.length <= 5) return
|
}).catch(() => [])
|
||||||
|
)
|
||||||
|
.filter((file) => path.basename(file) === file)
|
||||||
|
.sort()
|
||||||
|
if (files.length <= keep) return
|
||||||
|
|
||||||
const filesToDelete = files.slice(0, -10)
|
const doomed = files.slice(0, -keep)
|
||||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(error: Error, depth = 0): string {
|
function formatError(error: Error, depth = 0): string {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user