Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f6135daf | ||
|
|
a2cb4909da | ||
|
|
3c74c0db30 | ||
|
|
7a05ba47d1 | ||
|
|
36745caa2a | ||
|
|
c2403d0f15 | ||
|
|
34e2429c49 | ||
|
|
1fa55dcca7 | ||
|
|
ad0ff9b651 | ||
|
|
10ba68c772 | ||
|
|
9ef9fac6db | ||
|
|
ef2faaac4c | ||
|
|
e74c99320f | ||
|
|
e8471256f2 | ||
|
|
43b37346b6 | ||
|
|
d199648aeb | ||
|
|
a06f40297b | ||
|
|
59c0fc28ee | ||
|
|
b22add292c | ||
|
|
67aaecacac | ||
|
|
29c202e6ab | ||
|
|
dcbf11f41a | ||
|
|
14ccff4037 | ||
|
|
5b8b874732 | ||
|
|
1d81c0266c | ||
|
|
913120759a | ||
|
|
7a6ce05d09 | ||
|
|
1dc69359d5 | ||
|
|
329fcb040b | ||
|
|
bf50d1c028 | ||
|
|
b8801dbd22 | ||
|
|
f7c6943817 | ||
|
|
91fe4db27c | ||
|
|
21d7a85e76 | ||
|
|
663e798e76 | ||
|
|
5bc2d2498d | ||
|
|
c22e34853d | ||
|
|
6825b0bbc7 | ||
|
|
3644581b55 | ||
|
|
79cc15335e | ||
|
|
ca6200121b | ||
|
|
7239b38b7f | ||
|
|
9ae8dc2d01 | ||
|
|
7164662be2 | ||
|
|
94f71f59a3 | ||
|
|
3eb6508a64 | ||
|
|
6fdb8ab90d | ||
|
|
321bf1f8e1 | ||
|
|
62bd023086 | ||
|
|
cb1a50055c | ||
|
|
65e3348232 | ||
|
|
a6b9f0dac1 | ||
|
|
34f5bdbc99 | ||
|
|
0b4fe14b0a | ||
|
|
7230cd2683 | ||
|
|
a915fe74be | ||
|
|
26d35583c5 | ||
|
|
ae17b416b8 | ||
|
|
8ffadde85c | ||
|
|
3c0ad70653 | ||
|
|
264418c0cd | ||
|
|
fa2c69f09c | ||
|
|
113304a058 |
@@ -25,6 +25,7 @@ kommander
|
|||||||
-opencodeengineer bot that spams issues
|
-opencodeengineer bot that spams issues
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
-ricardo-m-l
|
||||||
-robinmordasiewicz
|
-robinmordasiewicz
|
||||||
simonklee
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.ref_name != 'beta'
|
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -390,7 +389,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -596,7 +595,7 @@ jobs:
|
|||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
pattern: latest-yml-*
|
pattern: latest-yml-*
|
||||||
path: /tmp/latest-yml
|
path: /tmp/latest-yml
|
||||||
|
|||||||
@@ -319,6 +319,7 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
@@ -370,6 +371,7 @@
|
|||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cli-sound": "1.1.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
@@ -707,6 +709,8 @@
|
|||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||||
|
|
||||||
|
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||||
@@ -2665,6 +2669,8 @@
|
|||||||
|
|
||||||
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||||
|
|
||||||
|
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
|
||||||
|
|
||||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||||
@@ -3089,6 +3095,8 @@
|
|||||||
|
|
||||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
|
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
|
||||||
|
|
||||||
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
||||||
|
|
||||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||||
@@ -4409,6 +4417,8 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||||
|
|
||||||
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||||
|
|
||||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||||
@@ -5003,6 +5013,8 @@
|
|||||||
|
|
||||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
|
"x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
|
||||||
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
|
"aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
|
||||||
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
|
"aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
|
||||||
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
|
"x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
const payload = event.payload
|
if (event.payload.type === "sync") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = event.payload as Event
|
||||||
|
|
||||||
const k = key(directory, payload)
|
const k = key(directory, payload)
|
||||||
if (k) {
|
if (k) {
|
||||||
const i = coalesced.get(k)
|
const i = coalesced.get(k)
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
<div class="relative w-full py-1">
|
<div class="relative w-full py-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={(e: MouseEvent) => {
|
onClick={(e: MouseEvent) => {
|
||||||
props.loadMore()
|
props.loadMore()
|
||||||
|
|||||||
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
|
|||||||
onClick={props.onResumeScroll}
|
onClick={props.onResumeScroll}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
|
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||||
style={{
|
style={{
|
||||||
"box-shadow":
|
"box-shadow":
|
||||||
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
|
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="arrow-down-to-line" size="small" />
|
<Icon name="arrow-down-to-line" size="small" />
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
|
|||||||
y: state.y,
|
y: state.y,
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
show: true,
|
show: false,
|
||||||
title: "OpenCode",
|
title: "OpenCode",
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
|
|||||||
wireZoom(win)
|
wireZoom(win)
|
||||||
injectGlobals(win, globals)
|
injectGlobals(win, globals)
|
||||||
|
|
||||||
|
win.once("ready-to-show", () => {
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
Use these rules when writing or migrating Effect code.
|
Use these rules when writing or migrating Effect code.
|
||||||
|
|
||||||
See `specs/effect-migration.md` for the compact pattern reference and examples.
|
See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
|||||||
|
|
||||||
## Effect.cached for deduplication
|
## Effect.cached for deduplication
|
||||||
|
|
||||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
|
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||||
|
|
||||||
## Instance.bind — ALS for native callbacks
|
## Instance.bind — ALS for native callbacks
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_workspace` (
|
||||||
|
`id` text PRIMARY KEY,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`name` text DEFAULT '' NOT NULL,
|
||||||
|
`branch` text,
|
||||||
|
`directory` text,
|
||||||
|
`extra` text,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
|
||||||
|
DROP TABLE `workspace`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `session_entry` (
|
||||||
|
`id` text PRIMARY KEY,
|
||||||
|
`session_id` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`time_created` integer NOT NULL,
|
||||||
|
`time_updated` integer NOT NULL,
|
||||||
|
`data` text NOT NULL,
|
||||||
|
CONSTRAINT `fk_session_entry_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `session_entry_session_idx` ON `session_entry` (`session_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `session_entry_session_type_idx` ON `session_entry` (`session_id`,`type`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `session_entry_time_created_idx` ON `session_entry` (`time_created`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,11 @@
|
|||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
|
||||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
|
||||||
"lint": "echo 'Running lint checks...' && bun test --coverage",
|
|
||||||
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
|
|
||||||
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
|
|
||||||
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
|
|
||||||
"db": "bun drizzle-kit"
|
"db": "bun drizzle-kit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"randomField": "this-is-a-random-value-12345",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
@@ -83,6 +76,7 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
@@ -134,6 +128,7 @@
|
|||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cli-sound": "1.1.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|||||||
@@ -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,238 @@
|
|||||||
|
# Facade removal checklist
|
||||||
|
|
||||||
|
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
|
||||||
|
|
||||||
|
As of 2026-04-13, latest `origin/dev`:
|
||||||
|
|
||||||
|
- `src/` still has 15 `makeRuntime(...)` call sites.
|
||||||
|
- 13 of those are still in scope for facade removal.
|
||||||
|
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
|
||||||
|
|
||||||
|
Recent progress:
|
||||||
|
|
||||||
|
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
|
||||||
|
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
|
||||||
|
|
||||||
|
## Priority hotspots
|
||||||
|
|
||||||
|
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
|
||||||
|
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
|
||||||
|
|
||||||
|
## Completed Batches
|
||||||
|
|
||||||
|
Low-risk batch, all merged:
|
||||||
|
|
||||||
|
1. `src/pty/index.ts`
|
||||||
|
2. `src/skill/index.ts`
|
||||||
|
3. `src/project/vcs.ts`
|
||||||
|
4. `src/tool/registry.ts`
|
||||||
|
5. `src/auth/index.ts`
|
||||||
|
|
||||||
|
Caller-heavy batch, all merged:
|
||||||
|
|
||||||
|
1. `src/config/config.ts`
|
||||||
|
2. `src/provider/provider.ts`
|
||||||
|
3. `src/file/index.ts`
|
||||||
|
4. `src/lsp/index.ts`
|
||||||
|
5. `src/mcp/index.ts`
|
||||||
|
|
||||||
|
Shared pattern:
|
||||||
|
|
||||||
|
- one service file still exports `makeRuntime(...)` + async facades
|
||||||
|
- one or two route or CLI entrypoints call those facades directly
|
||||||
|
- tests call the facade directly and need to switch to `yield* svc.method(...)`
|
||||||
|
- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
|
||||||
|
|
||||||
|
## Done means
|
||||||
|
|
||||||
|
For each service in the low-risk batch, the work is complete only when all of these are true:
|
||||||
|
|
||||||
|
1. all production callers stop using `Namespace.method(...)` facade calls
|
||||||
|
2. all direct test callers stop using the facade and instead yield the service from context
|
||||||
|
3. the service file no longer has `makeRuntime(...)`
|
||||||
|
4. the service file no longer exports runtime-backed facade helpers
|
||||||
|
5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
|
||||||
|
|
||||||
|
## Caller templates
|
||||||
|
|
||||||
|
### Route handlers
|
||||||
|
|
||||||
|
Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const value = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.list()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
|
||||||
|
|
||||||
|
### Plain async CLI or script entrypoints
|
||||||
|
|
||||||
|
If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const skills = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
yield* auth.set(key, info)
|
||||||
|
return yield* skill.all()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
|
||||||
|
|
||||||
|
This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
|
||||||
|
|
||||||
|
### Bootstrap or fire-and-forget startup code
|
||||||
|
|
||||||
|
If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not reintroduce a dedicated runtime in the service just for bootstrap.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Convert facade tests to full effect style.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it.effect("does the thing", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Pty.Service
|
||||||
|
const info = yield* svc.create({ command: "cat", title: "a" })
|
||||||
|
yield* svc.remove(info.id)
|
||||||
|
}).pipe(Effect.provide(Pty.defaultLayer)),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
|
||||||
|
|
||||||
|
Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
|
||||||
|
|
||||||
|
If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const infra = CrossSpawnSpawner.defaultLayer
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
|
||||||
|
```
|
||||||
|
|
||||||
|
Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
|
||||||
|
|
||||||
|
## Questions already answered
|
||||||
|
|
||||||
|
### Do we need to effectify the whole caller first?
|
||||||
|
|
||||||
|
No.
|
||||||
|
|
||||||
|
- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
|
||||||
|
- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
|
||||||
|
- bootstrap: use the existing bootstrap runtime
|
||||||
|
|
||||||
|
Facade removal does not require a bigger refactor than that.
|
||||||
|
|
||||||
|
### Should tests keep calling the namespace from async test bodies?
|
||||||
|
|
||||||
|
No. Convert them now.
|
||||||
|
|
||||||
|
The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
|
||||||
|
|
||||||
|
### Should we keep `runPromise` exported for convenience?
|
||||||
|
|
||||||
|
No. For this batch the goal is to delete the service-local runtime entirely.
|
||||||
|
|
||||||
|
### What if a route has websocket callbacks or nested async handlers?
|
||||||
|
|
||||||
|
Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
|
||||||
|
|
||||||
|
### Should we use one `runPromise` per service call?
|
||||||
|
|
||||||
|
No.
|
||||||
|
|
||||||
|
Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
|
||||||
|
|
||||||
|
Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
|
||||||
|
|
||||||
|
### Should we wrap a single service expression in `Effect.gen(...)`?
|
||||||
|
|
||||||
|
Usually no.
|
||||||
|
|
||||||
|
Prefer the direct form when there is only one expression:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
|
||||||
|
|
||||||
|
## Learnings
|
||||||
|
|
||||||
|
These were the recurring mistakes and useful corrections from the first two batches:
|
||||||
|
|
||||||
|
1. Tests should usually provide the specific service layer, not `AppRuntime`.
|
||||||
|
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
|
||||||
|
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
|
||||||
|
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
|
||||||
|
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
||||||
|
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
||||||
|
|
||||||
|
## Next batch
|
||||||
|
|
||||||
|
Recommended next five, in order:
|
||||||
|
|
||||||
|
1. `src/permission/index.ts`
|
||||||
|
2. `src/agent/agent.ts`
|
||||||
|
3. `src/session/summary.ts`
|
||||||
|
4. `src/session/revert.ts`
|
||||||
|
5. `src/mcp/auth.ts`
|
||||||
|
|
||||||
|
Why this batch:
|
||||||
|
|
||||||
|
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
|
||||||
|
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
|
||||||
|
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
|
||||||
|
|
||||||
|
After that batch, the expected follow-up is the main session cluster:
|
||||||
|
|
||||||
|
1. `src/session/index.ts`
|
||||||
|
2. `src/session/prompt.ts`
|
||||||
|
3. `src/session/compaction.ts`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
|
||||||
|
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
|
||||||
|
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
|
||||||
|
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
|
||||||
|
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
|
||||||
|
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
|
||||||
|
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
|
||||||
|
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
|
||||||
|
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
|
||||||
|
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
|
||||||
|
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
|
||||||
|
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
|
||||||
|
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
|
||||||
|
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
|
||||||
|
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
|
||||||
|
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
|
||||||
|
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
|
||||||
|
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
|
||||||
|
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
|
||||||
|
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
|
||||||
|
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
|
||||||
|
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
|
||||||
|
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
|
||||||
|
|
||||||
|
## Excluded `makeRuntime(...)` sites
|
||||||
|
|
||||||
|
- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
|
||||||
|
- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.
|
||||||
@@ -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.
|
||||||
+20
-100
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||||||
|
|
||||||
## Migration checklist
|
## Migration checklist
|
||||||
|
|
||||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
|
||||||
|
|
||||||
|
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
|
||||||
|
|
||||||
- [x] `Account` — `account/index.ts`
|
- [x] `Account` — `account/index.ts`
|
||||||
- [x] `Agent` — `agent/agent.ts`
|
- [x] `Agent` — `agent/agent.ts`
|
||||||
@@ -221,59 +223,16 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `Provider` — `provider/provider.ts`
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
- [x] `Storage` — `storage/storage.ts`
|
- [x] `Storage` — `storage/storage.ts`
|
||||||
- [x] `ShareNext` — `share/share-next.ts`
|
- [x] `ShareNext` — `share/share-next.ts`
|
||||||
|
|
||||||
Still open:
|
|
||||||
|
|
||||||
- [x] `SessionTodo` — `session/todo.ts`
|
- [x] `SessionTodo` — `session/todo.ts`
|
||||||
- [ ] `SyncEvent` — `sync/index.ts`
|
|
||||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
|
||||||
|
|
||||||
## Tool interface → Effect
|
Still open at the service-shape level:
|
||||||
|
|
||||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
|
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||||
|
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||||
|
|
||||||
1. Migrate each tool body to return Effects
|
## Tool migration
|
||||||
2. Keep `Tool.define()` inputs Effect-native
|
|
||||||
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
|
||||||
|
|
||||||
### Tool migration details
|
Tool-specific migration guidance and checklist live in `tools.md`.
|
||||||
|
|
||||||
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
|
|
||||||
|
|
||||||
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
|
||||||
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
|
|
||||||
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
|
|
||||||
|
|
||||||
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
|
||||||
|
|
||||||
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
|
||||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
|
|
||||||
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
|
||||||
|
|
||||||
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
|
||||||
|
|
||||||
Individual tools, ordered by value:
|
|
||||||
|
|
||||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
|
||||||
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
|
||||||
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
|
||||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
|
||||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
|
||||||
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
|
||||||
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
|
||||||
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
|
||||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
|
||||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
|
||||||
- [ ] `task.ts` — MEDIUM: task state management
|
|
||||||
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
|
||||||
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
|
||||||
- [ ] `glob.ts` — LOW: simple async generator
|
|
||||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
|
||||||
- [ ] `question.ts` — LOW: prompt wrapper
|
|
||||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
|
||||||
- [ ] `todo.ts` — LOW: todo persistence wrapper
|
|
||||||
- [ ] `invalid.ts` — LOW: invalid-tool fallback
|
|
||||||
- [ ] `plan.ts` — LOW: plan file operations
|
|
||||||
|
|
||||||
## Effect service adoption in already-migrated code
|
## Effect service adoption in already-migrated code
|
||||||
|
|
||||||
@@ -281,27 +240,19 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
|
|||||||
|
|
||||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||||
|
|
||||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
|
||||||
|
|
||||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||||
|
|
||||||
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
|
||||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||||
|
|
||||||
## Filesystem consolidation
|
## Filesystem consolidation
|
||||||
|
|
||||||
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
|
||||||
|
|
||||||
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||||
|
|
||||||
Current raw fs users that will convert during tool migration:
|
|
||||||
|
|
||||||
- `tool/read.ts` — fs.createReadStream, readline
|
|
||||||
- `tool/apply_patch.ts` — fs/promises
|
|
||||||
- `file/ripgrep.ts` — fs/promises
|
|
||||||
- `patch/index.ts` — fs, fs/promises
|
|
||||||
|
|
||||||
## Primitives & utilities
|
## Primitives & utilities
|
||||||
|
|
||||||
@@ -312,7 +263,9 @@ Current raw fs users that will convert during tool migration:
|
|||||||
|
|
||||||
## Destroying the facades
|
## Destroying the facades
|
||||||
|
|
||||||
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
|
||||||
|
|
||||||
|
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||||
|
|
||||||
### Process
|
### Process
|
||||||
|
|
||||||
@@ -341,47 +294,14 @@ For each service, the migration is roughly:
|
|||||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
|
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
||||||
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||||
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
|
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
|
||||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||||
|
|
||||||
## Route handler effectification
|
## Route handler effectification
|
||||||
|
|
||||||
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
Route-handler migration guidance and checklist live in `routes.md`.
|
||||||
|
|
||||||
```ts
|
|
||||||
// Before — one facade call per service
|
|
||||||
;async (c) => {
|
|
||||||
await SessionRunState.assertNotBusy(id)
|
|
||||||
await Session.removeMessage({ sessionID: id, messageID })
|
|
||||||
return c.json(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// After — one Effect.gen, yield services from context
|
|
||||||
;async (c) => {
|
|
||||||
await AppRuntime.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* SessionRunState.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
yield* state.assertNotBusy(id)
|
|
||||||
yield* session.removeMessage({ sessionID: id, messageID })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return c.json(true)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
|
|
||||||
|
|
||||||
Route files to convert (each handler that calls facades should be wrapped):
|
|
||||||
|
|
||||||
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
|
|
||||||
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
|
|
||||||
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
|
|
||||||
- [ ] `server/routes/question.ts` — uses Question
|
|
||||||
- [ ] `server/routes/pty.ts` — uses Pty
|
|
||||||
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
|
|
||||||
@@ -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`
|
||||||
@@ -40,6 +40,7 @@ import type { ACPConfig } from "./types"
|
|||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { Agent as AgentModule } from "../agent/agent"
|
import { Agent as AgentModule } from "../agent/agent"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
@@ -1166,7 +1167,7 @@ export namespace ACP {
|
|||||||
this.sessionManager.get(sessionId).modeId ||
|
this.sessionManager.get(sessionId).modeId ||
|
||||||
(await (async () => {
|
(await (async () => {
|
||||||
if (!availableModes.length) return undefined
|
if (!availableModes.length) return undefined
|
||||||
const defaultAgentName = await AgentModule.defaultAgent()
|
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||||
const resolvedModeId =
|
const resolvedModeId =
|
||||||
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||||
@@ -1367,7 +1368,8 @@ export namespace ACP {
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
this.sessionManager.setModel(session.id, model)
|
this.sessionManager.setModel(session.id, model)
|
||||||
}
|
}
|
||||||
const agent = session.modeId ?? (await AgentModule.defaultAgent())
|
const agent =
|
||||||
|
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
|
||||||
|
|
||||||
const parts: Array<
|
const parts: Array<
|
||||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { Plugin } from "@/plugin"
|
|||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Effect, Context, Layer } from "effect"
|
import { Effect, Context, Layer } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace Agent {
|
export namespace Agent {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -74,6 +73,7 @@ export namespace Agent {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
|
|
||||||
@@ -336,9 +336,7 @@ export namespace Agent {
|
|||||||
const language = yield* provider.getLanguage(resolved)
|
const language = yield* provider.getLanguage(resolved)
|
||||||
|
|
||||||
const system = [PROMPT_GENERATE]
|
const system = [PROMPT_GENERATE]
|
||||||
yield* Effect.promise(() =>
|
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
||||||
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
|
|
||||||
)
|
|
||||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||||
|
|
||||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||||
@@ -399,27 +397,10 @@ export namespace Agent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get(agent: string) {
|
|
||||||
return runPromise((svc) => svc.get(agent))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((svc) => svc.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function defaultAgent() {
|
|
||||||
return runPromise((svc) => svc.defaultAgent())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
|
|
||||||
return runPromise((svc) => svc.generate(input))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.wav" {
|
||||||
|
const file: string
|
||||||
|
export default file
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { AppFileSystem } from "../filesystem"
|
import { AppFileSystem } from "../filesystem"
|
||||||
@@ -89,22 +88,4 @@ export namespace Auth {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
|
||||||
return runPromise((service) => service.get(providerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function all(): Promise<Record<string, Info>> {
|
|
||||||
return runPromise((service) => service.all())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function set(key: string, info: Info) {
|
|
||||||
return runPromise((service) => service.set(key, info))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(key: string) {
|
|
||||||
return runPromise((service) => service.remove(key))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,25 +16,18 @@ export namespace BusEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function payloads() {
|
export function payloads() {
|
||||||
return z
|
return registry
|
||||||
.discriminatedUnion(
|
.entries()
|
||||||
"type",
|
.map(([type, def]) => {
|
||||||
registry
|
return z
|
||||||
.entries()
|
.object({
|
||||||
.map(([type, def]) => {
|
type: z.literal(type),
|
||||||
return z
|
properties: def.properties,
|
||||||
.object({
|
})
|
||||||
type: z.literal(type),
|
.meta({
|
||||||
properties: def.properties,
|
ref: "Event" + "." + def.type,
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Event" + "." + def.type,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.toArray() as any,
|
|
||||||
)
|
|
||||||
.meta({
|
|
||||||
ref: "Event",
|
|
||||||
})
|
})
|
||||||
|
.toArray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
@@ -110,7 +111,9 @@ const AgentCreateCommand = cmd({
|
|||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
spinner.start("Generating agent configuration...")
|
spinner.start("Generating agent configuration...")
|
||||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||||
const generated = await Agent.generate({ description, model }).catch((error) => {
|
const generated = await AppRuntime.runPromise(
|
||||||
|
Agent.Service.use((svc) => svc.generate({ description, model })),
|
||||||
|
).catch((error) => {
|
||||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||||
if (isFullyNonInteractive) process.exit(1)
|
if (isFullyNonInteractive) process.exit(1)
|
||||||
throw new UI.CancelledError()
|
throw new UI.CancelledError()
|
||||||
@@ -220,7 +223,7 @@ const AgentListCommand = cmd({
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
async fn() {
|
async fn() {
|
||||||
const agents = await Agent.list()
|
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||||
const sortedAgents = agents.sort((a, b) => {
|
const sortedAgents = agents.sort((a, b) => {
|
||||||
if (a.native !== b.native) {
|
if (a.native !== b.native) {
|
||||||
return a.native ? -1 : 1
|
return a.native ? -1 : 1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Permission } from "../../../permission"
|
|||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export const AgentCommand = cmd({
|
export const AgentCommand = cmd({
|
||||||
command: "agent <name>",
|
command: "agent <name>",
|
||||||
@@ -34,7 +35,7 @@ export const AgentCommand = cmd({
|
|||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const agentName = args.name as string
|
const agentName = args.name as string
|
||||||
const agent = await Agent.get(agentName)
|
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||||
@@ -71,11 +72,17 @@ export const AgentCommand = cmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function getAvailableTools(agent: Agent.Info) {
|
async function getAvailableTools(agent: Agent.Info) {
|
||||||
const model = agent.model ?? (await Provider.defaultModel())
|
return AppRuntime.runPromise(
|
||||||
return ToolRegistry.tools({
|
Effect.gen(function* () {
|
||||||
...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 { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { File } from "../../../file"
|
import { File } from "../../../file"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const results = await File.search({ query: args.query })
|
const results = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
process.stdout.write(results.join(EOL) + EOL)
|
process.stdout.write(results.join(EOL) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const content = await File.read(args.path)
|
const content = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.read(args.path))
|
||||||
|
}),
|
||||||
|
)
|
||||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
|
|||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const status = await File.status()
|
const status = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.status())
|
||||||
|
}),
|
||||||
|
)
|
||||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const files = await File.list(args.path)
|
const files = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.list(args.path))
|
||||||
|
}),
|
||||||
|
)
|
||||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,8 @@ import { Global } from "../../global"
|
|||||||
import { modify, applyEdits } from "jsonc-parser"
|
import { modify, applyEdits } from "jsonc-parser"
|
||||||
import { Filesystem } from "../../util/filesystem"
|
import { Filesystem } from "../../util/filesystem"
|
||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
|||||||
return isMcpConfigured(config) && config.type === "remote"
|
return isMcpConfigured(config) && config.type === "remote"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredServers(config: Config.Info) {
|
||||||
|
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthServers(config: Config.Info) {
|
||||||
|
return configuredServers(config).filter(
|
||||||
|
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listState() {
|
||||||
|
return AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cfg = yield* Config.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const config = yield* cfg.get()
|
||||||
|
const statuses = yield* mcp.status()
|
||||||
|
const stored = yield* Effect.all(
|
||||||
|
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return { config, statuses, stored }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authState() {
|
||||||
|
return AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cfg = yield* Config.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const config = yield* cfg.get()
|
||||||
|
const auth = yield* Effect.all(
|
||||||
|
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return { config, auth }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const McpCommand = cmd({
|
export const McpCommand = cmd({
|
||||||
command: "mcp",
|
command: "mcp",
|
||||||
describe: "manage MCP (Model Context Protocol) servers",
|
describe: "manage MCP (Model Context Protocol) servers",
|
||||||
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP Servers")
|
prompts.intro("MCP Servers")
|
||||||
|
|
||||||
const config = await Config.get()
|
const { config, statuses, stored } = await listState()
|
||||||
const mcpServers = config.mcp ?? {}
|
const servers = configuredServers(config)
|
||||||
const statuses = await MCP.status()
|
|
||||||
|
|
||||||
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
|
|
||||||
isMcpConfigured(entry[1]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
prompts.log.warn("No MCP servers configured")
|
prompts.log.warn("No MCP servers configured")
|
||||||
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
|
|||||||
for (const [name, serverConfig] of servers) {
|
for (const [name, serverConfig] of servers) {
|
||||||
const status = statuses[name]
|
const status = statuses[name]
|
||||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||||
const hasStoredTokens = await MCP.hasStoredTokens(name)
|
const hasStoredTokens = stored[name]
|
||||||
|
|
||||||
let statusIcon: string
|
let statusIcon: string
|
||||||
let statusText: string
|
let statusText: string
|
||||||
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Authentication")
|
prompts.intro("MCP OAuth Authentication")
|
||||||
|
|
||||||
const config = await Config.get()
|
const { config, auth } = await authState()
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
|
const servers = oauthServers(config)
|
||||||
|
|
||||||
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
if (servers.length === 0) {
|
||||||
const oauthServers = Object.entries(mcpServers).filter(
|
|
||||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (oauthServers.length === 0) {
|
|
||||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||||
prompts.log.info(`
|
prompts.log.info(`
|
||||||
@@ -177,19 +211,17 @@ export const McpAuthCommand = cmd({
|
|||||||
let serverName = args.name
|
let serverName = args.name
|
||||||
if (!serverName) {
|
if (!serverName) {
|
||||||
// Build options with auth status
|
// Build options with auth status
|
||||||
const options = await Promise.all(
|
const options = servers.map(([name, cfg]) => {
|
||||||
oauthServers.map(async ([name, cfg]) => {
|
const authStatus = auth[name]
|
||||||
const authStatus = await MCP.getAuthStatus(name)
|
const icon = getAuthStatusIcon(authStatus)
|
||||||
const icon = getAuthStatusIcon(authStatus)
|
const statusText = getAuthStatusText(authStatus)
|
||||||
const statusText = getAuthStatusText(authStatus)
|
const url = cfg.url
|
||||||
const url = cfg.url
|
return {
|
||||||
return {
|
label: `${icon} ${name} (${statusText})`,
|
||||||
label: `${icon} ${name} (${statusText})`,
|
value: name,
|
||||||
value: name,
|
hint: url,
|
||||||
hint: url,
|
}
|
||||||
}
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const selected = await prompts.select({
|
const selected = await prompts.select({
|
||||||
message: "Select MCP server to authenticate",
|
message: "Select MCP server to authenticate",
|
||||||
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
const authStatus = await MCP.getAuthStatus(serverName)
|
const authStatus =
|
||||||
|
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
|
||||||
if (authStatus === "authenticated") {
|
if (authStatus === "authenticated") {
|
||||||
const confirm = await prompts.confirm({
|
const confirm = await prompts.confirm({
|
||||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||||
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await MCP.authenticate(serverName)
|
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
|
||||||
|
|
||||||
if (status.status === "connected") {
|
if (status.status === "connected") {
|
||||||
spinner.stop("Authentication successful!")
|
spinner.stop("Authentication successful!")
|
||||||
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Status")
|
prompts.intro("MCP OAuth Status")
|
||||||
|
|
||||||
const config = await Config.get()
|
const { config, auth } = await authState()
|
||||||
const mcpServers = config.mcp ?? {}
|
const servers = oauthServers(config)
|
||||||
|
|
||||||
// Get OAuth-capable servers
|
if (servers.length === 0) {
|
||||||
const oauthServers = Object.entries(mcpServers).filter(
|
|
||||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (oauthServers.length === 0) {
|
|
||||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, serverConfig] of oauthServers) {
|
for (const [name, serverConfig] of servers) {
|
||||||
const authStatus = await MCP.getAuthStatus(name)
|
const authStatus = auth[name]
|
||||||
const icon = getAuthStatusIcon(authStatus)
|
const icon = getAuthStatusIcon(authStatus)
|
||||||
const statusText = getAuthStatusText(authStatus)
|
const statusText = getAuthStatusText(authStatus)
|
||||||
const url = serverConfig.url
|
const url = serverConfig.url
|
||||||
@@ -312,7 +340,7 @@ export const McpAuthListCommand = cmd({
|
|||||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
|
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -333,8 +361,7 @@ export const McpLogoutCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Logout")
|
prompts.intro("MCP OAuth Logout")
|
||||||
|
|
||||||
const authPath = path.join(Global.Path.data, "mcp-auth.json")
|
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
|
||||||
const credentials = await McpAuth.all()
|
|
||||||
const serverNames = Object.keys(credentials)
|
const serverNames = Object.keys(credentials)
|
||||||
|
|
||||||
if (serverNames.length === 0) {
|
if (serverNames.length === 0) {
|
||||||
@@ -372,7 +399,7 @@ export const McpLogoutCommand = cmd({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MCP.removeAuth(serverName)
|
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
|
||||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
},
|
},
|
||||||
@@ -595,7 +622,7 @@ export const McpDebugCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Debug")
|
prompts.intro("MCP OAuth Debug")
|
||||||
|
|
||||||
const config = await Config.get()
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
const serverName = args.name
|
const serverName = args.name
|
||||||
|
|
||||||
@@ -622,10 +649,18 @@ export const McpDebugCommand = cmd({
|
|||||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||||
|
|
||||||
// Check stored auth status
|
// Check stored auth status
|
||||||
const authStatus = await MCP.getAuthStatus(serverName)
|
const { authStatus, entry } = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const auth = yield* McpAuth.Service
|
||||||
|
return {
|
||||||
|
authStatus: yield* mcp.getAuthStatus(serverName),
|
||||||
|
entry: yield* auth.get(serverName),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||||
|
|
||||||
const entry = await McpAuth.get(serverName)
|
|
||||||
if (entry?.tokens) {
|
if (entry?.tokens) {
|
||||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||||
if (entry.tokens.expiresAt) {
|
if (entry.tokens.expiresAt) {
|
||||||
@@ -681,6 +716,11 @@ export const McpDebugCommand = cmd({
|
|||||||
|
|
||||||
// Try to discover OAuth metadata
|
// Try to discover OAuth metadata
|
||||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||||
|
const auth = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* McpAuth.Service
|
||||||
|
}),
|
||||||
|
)
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthProvider(
|
||||||
serverName,
|
serverName,
|
||||||
serverConfig.url,
|
serverConfig.url,
|
||||||
@@ -693,6 +733,7 @@ export const McpDebugCommand = cmd({
|
|||||||
{
|
{
|
||||||
onRedirect: async () => {},
|
onRedirect: async () => {},
|
||||||
},
|
},
|
||||||
|
auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -319,6 +340,12 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
const hooks = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
return yield* plugin.list()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const priority: Record<string, number> = {
|
const priority: Record<string, number> = {
|
||||||
opencode: 0,
|
opencode: 0,
|
||||||
@@ -330,7 +357,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
vercel: 6,
|
vercel: 6,
|
||||||
}
|
}
|
||||||
const pluginProviders = resolvePluginProviders({
|
const pluginProviders = resolvePluginProviders({
|
||||||
hooks: await Plugin.list(),
|
hooks,
|
||||||
existingProviders: providers,
|
existingProviders: providers,
|
||||||
disabled,
|
disabled,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -387,7 +414,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
provider = selected as string
|
provider = selected as string
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
if (plugin && plugin.auth) {
|
if (plugin && plugin.auth) {
|
||||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||||
if (handled) return
|
if (handled) return
|
||||||
@@ -401,7 +428,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||||
provider = custom.replace(/^@ai-sdk\//, "")
|
provider = custom.replace(/^@ai-sdk\//, "")
|
||||||
|
|
||||||
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
if (customPlugin && customPlugin.auth) {
|
if (customPlugin && customPlugin.auth) {
|
||||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||||
if (handled) return
|
if (handled) return
|
||||||
@@ -441,7 +468,7 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
await Auth.set(provider, {
|
await put(provider, {
|
||||||
type: "api",
|
type: "api",
|
||||||
key,
|
key,
|
||||||
})
|
})
|
||||||
@@ -457,22 +484,33 @@ export const ProvidersLogoutCommand = cmd({
|
|||||||
describe: "log out from a configured provider",
|
describe: "log out from a configured provider",
|
||||||
async handler(_args) {
|
async handler(_args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
return Object.entries(yield* auth.all())
|
||||||
|
}),
|
||||||
|
)
|
||||||
prompts.intro("Remove credential")
|
prompts.intro("Remove credential")
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
prompts.log.error("No credentials found")
|
prompts.log.error("No credentials found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const database = await ModelsDev.get()
|
const database = await ModelsDev.get()
|
||||||
const providerID = await prompts.select({
|
const selected = await prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
options: credentials.map(([key, value]) => ({
|
options: credentials.map(([key, value]) => ({
|
||||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||||
value: key,
|
value: key,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||||
await Auth.remove(providerID)
|
const providerID = selected as string
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.remove(providerID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
prompts.outro("Logout successful")
|
prompts.outro("Logout successful")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
|
|||||||
import { BashTool } from "../../tool/bash"
|
import { BashTool } from "../../tool/bash"
|
||||||
import { TodoWriteTool } from "../../tool/todo"
|
import { TodoWriteTool } from "../../tool/todo"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
type ToolProps<T> = {
|
type ToolProps<T> = {
|
||||||
input: Tool.InferParameters<T>
|
input: Tool.InferParameters<T>
|
||||||
@@ -573,6 +574,7 @@ export const RunCommand = cmd({
|
|||||||
// Validate agent if specified
|
// Validate agent if specified
|
||||||
const agent = await (async () => {
|
const agent = await (async () => {
|
||||||
if (!args.agent) return undefined
|
if (!args.agent) return undefined
|
||||||
|
const name = args.agent
|
||||||
|
|
||||||
// When attaching, validate against the running server instead of local Instance state.
|
// When attaching, validate against the running server instead of local Instance state.
|
||||||
if (args.attach) {
|
if (args.attach) {
|
||||||
@@ -590,12 +592,12 @@ export const RunCommand = cmd({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = modes.find((a) => a.name === args.agent)
|
const agent = modes.find((a) => a.name === name)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
UI.println(
|
UI.println(
|
||||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||||
UI.Style.TEXT_NORMAL,
|
UI.Style.TEXT_NORMAL,
|
||||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
`agent "${name}" not found. Falling back to default agent`,
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -604,20 +606,20 @@ export const RunCommand = cmd({
|
|||||||
UI.println(
|
UI.println(
|
||||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||||
UI.Style.TEXT_NORMAL,
|
UI.Style.TEXT_NORMAL,
|
||||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return args.agent
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await Agent.get(args.agent)
|
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
UI.println(
|
UI.println(
|
||||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||||
UI.Style.TEXT_NORMAL,
|
UI.Style.TEXT_NORMAL,
|
||||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
`agent "${name}" not found. Falling back to default agent`,
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -625,11 +627,11 @@ export const RunCommand = cmd({
|
|||||||
UI.println(
|
UI.println(
|
||||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||||
UI.Style.TEXT_NORMAL,
|
UI.Style.TEXT_NORMAL,
|
||||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return args.agent
|
return name
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const sessionID = await session(sdk)
|
const sessionID = await session(sdk)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
import { Selection } from "@tui/util/selection"
|
||||||
|
import { Terminal } from "@tui/util/terminal"
|
||||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import {
|
import {
|
||||||
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
|
|||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|
||||||
// can't set raw mode if not a TTY
|
|
||||||
if (!process.stdin.isTTY) return "dark"
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let timeout: NodeJS.Timeout
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
process.stdin.setRawMode(false)
|
|
||||||
process.stdin.removeListener("data", handler)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
|
||||||
const str = data.toString()
|
|
||||||
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
|
||||||
if (match) {
|
|
||||||
cleanup()
|
|
||||||
const color = match[1]
|
|
||||||
// Parse RGB values from color string
|
|
||||||
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
|
||||||
let r = 0,
|
|
||||||
g = 0,
|
|
||||||
b = 0
|
|
||||||
|
|
||||||
if (color.startsWith("rgb:")) {
|
|
||||||
const parts = color.substring(4).split("/")
|
|
||||||
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
|
||||||
} else if (color.startsWith("#")) {
|
|
||||||
r = parseInt(color.substring(1, 3), 16)
|
|
||||||
g = parseInt(color.substring(3, 5), 16)
|
|
||||||
b = parseInt(color.substring(5, 7), 16)
|
|
||||||
} else if (color.startsWith("rgb(")) {
|
|
||||||
const parts = color.substring(4, color.length - 1).split(",")
|
|
||||||
r = parseInt(parts[0])
|
|
||||||
g = parseInt(parts[1])
|
|
||||||
b = parseInt(parts[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate luminance using relative luminance formula
|
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
||||||
|
|
||||||
// Determine if dark or light based on luminance threshold
|
|
||||||
resolve(luminance > 0.5 ? "light" : "dark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
|
||||||
process.stdin.on("data", handler)
|
|
||||||
process.stdout.write("\x1b]11;?\x07")
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
cleanup()
|
|
||||||
resolve("dark")
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
|
|
||||||
@@ -178,7 +119,7 @@ export function tui(input: {
|
|||||||
const unguard = win32InstallCtrlCGuard()
|
const unguard = win32InstallCtrlCGuard()
|
||||||
win32DisableProcessedInput()
|
win32DisableProcessedInput()
|
||||||
|
|
||||||
const mode = await getTerminalBackgroundColor()
|
const mode = await Terminal.getTerminalBackgroundColor()
|
||||||
|
|
||||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises"
|
|||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
|
type Adaptor = {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
baseUrl: sdk.url,
|
baseUrl: sdk.url,
|
||||||
@@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [creating, setCreating] = createSignal<string>()
|
const [creating, setCreating] = createSignal<string>()
|
||||||
|
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
|
void (async () => {
|
||||||
|
const dir = sync.path.directory || sdk.directory
|
||||||
|
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
||||||
|
if (dir) url.searchParams.set("directory", dir)
|
||||||
|
const res = await sdk
|
||||||
|
.fetch(url)
|
||||||
|
.then((x) => x.json() as Promise<Adaptor[]>)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!res) {
|
||||||
|
toast.show({
|
||||||
|
message: "Failed to load workspace adaptors",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAdaptors(res)
|
||||||
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
@@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
const list = adaptors()
|
||||||
{
|
if (!list) {
|
||||||
title: "Worktree",
|
return [
|
||||||
value: "worktree" as const,
|
{
|
||||||
description: "Create a local git worktree",
|
title: "Loading workspaces...",
|
||||||
},
|
value: "loading" as const,
|
||||||
]
|
description: "Fetching available workspace adaptors",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return list.map((item) => ({
|
||||||
|
title: item.name,
|
||||||
|
value: item.type,
|
||||||
|
description: item.description,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = async (type: string) => {
|
const create = async (type: string) => {
|
||||||
@@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
|||||||
skipFilter={true}
|
skipFilter={true}
|
||||||
options={options()}
|
options={options()}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option.value === "creating") return
|
if (option.value === "creating" || option.value === "loading") return
|
||||||
void create(option.value)
|
void create(option.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,82 +1,630 @@
|
|||||||
import { TextAttributes, RGBA } from "@opentui/core"
|
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { For, type JSX } from "solid-js"
|
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
import { useTheme, tint } from "@tui/context/theme"
|
||||||
import { logo, marks } from "@/cli/logo"
|
import { Sound } from "@tui/util/sound"
|
||||||
|
import { logo } from "@/cli/logo"
|
||||||
|
|
||||||
// Shadow markers (rendered chars in parens):
|
// Shadow markers (rendered chars in parens):
|
||||||
// _ = full shadow cell (space with bg=shadow)
|
// _ = full shadow cell (space with bg=shadow)
|
||||||
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
||||||
// ~ = shadow top only (▀ with fg=shadow)
|
// ~ = shadow top only (▀ with fg=shadow)
|
||||||
const SHADOW_MARKER = new RegExp(`[${marks}]`)
|
const GAP = 1
|
||||||
|
const WIDTH = 0.76
|
||||||
|
const GAIN = 2.3
|
||||||
|
const FLASH = 2.15
|
||||||
|
const TRAIL = 0.28
|
||||||
|
const SWELL = 0.24
|
||||||
|
const WIDE = 1.85
|
||||||
|
const DRIFT = 1.45
|
||||||
|
const EXPAND = 1.62
|
||||||
|
const LIFE = 1020
|
||||||
|
const CHARGE = 3000
|
||||||
|
const HOLD = 90
|
||||||
|
const SINK = 40
|
||||||
|
const ARC = 2.2
|
||||||
|
const FORK = 1.2
|
||||||
|
const DIM = 1.04
|
||||||
|
const KICK = 0.86
|
||||||
|
const LAG = 60
|
||||||
|
const SUCK = 0.34
|
||||||
|
const SHIMMER_IN = 60
|
||||||
|
const SHIMMER_OUT = 2.8
|
||||||
|
const TRACE = 0.033
|
||||||
|
const TAIL = 1.8
|
||||||
|
const TRACE_IN = 200
|
||||||
|
const GLOW_OUT = 1600
|
||||||
|
const PEAK = RGBA.fromInts(255, 255, 255)
|
||||||
|
|
||||||
|
type Ring = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
at: number
|
||||||
|
force: number
|
||||||
|
kick: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hold = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
at: number
|
||||||
|
glyph: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
at: number
|
||||||
|
glyph: number | undefined
|
||||||
|
level: number
|
||||||
|
rise: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Glow = {
|
||||||
|
glyph: number
|
||||||
|
at: number
|
||||||
|
force: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame = {
|
||||||
|
t: number
|
||||||
|
list: Ring[]
|
||||||
|
hold: Hold | undefined
|
||||||
|
release: Release | undefined
|
||||||
|
glow: Glow | undefined
|
||||||
|
spark: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEFT = logo.left[0]?.length ?? 0
|
||||||
|
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
|
||||||
|
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||||
|
const NEAR = [
|
||||||
|
[1, 0],
|
||||||
|
[1, 1],
|
||||||
|
[0, 1],
|
||||||
|
[-1, 1],
|
||||||
|
[-1, 0],
|
||||||
|
[-1, -1],
|
||||||
|
[0, -1],
|
||||||
|
[1, -1],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Trace = {
|
||||||
|
glyph: number
|
||||||
|
i: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number) {
|
||||||
|
return Math.max(0, Math.min(1, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * clamp(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ease(t: number) {
|
||||||
|
const p = clamp(t)
|
||||||
|
return p * p * (3 - 2 * p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(t: number) {
|
||||||
|
const p = clamp(t)
|
||||||
|
return ease(p * p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramp(t: number, start: number, end: number) {
|
||||||
|
if (end <= start) return ease(t >= end ? 1 : 0)
|
||||||
|
return ease((t - start) / (end - start))
|
||||||
|
}
|
||||||
|
|
||||||
|
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||||
|
const mid = tint(base, theme.primary, 0.84)
|
||||||
|
const top = tint(theme.primary, PEAK, 0.96)
|
||||||
|
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
|
||||||
|
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||||
|
if (n >= 0) return glow(base, theme, n)
|
||||||
|
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghost(n: number, scale: number) {
|
||||||
|
if (n < 0) return n
|
||||||
|
return n * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
function noise(x: number, y: number, t: number) {
|
||||||
|
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
|
||||||
|
return n - Math.floor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lit(char: string) {
|
||||||
|
return char !== " " && char !== "_" && char !== "~"
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(x: number, y: number) {
|
||||||
|
return `${x},${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(list: Array<{ x: number; y: number }>) {
|
||||||
|
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
|
||||||
|
const path: Array<{ x: number; y: number }> = []
|
||||||
|
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
|
||||||
|
let dir = { x: 1, y: 0 }
|
||||||
|
|
||||||
|
while (cur) {
|
||||||
|
path.push(cur)
|
||||||
|
left.delete(key(cur.x, cur.y))
|
||||||
|
if (!left.size) return path
|
||||||
|
|
||||||
|
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
|
||||||
|
.filter((item): item is { x: number; y: number } => !!item)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ax = a.x - cur.x
|
||||||
|
const ay = a.y - cur.y
|
||||||
|
const bx = b.x - cur.x
|
||||||
|
const by = b.y - cur.y
|
||||||
|
const adot = ax * dir.x + ay * dir.y
|
||||||
|
const bdot = bx * dir.x + by * dir.y
|
||||||
|
if (adot !== bdot) return bdot - adot
|
||||||
|
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
|
||||||
|
})[0]
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
cur = [...left.values()].sort((a, b) => {
|
||||||
|
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
|
||||||
|
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
|
||||||
|
return da - db
|
||||||
|
})[0]
|
||||||
|
dir = { x: 1, y: 0 }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = { x: next.x - cur.x, y: next.y - cur.y }
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGlyphs() {
|
||||||
|
const cells = [] as Array<{ x: number; y: number }>
|
||||||
|
|
||||||
|
for (let y = 0; y < FULL.length; y++) {
|
||||||
|
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
||||||
|
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const glyph = new Map<string, number>()
|
||||||
|
const trace = new Map<string, Trace>()
|
||||||
|
const center = new Map<number, { x: number; y: number }>()
|
||||||
|
let id = 0
|
||||||
|
|
||||||
|
for (const item of cells) {
|
||||||
|
const start = key(item.x, item.y)
|
||||||
|
if (seen.has(start)) continue
|
||||||
|
const stack = [item]
|
||||||
|
const part = [] as Array<{ x: number; y: number }>
|
||||||
|
seen.add(start)
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop()!
|
||||||
|
part.push(cur)
|
||||||
|
glyph.set(key(cur.x, cur.y), id)
|
||||||
|
for (const [dx, dy] of NEAR) {
|
||||||
|
const next = all.get(key(cur.x + dx, cur.y + dy))
|
||||||
|
if (!next) continue
|
||||||
|
const mark = key(next.x, next.y)
|
||||||
|
if (seen.has(mark)) continue
|
||||||
|
seen.add(mark)
|
||||||
|
stack.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = route(part)
|
||||||
|
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
|
||||||
|
center.set(id, {
|
||||||
|
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
|
||||||
|
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
|
||||||
|
})
|
||||||
|
id++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { glyph, trace, center }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAP = mapGlyphs()
|
||||||
|
|
||||||
|
function shimmer(x: number, y: number, frame: Frame) {
|
||||||
|
return frame.list.reduce((best, item) => {
|
||||||
|
const age = frame.t - item.at
|
||||||
|
if (age < SHIMMER_IN || age > LIFE) return best
|
||||||
|
const dx = x + 0.5 - item.x
|
||||||
|
const dy = y * 2 + 1 - item.y
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const p = age / LIFE
|
||||||
|
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
|
const lag = r - dist
|
||||||
|
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||||
|
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||||
|
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
|
||||||
|
const n = band * wobble * (1 - p) ** 1.45
|
||||||
|
if (n > best) return n
|
||||||
|
return best
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remain(x: number, y: number, item: Release, t: number) {
|
||||||
|
const age = t - item.at
|
||||||
|
if (age < 0 || age > LIFE) return 0
|
||||||
|
const p = age / LIFE
|
||||||
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
|
if (dist > r) return 1
|
||||||
|
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||||
|
return frame.list.reduce((sum, item) => {
|
||||||
|
const age = frame.t - item.at
|
||||||
|
if (age < 0 || age > LIFE) return sum
|
||||||
|
const p = age / LIFE
|
||||||
|
const dx = x + 0.5 - item.x
|
||||||
|
const dy = y * 2 + 1 - item.y
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
|
const fade = (1 - p) ** 1.32
|
||||||
|
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||||
|
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||||
|
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
|
||||||
|
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
|
||||||
|
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
|
||||||
|
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
|
||||||
|
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
|
||||||
|
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
|
||||||
|
return sum + edge + swell + trail + flash + wake - kick - suck
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(x: number, y: number, frame: Frame) {
|
||||||
|
const held = frame.hold
|
||||||
|
const rest = frame.release
|
||||||
|
const item = held ?? rest
|
||||||
|
if (!item) return 0
|
||||||
|
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||||
|
const level = held ? push(rise) : rest!.level
|
||||||
|
const body = rise
|
||||||
|
const storm = level * level
|
||||||
|
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
|
||||||
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
const spin = frame.t * lerp(0.008, 0.018, storm)
|
||||||
|
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
|
||||||
|
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
|
||||||
|
const shell =
|
||||||
|
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
|
||||||
|
const ember =
|
||||||
|
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
|
||||||
|
lerp(0.02, 0.78, body)
|
||||||
|
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
|
||||||
|
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
|
||||||
|
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
|
||||||
|
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
|
||||||
|
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
|
||||||
|
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
|
||||||
|
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
|
||||||
|
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
|
||||||
|
const flicker =
|
||||||
|
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||||
|
Math.exp(-(dist * dist) / 0.15) *
|
||||||
|
lerp(0.08, 0.42, body)
|
||||||
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||||
|
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(x: number, y: number, frame: Frame) {
|
||||||
|
const held = frame.hold
|
||||||
|
const rest = frame.release
|
||||||
|
const item = held ?? rest
|
||||||
|
if (!item) return 0
|
||||||
|
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||||
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||||
|
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(x: number, y: number) {
|
||||||
|
const direct = MAP.glyph.get(key(x, y))
|
||||||
|
if (direct !== undefined) return direct
|
||||||
|
|
||||||
|
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||||
|
(item): item is number => item !== undefined,
|
||||||
|
)
|
||||||
|
return near
|
||||||
|
}
|
||||||
|
|
||||||
|
function trace(x: number, y: number, frame: Frame) {
|
||||||
|
const held = frame.hold
|
||||||
|
const rest = frame.release
|
||||||
|
const item = held ?? rest
|
||||||
|
if (!item || item.glyph === undefined) return 0
|
||||||
|
const step = MAP.trace.get(key(x, y))
|
||||||
|
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||||
|
const age = frame.t - item.at
|
||||||
|
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||||
|
const appear = held ? ramp(age, 0, TRACE_IN) : 1
|
||||||
|
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
|
||||||
|
const head = (age * speed) % step.l
|
||||||
|
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||||
|
const tail = (head - TAIL + step.l) % step.l
|
||||||
|
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||||
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||||
|
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||||
|
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||||
|
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||||
|
return (core + glow + trail) * appear * fade
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloom(x: number, y: number, frame: Frame) {
|
||||||
|
const item = frame.glow
|
||||||
|
if (!item) return 0
|
||||||
|
const glyph = MAP.glyph.get(key(x, y))
|
||||||
|
if (glyph !== item.glyph) return 0
|
||||||
|
const age = frame.t - item.at
|
||||||
|
if (age < 0 || age > GLOW_OUT) return 0
|
||||||
|
const p = age / GLOW_OUT
|
||||||
|
const flash = (1 - p) ** 2
|
||||||
|
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
||||||
|
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
||||||
|
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||||
|
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||||
|
}
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const [rings, setRings] = createSignal<Ring[]>([])
|
||||||
|
const [hold, setHold] = createSignal<Hold>()
|
||||||
|
const [release, setRelease] = createSignal<Release>()
|
||||||
|
const [glow, setGlow] = createSignal<Glow>()
|
||||||
|
const [now, setNow] = createSignal(0)
|
||||||
|
let box: BoxRenderable | undefined
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let hum = false
|
||||||
|
|
||||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
const stop = () => {
|
||||||
const shadow = tint(theme.background, fg, 0.25)
|
if (!timer) return
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const t = performance.now()
|
||||||
|
setNow(t)
|
||||||
|
const item = hold()
|
||||||
|
if (item && !hum && t - item.at >= HOLD) {
|
||||||
|
hum = true
|
||||||
|
Sound.start()
|
||||||
|
}
|
||||||
|
if (item && t - item.at >= CHARGE) {
|
||||||
|
burst(item.x, item.y)
|
||||||
|
}
|
||||||
|
let live = false
|
||||||
|
setRings((list) => {
|
||||||
|
const next = list.filter((item) => t - item.at < LIFE)
|
||||||
|
live = next.length > 0
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
const flash = glow()
|
||||||
|
if (flash && t - flash.at >= GLOW_OUT) {
|
||||||
|
setGlow(undefined)
|
||||||
|
}
|
||||||
|
if (!live) setRelease(undefined)
|
||||||
|
if (live || hold() || release() || glow()) return
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (timer) return
|
||||||
|
timer = setInterval(tick, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = (x: number, y: number) => {
|
||||||
|
const char = FULL[y]?.[x]
|
||||||
|
return char !== undefined && char !== " "
|
||||||
|
}
|
||||||
|
|
||||||
|
const press = (x: number, y: number, t: number) => {
|
||||||
|
const last = hold()
|
||||||
|
if (last) burst(last.x, last.y)
|
||||||
|
setNow(t)
|
||||||
|
if (!last) setRelease(undefined)
|
||||||
|
setHold({ x, y, at: t, glyph: select(x, y) })
|
||||||
|
hum = false
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const burst = (x: number, y: number) => {
|
||||||
|
const item = hold()
|
||||||
|
if (!item) return
|
||||||
|
hum = false
|
||||||
|
const t = performance.now()
|
||||||
|
const age = t - item.at
|
||||||
|
const rise = ramp(age, HOLD, CHARGE)
|
||||||
|
const level = push(rise)
|
||||||
|
setHold(undefined)
|
||||||
|
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
|
||||||
|
if (item.glyph !== undefined) {
|
||||||
|
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
|
||||||
|
}
|
||||||
|
setRings((list) => [
|
||||||
|
...list,
|
||||||
|
{
|
||||||
|
x: x + 0.5,
|
||||||
|
y: y * 2 + 1,
|
||||||
|
at: t,
|
||||||
|
force: lerp(0.82, 2.55, level),
|
||||||
|
kick: lerp(0.32, 0.32 + KICK, level),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setNow(t)
|
||||||
|
start()
|
||||||
|
Sound.pulse(lerp(0.8, 1, level))
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = createMemo(() => {
|
||||||
|
const t = now()
|
||||||
|
const item = hold()
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
list: rings(),
|
||||||
|
hold: item,
|
||||||
|
release: release(),
|
||||||
|
glow: glow(),
|
||||||
|
spark: item ? noise(item.x, item.y, t) : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const dusk = createMemo(() => {
|
||||||
|
const base = frame()
|
||||||
|
const t = base.t - LAG
|
||||||
|
const item = base.hold
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
list: base.list,
|
||||||
|
hold: item,
|
||||||
|
release: base.release,
|
||||||
|
glow: base.glow,
|
||||||
|
spark: item ? noise(item.x, item.y, t) : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderLine = (
|
||||||
|
line: string,
|
||||||
|
y: number,
|
||||||
|
ink: RGBA,
|
||||||
|
bold: boolean,
|
||||||
|
off: number,
|
||||||
|
frame: Frame,
|
||||||
|
dusk: Frame,
|
||||||
|
): JSX.Element[] => {
|
||||||
|
const shadow = tint(theme.background, ink, 0.25)
|
||||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||||
const elements: JSX.Element[] = []
|
|
||||||
let i = 0
|
|
||||||
|
|
||||||
while (i < line.length) {
|
return [...line].map((char, i) => {
|
||||||
const rest = line.slice(i)
|
const h = field(off + i, y, frame)
|
||||||
const markerIndex = rest.search(SHADOW_MARKER)
|
const n = wave(off + i, y, frame, lit(char)) + h
|
||||||
|
const s = wave(off + i, y, dusk, false) + h
|
||||||
|
const p = lit(char) ? pick(off + i, y, frame) : 0
|
||||||
|
const e = lit(char) ? trace(off + i, y, frame) : 0
|
||||||
|
const b = lit(char) ? bloom(off + i, y, frame) : 0
|
||||||
|
const q = shimmer(off + i, y, frame)
|
||||||
|
|
||||||
if (markerIndex === -1) {
|
if (char === "_") {
|
||||||
elements.push(
|
return (
|
||||||
<text fg={fg} attributes={attrs} selectable={false}>
|
<text
|
||||||
{rest}
|
fg={shade(ink, theme, s * 0.08)}
|
||||||
</text>,
|
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||||
)
|
attributes={attrs}
|
||||||
break
|
selectable={false}
|
||||||
}
|
>
|
||||||
|
{" "}
|
||||||
if (markerIndex > 0) {
|
</text>
|
||||||
elements.push(
|
|
||||||
<text fg={fg} attributes={attrs} selectable={false}>
|
|
||||||
{rest.slice(0, markerIndex)}
|
|
||||||
</text>,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = rest[markerIndex]
|
if (char === "^") {
|
||||||
switch (marker) {
|
return (
|
||||||
case "_":
|
<text
|
||||||
elements.push(
|
fg={shade(ink, theme, n + p + e + b)}
|
||||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||||
{" "}
|
attributes={attrs}
|
||||||
</text>,
|
selectable={false}
|
||||||
)
|
>
|
||||||
break
|
▀
|
||||||
case "^":
|
</text>
|
||||||
elements.push(
|
)
|
||||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
|
||||||
▀
|
|
||||||
</text>,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "~":
|
|
||||||
elements.push(
|
|
||||||
<text fg={shadow} attributes={attrs} selectable={false}>
|
|
||||||
▀
|
|
||||||
</text>,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i += markerIndex + 1
|
if (char === "~") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === " ") {
|
||||||
|
return (
|
||||||
|
<text fg={ink} attributes={attrs} selectable={false}>
|
||||||
|
{char}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
|
{char}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
hum = false
|
||||||
|
Sound.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mouse = (evt: MouseEvent) => {
|
||||||
|
if (!box) return
|
||||||
|
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||||
|
const x = evt.x - box.x
|
||||||
|
const y = evt.y - box.y
|
||||||
|
if (!hit(x, y)) return
|
||||||
|
if (evt.type === "drag" && hold()) return
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
const t = performance.now()
|
||||||
|
press(x, y, t)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements
|
if (!hold()) return
|
||||||
|
if (evt.type === "up") {
|
||||||
|
const item = hold()
|
||||||
|
if (!item) return
|
||||||
|
burst(item.x, item.y)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box>
|
<box ref={(item: BoxRenderable) => (box = item)}>
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
width={FULL[0]?.length ?? 0}
|
||||||
|
height={FULL.length}
|
||||||
|
zIndex={1}
|
||||||
|
onMouse={mouse}
|
||||||
|
/>
|
||||||
<For each={logo.left}>
|
<For each={logo.left}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
|
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
||||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
|
<box flexDirection="row">
|
||||||
|
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export function useEvent() {
|
|||||||
|
|
||||||
function subscribe(handler: (event: Event) => void) {
|
function subscribe(handler: (event: Event) => void) {
|
||||||
return sdk.event.on("event", (event) => {
|
return sdk.event.on("event", (event) => {
|
||||||
|
if (event.payload.type === "sync") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Special hack for truly global events
|
// Special hack for truly global events
|
||||||
if (event.directory === "global") {
|
if (event.directory === "global") {
|
||||||
handler(event.payload)
|
handler(event.payload)
|
||||||
|
|||||||
@@ -31,16 +31,6 @@ import { batch, createEffect, on } from "solid-js"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||||
|
|
||||||
type SessionDiffSummary = Pick<Snapshot.FileDiff, "file" | "additions" | "deletions">
|
|
||||||
|
|
||||||
function summarizeDiff(diff?: Snapshot.FileDiff[]): SessionDiffSummary[] {
|
|
||||||
return (diff ?? []).map((item) => ({
|
|
||||||
file: item.file,
|
|
||||||
additions: item.additions,
|
|
||||||
deletions: item.deletions,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -65,7 +55,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
}
|
}
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SessionDiffSummary[]
|
[sessionID: string]: Snapshot.FileDiff[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
@@ -203,7 +193,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "session.diff":
|
case "session.diff":
|
||||||
setStore("session_diff", event.properties.sessionID, summarizeDiff(event.properties.diff))
|
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
||||||
break
|
break
|
||||||
|
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
@@ -513,7 +503,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
for (const message of messages.data!) {
|
for (const message of messages.data!) {
|
||||||
draft.part[message.info.id] = message.parts
|
draft.part[message.info.id] = message.parts
|
||||||
}
|
}
|
||||||
draft.session_diff[sessionID] = summarizeDiff(diff.data)
|
draft.session_diff[sessionID] = diff.data ?? []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
fullSyncedSessions.add(sessionID)
|
fullSyncedSessions.add(sessionID)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Player } from "cli-sound"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { basename, join } from "node:path"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
import { which } from "@/util/which"
|
||||||
|
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
|
||||||
|
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
|
||||||
|
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
|
||||||
|
import charge from "../asset/charge.wav" with { type: "file" }
|
||||||
|
|
||||||
|
const FILE = [pulseA, pulseB, pulseC]
|
||||||
|
|
||||||
|
const HUM = charge
|
||||||
|
const DIR = join(tmpdir(), "opencode-sfx")
|
||||||
|
|
||||||
|
const LIST = [
|
||||||
|
"ffplay",
|
||||||
|
"mpv",
|
||||||
|
"mpg123",
|
||||||
|
"mpg321",
|
||||||
|
"mplayer",
|
||||||
|
"afplay",
|
||||||
|
"play",
|
||||||
|
"omxplayer",
|
||||||
|
"aplay",
|
||||||
|
"cmdmp3",
|
||||||
|
"cvlc",
|
||||||
|
"powershell.exe",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Kind = (typeof LIST)[number]
|
||||||
|
|
||||||
|
function args(kind: Kind, file: string, volume: number) {
|
||||||
|
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
|
||||||
|
if (kind === "mpv")
|
||||||
|
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
|
||||||
|
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
|
||||||
|
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
|
||||||
|
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
|
||||||
|
if (kind === "play") return [kind, "-v", String(volume), file]
|
||||||
|
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
|
||||||
|
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Sound {
|
||||||
|
let item: Player | null | undefined
|
||||||
|
let kind: Kind | null | undefined
|
||||||
|
let proc: Process.Child | undefined
|
||||||
|
let tail: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||||
|
let seq = 0
|
||||||
|
let shot = 0
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (item !== undefined) return item
|
||||||
|
try {
|
||||||
|
item = new Player({ volume: 0.35 })
|
||||||
|
} catch {
|
||||||
|
item = null
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
async function file(path: string) {
|
||||||
|
mkdirSync(DIR, { recursive: true })
|
||||||
|
const next = join(DIR, basename(path))
|
||||||
|
const out = Bun.file(next)
|
||||||
|
if (await out.exists()) return next
|
||||||
|
await Bun.write(out, Bun.file(path))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset() {
|
||||||
|
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick() {
|
||||||
|
if (kind !== undefined) return kind
|
||||||
|
kind = LIST.find((item) => which(item)) ?? null
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(file: string, volume: number) {
|
||||||
|
const kind = pick()
|
||||||
|
if (!kind) return
|
||||||
|
return Process.spawn(args(kind, file, volume), {
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (!tail) return
|
||||||
|
clearTimeout(tail)
|
||||||
|
tail = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(file: string, volume: number) {
|
||||||
|
const item = load()
|
||||||
|
if (!item) return run(file, volume)?.exited
|
||||||
|
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function start() {
|
||||||
|
stop()
|
||||||
|
const id = ++seq
|
||||||
|
void asset().then(({ hum }) => {
|
||||||
|
if (id !== seq) return
|
||||||
|
const next = run(hum, 0.24)
|
||||||
|
if (!next) return
|
||||||
|
proc = next
|
||||||
|
void next.exited.then(
|
||||||
|
() => {
|
||||||
|
if (id !== seq) return
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (id !== seq) return
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop(delay = 0) {
|
||||||
|
seq++
|
||||||
|
clear()
|
||||||
|
if (!proc) return
|
||||||
|
const next = proc
|
||||||
|
if (delay <= 0) {
|
||||||
|
proc = undefined
|
||||||
|
void Process.stop(next).catch(() => undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tail = setTimeout(() => {
|
||||||
|
tail = undefined
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
void Process.stop(next).catch(() => undefined)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pulse(scale = 1) {
|
||||||
|
stop(140)
|
||||||
|
const index = shot++ % FILE.length
|
||||||
|
void asset()
|
||||||
|
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispose() {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
|
|||||||
|
|
||||||
export namespace Terminal {
|
export namespace Terminal {
|
||||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||||
|
|
||||||
|
function parse(color: string): RGBA | null {
|
||||||
|
if (color.startsWith("rgb:")) {
|
||||||
|
const parts = color.substring(4).split("/")
|
||||||
|
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||||
|
}
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return RGBA.fromHex(color)
|
||||||
|
}
|
||||||
|
if (color.startsWith("rgb(")) {
|
||||||
|
const parts = color.substring(4, color.length - 1).split(",")
|
||||||
|
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mode(bg: RGBA | null): "dark" | "light" {
|
||||||
|
if (!bg) return "dark"
|
||||||
|
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||||
|
return luminance > 0.5 ? "light" : "dark"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query terminal colors including background, foreground, and palette (0-15).
|
* Query terminal colors including background, foreground, and palette (0-15).
|
||||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||||
@@ -31,46 +53,26 @@ export namespace Terminal {
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseColor = (colorStr: string): RGBA | null => {
|
|
||||||
if (colorStr.startsWith("rgb:")) {
|
|
||||||
const parts = colorStr.substring(4).split("/")
|
|
||||||
return RGBA.fromInts(
|
|
||||||
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
|
|
||||||
parseInt(parts[1], 16) >> 8,
|
|
||||||
parseInt(parts[2], 16) >> 8,
|
|
||||||
255,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (colorStr.startsWith("#")) {
|
|
||||||
return RGBA.fromHex(colorStr)
|
|
||||||
}
|
|
||||||
if (colorStr.startsWith("rgb(")) {
|
|
||||||
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
|
|
||||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
const handler = (data: Buffer) => {
|
||||||
const str = data.toString()
|
const str = data.toString()
|
||||||
|
|
||||||
// Match OSC 11 (background color)
|
// Match OSC 11 (background color)
|
||||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
if (bgMatch) {
|
if (bgMatch) {
|
||||||
background = parseColor(bgMatch[1])
|
background = parse(bgMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match OSC 10 (foreground color)
|
// Match OSC 10 (foreground color)
|
||||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||||
if (fgMatch) {
|
if (fgMatch) {
|
||||||
foreground = parseColor(fgMatch[1])
|
foreground = parse(fgMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match OSC 4 (palette colors)
|
// Match OSC 4 (palette colors)
|
||||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||||
for (const match of paletteMatches) {
|
for (const match of paletteMatches) {
|
||||||
const index = parseInt(match[1])
|
const index = parseInt(match[1])
|
||||||
const color = parseColor(match[2])
|
const color = parse(match[2])
|
||||||
if (color) paletteColors[index] = color
|
if (color) paletteColors[index] = color
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +102,36 @@ export namespace Terminal {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||||
|
// needs OSC 11 and should resolve on the first background response instead of
|
||||||
|
// waiting on the full palette query used by system theme generation.
|
||||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
const result = await colors()
|
if (!process.stdin.isTTY) return "dark"
|
||||||
if (!result.background) return "dark"
|
|
||||||
|
|
||||||
const { r, g, b } = result.background
|
return new Promise((resolve) => {
|
||||||
// Calculate luminance using relative luminance formula
|
let timeout: NodeJS.Timeout
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
||||||
|
|
||||||
// Determine if dark or light based on luminance threshold
|
const cleanup = () => {
|
||||||
return luminance > 0.5 ? "light" : "dark"
|
process.stdin.setRawMode(false)
|
||||||
|
process.stdin.removeListener("data", handler)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (data: Buffer) => {
|
||||||
|
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
|
if (!match) return
|
||||||
|
cleanup()
|
||||||
|
resolve(mode(parse(match[1])))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.setRawMode(true)
|
||||||
|
process.stdin.on("data", handler)
|
||||||
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
resolve("dark")
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
export namespace TuiConfig {
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
@@ -51,7 +52,7 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installDeps(dir: string): Promise<void> {
|
function installDeps(dir: string): Promise<void> {
|
||||||
return Config.installDependencies(dir)
|
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string) {
|
async function mergeFile(acc: Acc, file: string) {
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import type { Adaptor } from "../types"
|
import type { ProjectID } from "@/project/schema"
|
||||||
|
import type { WorkspaceAdaptor } from "../types"
|
||||||
|
|
||||||
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
|
export type WorkspaceAdaptorEntry = {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
|
||||||
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
|
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdaptor(type: string): Promise<Adaptor> {
|
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||||
return ADAPTORS[type]()
|
|
||||||
|
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
|
||||||
|
const custom = state.get(projectID)?.get(type)
|
||||||
|
if (custom) return custom
|
||||||
|
|
||||||
|
const builtin = BUILTIN[type]
|
||||||
|
if (builtin) return builtin()
|
||||||
|
|
||||||
|
throw new Error(`Unknown workspace adaptor: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installAdaptor(type: string, adaptor: Adaptor) {
|
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||||
// This is experimental: mostly used for testing right now, but we
|
const builtin = await Promise.all(
|
||||||
// will likely allow this in the future. Need to figure out the
|
Object.entries(BUILTIN).map(async ([type, init]) => {
|
||||||
// TypeScript story
|
const adaptor = await init()
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: adaptor.name,
|
||||||
|
description: adaptor.description,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
|
||||||
|
type,
|
||||||
|
name: adaptor.name,
|
||||||
|
description: adaptor.description,
|
||||||
|
}))
|
||||||
|
return [...builtin, ...custom]
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-expect-error we force the builtin types right now, but we
|
// Plugins can be loaded per-project so we need to scope them. If you
|
||||||
// will implement a way to extend the types for custom adaptors
|
// want to install a global one pass `ProjectID.global`
|
||||||
ADAPTORS[type] = () => adaptor
|
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
|
||||||
|
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
|
||||||
|
adaptors.set(type, adaptor)
|
||||||
|
state.set(projectID, adaptors)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { type Adaptor, WorkspaceInfo } from "../types"
|
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||||
|
|
||||||
const Config = WorkspaceInfo.extend({
|
const WorktreeConfig = z.object({
|
||||||
name: WorkspaceInfo.shape.name.unwrap(),
|
name: WorkspaceInfo.shape.name,
|
||||||
branch: WorkspaceInfo.shape.branch.unwrap(),
|
branch: WorkspaceInfo.shape.branch.unwrap(),
|
||||||
directory: WorkspaceInfo.shape.directory.unwrap(),
|
directory: WorkspaceInfo.shape.directory.unwrap(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Config = z.infer<typeof Config>
|
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||||
|
name: "Worktree",
|
||||||
export const WorktreeAdaptor: Adaptor = {
|
description: "Create a git worktree",
|
||||||
async configure(info) {
|
async configure(info) {
|
||||||
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
|
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
name: worktree.name,
|
name: worktree.name,
|
||||||
@@ -21,19 +22,23 @@ export const WorktreeAdaptor: Adaptor = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async create(info) {
|
async create(info) {
|
||||||
const config = Config.parse(info)
|
const config = WorktreeConfig.parse(info)
|
||||||
await Worktree.createFromInfo({
|
await AppRuntime.runPromise(
|
||||||
name: config.name,
|
Worktree.Service.use((svc) =>
|
||||||
directory: config.directory,
|
svc.createFromInfo({
|
||||||
branch: config.branch,
|
name: config.name,
|
||||||
})
|
directory: config.directory,
|
||||||
|
branch: config.branch,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
async remove(info) {
|
async remove(info) {
|
||||||
const config = Config.parse(info)
|
const config = WorktreeConfig.parse(info)
|
||||||
await Worktree.remove({ directory: config.directory })
|
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||||
},
|
},
|
||||||
target(info) {
|
target(info) {
|
||||||
const config = Config.parse(info)
|
const config = WorktreeConfig.parse(info)
|
||||||
return {
|
return {
|
||||||
type: "local",
|
type: "local",
|
||||||
directory: config.directory,
|
directory: config.directory,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
|
|||||||
export const WorkspaceInfo = z.object({
|
export const WorkspaceInfo = z.object({
|
||||||
id: WorkspaceID.zod,
|
id: WorkspaceID.zod,
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
|
name: z.string(),
|
||||||
branch: z.string().nullable(),
|
branch: z.string().nullable(),
|
||||||
name: z.string().nullable(),
|
|
||||||
directory: z.string().nullable(),
|
directory: z.string().nullable(),
|
||||||
extra: z.unknown().nullable(),
|
extra: z.unknown().nullable(),
|
||||||
projectID: ProjectID.zod,
|
projectID: ProjectID.zod,
|
||||||
@@ -24,9 +24,11 @@ export type Target =
|
|||||||
headers?: HeadersInit
|
headers?: HeadersInit
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Adaptor = {
|
export type WorkspaceAdaptor = {
|
||||||
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
name: string
|
||||||
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
description: string
|
||||||
remove(config: WorkspaceInfo): Promise<void>
|
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||||
target(config: WorkspaceInfo): Target | Promise<Target>
|
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||||
|
remove(info: WorkspaceInfo): Promise<void>
|
||||||
|
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
|
|||||||
export const WorkspaceTable = sqliteTable("workspace", {
|
export const WorkspaceTable = sqliteTable("workspace", {
|
||||||
id: text().$type<WorkspaceID>().primaryKey(),
|
id: text().$type<WorkspaceID>().primaryKey(),
|
||||||
type: text().notNull(),
|
type: text().notNull(),
|
||||||
|
name: text().notNull().default(""),
|
||||||
branch: text(),
|
branch: text(),
|
||||||
name: text(),
|
|
||||||
directory: text(),
|
directory: text(),
|
||||||
extra: text({ mode: "json" }),
|
extra: text({ mode: "json" }),
|
||||||
project_id: text()
|
project_id: text()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { Slug } from "@opencode-ai/util/slug"
|
||||||
import { WorkspaceTable } from "./workspace.sql"
|
import { WorkspaceTable } from "./workspace.sql"
|
||||||
import { getAdaptor } from "./adaptors"
|
import { getAdaptor } from "./adaptors"
|
||||||
import { WorkspaceInfo } from "./types"
|
import { WorkspaceInfo } from "./types"
|
||||||
@@ -66,9 +67,9 @@ export namespace Workspace {
|
|||||||
|
|
||||||
export const create = fn(CreateInput, async (input) => {
|
export const create = fn(CreateInput, async (input) => {
|
||||||
const id = WorkspaceID.ascending(input.id)
|
const id = WorkspaceID.ascending(input.id)
|
||||||
const adaptor = await getAdaptor(input.type)
|
const adaptor = await getAdaptor(input.projectID, input.type)
|
||||||
|
|
||||||
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
|
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
|
||||||
|
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
id,
|
id,
|
||||||
@@ -124,7 +125,7 @@ export namespace Workspace {
|
|||||||
stopSync(id)
|
stopSync(id)
|
||||||
|
|
||||||
const info = fromRow(row)
|
const info = fromRow(row)
|
||||||
const adaptor = await getAdaptor(row.type)
|
const adaptor = await getAdaptor(info.projectID, row.type)
|
||||||
adaptor.remove(info)
|
adaptor.remove(info)
|
||||||
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||||
return info
|
return info
|
||||||
@@ -162,7 +163,7 @@ export namespace Workspace {
|
|||||||
log.info("connecting to sync: " + space.id)
|
log.info("connecting to sync: " + space.id)
|
||||||
|
|
||||||
setStatus(space.id, "connecting")
|
setStatus(space.id, "connecting")
|
||||||
const adaptor = await getAdaptor(space.type)
|
const adaptor = await getAdaptor(space.projectID, space.type)
|
||||||
const target = await adaptor.target(space)
|
const target = await adaptor.target(space)
|
||||||
|
|
||||||
if (target.type === "local") return
|
if (target.type === "local") return
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
|
|||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
|
|
||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
Observability.layer,
|
// Observability.layer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Bus.defaultLayer,
|
Bus.defaultLayer,
|
||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
SessionShare.defaultLayer,
|
SessionShare.defaultLayer,
|
||||||
)
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|
||||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { memoMap } from "./run-service"
|
import { memoMap } from "./run-service"
|
||||||
|
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import { LSP } from "@/lsp"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import { File } from "@/file"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Observability } from "./oltp"
|
||||||
|
|
||||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
|
export const BootstrapLayer = Layer.mergeAll(
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
ShareNext.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
LSP.defaultLayer,
|
||||||
|
File.defaultLayer,
|
||||||
|
FileWatcher.defaultLayer,
|
||||||
|
Vcs.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
|
Bus.defaultLayer,
|
||||||
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|
||||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||||
|
|||||||
Vendored
+45
-19
@@ -1,28 +1,54 @@
|
|||||||
import { Instance } from "../project/instance"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
export namespace Env {
|
export namespace Env {
|
||||||
const state = Instance.state(() => {
|
type State = Record<string, string | undefined>
|
||||||
// Create a shallow copy to isolate environment per instance
|
|
||||||
// Prevents parallel tests from interfering with each other's env vars
|
|
||||||
return { ...process.env } as Record<string, string | undefined>
|
|
||||||
})
|
|
||||||
|
|
||||||
export function get(key: string) {
|
export interface Interface {
|
||||||
const env = state()
|
readonly get: (key: string) => Effect.Effect<string | undefined>
|
||||||
return env[key]
|
readonly all: () => Effect.Effect<State>
|
||||||
|
readonly set: (key: string, value: string) => Effect.Effect<void>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function all() {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
|
||||||
return state()
|
|
||||||
}
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Env.state")(() =>
|
||||||
|
Effect.succeed(
|
||||||
|
// Create a shallow copy to isolate environment per instance
|
||||||
|
// Prevents parallel tests from interfering with each other's env vars
|
||||||
|
{ ...process.env } as State,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
|
||||||
|
|
||||||
|
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
|
||||||
|
|
||||||
|
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
env[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Env.remove")(function* (key: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
delete env[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
const rt = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export function set(key: string, value: string) {
|
export function set(key: string, value: string) {
|
||||||
const env = state()
|
return rt.runSync((svc) => svc.set(key, value))
|
||||||
env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remove(key: string) {
|
|
||||||
const env = state()
|
|
||||||
delete env[key]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
@@ -644,26 +643,4 @@ export namespace File {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
|
||||||
return runPromise((svc) => svc.status())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
|
||||||
return runPromise((svc) => svc.read(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
|
||||||
return runPromise((svc) => svc.list(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
|
||||||
return runPromise((svc) => svc.search(input))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
@@ -94,8 +94,43 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
const Result = z.union([Begin, Match, End, Summary])
|
const Result = z.union([Begin, Match, End, Summary])
|
||||||
|
|
||||||
|
const Hit = Schema.Struct({
|
||||||
|
type: Schema.Literal("match"),
|
||||||
|
data: Schema.Struct({
|
||||||
|
path: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
lines: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
line_number: Schema.Number,
|
||||||
|
absolute_offset: Schema.Number,
|
||||||
|
submatches: Schema.mutable(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
match: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
}),
|
||||||
|
start: Schema.Number,
|
||||||
|
end: Schema.Number,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Row = Schema.Union([
|
||||||
|
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
|
||||||
|
Hit,
|
||||||
|
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
|
||||||
|
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
|
||||||
|
|
||||||
export type Result = z.infer<typeof Result>
|
export type Result = z.infer<typeof Result>
|
||||||
export type Match = z.infer<typeof Match>
|
export type Match = z.infer<typeof Match>
|
||||||
|
export type Item = Match["data"]
|
||||||
export type Begin = z.infer<typeof Begin>
|
export type Begin = z.infer<typeof Begin>
|
||||||
export type End = z.infer<typeof End>
|
export type End = z.infer<typeof End>
|
||||||
export type Summary = z.infer<typeof Summary>
|
export type Summary = z.infer<typeof Summary>
|
||||||
@@ -289,6 +324,14 @@ export namespace Ripgrep {
|
|||||||
follow?: boolean
|
follow?: boolean
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
}) => Stream.Stream<string, PlatformError>
|
}) => Stream.Stream<string, PlatformError>
|
||||||
|
readonly search: (input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
|
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||||
@@ -298,6 +341,33 @@ export namespace Ripgrep {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* AppFileSystem.Service
|
||||||
|
const bin = Effect.fn("Ripgrep.path")(function* () {
|
||||||
|
return yield* Effect.promise(() => filepath())
|
||||||
|
})
|
||||||
|
const args = Effect.fn("Ripgrep.args")(function* (input: {
|
||||||
|
mode: "files" | "search"
|
||||||
|
glob?: string[]
|
||||||
|
hidden?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
limit?: number
|
||||||
|
pattern?: string
|
||||||
|
file?: string[]
|
||||||
|
}) {
|
||||||
|
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
||||||
|
if (input.follow) out.push("--follow")
|
||||||
|
if (input.hidden !== false) out.push("--hidden")
|
||||||
|
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
|
||||||
|
if (input.glob) {
|
||||||
|
for (const g of input.glob) {
|
||||||
|
out.push(`--glob=${g}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.limit) out.push(`--max-count=${input.limit}`)
|
||||||
|
if (input.mode === "search") out.push("--no-messages")
|
||||||
|
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
||||||
cwd: string
|
cwd: string
|
||||||
@@ -306,7 +376,7 @@ export namespace Ripgrep {
|
|||||||
follow?: boolean
|
follow?: boolean
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
}) {
|
}) {
|
||||||
const rgPath = yield* Effect.promise(() => filepath())
|
const rgPath = yield* bin()
|
||||||
const isDir = yield* afs.isDir(input.cwd)
|
const isDir = yield* afs.isDir(input.cwd)
|
||||||
if (!isDir) {
|
if (!isDir) {
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
@@ -318,23 +388,79 @@ export namespace Ripgrep {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [rgPath, "--files", "--glob=!.git/*"]
|
const cmd = yield* args({
|
||||||
if (input.follow) args.push("--follow")
|
mode: "files",
|
||||||
if (input.hidden !== false) args.push("--hidden")
|
glob: input.glob,
|
||||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
hidden: input.hidden,
|
||||||
if (input.glob) {
|
follow: input.follow,
|
||||||
for (const g of input.glob) {
|
maxDepth: input.maxDepth,
|
||||||
args.push(`--glob=${g}`)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spawner
|
return spawner
|
||||||
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
|
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
|
||||||
.pipe(Stream.filter((line: string) => line.length > 0))
|
.pipe(Stream.filter((line: string) => line.length > 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("Ripgrep.search")(function* (input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
|
}) {
|
||||||
|
return yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cmd = yield* args({
|
||||||
|
mode: "search",
|
||||||
|
glob: input.glob,
|
||||||
|
follow: input.follow,
|
||||||
|
limit: input.limit,
|
||||||
|
pattern: input.pattern,
|
||||||
|
file: input.file,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handle = yield* spawner.spawn(
|
||||||
|
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
|
cwd: input.cwd,
|
||||||
|
stdin: "ignore",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [items, stderr, code] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
Stream.decodeText(handle.stdout).pipe(
|
||||||
|
Stream.splitLines,
|
||||||
|
Stream.filter((line) => line.length > 0),
|
||||||
|
Stream.mapEffect((line) =>
|
||||||
|
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
|
||||||
|
),
|
||||||
|
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
|
||||||
|
Stream.map((row): Item => row.data),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.map((chunk) => [...chunk]),
|
||||||
|
),
|
||||||
|
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||||
|
handle.exitCode,
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (code !== 0 && code !== 1 && code !== 2) {
|
||||||
|
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
partial: code === 2,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
files: (input) => Stream.unwrap(files(input)),
|
files: (input) => Stream.unwrap(files(input)),
|
||||||
|
search,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -401,46 +527,4 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: {
|
|
||||||
cwd: string
|
|
||||||
pattern: string
|
|
||||||
glob?: string[]
|
|
||||||
limit?: number
|
|
||||||
follow?: boolean
|
|
||||||
}) {
|
|
||||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
|
|
||||||
if (input.glob) {
|
|
||||||
for (const g of input.glob) {
|
|
||||||
args.push(`--glob=${g}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.limit) {
|
|
||||||
args.push(`--max-count=${input.limit}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("--")
|
|
||||||
args.push(input.pattern)
|
|
||||||
|
|
||||||
const result = await Process.text(args, {
|
|
||||||
cwd: input.cwd,
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
||||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
|
||||||
// Parse JSON lines from ripgrep output
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.map((line) => JSON.parse(line))
|
|
||||||
.map((parsed) => Result.parse(parsed))
|
|
||||||
.filter((r) => r.type === "match")
|
|
||||||
.map((r) => r.data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +13,7 @@ export namespace Identifier {
|
|||||||
pty: "pty",
|
pty: "pty",
|
||||||
tool: "tool",
|
tool: "tool",
|
||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
|
entry: "ent",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function schema(prefix: keyof typeof prefixes) {
|
export function schema(prefix: keyof typeof prefixes) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import z from "zod"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace McpAuth {
|
export namespace McpAuth {
|
||||||
export const Tokens = z.object({
|
export const Tokens = z.object({
|
||||||
@@ -142,32 +141,4 @@ export namespace McpAuth {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
// Async facades for backward compat (used by McpOAuthProvider, CLI)
|
|
||||||
|
|
||||||
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
|
|
||||||
|
|
||||||
export const getForUrl = async (mcpName: string, serverUrl: string) =>
|
|
||||||
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
|
|
||||||
|
|
||||||
export const all = async () => runPromise((svc) => svc.all())
|
|
||||||
|
|
||||||
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
|
|
||||||
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
|
|
||||||
|
|
||||||
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
|
|
||||||
|
|
||||||
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
|
|
||||||
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
|
|
||||||
|
|
||||||
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
|
|
||||||
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
|
|
||||||
|
|
||||||
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
|
|
||||||
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
|
|
||||||
|
|
||||||
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
|
|
||||||
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import open from "open"
|
|||||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||||
import { EffectLogger } from "@/effect/logger"
|
import { EffectLogger } from "@/effect/logger"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
@@ -294,6 +293,7 @@ export namespace MCP {
|
|||||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
auth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,6 +745,7 @@ export namespace MCP {
|
|||||||
capturedUrl = url
|
capturedUrl = url
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
||||||
@@ -890,37 +891,4 @@ export namespace MCP {
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
// --- Async facade functions ---
|
|
||||||
|
|
||||||
export const status = async () => runPromise((svc) => svc.status())
|
|
||||||
|
|
||||||
export const tools = async () => runPromise((svc) => svc.tools())
|
|
||||||
|
|
||||||
export const prompts = async () => runPromise((svc) => svc.prompts())
|
|
||||||
|
|
||||||
export const resources = async () => runPromise((svc) => svc.resources())
|
|
||||||
|
|
||||||
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
|
|
||||||
|
|
||||||
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
|
|
||||||
|
|
||||||
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
|
||||||
|
|
||||||
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
|
||||||
|
|
||||||
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
|
||||||
|
|
||||||
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
|
|
||||||
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
|
|
||||||
|
|
||||||
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
|
|
||||||
|
|
||||||
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
|
|
||||||
|
|
||||||
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
|
|
||||||
|
|
||||||
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
OAuthClientInformation,
|
OAuthClientInformation,
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { McpAuth } from "./auth"
|
import { McpAuth } from "./auth"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
private serverUrl: string,
|
private serverUrl: string,
|
||||||
private config: McpOAuthConfig,
|
private config: McpOAuthConfig,
|
||||||
private callbacks: McpOAuthCallbacks,
|
private callbacks: McpOAuthCallbacks,
|
||||||
|
private auth: McpAuth.Interface,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
@@ -61,7 +63,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
|
|
||||||
// Check stored client info (from dynamic registration)
|
// Check stored client info (from dynamic registration)
|
||||||
// Use getForUrl to validate credentials are for the current server URL
|
// Use getForUrl to validate credentials are for the current server URL
|
||||||
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
|
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||||
if (entry?.clientInfo) {
|
if (entry?.clientInfo) {
|
||||||
// Check if client secret has expired
|
// Check if client secret has expired
|
||||||
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
||||||
@@ -79,15 +81,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||||
await McpAuth.updateClientInfo(
|
await Effect.runPromise(
|
||||||
this.mcpName,
|
this.auth.updateClientInfo(
|
||||||
{
|
this.mcpName,
|
||||||
clientId: info.client_id,
|
{
|
||||||
clientSecret: info.client_secret,
|
clientId: info.client_id,
|
||||||
clientIdIssuedAt: info.client_id_issued_at,
|
clientSecret: info.client_secret,
|
||||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
clientIdIssuedAt: info.client_id_issued_at,
|
||||||
},
|
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||||
this.serverUrl,
|
},
|
||||||
|
this.serverUrl,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
log.info("saved dynamically registered client", {
|
log.info("saved dynamically registered client", {
|
||||||
mcpName: this.mcpName,
|
mcpName: this.mcpName,
|
||||||
@@ -97,7 +101,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
|
|
||||||
async tokens(): Promise<OAuthTokens | undefined> {
|
async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
// Use getForUrl to validate tokens are for the current server URL
|
// Use getForUrl to validate tokens are for the current server URL
|
||||||
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
|
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||||
if (!entry?.tokens) return undefined
|
if (!entry?.tokens) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -112,15 +116,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
await McpAuth.updateTokens(
|
await Effect.runPromise(
|
||||||
this.mcpName,
|
this.auth.updateTokens(
|
||||||
{
|
this.mcpName,
|
||||||
accessToken: tokens.access_token,
|
{
|
||||||
refreshToken: tokens.refresh_token,
|
accessToken: tokens.access_token,
|
||||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
refreshToken: tokens.refresh_token,
|
||||||
scope: tokens.scope,
|
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||||
},
|
scope: tokens.scope,
|
||||||
this.serverUrl,
|
},
|
||||||
|
this.serverUrl,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
log.info("saved oauth tokens", { mcpName: this.mcpName })
|
log.info("saved oauth tokens", { mcpName: this.mcpName })
|
||||||
}
|
}
|
||||||
@@ -131,11 +137,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
|
await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
async codeVerifier(): Promise<string> {
|
async codeVerifier(): Promise<string> {
|
||||||
const entry = await McpAuth.get(this.mcpName)
|
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||||
if (!entry?.codeVerifier) {
|
if (!entry?.codeVerifier) {
|
||||||
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
|
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
|
||||||
}
|
}
|
||||||
@@ -143,11 +149,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveState(state: string): Promise<void> {
|
async saveState(state: string): Promise<void> {
|
||||||
await McpAuth.updateOAuthState(this.mcpName, state)
|
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
async state(): Promise<string> {
|
async state(): Promise<string> {
|
||||||
const entry = await McpAuth.get(this.mcpName)
|
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||||
if (entry?.oauthState) {
|
if (entry?.oauthState) {
|
||||||
return entry.oauthState
|
return entry.oauthState
|
||||||
}
|
}
|
||||||
@@ -159,28 +165,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
.join("")
|
.join("")
|
||||||
await McpAuth.updateOAuthState(this.mcpName, newState)
|
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
|
||||||
return newState
|
return newState
|
||||||
}
|
}
|
||||||
|
|
||||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||||
log.info("invalidating credentials", { mcpName: this.mcpName, type })
|
log.info("invalidating credentials", { mcpName: this.mcpName, type })
|
||||||
const entry = await McpAuth.get(this.mcpName)
|
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "all":
|
case "all":
|
||||||
await McpAuth.remove(this.mcpName)
|
await Effect.runPromise(this.auth.remove(this.mcpName))
|
||||||
break
|
break
|
||||||
case "client":
|
case "client":
|
||||||
delete entry.clientInfo
|
delete entry.clientInfo
|
||||||
await McpAuth.set(this.mcpName, entry)
|
await Effect.runPromise(this.auth.set(this.mcpName, entry))
|
||||||
break
|
break
|
||||||
case "tokens":
|
case "tokens":
|
||||||
delete entry.tokens
|
delete entry.tokens
|
||||||
await McpAuth.set(this.mcpName, entry)
|
await Effect.runPromise(this.auth.set(this.mcpName, entry))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Bus } from "@/bus"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
@@ -308,18 +307,4 @@ export namespace Permission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function ask(input: z.infer<typeof AskInput>) {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((s) => s.list())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
|
import type {
|
||||||
|
Hooks,
|
||||||
|
PluginInput,
|
||||||
|
Plugin as PluginInstance,
|
||||||
|
PluginModule,
|
||||||
|
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
||||||
|
} from "@opencode-ai/plugin"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@@ -14,10 +20,11 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
|
|||||||
import { Effect, Layer, Context, Stream } from "effect"
|
import { Effect, Layer, Context, Stream } from "effect"
|
||||||
import { EffectLogger } from "@/effect/logger"
|
import { EffectLogger } from "@/effect/logger"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { PluginLoader } from "./loader"
|
import { PluginLoader } from "./loader"
|
||||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||||
|
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||||
|
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
@@ -132,6 +139,11 @@ export namespace Plugin {
|
|||||||
project: ctx.project,
|
project: ctx.project,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
|
experimental_workspace: {
|
||||||
|
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
||||||
|
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
||||||
|
},
|
||||||
|
},
|
||||||
get serverUrl(): URL {
|
get serverUrl(): URL {
|
||||||
return Server.url ?? new URL("http://localhost:4096")
|
return Server.url ?? new URL("http://localhost:4096")
|
||||||
},
|
},
|
||||||
@@ -277,21 +289,4 @@ export namespace Plugin {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function trigger<
|
|
||||||
Name extends TriggerName,
|
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
|
||||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
|
||||||
return runPromise((svc) => svc.trigger(name, input, output))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(): Promise<Hooks[]> {
|
|
||||||
return runPromise((svc) => svc.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,26 @@ import { Bus } from "../bus"
|
|||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import * as Effect from "effect/Effect"
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export const InstanceBootstrap = Effect.gen(function* () {
|
||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
yield* Plugin.Service.use((svc) => svc.init())
|
||||||
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
|
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
await LSP.init()
|
yield* LSP.Service.use((svc) => svc.init())
|
||||||
File.init()
|
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
Vcs.init()
|
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
Snapshot.init()
|
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
yield* Bus.Service.use((svc) =>
|
||||||
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"))
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ const disposal = {
|
|||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitDisposed(directory: string) {}
|
|
||||||
|
|
||||||
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||||
return iife(async () => {
|
return iife(async () => {
|
||||||
const ctx =
|
const ctx =
|
||||||
|
|||||||
@@ -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
@@ -18,11 +18,11 @@ import { Flag } from "../flag/flag"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { EffectLogger } from "@/effect/logger"
|
import { EffectLogger } from "@/effect/logger"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
// Direct imports for bundled providers
|
// Direct imports for bundled providers
|
||||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||||
@@ -46,6 +46,7 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
|
|||||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||||
import { createVercel } from "@ai-sdk/vercel"
|
import { createVercel } from "@ai-sdk/vercel"
|
||||||
import { createVenice } from "venice-ai-sdk-provider"
|
import { createVenice } from "venice-ai-sdk-provider"
|
||||||
|
import { createAlibaba } from "@ai-sdk/alibaba"
|
||||||
import {
|
import {
|
||||||
createGitLab,
|
createGitLab,
|
||||||
VERSION as GITLAB_PROVIDER_VERSION,
|
VERSION as GITLAB_PROVIDER_VERSION,
|
||||||
@@ -115,8 +116,8 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function e2eURL() {
|
function e2eURL(env: Record<string, string | undefined>) {
|
||||||
const url = Env.get("OPENCODE_E2E_LLM_URL")
|
const url = env["OPENCODE_E2E_LLM_URL"]
|
||||||
if (typeof url !== "string" || url === "") return
|
if (typeof url !== "string" || url === "") return
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,7 @@ export namespace Provider {
|
|||||||
"@ai-sdk/togetherai": createTogetherAI,
|
"@ai-sdk/togetherai": createTogetherAI,
|
||||||
"@ai-sdk/perplexity": createPerplexity,
|
"@ai-sdk/perplexity": createPerplexity,
|
||||||
"@ai-sdk/vercel": createVercel,
|
"@ai-sdk/vercel": createVercel,
|
||||||
|
"@ai-sdk/alibaba": createAlibaba,
|
||||||
"gitlab-ai-provider": createGitLab,
|
"gitlab-ai-provider": createGitLab,
|
||||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||||
"venice-ai-sdk-provider": createVenice,
|
"venice-ai-sdk-provider": createVenice,
|
||||||
@@ -170,7 +172,7 @@ export namespace Provider {
|
|||||||
return sdk.responses === undefined && sdk.chat === undefined
|
return sdk.responses === undefined && sdk.chat === undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function custom(dep: CustomDep): Record<string, CustomLoader> {
|
function custom(dep: CustomDep, env: Env.Interface): Record<string, CustomLoader> {
|
||||||
return {
|
return {
|
||||||
anthropic: () =>
|
anthropic: () =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
@@ -182,9 +184,9 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
opencode: Effect.fnUntraced(function* (input: Info) {
|
opencode: Effect.fnUntraced(function* (input: Info) {
|
||||||
const env = Env.all()
|
const vals = yield* env.all()
|
||||||
const hasKey = iife(() => {
|
const hasKey = iife(() => {
|
||||||
if (input.env.some((item) => env[item])) return true
|
if (input.env.some((item) => vals[item])) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
const ok =
|
const ok =
|
||||||
@@ -229,14 +231,13 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}),
|
}),
|
||||||
azure: (provider) => {
|
azure: Effect.fnUntraced(function* (provider: Info) {
|
||||||
const resource = iife(() => {
|
const resource =
|
||||||
const name = provider.options?.resourceName
|
typeof provider.options?.resourceName === "string" && provider.options.resourceName.trim() !== ""
|
||||||
if (typeof name === "string" && name.trim() !== "") return name
|
? provider.options.resourceName
|
||||||
return Env.get("AZURE_RESOURCE_NAME")
|
: yield* env.get("AZURE_RESOURCE_NAME")
|
||||||
})
|
|
||||||
|
|
||||||
return Effect.succeed({
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
@@ -252,11 +253,11 @@ export namespace Provider {
|
|||||||
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
},
|
}),
|
||||||
"azure-cognitive-services": () => {
|
"azure-cognitive-services": Effect.fnUntraced(function* () {
|
||||||
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
const resource = yield* env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||||
return Effect.succeed({
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
@@ -267,25 +268,25 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
baseURL: resource ? `https://${resource}.cognitiveservices.azure.com/openai` : undefined,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
},
|
}),
|
||||||
"amazon-bedrock": Effect.fnUntraced(function* () {
|
"amazon-bedrock": Effect.fnUntraced(function* () {
|
||||||
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
|
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
|
||||||
const auth = yield* dep.auth("amazon-bedrock")
|
const auth = yield* dep.auth("amazon-bedrock")
|
||||||
|
|
||||||
// Region precedence: 1) config file, 2) env var, 3) default
|
// Region precedence: 1) config file, 2) env var, 3) default
|
||||||
const configRegion = providerConfig?.options?.region
|
const configRegion = providerConfig?.options?.region
|
||||||
const envRegion = Env.get("AWS_REGION")
|
const envRegion = yield* env.get("AWS_REGION")
|
||||||
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
||||||
|
|
||||||
// Profile: config file takes precedence over env var
|
// Profile: config file takes precedence over env var
|
||||||
const configProfile = providerConfig?.options?.profile
|
const configProfile = providerConfig?.options?.profile
|
||||||
const envProfile = Env.get("AWS_PROFILE")
|
const envProfile = yield* env.get("AWS_PROFILE")
|
||||||
const profile = configProfile ?? envProfile
|
const profile = configProfile ?? envProfile
|
||||||
|
|
||||||
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
const awsAccessKeyId = yield* env.get("AWS_ACCESS_KEY_ID")
|
||||||
|
|
||||||
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
|
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
|
||||||
// until the scope of the Env API is clarified (test only or runtime?)
|
// until the scope of the Env API is clarified (test only or runtime?)
|
||||||
@@ -299,7 +300,7 @@ export namespace Provider {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
|
const awsWebIdentityTokenFile = yield* env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
|
||||||
|
|
||||||
const containerCreds = Boolean(
|
const containerCreds = Boolean(
|
||||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||||
@@ -437,24 +438,24 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"google-vertex": (provider) => {
|
"google-vertex": Effect.fnUntraced(function* (provider: Info) {
|
||||||
const project =
|
const project =
|
||||||
provider.options?.project ??
|
provider.options?.project ??
|
||||||
Env.get("GOOGLE_CLOUD_PROJECT") ??
|
(yield* env.get("GOOGLE_CLOUD_PROJECT")) ??
|
||||||
Env.get("GCP_PROJECT") ??
|
(yield* env.get("GCP_PROJECT")) ??
|
||||||
Env.get("GCLOUD_PROJECT")
|
(yield* env.get("GCLOUD_PROJECT"))
|
||||||
|
|
||||||
const location = String(
|
const location = String(
|
||||||
provider.options?.location ??
|
provider.options?.location ??
|
||||||
Env.get("GOOGLE_VERTEX_LOCATION") ??
|
(yield* env.get("GOOGLE_VERTEX_LOCATION")) ??
|
||||||
Env.get("GOOGLE_CLOUD_LOCATION") ??
|
(yield* env.get("GOOGLE_CLOUD_LOCATION")) ??
|
||||||
Env.get("VERTEX_LOCATION") ??
|
(yield* env.get("VERTEX_LOCATION")) ??
|
||||||
"us-central1",
|
"us-central1",
|
||||||
)
|
)
|
||||||
|
|
||||||
const autoload = Boolean(project)
|
const autoload = Boolean(project)
|
||||||
if (!autoload) return Effect.succeed({ autoload: false })
|
if (!autoload) return { autoload: false }
|
||||||
return Effect.succeed({
|
return {
|
||||||
autoload: true,
|
autoload: true,
|
||||||
vars(_options: Record<string, any>) {
|
vars(_options: Record<string, any>) {
|
||||||
const endpoint =
|
const endpoint =
|
||||||
@@ -483,14 +484,17 @@ export namespace Provider {
|
|||||||
const id = String(modelID).trim()
|
const id = String(modelID).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
},
|
}),
|
||||||
"google-vertex-anthropic": () => {
|
"google-vertex-anthropic": Effect.fnUntraced(function* () {
|
||||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
const project =
|
||||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
|
(yield* env.get("GOOGLE_CLOUD_PROJECT")) ??
|
||||||
|
(yield* env.get("GCP_PROJECT")) ??
|
||||||
|
(yield* env.get("GCLOUD_PROJECT"))
|
||||||
|
const location = (yield* env.get("GOOGLE_CLOUD_LOCATION")) ?? (yield* env.get("VERTEX_LOCATION")) ?? "global"
|
||||||
const autoload = Boolean(project)
|
const autoload = Boolean(project)
|
||||||
if (!autoload) return Effect.succeed({ autoload: false })
|
if (!autoload) return { autoload: false }
|
||||||
return Effect.succeed({
|
return {
|
||||||
autoload: true,
|
autoload: true,
|
||||||
options: {
|
options: {
|
||||||
project,
|
project,
|
||||||
@@ -500,8 +504,8 @@ export namespace Provider {
|
|||||||
const id = String(modelID).trim()
|
const id = String(modelID).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
},
|
}),
|
||||||
"sap-ai-core": Effect.fnUntraced(function* () {
|
"sap-ai-core": Effect.fnUntraced(function* () {
|
||||||
const auth = yield* dep.auth("sap-ai-core")
|
const auth = yield* dep.auth("sap-ai-core")
|
||||||
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
|
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
|
||||||
@@ -537,14 +541,11 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
gitlab: Effect.fnUntraced(function* (input: Info) {
|
gitlab: Effect.fnUntraced(function* (input: Info) {
|
||||||
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
const instanceUrl = (yield* env.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
|
||||||
|
|
||||||
const auth = yield* dep.auth(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
const apiKey = yield* Effect.sync(() => {
|
const apiKey =
|
||||||
if (auth?.type === "oauth") return auth.access
|
auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : yield* env.get("GITLAB_TOKEN")
|
||||||
if (auth?.type === "api") return auth.key
|
|
||||||
return Env.get("GITLAB_TOKEN")
|
|
||||||
})
|
|
||||||
|
|
||||||
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
||||||
|
|
||||||
@@ -680,7 +681,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
const auth = yield* dep.auth(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
const accountId =
|
const accountId =
|
||||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
(yield* env.get("CLOUDFLARE_ACCOUNT_ID")) || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||||
if (!accountId)
|
if (!accountId)
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
@@ -692,7 +693,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = yield* Effect.gen(function* () {
|
const apiKey = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
const envToken = yield* env.get("CLOUDFLARE_API_KEY")
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
@@ -722,9 +723,9 @@ export namespace Provider {
|
|||||||
|
|
||||||
const auth = yield* dep.auth(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
const accountId =
|
const accountId =
|
||||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
(yield* env.get("CLOUDFLARE_ACCOUNT_ID")) || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||||
const gateway =
|
const gateway =
|
||||||
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
(yield* env.get("CLOUDFLARE_GATEWAY_ID")) || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||||
|
|
||||||
if (!accountId || !gateway) {
|
if (!accountId || !gateway) {
|
||||||
const missing = [
|
const missing = [
|
||||||
@@ -743,7 +744,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
// Get API token from env or auth - required for authenticated gateways
|
// Get API token from env or auth - required for authenticated gateways
|
||||||
const apiToken = yield* Effect.gen(function* () {
|
const apiToken = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
const envToken = (yield* env.get("CLOUDFLARE_API_TOKEN")) || (yield* env.get("CF_AIG_TOKEN"))
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
@@ -1028,12 +1029,18 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
|
const layer: Layer.Layer<
|
||||||
|
Service,
|
||||||
|
never,
|
||||||
|
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
|
||||||
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
const env = yield* Env.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(() =>
|
const state = yield* InstanceState.make<State>(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -1179,11 +1186,11 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// load env
|
// load env
|
||||||
const env = Env.all()
|
const vals = yield* env.all()
|
||||||
for (const [id, provider] of Object.entries(database)) {
|
for (const [id, provider] of Object.entries(database)) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
|
const apiKey = provider.env.map((item) => vals[item]).find(Boolean)
|
||||||
if (!apiKey) continue
|
if (!apiKey) continue
|
||||||
mergeProvider(providerID, {
|
mergeProvider(providerID, {
|
||||||
source: "env",
|
source: "env",
|
||||||
@@ -1226,7 +1233,7 @@ export namespace Provider {
|
|||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, fn] of Object.entries(custom(dep))) {
|
for (const [id, fn] of Object.entries(custom(dep, env))) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
const data = database[providerID]
|
const data = database[providerID]
|
||||||
@@ -1356,7 +1363,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||||
|
|
||||||
async function resolveSDK(model: Model, s: State) {
|
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
|
||||||
try {
|
try {
|
||||||
using _ = log.time("getSDK", {
|
using _ = log.time("getSDK", {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
@@ -1387,7 +1394,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
|
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
|
||||||
const val = Env.get(String(key))
|
const val = envs[String(key)]
|
||||||
return val ?? item
|
return val ?? item
|
||||||
})
|
})
|
||||||
return url
|
return url
|
||||||
@@ -1519,9 +1526,10 @@ export namespace Provider {
|
|||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const key = `${model.providerID}/${model.id}`
|
const key = `${model.providerID}/${model.id}`
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
const vals = yield* env.all()
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const url = e2eURL()
|
const url = e2eURL(vals)
|
||||||
if (url) {
|
if (url) {
|
||||||
const language = createOpenAICompatible({
|
const language = createOpenAICompatible({
|
||||||
name: model.providerID,
|
name: model.providerID,
|
||||||
@@ -1533,7 +1541,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const sdk = await resolveSDK(model, s)
|
const sdk = await resolveSDK(model, s, vals)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
const language = s.modelLoaders[model.providerID]
|
||||||
@@ -1631,12 +1639,17 @@ export namespace Provider {
|
|||||||
if (cfg.model) return parseModel(cfg.model)
|
if (cfg.model) return parseModel(cfg.model)
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const recent = yield* Effect.promise(() =>
|
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
|
||||||
Filesystem.readJson<{
|
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
|
||||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
if (!isRecord(x) || !Array.isArray(x.recent)) return []
|
||||||
}>(path.join(Global.Path.state, "model.json"))
|
return x.recent.flatMap((item) => {
|
||||||
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : []))
|
if (!isRecord(item)) return []
|
||||||
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []),
|
if (typeof item.providerID !== "string") return []
|
||||||
|
if (typeof item.modelID !== "string") return []
|
||||||
|
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
|
||||||
)
|
)
|
||||||
for (const entry of recent) {
|
for (const entry of recent) {
|
||||||
const provider = s.providers[entry.providerID]
|
const provider = s.providers[entry.providerID]
|
||||||
@@ -1663,42 +1676,14 @@ export namespace Provider {
|
|||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
layer.pipe(
|
layer.pipe(
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((svc) => svc.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProvider(providerID: ProviderID) {
|
|
||||||
return runPromise((svc) => svc.getProvider(providerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
|
||||||
return runPromise((svc) => svc.getModel(providerID, modelID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLanguage(model: Model) {
|
|
||||||
return runPromise((svc) => svc.getLanguage(model))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closest(providerID: ProviderID, query: string[]) {
|
|
||||||
return runPromise((svc) => svc.closest(providerID, query))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSmallModel(providerID: ProviderID) {
|
|
||||||
return runPromise((svc) => svc.getSmallModel(providerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function defaultModel() {
|
|
||||||
return runPromise((svc) => svc.defaultModel())
|
|
||||||
}
|
|
||||||
|
|
||||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
||||||
export function sort<T extends { id: string }>(models: T[]) {
|
export function sort<T extends { id: string }>(models: T[]) {
|
||||||
return sortBy(
|
return sortBy(
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ export namespace ProviderTransform {
|
|||||||
copilot: {
|
copilot: {
|
||||||
copilot_cache_control: { type: "ephemeral" },
|
copilot_cache_control: { type: "ephemeral" },
|
||||||
},
|
},
|
||||||
|
alibaba: {
|
||||||
|
cacheControl: { type: "ephemeral" },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const msg of unique([...system, ...final])) {
|
for (const msg of unique([...system, ...final])) {
|
||||||
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
|
|||||||
model.api.id.includes("claude") ||
|
model.api.id.includes("claude") ||
|
||||||
model.id.includes("anthropic") ||
|
model.id.includes("anthropic") ||
|
||||||
model.id.includes("claude") ||
|
model.id.includes("claude") ||
|
||||||
model.api.npm === "@ai-sdk/anthropic") &&
|
model.api.npm === "@ai-sdk/anthropic" ||
|
||||||
|
model.api.npm === "@ai-sdk/alibaba") &&
|
||||||
model.api.npm !== "@ai-sdk/gateway"
|
model.api.npm !== "@ai-sdk/gateway"
|
||||||
) {
|
) {
|
||||||
msgs = applyCaching(msgs, model)
|
msgs = applyCaching(msgs, model)
|
||||||
@@ -828,7 +832,16 @@ export namespace ProviderTransform {
|
|||||||
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||||
result["reasoningEffort"] = "medium"
|
result["reasoningEffort"] = "medium"
|
||||||
result["reasoningSummary"] = "auto"
|
// Only inject reasoningSummary for providers that support it natively.
|
||||||
|
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
|
||||||
|
// parameter and return "Unknown parameter: 'reasoningSummary'".
|
||||||
|
if (
|
||||||
|
input.model.api.npm === "@ai-sdk/openai" ||
|
||||||
|
input.model.api.npm === "@ai-sdk/azure" ||
|
||||||
|
input.model.api.npm === "@ai-sdk/github-copilot"
|
||||||
|
) {
|
||||||
|
result["reasoningSummary"] = "auto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set textVerbosity for non-chat gpt-5.x models
|
// Only set textVerbosity for non-chat gpt-5.x models
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { ProviderID } from "@/provider/schema"
|
import { ProviderID } from "@/provider/schema"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||||
@@ -39,7 +41,12 @@ export function ControlPlaneRoutes(): Hono {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const providerID = c.req.valid("param").providerID
|
const providerID = c.req.valid("param").providerID
|
||||||
const info = c.req.valid("json")
|
const info = c.req.valid("json")
|
||||||
await Auth.set(providerID, info)
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.set(providerID, info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -69,7 +76,12 @@ export function ControlPlaneRoutes(): Hono {
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const providerID = c.req.valid("param").providerID
|
const providerID = c.req.valid("param").providerID
|
||||||
await Auth.remove(providerID)
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
yield* auth.remove(providerID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { mapValues } from "remeda"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Config.get())
|
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.patch(
|
.patch(
|
||||||
@@ -54,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
validator("json", Config.Info),
|
validator("json", Config.Info),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
await Config.update(config)
|
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||||
return c.json(config)
|
return c.json(config)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
using _ = log.time("providers")
|
using _ = log.time("providers")
|
||||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
|
const providers = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Provider.Service
|
||||||
|
return mapValues(yield* svc.list(), (item) => item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json({
|
return c.json({
|
||||||
providers: Object.values(providers),
|
providers: Object.values(providers),
|
||||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import z from "zod"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, resolver } from "hono-openapi"
|
import { describeRoute, resolver } from "hono-openapi"
|
||||||
import { streamSSE } from "hono/streaming"
|
import { streamSSE } from "hono/streaming"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { SyncEvent } from "@/sync"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { AsyncQueue } from "../../util/queue"
|
import { AsyncQueue } from "../../util/queue"
|
||||||
|
|
||||||
@@ -20,7 +22,11 @@ export const EventRoutes = () =>
|
|||||||
description: "Event stream",
|
description: "Event stream",
|
||||||
content: {
|
content: {
|
||||||
"text/event-stream": {
|
"text/event-stream": {
|
||||||
schema: resolver(BusEvent.payloads()),
|
schema: resolver(
|
||||||
|
z.union(BusEvent.payloads()).meta({
|
||||||
|
ref: "Event",
|
||||||
|
}),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,7 +162,13 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await ToolRegistry.ids())
|
const ids = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
return yield* registry.ids()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(ids)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -205,11 +211,17 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { provider, model } = c.req.valid("query")
|
const { provider, model } = c.req.valid("query")
|
||||||
const tools = await ToolRegistry.tools({
|
const tools = await AppRuntime.runPromise(
|
||||||
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,
|
||||||
@@ -242,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", Worktree.CreateInput.optional()),
|
validator("json", Worktree.CreateInput.optional()),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const worktree = await Worktree.create(body)
|
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
|
||||||
return c.json(worktree)
|
return c.json(worktree)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -289,7 +301,7 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", Worktree.RemoveInput),
|
validator("json", Worktree.RemoveInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Worktree.remove(body)
|
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
|
||||||
await Project.removeSandbox(Instance.project.id, body.directory)
|
await Project.removeSandbox(Instance.project.id, body.directory)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
@@ -315,7 +327,7 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", Worktree.ResetInput),
|
validator("json", Worktree.ResetInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Worktree.reset(body)
|
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -396,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await MCP.resources())
|
return c.json(
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
return yield* mcp.resources()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { File } from "../../file"
|
import { File } from "../../file"
|
||||||
import { Ripgrep } from "../../file/ripgrep"
|
import { Ripgrep } from "../../file/ripgrep"
|
||||||
import { LSP } from "../../lsp"
|
import { LSP } from "../../lsp"
|
||||||
@@ -34,12 +36,10 @@ export const FileRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const pattern = c.req.valid("query").pattern
|
const pattern = c.req.valid("query").pattern
|
||||||
const result = await Ripgrep.search({
|
const result = await AppRuntime.runPromise(
|
||||||
cwd: Instance.directory,
|
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||||
pattern,
|
)
|
||||||
limit: 10,
|
return c.json(result.items)
|
||||||
})
|
|
||||||
return c.json(result)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -73,12 +73,18 @@ export const FileRoutes = lazy(() =>
|
|||||||
const dirs = c.req.valid("query").dirs
|
const dirs = c.req.valid("query").dirs
|
||||||
const type = c.req.valid("query").type
|
const type = c.req.valid("query").type
|
||||||
const limit = c.req.valid("query").limit
|
const limit = c.req.valid("query").limit
|
||||||
const results = await File.search({
|
const results = await AppRuntime.runPromise(
|
||||||
query,
|
Effect.gen(function* () {
|
||||||
limit: limit ?? 10,
|
return yield* File.Service.use((svc) =>
|
||||||
dirs: dirs !== "false",
|
svc.search({
|
||||||
type,
|
query,
|
||||||
})
|
limit: limit ?? 10,
|
||||||
|
dirs: dirs !== "false",
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(results)
|
return c.json(results)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -106,11 +112,6 @@ export const FileRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
/*
|
|
||||||
const query = c.req.valid("query").query
|
|
||||||
const result = await LSP.workspaceSymbol(query)
|
|
||||||
return c.json(result)
|
|
||||||
*/
|
|
||||||
return c.json([])
|
return c.json([])
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -139,7 +140,11 @@ export const FileRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const path = c.req.valid("query").path
|
const path = c.req.valid("query").path
|
||||||
const content = await File.list(path)
|
const content = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.list(path))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(content)
|
return c.json(content)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -168,7 +173,11 @@ export const FileRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const path = c.req.valid("query").path
|
const path = c.req.valid("query").path
|
||||||
const content = await File.read(path)
|
const content = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.read(path))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(content)
|
return c.json(content)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -190,7 +199,11 @@ export const FileRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const content = await File.status()
|
const content = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* File.Service.use((svc) => svc.status())
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(content)
|
return c.json(content)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
directory: z.string(),
|
directory: z.string(),
|
||||||
project: z.string().optional(),
|
project: z.string().optional(),
|
||||||
workspace: z.string().optional(),
|
workspace: z.string().optional(),
|
||||||
payload: BusEvent.payloads(),
|
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "GlobalEvent",
|
ref: "GlobalEvent",
|
||||||
@@ -135,52 +135,6 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
|
||||||
"/sync-event",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Subscribe to global sync events",
|
|
||||||
description: "Get global sync events",
|
|
||||||
operationId: "global.sync-event.subscribe",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Event stream",
|
|
||||||
content: {
|
|
||||||
"text/event-stream": {
|
|
||||||
schema: resolver(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
payload: SyncEvent.payloads(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "SyncEvent",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
log.info("global sync event connected")
|
|
||||||
c.header("Cache-Control", "no-cache, no-transform")
|
|
||||||
c.header("X-Accel-Buffering", "no")
|
|
||||||
c.header("X-Content-Type-Options", "nosniff")
|
|
||||||
return streamEvents(c, (q) => {
|
|
||||||
return SyncEvent.subscribeAll(({ def, event }) => {
|
|
||||||
// TODO: don't pass def, just pass the type (and it should
|
|
||||||
// be versioned)
|
|
||||||
q.push(
|
|
||||||
JSON.stringify({
|
|
||||||
payload: {
|
|
||||||
...event,
|
|
||||||
type: SyncEvent.versionedType(def.type, def.version),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
.get(
|
||||||
"/config",
|
"/config",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -199,7 +153,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Config.getGlobal())
|
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.patch(
|
.patch(
|
||||||
@@ -223,7 +177,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
validator("json", Config.Info),
|
validator("json", Config.Info),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
const next = await Config.updateGlobal(config)
|
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||||
return c.json(next)
|
return c.json(next)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Format } from "../../format"
|
import { Format } from "../../format"
|
||||||
import { TuiRoutes } from "./tui"
|
import { TuiRoutes } from "./tui"
|
||||||
@@ -119,11 +120,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
return c.json(
|
||||||
return c.json({
|
await AppRuntime.runPromise(
|
||||||
branch,
|
Effect.gen(function* () {
|
||||||
default_branch,
|
const vcs = yield* Vcs.Service
|
||||||
})
|
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||||
|
concurrency: 2,
|
||||||
|
})
|
||||||
|
return { branch, default_branch }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -150,7 +157,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
return c.json(
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
return yield* vcs.diff(c.req.valid("query").mode)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -193,7 +207,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const modes = await Agent.list()
|
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||||
return c.json(modes)
|
return c.json(modes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -215,7 +229,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const skills = await Skill.all()
|
const skills = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
return yield* skill.all()
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(skills)
|
return c.json(skills)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -237,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await LSP.status())
|
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
|
||||||
|
return c.json(items)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
export const McpRoutes = lazy(() =>
|
export const McpRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await MCP.status())
|
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { name, config } = c.req.valid("json")
|
const { name, config } = c.req.valid("json")
|
||||||
const result = await MCP.add(name, config)
|
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
|
||||||
return c.json(result.status)
|
return c.json(result.status)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const name = c.req.param("name")
|
const name = c.req.param("name")
|
||||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
const result = await AppRuntime.runPromise(
|
||||||
if (!supportsOAuth) {
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const supports = yield* mcp.supportsOAuth(name)
|
||||||
|
if (!supports) return { supports }
|
||||||
|
return {
|
||||||
|
supports,
|
||||||
|
auth: yield* mcp.startAuth(name),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!result.supports) {
|
||||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||||
}
|
}
|
||||||
const result = await MCP.startAuth(name)
|
return c.json(result.auth)
|
||||||
return c.json(result)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const name = c.req.param("name")
|
const name = c.req.param("name")
|
||||||
const { code } = c.req.valid("json")
|
const { code } = c.req.valid("json")
|
||||||
const status = await MCP.finishAuth(name, code)
|
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
|
||||||
return c.json(status)
|
return c.json(status)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const name = c.req.param("name")
|
const name = c.req.param("name")
|
||||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
const result = await AppRuntime.runPromise(
|
||||||
if (!supportsOAuth) {
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const supports = yield* mcp.supportsOAuth(name)
|
||||||
|
if (!supports) return { supports }
|
||||||
|
return {
|
||||||
|
supports,
|
||||||
|
status: yield* mcp.authenticate(name),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!result.supports) {
|
||||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||||
}
|
}
|
||||||
const status = await MCP.authenticate(name)
|
return c.json(result.status)
|
||||||
return c.json(status)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.delete(
|
.delete(
|
||||||
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const name = c.req.param("name")
|
const name = c.req.param("name")
|
||||||
await MCP.removeAuth(name)
|
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
|
||||||
return c.json({ success: true as const })
|
return c.json({ success: true as const })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
validator("param", z.object({ name: z.string() })),
|
validator("param", z.object({ name: z.string() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { name } = c.req.valid("param")
|
const { name } = c.req.valid("param")
|
||||||
await MCP.connect(name)
|
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
|
|||||||
validator("param", z.object({ name: z.string() })),
|
validator("param", z.object({ name: z.string() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { name } = c.req.valid("param")
|
const { name } = c.req.valid("param")
|
||||||
await MCP.disconnect(name)
|
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
|||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
if (!workspaceID) {
|
if (!workspaceID) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
async fn() {
|
async fn() {
|
||||||
return next()
|
return next()
|
||||||
},
|
},
|
||||||
@@ -94,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const adaptor = await getAdaptor(workspace.type)
|
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
|
||||||
const target = await adaptor.target(workspace)
|
const target = await adaptor.target(workspace)
|
||||||
|
|
||||||
if (target.type === "local") {
|
if (target.type === "local") {
|
||||||
@@ -103,7 +104,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
fn: () =>
|
fn: () =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
async fn() {
|
async fn() {
|
||||||
return next()
|
return next()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
@@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Permission.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.requestID,
|
Permission.Service.use((svc) =>
|
||||||
reply: json.reply,
|
svc.reply({
|
||||||
message: json.message,
|
requestID: params.requestID,
|
||||||
})
|
reply: json.reply,
|
||||||
|
message: json.message,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const permissions = await Permission.list()
|
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||||
return c.json(permissions)
|
return c.json(permissions)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ProjectID } from "../../project/schema"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
export const ProjectRoutes = lazy(() =>
|
export const ProjectRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -83,7 +84,7 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
directory: dir,
|
directory: dir,
|
||||||
worktree: dir,
|
worktree: dir,
|
||||||
project: next,
|
project: next,
|
||||||
init: InstanceBootstrap,
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
})
|
})
|
||||||
return c.json(next)
|
return c.json(next)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { mapValues } from "remeda"
|
|||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -40,27 +41,36 @@ export const ProviderRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = await Config.get()
|
const result = await AppRuntime.runPromise(
|
||||||
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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Hono, type MiddlewareHandler } from "hono"
|
import { Hono, type MiddlewareHandler } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
import { NotFoundError } from "../../storage/db"
|
import { NotFoundError } from "../../storage/db"
|
||||||
@@ -27,7 +29,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(await Pty.list())
|
return c.json(
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.list()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -50,7 +59,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("json", Pty.CreateInput),
|
validator("json", Pty.CreateInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.create(c.req.valid("json"))
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.create(c.req.valid("json"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(info)
|
return c.json(info)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -74,7 +88,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.get(c.req.valid("param").ptyID)
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.get(c.req.valid("param").ptyID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new NotFoundError({ message: "Session not found" })
|
throw new NotFoundError({ message: "Session not found" })
|
||||||
}
|
}
|
||||||
@@ -102,7 +121,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
validator("json", Pty.UpdateInput),
|
validator("json", Pty.UpdateInput),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
const info = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(info)
|
return c.json(info)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -126,7 +150,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
await Pty.remove(c.req.valid("param").ptyID)
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
yield* pty.remove(c.req.valid("param").ptyID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -150,6 +179,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
|
type Handler = {
|
||||||
|
onMessage: (message: string | ArrayBuffer) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
||||||
const cursor = (() => {
|
const cursor = (() => {
|
||||||
const value = c.req.query("cursor")
|
const value = c.req.query("cursor")
|
||||||
@@ -158,8 +192,17 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||||
return parsed
|
return parsed
|
||||||
})()
|
})()
|
||||||
let handler: Awaited<ReturnType<typeof Pty.connect>>
|
let handler: Handler | undefined
|
||||||
if (!(await Pty.get(id))) throw new Error("Session not found")
|
if (
|
||||||
|
!(await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.get(id)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new Error("Session not found")
|
||||||
|
}
|
||||||
|
|
||||||
type Socket = {
|
type Socket = {
|
||||||
readyState: number
|
readyState: number
|
||||||
@@ -185,7 +228,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
|||||||
ws.close()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler = await Pty.connect(id, socket, cursor)
|
handler = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pty = yield* Pty.Service
|
||||||
|
return yield* pty.connect(id, socket, cursor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
ready = true
|
ready = true
|
||||||
for (const msg of pending) handler?.onMessage(msg)
|
for (const msg of pending) handler?.onMessage(msg)
|
||||||
pending.length = 0
|
pending.length = 0
|
||||||
|
|||||||
@@ -474,10 +474,14 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const query = c.req.valid("query")
|
const query = c.req.valid("query")
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const result = await SessionSummary.diff({
|
const result = await AppRuntime.runPromise(
|
||||||
sessionID: params.sessionID,
|
SessionSummary.Service.use((summary) =>
|
||||||
messageID: query.messageID,
|
summary.diff({
|
||||||
})
|
sessionID: params.sessionID,
|
||||||
|
messageID: query.messageID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(result)
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -547,27 +551,38 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const session = await Session.get(sessionID)
|
await AppRuntime.runPromise(
|
||||||
await SessionRevert.cleanup(session)
|
Effect.gen(function* () {
|
||||||
const msgs = await Session.messages({ sessionID })
|
const session = yield* Session.Service
|
||||||
let currentAgent = await Agent.defaultAgent()
|
const revert = yield* SessionRevert.Service
|
||||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
const compact = yield* SessionCompaction.Service
|
||||||
const info = msgs[i].info
|
const prompt = yield* SessionPrompt.Service
|
||||||
if (info.role === "user") {
|
const agent = yield* Agent.Service
|
||||||
currentAgent = info.agent || (await Agent.defaultAgent())
|
|
||||||
break
|
yield* revert.cleanup(yield* session.get(sessionID))
|
||||||
}
|
const msgs = yield* session.messages({ sessionID })
|
||||||
}
|
const defaultAgent = yield* agent.defaultAgent()
|
||||||
await SessionCompaction.create({
|
let currentAgent = defaultAgent
|
||||||
sessionID,
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
agent: currentAgent,
|
const info = msgs[i].info
|
||||||
model: {
|
if (info.role === "user") {
|
||||||
providerID: body.providerID,
|
currentAgent = info.agent || defaultAgent
|
||||||
modelID: body.modelID,
|
break
|
||||||
},
|
}
|
||||||
auto: body.auto,
|
}
|
||||||
})
|
|
||||||
await SessionPrompt.loop({ sessionID })
|
yield* compact.create({
|
||||||
|
sessionID,
|
||||||
|
agent: currentAgent,
|
||||||
|
model: {
|
||||||
|
providerID: body.providerID,
|
||||||
|
modelID: body.modelID,
|
||||||
|
},
|
||||||
|
auto: body.auto,
|
||||||
|
})
|
||||||
|
yield* prompt.loop({ sessionID })
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -985,10 +1000,14 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
log.info("revert", c.req.valid("json"))
|
log.info("revert", c.req.valid("json"))
|
||||||
const session = await SessionRevert.revert({
|
const session = await AppRuntime.runPromise(
|
||||||
sessionID,
|
SessionRevert.Service.use((svc) =>
|
||||||
...c.req.valid("json"),
|
svc.revert({
|
||||||
})
|
sessionID,
|
||||||
|
...c.req.valid("json"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1018,7 +1037,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const session = await SessionRevert.unrevert({ sessionID })
|
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1051,10 +1070,14 @@ export const SessionRoutes = lazy(() =>
|
|||||||
validator("json", z.object({ response: Permission.Reply })),
|
validator("json", z.object({ response: Permission.Reply })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
Permission.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.permissionID,
|
Permission.Service.use((svc) =>
|
||||||
reply: c.req.valid("json").response,
|
svc.reply({
|
||||||
})
|
requestID: params.permissionID,
|
||||||
|
reply: c.req.valid("json").response,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,13 +1,41 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { listAdaptors } from "../../control-plane/adaptors"
|
||||||
import { Workspace } from "../../control-plane/workspace"
|
import { Workspace } from "../../control-plane/workspace"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
|
||||||
|
const WorkspaceAdaptor = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export const WorkspaceRoutes = lazy(() =>
|
export const WorkspaceRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
|
.get(
|
||||||
|
"/adaptor",
|
||||||
|
describeRoute({
|
||||||
|
summary: "List workspace adaptors",
|
||||||
|
description: "List all available workspace adaptors for the current project.",
|
||||||
|
operationId: "experimental.workspace.adaptor.list",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Workspace adaptors",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.array(WorkspaceAdaptor)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(await listAdaptors(Instance.project.id))
|
||||||
|
},
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import z from "zod"
|
|||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { fn } from "@/util/fn"
|
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { NotFoundError } from "@/storage/db"
|
import { NotFoundError } from "@/storage/db"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { isOverflow as overflow } from "./overflow"
|
import { isOverflow as overflow } from "./overflow"
|
||||||
|
|
||||||
@@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!replay) {
|
if (!replay) {
|
||||||
const continueMsg = yield* session.updateMessage({
|
const info = yield* provider.getProvider(userMessage.model.providerID)
|
||||||
id: MessageID.ascending(),
|
if (
|
||||||
role: "user",
|
(yield* plugin.trigger(
|
||||||
sessionID: input.sessionID,
|
"experimental.compaction.autocontinue",
|
||||||
time: { created: Date.now() },
|
{
|
||||||
agent: userMessage.agent,
|
sessionID: input.sessionID,
|
||||||
model: userMessage.model,
|
agent: userMessage.agent,
|
||||||
})
|
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||||
const text =
|
provider: {
|
||||||
(input.overflow
|
source: info.source,
|
||||||
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
info,
|
||||||
: "") +
|
options: info.options,
|
||||||
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
},
|
||||||
yield* session.updatePart({
|
message: userMessage,
|
||||||
id: PartID.ascending(),
|
overflow: input.overflow === true,
|
||||||
messageID: continueMsg.id,
|
},
|
||||||
sessionID: input.sessionID,
|
{ enabled: true },
|
||||||
type: "text",
|
)).enabled
|
||||||
synthetic: true,
|
) {
|
||||||
text,
|
const continueMsg = yield* session.updateMessage({
|
||||||
time: {
|
id: MessageID.ascending(),
|
||||||
start: Date.now(),
|
role: "user",
|
||||||
end: Date.now(),
|
sessionID: input.sessionID,
|
||||||
},
|
time: { created: Date.now() },
|
||||||
})
|
agent: userMessage.agent,
|
||||||
|
model: userMessage.model,
|
||||||
|
})
|
||||||
|
const text =
|
||||||
|
(input.overflow
|
||||||
|
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
||||||
|
: "") +
|
||||||
|
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: continueMsg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text,
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
end: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
|
|||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
|
||||||
return runPromise((svc) => svc.isOverflow(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prune(input: { sessionID: SessionID }) {
|
|
||||||
return runPromise((svc) => svc.prune(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const create = fn(
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
agent: z.string(),
|
|
||||||
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
|
|
||||||
auto: z.boolean(),
|
|
||||||
overflow: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
(input) => runPromise((svc) => svc.create(input)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Cause, Effect, Layer, Record, Context } from "effect"
|
import { Context, Effect, Layer, Record } from "effect"
|
||||||
import * as Queue from "effect/Queue"
|
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||||
import { mergeDeep, pipe } from "remeda"
|
import { mergeDeep, pipe } from "remeda"
|
||||||
@@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard"
|
|||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
|
const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
|
||||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||||
|
type Result = Awaited<ReturnType<typeof streamText>>
|
||||||
|
|
||||||
export type StreamInput = {
|
export type StreamInput = {
|
||||||
user: MessageV2.User
|
user: MessageV2.User
|
||||||
@@ -45,7 +47,7 @@ export namespace LLM {
|
|||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
|
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||||
@@ -53,12 +55,340 @@ export namespace LLM {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> =
|
||||||
Service,
|
Layer.effect(
|
||||||
Effect.gen(function* () {
|
Service,
|
||||||
return Service.of({
|
Effect.gen(function* () {
|
||||||
stream(input) {
|
const auth = yield* Auth.Service
|
||||||
return Stream.scoped(
|
const config = yield* Config.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
|
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
||||||
|
const l = log
|
||||||
|
.clone()
|
||||||
|
.tag("providerID", input.model.providerID)
|
||||||
|
.tag("modelID", input.model.id)
|
||||||
|
.tag("sessionID", input.sessionID)
|
||||||
|
.tag("small", (input.small ?? false).toString())
|
||||||
|
.tag("agent", input.agent.name)
|
||||||
|
.tag("mode", input.agent.mode)
|
||||||
|
l.info("stream", {
|
||||||
|
modelID: input.model.id,
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [language, cfg, item, info] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
provider.getLanguage(input.model),
|
||||||
|
config.get(),
|
||||||
|
provider.getProvider(input.model.providerID),
|
||||||
|
auth.get(input.model.providerID),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: move this to a proper hook
|
||||||
|
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
|
||||||
|
|
||||||
|
const system: string[] = []
|
||||||
|
system.push(
|
||||||
|
[
|
||||||
|
// use agent prompt otherwise provider prompt
|
||||||
|
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||||
|
// any custom prompt passed into this call
|
||||||
|
...input.system,
|
||||||
|
// any custom prompt from last user message
|
||||||
|
...(input.user.system ? [input.user.system] : []),
|
||||||
|
]
|
||||||
|
.filter((x) => x)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const header = system[0]
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"experimental.chat.system.transform",
|
||||||
|
{ sessionID: input.sessionID, model: input.model },
|
||||||
|
{ system },
|
||||||
|
)
|
||||||
|
// rejoin to maintain 2-part structure for caching if header unchanged
|
||||||
|
if (system.length > 2 && system[0] === header) {
|
||||||
|
const rest = system.slice(1)
|
||||||
|
system.length = 0
|
||||||
|
system.push(header, rest.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant =
|
||||||
|
!input.small && input.model.variants && input.user.model.variant
|
||||||
|
? input.model.variants[input.user.model.variant]
|
||||||
|
: {}
|
||||||
|
const base = input.small
|
||||||
|
? ProviderTransform.smallOptions(input.model)
|
||||||
|
: ProviderTransform.options({
|
||||||
|
model: input.model,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
providerOptions: item.options,
|
||||||
|
})
|
||||||
|
const options: Record<string, any> = pipe(
|
||||||
|
base,
|
||||||
|
mergeDeep(input.model.options),
|
||||||
|
mergeDeep(input.agent.options),
|
||||||
|
mergeDeep(variant),
|
||||||
|
)
|
||||||
|
if (isOpenaiOauth) {
|
||||||
|
options.instructions = system.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||||
|
const messages = isOpenaiOauth
|
||||||
|
? input.messages
|
||||||
|
: isWorkflow
|
||||||
|
? input.messages
|
||||||
|
: [
|
||||||
|
...system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...input.messages,
|
||||||
|
]
|
||||||
|
|
||||||
|
const params = yield* plugin.trigger(
|
||||||
|
"chat.params",
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent.name,
|
||||||
|
model: input.model,
|
||||||
|
provider: item,
|
||||||
|
message: input.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
temperature: input.model.capabilities.temperature
|
||||||
|
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
||||||
|
: undefined,
|
||||||
|
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||||
|
topK: ProviderTransform.topK(input.model),
|
||||||
|
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { headers } = yield* plugin.trigger(
|
||||||
|
"chat.headers",
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent.name,
|
||||||
|
model: input.model,
|
||||||
|
provider: item,
|
||||||
|
message: input.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tools = resolveTools(input)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Add a dummy tool that is never called to satisfy this validation.
|
||||||
|
// This is enabled for:
|
||||||
|
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
|
||||||
|
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
|
||||||
|
const isLiteLLMProxy =
|
||||||
|
item.options?.["litellmProxy"] === true ||
|
||||||
|
input.model.providerID.toLowerCase().includes("litellm") ||
|
||||||
|
input.model.api.id.toLowerCase().includes("litellm")
|
||||||
|
|
||||||
|
// LiteLLM/Bedrock rejects requests where the message history contains tool
|
||||||
|
// calls but no tools param is present. When there are no active tools (e.g.
|
||||||
|
// during compaction), inject a stub tool to satisfy the validation requirement.
|
||||||
|
// The stub description explicitly tells the model not to call it.
|
||||||
|
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
|
||||||
|
tools["_noop"] = tool({
|
||||||
|
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
reason: { type: "string", description: "Unused" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up toolExecutor for DWS workflow models so that tool calls
|
||||||
|
// from the workflow service are executed via opencode's tool system
|
||||||
|
// and results sent back over the WebSocket.
|
||||||
|
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||||
|
const workflowModel = language as GitLabWorkflowLanguageModel & {
|
||||||
|
sessionID?: string
|
||||||
|
sessionPreapprovedTools?: string[]
|
||||||
|
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
||||||
|
}
|
||||||
|
workflowModel.sessionID = input.sessionID
|
||||||
|
workflowModel.systemPrompt = system.join("\n")
|
||||||
|
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||||
|
const t = tools[toolName]
|
||||||
|
if (!t || !t.execute) {
|
||||||
|
return { result: "", error: `Unknown tool: ${toolName}` }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await t.execute!(JSON.parse(argsJson), {
|
||||||
|
toolCallId: _requestID,
|
||||||
|
messages: input.messages,
|
||||||
|
abortSignal: input.abort,
|
||||||
|
})
|
||||||
|
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
|
||||||
|
return {
|
||||||
|
result: output,
|
||||||
|
metadata: typeof result === "object" ? result?.metadata : undefined,
|
||||||
|
title: typeof result === "object" ? result?.title : undefined,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return { result: "", error: e.message ?? String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
||||||
|
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
|
||||||
|
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
||||||
|
return !match || match.action !== "ask"
|
||||||
|
})
|
||||||
|
|
||||||
|
const approvedToolsForSession = new Set<string>()
|
||||||
|
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
|
||||||
|
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
||||||
|
// Auto-approve tools that were already approved in this session
|
||||||
|
// (prevents infinite approval loops for server-side MCP tools)
|
||||||
|
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
|
||||||
|
return { approved: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = PermissionID.ascending()
|
||||||
|
let reply: Permission.Reply | undefined
|
||||||
|
let unsub: (() => void) | undefined
|
||||||
|
try {
|
||||||
|
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
|
||||||
|
if (evt.properties.requestID === id) reply = evt.properties.reply
|
||||||
|
})
|
||||||
|
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(t.args) as Record<string, unknown>
|
||||||
|
const title = (parsed?.title ?? parsed?.name ?? "") as string
|
||||||
|
return title ? `${t.name}: ${title}` : t.name
|
||||||
|
} catch {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const uniquePatterns = [...new Set(toolPatterns)] as string[]
|
||||||
|
await perms.runPromise((svc) =>
|
||||||
|
svc.ask({
|
||||||
|
id,
|
||||||
|
sessionID: SessionID.make(input.sessionID),
|
||||||
|
permission: "workflow_tool_approval",
|
||||||
|
patterns: uniquePatterns,
|
||||||
|
metadata: { tools: approvalTools },
|
||||||
|
always: uniquePatterns,
|
||||||
|
ruleset: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
||||||
|
workflowModel.sessionPreapprovedTools = [
|
||||||
|
...(workflowModel.sessionPreapprovedTools ?? []),
|
||||||
|
...uniqueNames,
|
||||||
|
]
|
||||||
|
return { approved: true }
|
||||||
|
} catch {
|
||||||
|
return { approved: false }
|
||||||
|
} finally {
|
||||||
|
unsub?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamText({
|
||||||
|
onError(error) {
|
||||||
|
l.error("stream error", {
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async experimental_repairToolCall(failed) {
|
||||||
|
const lower = failed.toolCall.toolName.toLowerCase()
|
||||||
|
if (lower !== failed.toolCall.toolName && tools[lower]) {
|
||||||
|
l.info("repairing tool call", {
|
||||||
|
tool: failed.toolCall.toolName,
|
||||||
|
repaired: lower,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...failed.toolCall,
|
||||||
|
toolName: lower,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...failed.toolCall,
|
||||||
|
input: JSON.stringify({
|
||||||
|
tool: failed.toolCall.toolName,
|
||||||
|
error: failed.error.message,
|
||||||
|
}),
|
||||||
|
toolName: "invalid",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
temperature: params.temperature,
|
||||||
|
topP: params.topP,
|
||||||
|
topK: params.topK,
|
||||||
|
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||||
|
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
||||||
|
tools,
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
maxOutputTokens: params.maxOutputTokens,
|
||||||
|
abortSignal: input.abort,
|
||||||
|
headers: {
|
||||||
|
...(input.model.providerID.startsWith("opencode")
|
||||||
|
? {
|
||||||
|
"x-opencode-project": Instance.project.id,
|
||||||
|
"x-opencode-session": input.sessionID,
|
||||||
|
"x-opencode-request": input.user.id,
|
||||||
|
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"x-session-affinity": input.sessionID,
|
||||||
|
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
|
}),
|
||||||
|
...input.model.headers,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
maxRetries: input.retries ?? 0,
|
||||||
|
messages,
|
||||||
|
model: wrapLanguageModel({
|
||||||
|
model: language,
|
||||||
|
middleware: [
|
||||||
|
{
|
||||||
|
specificationVersion: "v3" as const,
|
||||||
|
async transformParams(args) {
|
||||||
|
if (args.type === "stream") {
|
||||||
|
// @ts-expect-error
|
||||||
|
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||||
|
}
|
||||||
|
return args.params
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
experimental_telemetry: {
|
||||||
|
isEnabled: cfg.experimental?.openTelemetry,
|
||||||
|
metadata: {
|
||||||
|
userId: cfg.username ?? "unknown",
|
||||||
|
sessionId: input.sessionID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream: Interface["stream"] = (input) =>
|
||||||
|
Stream.scoped(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctrl = yield* Effect.acquireRelease(
|
const ctrl = yield* Effect.acquireRelease(
|
||||||
@@ -66,7 +396,7 @@ export namespace LLM {
|
|||||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||||
|
|
||||||
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
||||||
e instanceof Error ? e : new Error(String(e)),
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
@@ -74,325 +404,19 @@ export namespace LLM {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
return Service.of({ stream })
|
||||||
|
|
||||||
export async function stream(input: StreamRequest) {
|
|
||||||
const l = log
|
|
||||||
.clone()
|
|
||||||
.tag("providerID", input.model.providerID)
|
|
||||||
.tag("modelID", input.model.id)
|
|
||||||
.tag("sessionID", input.sessionID)
|
|
||||||
.tag("small", (input.small ?? false).toString())
|
|
||||||
.tag("agent", input.agent.name)
|
|
||||||
.tag("mode", input.agent.mode)
|
|
||||||
l.info("stream", {
|
|
||||||
modelID: input.model.id,
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
})
|
|
||||||
const [language, cfg, provider, auth] = await Promise.all([
|
|
||||||
Provider.getLanguage(input.model),
|
|
||||||
Config.get(),
|
|
||||||
Provider.getProvider(input.model.providerID),
|
|
||||||
Auth.get(input.model.providerID),
|
|
||||||
])
|
|
||||||
// TODO: move this to a proper hook
|
|
||||||
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
|
|
||||||
|
|
||||||
const system: string[] = []
|
|
||||||
system.push(
|
|
||||||
[
|
|
||||||
// use agent prompt otherwise provider prompt
|
|
||||||
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
|
||||||
// any custom prompt passed into this call
|
|
||||||
...input.system,
|
|
||||||
// any custom prompt from last user message
|
|
||||||
...(input.user.system ? [input.user.system] : []),
|
|
||||||
]
|
|
||||||
.filter((x) => x)
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const header = system[0]
|
|
||||||
await Plugin.trigger(
|
|
||||||
"experimental.chat.system.transform",
|
|
||||||
{ sessionID: input.sessionID, model: input.model },
|
|
||||||
{ system },
|
|
||||||
)
|
|
||||||
// rejoin to maintain 2-part structure for caching if header unchanged
|
|
||||||
if (system.length > 2 && system[0] === header) {
|
|
||||||
const rest = system.slice(1)
|
|
||||||
system.length = 0
|
|
||||||
system.push(header, rest.join("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant =
|
|
||||||
!input.small && input.model.variants && input.user.model.variant
|
|
||||||
? input.model.variants[input.user.model.variant]
|
|
||||||
: {}
|
|
||||||
const base = input.small
|
|
||||||
? ProviderTransform.smallOptions(input.model)
|
|
||||||
: ProviderTransform.options({
|
|
||||||
model: input.model,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
providerOptions: provider.options,
|
|
||||||
})
|
|
||||||
const options: Record<string, any> = pipe(
|
|
||||||
base,
|
|
||||||
mergeDeep(input.model.options),
|
|
||||||
mergeDeep(input.agent.options),
|
|
||||||
mergeDeep(variant),
|
|
||||||
)
|
|
||||||
if (isOpenaiOauth) {
|
|
||||||
options.instructions = system.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
|
||||||
const messages = isOpenaiOauth
|
|
||||||
? input.messages
|
|
||||||
: isWorkflow
|
|
||||||
? input.messages
|
|
||||||
: [
|
|
||||||
...system.map(
|
|
||||||
(x): ModelMessage => ({
|
|
||||||
role: "system",
|
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...input.messages,
|
|
||||||
]
|
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
|
||||||
"chat.params",
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: input.agent.name,
|
|
||||||
model: input.model,
|
|
||||||
provider,
|
|
||||||
message: input.user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
temperature: input.model.capabilities.temperature
|
|
||||||
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
|
||||||
: undefined,
|
|
||||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
|
||||||
topK: ProviderTransform.topK(input.model),
|
|
||||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
|
|
||||||
options,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { headers } = await Plugin.trigger(
|
|
||||||
"chat.headers",
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: input.agent.name,
|
|
||||||
model: input.model,
|
|
||||||
provider,
|
|
||||||
message: input.user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = await resolveTools(input)
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// Add a dummy tool that is never called to satisfy this validation.
|
|
||||||
// This is enabled for:
|
|
||||||
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
|
|
||||||
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
|
|
||||||
const isLiteLLMProxy =
|
|
||||||
provider.options?.["litellmProxy"] === true ||
|
|
||||||
input.model.providerID.toLowerCase().includes("litellm") ||
|
|
||||||
input.model.api.id.toLowerCase().includes("litellm")
|
|
||||||
|
|
||||||
// LiteLLM/Bedrock rejects requests where the message history contains tool
|
|
||||||
// calls but no tools param is present. When there are no active tools (e.g.
|
|
||||||
// during compaction), inject a stub tool to satisfy the validation requirement.
|
|
||||||
// The stub description explicitly tells the model not to call it.
|
|
||||||
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
|
|
||||||
tools["_noop"] = tool({
|
|
||||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
|
||||||
inputSchema: jsonSchema({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
reason: { type: "string", description: "Unused" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire up toolExecutor for DWS workflow models so that tool calls
|
|
||||||
// from the workflow service are executed via opencode's tool system
|
|
||||||
// and results sent back over the WebSocket.
|
|
||||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
|
||||||
const workflowModel = language as GitLabWorkflowLanguageModel & {
|
|
||||||
sessionID?: string
|
|
||||||
sessionPreapprovedTools?: string[]
|
|
||||||
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
|
||||||
}
|
|
||||||
workflowModel.sessionID = input.sessionID
|
|
||||||
workflowModel.systemPrompt = system.join("\n")
|
|
||||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
|
||||||
const t = tools[toolName]
|
|
||||||
if (!t || !t.execute) {
|
|
||||||
return { result: "", error: `Unknown tool: ${toolName}` }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await t.execute!(JSON.parse(argsJson), {
|
|
||||||
toolCallId: _requestID,
|
|
||||||
messages: input.messages,
|
|
||||||
abortSignal: input.abort,
|
|
||||||
})
|
|
||||||
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
|
|
||||||
return {
|
|
||||||
result: output,
|
|
||||||
metadata: typeof result === "object" ? result?.metadata : undefined,
|
|
||||||
title: typeof result === "object" ? result?.title : undefined,
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
return { result: "", error: e.message ?? String(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
|
||||||
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
|
|
||||||
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
|
||||||
return !match || match.action !== "ask"
|
|
||||||
})
|
|
||||||
|
|
||||||
const approvedToolsForSession = new Set<string>()
|
|
||||||
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
|
|
||||||
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
|
||||||
// Auto-approve tools that were already approved in this session
|
|
||||||
// (prevents infinite approval loops for server-side MCP tools)
|
|
||||||
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
|
|
||||||
return { approved: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = PermissionID.ascending()
|
|
||||||
let reply: Permission.Reply | undefined
|
|
||||||
let unsub: (() => void) | undefined
|
|
||||||
try {
|
|
||||||
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
|
|
||||||
if (evt.properties.requestID === id) reply = evt.properties.reply
|
|
||||||
})
|
|
||||||
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(t.args) as Record<string, unknown>
|
|
||||||
const title = (parsed?.title ?? parsed?.name ?? "") as string
|
|
||||||
return title ? `${t.name}: ${title}` : t.name
|
|
||||||
} catch {
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const uniquePatterns = [...new Set(toolPatterns)] as string[]
|
|
||||||
await Permission.ask({
|
|
||||||
id,
|
|
||||||
sessionID: SessionID.make(input.sessionID),
|
|
||||||
permission: "workflow_tool_approval",
|
|
||||||
patterns: uniquePatterns,
|
|
||||||
metadata: { tools: approvalTools },
|
|
||||||
always: uniquePatterns,
|
|
||||||
ruleset: [],
|
|
||||||
})
|
|
||||||
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
|
||||||
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
|
|
||||||
return { approved: true }
|
|
||||||
} catch {
|
|
||||||
return { approved: false }
|
|
||||||
} finally {
|
|
||||||
unsub?.()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return streamText({
|
|
||||||
onError(error) {
|
|
||||||
l.error("stream error", {
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async experimental_repairToolCall(failed) {
|
|
||||||
const lower = failed.toolCall.toolName.toLowerCase()
|
|
||||||
if (lower !== failed.toolCall.toolName && tools[lower]) {
|
|
||||||
l.info("repairing tool call", {
|
|
||||||
tool: failed.toolCall.toolName,
|
|
||||||
repaired: lower,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...failed.toolCall,
|
|
||||||
toolName: lower,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...failed.toolCall,
|
|
||||||
input: JSON.stringify({
|
|
||||||
tool: failed.toolCall.toolName,
|
|
||||||
error: failed.error.message,
|
|
||||||
}),
|
|
||||||
toolName: "invalid",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
temperature: params.temperature,
|
|
||||||
topP: params.topP,
|
|
||||||
topK: params.topK,
|
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
|
||||||
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
|
||||||
tools,
|
|
||||||
toolChoice: input.toolChoice,
|
|
||||||
maxOutputTokens: params.maxOutputTokens,
|
|
||||||
abortSignal: input.abort,
|
|
||||||
headers: {
|
|
||||||
...(input.model.providerID.startsWith("opencode")
|
|
||||||
? {
|
|
||||||
"x-opencode-project": Instance.project.id,
|
|
||||||
"x-opencode-session": input.sessionID,
|
|
||||||
"x-opencode-request": input.user.id,
|
|
||||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
"x-session-affinity": input.sessionID,
|
|
||||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
|
||||||
}),
|
|
||||||
...input.model.headers,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
maxRetries: input.retries ?? 0,
|
|
||||||
messages,
|
|
||||||
model: wrapLanguageModel({
|
|
||||||
model: language,
|
|
||||||
middleware: [
|
|
||||||
{
|
|
||||||
specificationVersion: "v3" as const,
|
|
||||||
async transformParams(args) {
|
|
||||||
if (args.type === "stream") {
|
|
||||||
// @ts-expect-error
|
|
||||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
|
||||||
}
|
|
||||||
return args.params
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
experimental_telemetry: {
|
)
|
||||||
isEnabled: cfg.experimental?.openTelemetry,
|
|
||||||
metadata: {
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
userId: cfg.username ?? "unknown",
|
layer.pipe(
|
||||||
sessionId: input.sessionID,
|
Layer.provide(Auth.defaultLayer),
|
||||||
},
|
Layer.provide(Config.defaultLayer),
|
||||||
},
|
Layer.provide(Provider.defaultLayer),
|
||||||
})
|
Layer.provide(Plugin.defaultLayer),
|
||||||
}
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||||
const disabled = Permission.disabled(
|
const disabled = Permission.disabled(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Deferred, Effect, Layer, Context } from "effect"
|
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -89,6 +89,7 @@ export namespace SessionProcessor {
|
|||||||
| LLM.Service
|
| LLM.Service
|
||||||
| Permission.Service
|
| Permission.Service
|
||||||
| Plugin.Service
|
| Plugin.Service
|
||||||
|
| SessionSummary.Service
|
||||||
| SessionStatus.Service
|
| SessionStatus.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -101,6 +102,8 @@ export namespace SessionProcessor {
|
|||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
const summary = yield* SessionSummary.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
const status = yield* SessionStatus.Service
|
const status = yield* SessionStatus.Service
|
||||||
|
|
||||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||||
@@ -385,10 +388,12 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
ctx.snapshot = undefined
|
ctx.snapshot = undefined
|
||||||
}
|
}
|
||||||
SessionSummary.summarize({
|
yield* summary
|
||||||
sessionID: ctx.sessionID,
|
.summarize({
|
||||||
messageID: ctx.assistantMessage.parentID,
|
sessionID: ctx.sessionID,
|
||||||
})
|
messageID: ctx.assistantMessage.parentID,
|
||||||
|
})
|
||||||
|
.pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
if (
|
if (
|
||||||
!ctx.assistantMessage.summary &&
|
!ctx.assistantMessage.summary &&
|
||||||
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
||||||
@@ -603,6 +608,7 @@ export namespace SessionProcessor {
|
|||||||
Layer.provide(LLM.defaultLayer),
|
Layer.provide(LLM.defaultLayer),
|
||||||
Layer.provide(Permission.defaultLayer),
|
Layer.provide(Permission.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provide(SessionStatus.defaultLayer),
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NotFoundError, eq, and } from "../storage/db"
|
import { NotFoundError, eq, and, sql } from "../storage/db"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { Session } from "./index"
|
import { Session } from "./index"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
import { SessionTable, MessageTable, PartTable, SessionEntryTable } from "./session.sql"
|
||||||
import { ProjectTable } from "../project/project.sql"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { DateTime } from "effect"
|
||||||
|
import { SessionEntry } from "@/v2/session-entry"
|
||||||
|
|
||||||
const log = Log.create({ service: "session.projector" })
|
const log = Log.create({ service: "session.projector" })
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user