mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a4f5a654 | |||
| 8b9b9ad31e | |||
| 3729fd5706 | |||
| 74b14a2d4e | |||
| cdb951ec2f | |||
| fc01cad2b8 | |||
| c1ddc0ea2d | |||
| 319b7655b7 | |||
| 824c12c01a | |||
| 17b2900884 | |||
| 003010bdb6 | |||
| 82a4292934 | |||
| eea4253d67 | |||
| 1eacc3c339 | |||
| 1a509d62a0 | |||
| 4c4eef46f1 | |||
| d62ec7776e | |||
| cb1e5d9e41 | |||
| ca5f086759 | |||
| 57c40eb7c2 | |||
| 63035f977f | |||
| 514d2a36bc | |||
| 0b6fd5f612 | |||
| 029e7135b7 | |||
| c43591f8a2 | |||
| a2c22714cb | |||
| 312f10f797 | |||
| d1f05b0f3a | |||
| ccb0b320e1 | |||
| 5ee7edaf9e | |||
| 27190635ea |
@@ -26,6 +26,7 @@ kommander
|
|||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-robinmordasiewicz
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-toastythebot
|
||||||
|
|||||||
@@ -319,7 +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/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
"@ai-sdk/openai-compatible": "2.0.37",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -341,7 +341,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -357,7 +356,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||||
"@opentui/core": "0.1.97",
|
"@opentui/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
@@ -663,7 +662,7 @@
|
|||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
@@ -708,7 +707,7 @@
|
|||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||||
|
|
||||||
@@ -1162,8 +1161,6 @@
|
|||||||
|
|
||||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
|
||||||
|
|
||||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
@@ -1540,7 +1537,7 @@
|
|||||||
|
|
||||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="],
|
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -2386,7 +2383,7 @@
|
|||||||
|
|
||||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
"ai": ["ai@6.0.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="],
|
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||||
|
|
||||||
@@ -5006,7 +5003,11 @@
|
|||||||
|
|
||||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -5282,10 +5283,6 @@
|
|||||||
|
|
||||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
|
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
||||||
|
|
||||||
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||||
|
|
||||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||||
@@ -5554,7 +5551,9 @@
|
|||||||
|
|
||||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="],
|
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
||||||
|
|
||||||
@@ -5770,6 +5769,8 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||||
|
|
||||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||||
@@ -5952,8 +5953,6 @@
|
|||||||
|
|
||||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
@@ -6450,6 +6449,8 @@
|
|||||||
|
|
||||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -6822,6 +6823,8 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
|
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
|
||||||
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
|
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
|
||||||
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
|
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
|
||||||
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
|
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.46",
|
"effect": "4.0.0-beta.46",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -316,7 +316,8 @@
|
|||||||
|
|
||||||
/* Download Hero Section */
|
/* Download Hero Section */
|
||||||
[data-component="download-hero"] {
|
[data-component="download-hero"] {
|
||||||
display: grid;
|
/* display: grid; */
|
||||||
|
display: none;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.ts"
|
"default": "./src/pty/pty.bun.ts"
|
||||||
|
},
|
||||||
|
"#hono": {
|
||||||
|
"bun": "./src/server/adapter.bun.ts",
|
||||||
|
"node": "./src/server/adapter.node.ts",
|
||||||
|
"default": "./src/server/adapter.bun.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -78,7 +83,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/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -90,7 +95,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
"@ai-sdk/openai-compatible": "2.0.37",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -100,7 +105,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -116,7 +120,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||||
"@opentui/core": "0.1.97",
|
"@opentui/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `SessionSummary` — `session/summary.ts`
|
- [x] `SessionSummary` — `session/summary.ts`
|
||||||
- [x] `SessionRevert` — `session/revert.ts`
|
- [x] `SessionRevert` — `session/revert.ts`
|
||||||
- [x] `Instruction` — `session/instruction.ts`
|
- [x] `Instruction` — `session/instruction.ts`
|
||||||
|
- [x] `SystemPrompt` — `session/system.ts`
|
||||||
- [x] `Provider` — `provider/provider.ts`
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
- [x] `Storage` — `storage/storage.ts`
|
- [x] `Storage` — `storage/storage.ts`
|
||||||
- [x] `ShareNext` — `share/share-next.ts`
|
- [x] `ShareNext` — `share/share-next.ts`
|
||||||
@@ -229,24 +230,24 @@ Still open:
|
|||||||
|
|
||||||
## Tool interface → Effect
|
## Tool interface → Effect
|
||||||
|
|
||||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
`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:
|
||||||
|
|
||||||
1. Migrate each tool to return Effects
|
1. Migrate each tool body to return Effects
|
||||||
2. Update `Tool.define()` factory to work with Effects
|
2. Keep `Tool.define()` inputs Effect-native
|
||||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
||||||
|
|
||||||
### Tool migration details
|
### Tool migration details
|
||||||
|
|
||||||
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
|
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.
|
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
||||||
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
|
- 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.
|
- 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`:
|
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.
|
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
||||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
|
- 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.
|
- 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.
|
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
||||||
@@ -340,3 +341,47 @@ 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.
|
||||||
|
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||||
|
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
|
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
|
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||||
|
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
|
||||||
|
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||||
|
|
||||||
|
## Route handler effectification
|
||||||
|
|
||||||
|
Route 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
import { normalizeServerUrl } from "./url"
|
import { normalizeServerUrl } from "./url"
|
||||||
@@ -454,18 +453,4 @@ export namespace Account {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
|
||||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function active(): Promise<Info | undefined> {
|
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
|
||||||
return runPromise((service) => service.orgsByAccount())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
|
||||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,13 +398,11 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
await AppRuntime.runPromise(loginEffect(args.url))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => switchEffect())
|
await AppRuntime.runPromise(switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => orgsEffect())
|
await AppRuntime.runPromise(orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => openEffect())
|
await AppRuntime.runPromise(openEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
|||||||
import { TuiConfig } from "@/config/tui"
|
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"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
// can't set raw mode if not a TTY
|
// can't set raw mode if not a TTY
|
||||||
@@ -310,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
// - Ctrl+C copies and dismisses selection
|
// - Ctrl+C copies and dismisses selection
|
||||||
// - Esc dismisses selection
|
// - Esc dismisses selection
|
||||||
// - Most other key input dismisses selection and is passed through
|
// - Most other key input dismisses selection and is passed through
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||||
if (!Selection.copy(renderer, toast)) {
|
if (!Selection.copy(renderer, toast)) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function DialogMcp() {
|
|||||||
|
|
||||||
const keybinds = createMemo(() => [
|
const keybinds = createMemo(() => [
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("space")[0],
|
keybind: Keybind.parseOne("space"),
|
||||||
title: "toggle",
|
title: "toggle",
|
||||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||||
// Prevent toggling while an operation is already in progress
|
// Prevent toggling while an operation is already in progress
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function DialogSessionList() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("ctrl+w")[0],
|
keybind: Keybind.parseOne("ctrl+w"),
|
||||||
title: "new workspace",
|
title: "new workspace",
|
||||||
side: "right",
|
side: "right",
|
||||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createSignal } from "solid-js"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
export function ErrorComponent(props: {
|
export function ErrorComponent(props: {
|
||||||
error: Error
|
error: Error
|
||||||
@@ -25,7 +26,7 @@ export function ErrorComponent(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||||
handleExit()
|
handleExit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
|
// plainText directly and sync before any downstream reads.
|
||||||
|
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
||||||
|
setStore("prompt", "input", input.plainText)
|
||||||
|
syncExtmarksWithPromptParts()
|
||||||
|
}
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return
|
||||||
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
input.cursorOffset = input.plainText.length
|
input.cursorOffset = input.plainText.length
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSubmit={submit}
|
onSubmit={() => {
|
||||||
|
// IME: double-defer so the last composed character (e.g. Korean
|
||||||
|
// hangul) is flushed to plainText before we read it for submission.
|
||||||
|
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||||
|
}}
|
||||||
onPaste={async (event: PasteEvent) => {
|
onPaste={async (event: PasteEvent) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
worker.onerror = (e) => {
|
worker.onerror = (e) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("thread error", {
|
||||||
|
message: e.message,
|
||||||
|
filename: e.filename,
|
||||||
|
lineno: e.lineno,
|
||||||
|
colno: e.colno,
|
||||||
|
error: e.error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = Rpc.client<typeof rpc>(worker)
|
const client = Rpc.client<typeof rpc>(worker)
|
||||||
const error = (e: unknown) => {
|
const error = (e: unknown) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("process error", { error: errorMessage(e) })
|
||||||
}
|
}
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
client.call("reload", undefined).catch((err) => {
|
client.call("reload", undefined).catch((err) => {
|
||||||
|
|||||||
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
|
|
||||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
|
||||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
|
||||||
if (evt.name === "pageup") move(-10)
|
if (evt.name === "pageup") move(-10)
|
||||||
if (evt.name === "pagedown") move(10)
|
if (evt.name === "pagedown") move(10)
|
||||||
if (evt.name === "home") moveTo(0)
|
if (evt.name === "home") moveTo(0)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Selection } from "@tui/util/selection"
|
import { Selection } from "@tui/util/selection"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -72,12 +73,13 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
if (evt.defaultPrevented) return
|
if (evt.defaultPrevented) return
|
||||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
|
||||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
|
||||||
|
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
|
||||||
|
if (evt.name === "escape" || isCtrlC) {
|
||||||
if (renderer.getSelection()) {
|
if (renderer.getSelection()) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { memoMap } from "./run-service"
|
import { memoMap } from "./run-service"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
|
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
|
||||||
|
|
||||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
@@ -112,22 +111,4 @@ export namespace FileTime {
|
|||||||
).pipe(Layer.orDie)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
|
||||||
return runPromise((s) => s.get(sessionID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
|
||||||
return runPromise((s) => s.assert(sessionID, filepath))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import z from "zod"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
@@ -161,10 +160,4 @@ export namespace FileWatcher {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
|||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { CopilotModels } from "./models"
|
import { CopilotModels } from "./models"
|
||||||
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.copilot" })
|
const log = Log.create({ service: "plugin.copilot" })
|
||||||
|
|
||||||
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
|
|||||||
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
function fix(model: Model): Model {
|
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
||||||
|
function imgMsg(msg: any): boolean {
|
||||||
|
if (msg?.role !== "user") return false
|
||||||
|
|
||||||
|
// Handle the 3 api formats
|
||||||
|
|
||||||
|
const content = msg.content
|
||||||
|
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
||||||
|
if (!Array.isArray(content)) return false
|
||||||
|
return content.some(
|
||||||
|
(part: any) =>
|
||||||
|
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fix(model: Model, url: string): Model {
|
||||||
return {
|
return {
|
||||||
...model,
|
...model,
|
||||||
api: {
|
api: {
|
||||||
...model.api,
|
...model.api,
|
||||||
|
url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
async models(provider, ctx) {
|
async models(provider, ctx) {
|
||||||
if (ctx.auth?.type !== "oauth") {
|
if (ctx.auth?.type !== "oauth") {
|
||||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = ctx.auth
|
||||||
|
|
||||||
return CopilotModels.get(
|
return CopilotModels.get(
|
||||||
base(ctx.auth.enterpriseUrl),
|
base(auth.enterpriseUrl),
|
||||||
{
|
{
|
||||||
Authorization: `Bearer ${ctx.auth.refresh}`,
|
Authorization: `Bearer ${auth.refresh}`,
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
},
|
},
|
||||||
provider.models,
|
provider.models,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
log.error("failed to fetch copilot models", { error })
|
log.error("failed to fetch copilot models", { error })
|
||||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
return Object.fromEntries(
|
||||||
|
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const info = await getAuth()
|
const info = await getAuth()
|
||||||
if (!info || info.type !== "oauth") return {}
|
if (!info || info.type !== "oauth") return {}
|
||||||
|
|
||||||
const baseURL = base(info.enterpriseUrl)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseURL,
|
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||||
const info = await getAuth()
|
const info = await getAuth()
|
||||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
(msg: any) =>
|
(msg: any) =>
|
||||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||||
),
|
),
|
||||||
isAgent: last?.role !== "user",
|
isAgent: last?.role !== "user" || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
(item: any) =>
|
(item: any) =>
|
||||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||||
),
|
),
|
||||||
isAgent: last?.role !== "user",
|
isAgent: last?.role !== "user" || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
part.content.some((nested: any) => nested?.type === "image")),
|
part.content.some((nested: any) => nested?.type === "image")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ export namespace CopilotModels {
|
|||||||
(remote.capabilities.supports.vision ?? false) ||
|
(remote.capabilities.supports.vision ?? false) ||
|
||||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||||
|
|
||||||
|
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
providerID: "github-copilot",
|
providerID: "github-copilot",
|
||||||
api: {
|
api: {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
url,
|
url: isMsgApi ? `${url}/v1` : url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
// API response wins
|
// API response wins
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Plugin {
|
|||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||||
})
|
})
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
@@ -210,13 +210,15 @@ export namespace Plugin {
|
|||||||
return message
|
return message
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((message) =>
|
Effect.catch(() => {
|
||||||
bus.publish(Session.Event.Error, {
|
// TODO: make proper events for this
|
||||||
error: new NamedError.Unknown({
|
// bus.publish(Session.Event.Error, {
|
||||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
// error: new NamedError.Unknown({
|
||||||
}).toObject(),
|
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
}),
|
// }).toObject(),
|
||||||
),
|
// })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
@@ -11,6 +10,7 @@ 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 { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export async function InstanceBootstrap() {
|
||||||
@@ -20,7 +20,7 @@ export async function InstanceBootstrap() {
|
|||||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
File.init()
|
File.init()
|
||||||
FileWatcher.init()
|
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||||
Vcs.init()
|
Vcs.init()
|
||||||
Snapshot.init()
|
Snapshot.init()
|
||||||
|
|
||||||
|
|||||||
+63061
-58319
File diff suppressed because it is too large
Load Diff
@@ -774,7 +774,10 @@ export namespace ProviderTransform {
|
|||||||
result["chat_template_args"] = { enable_thinking: true }
|
result["chat_template_args"] = { enable_thinking: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
|
if (
|
||||||
|
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
|
||||||
|
input.model.api.npm === "@ai-sdk/openai-compatible"
|
||||||
|
) {
|
||||||
result["thinking"] = {
|
result["thinking"] = {
|
||||||
type: "enabled",
|
type: "enabled",
|
||||||
clear_thinking: false,
|
clear_thinking: false,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -199,26 +198,4 @@ export namespace Question {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function ask(input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}): Promise<Answer[]> {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID) {
|
|
||||||
return runPromise((s) => s.reject(requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((s) => s.list())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import { createBunWebSocket } from "hono/bun"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createBunWebSocket()
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const args = {
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: opts.hostname,
|
||||||
|
idleTimeout: 0,
|
||||||
|
websocket: ws.websocket,
|
||||||
|
} as const
|
||||||
|
const start = (port: number) => {
|
||||||
|
try {
|
||||||
|
return Bun.serve({ ...args, port })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||||
|
}
|
||||||
|
if (!server.port) {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
port: server.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
return Promise.resolve(server.stop(close))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createNodeWebSocket({ app })
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const start = (port: number) =>
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
const server = createAdaptorServer({ fetch: app.fetch })
|
||||||
|
ws.injectWebSocket(server)
|
||||||
|
const fail = (err: Error) => {
|
||||||
|
cleanup()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
const ready = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("error", fail)
|
||||||
|
server.off("listening", ready)
|
||||||
|
}
|
||||||
|
server.once("error", fail)
|
||||||
|
server.once("listening", ready)
|
||||||
|
server.listen(port, opts.hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
port: addr.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (close) {
|
||||||
|
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||||
|
server.closeAllConnections()
|
||||||
|
}
|
||||||
|
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||||
|
server.closeIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
|
||||||
|
export type Opts = {
|
||||||
|
port: number
|
||||||
|
hostname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Listener = {
|
||||||
|
port: number
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Runtime {
|
||||||
|
upgradeWebSocket: UpgradeWebSocket
|
||||||
|
listen(opts: Opts): Promise<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
create(app: Hono): Runtime
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { ProviderID } from "@/provider/schema"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||||
|
import z from "zod"
|
||||||
|
import { errors } from "../error"
|
||||||
|
import { GlobalRoutes } from "../instance/global"
|
||||||
|
|
||||||
|
export function ControlPlaneRoutes(): Hono {
|
||||||
|
const app = new Hono()
|
||||||
|
return app
|
||||||
|
.route("/global", GlobalRoutes())
|
||||||
|
.put(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Set auth credentials",
|
||||||
|
description: "Set authentication credentials",
|
||||||
|
operationId: "auth.set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully set authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validator("json", Auth.Info.zod),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
const info = c.req.valid("json")
|
||||||
|
await Auth.set(providerID, info)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Remove auth credentials",
|
||||||
|
description: "Remove authentication credentials",
|
||||||
|
operationId: "auth.remove",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully removed authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
await Auth.remove(providerID)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/doc",
|
||||||
|
openAPIRouteHandler(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "opencode",
|
||||||
|
version: "0.0.3",
|
||||||
|
description: "opencode api",
|
||||||
|
},
|
||||||
|
openapi: "3.1.1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
directory: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/log",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Write log",
|
||||||
|
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||||
|
operationId: "app.log",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Log entry written successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||||
|
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||||
|
message: z.string().meta({ description: "Log message" }),
|
||||||
|
extra: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Additional metadata for the log entry" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { service, level, message, extra } = c.req.valid("json")
|
||||||
|
const logger = Log.create({ service })
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
logger.debug(message, extra)
|
||||||
|
break
|
||||||
|
case "info":
|
||||||
|
logger.info(message, extra)
|
||||||
|
break
|
||||||
|
case "error":
|
||||||
|
logger.error(message, extra)
|
||||||
|
break
|
||||||
|
case "warn":
|
||||||
|
logger.warn(message, extra)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+40
-17
@@ -11,9 +11,11 @@ import { Session } from "../../session"
|
|||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
import { ConsoleState } from "../../config/console-state"
|
import { ConsoleState } from "../../config/console-state"
|
||||||
import { Account, AccountID, OrgID } from "../../account"
|
import { Account, AccountID, OrgID } from "../../account"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
import { WorkspaceRoutes } from "./workspace"
|
import { WorkspaceRoutes } from "./workspace"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
|
|
||||||
@@ -55,11 +57,20 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
|
const result = await AppRuntime.runPromise(
|
||||||
return c.json({
|
Effect.gen(function* () {
|
||||||
...consoleState,
|
const config = yield* Config.Service
|
||||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
const account = yield* Account.Service
|
||||||
})
|
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -80,17 +91,24 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
|
const orgs = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
const orgs = groups.flatMap((group) =>
|
const account = yield* Account.Service
|
||||||
group.orgs.map((org) => ({
|
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||||
accountID: group.account.id,
|
concurrency: "unbounded",
|
||||||
accountEmail: group.account.email,
|
})
|
||||||
accountUrl: group.account.url,
|
const info = Option.getOrUndefined(active)
|
||||||
orgID: org.id,
|
return groups.flatMap((group) =>
|
||||||
orgName: org.name,
|
group.orgs.map((org) => ({
|
||||||
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
|
accountID: group.account.id,
|
||||||
})),
|
accountEmail: group.account.email,
|
||||||
|
accountUrl: group.account.url,
|
||||||
|
orgID: org.id,
|
||||||
|
orgName: org.name,
|
||||||
|
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
return c.json({ orgs })
|
return c.json({ orgs })
|
||||||
},
|
},
|
||||||
@@ -115,7 +133,12 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", ConsoleSwitchBody),
|
validator("json", ConsoleSwitchBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const account = yield* Account.Service
|
||||||
|
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
+24
-80
@@ -1,53 +1,33 @@
|
|||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createHash } from "node:crypto"
|
import { Format } from "../../format"
|
||||||
import * as fs from "node:fs/promises"
|
import { TuiRoutes } from "./tui"
|
||||||
import { Log } from "../util/log"
|
import { Instance } from "../../project/instance"
|
||||||
import { Format } from "../format"
|
import { Vcs } from "../../project/vcs"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Instance } from "../project/instance"
|
import { Skill } from "../../skill"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Global } from "../../global"
|
||||||
import { Agent } from "../agent/agent"
|
import { LSP } from "../../lsp"
|
||||||
import { Skill } from "../skill"
|
import { Command } from "../../command"
|
||||||
import { Global } from "../global"
|
import { QuestionRoutes } from "./question"
|
||||||
import { LSP } from "../lsp"
|
import { PermissionRoutes } from "./permission"
|
||||||
import { Command } from "../command"
|
import { ProjectRoutes } from "./project"
|
||||||
import { Flag } from "../flag/flag"
|
import { SessionRoutes } from "./session"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { PtyRoutes } from "./pty"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { McpRoutes } from "./mcp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { FileRoutes } from "./file"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ConfigRoutes } from "./config"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { ExperimentalRoutes } from "./experimental"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
import { ProviderRoutes } from "./provider"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { EventRoutes } from "./event"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
|
||||||
import { EventRoutes } from "./routes/event"
|
|
||||||
import { errorHandler } from "./middleware"
|
|
||||||
import { getMimeType } from "hono/utils/mime"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||||
|
new Hono()
|
||||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
.use(WorkspaceRouterMiddleware(upgrade))
|
||||||
? Promise.resolve(null)
|
|
||||||
: // @ts-expect-error - generated file at build time
|
|
||||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
|
||||||
|
|
||||||
const DEFAULT_CSP =
|
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
|
||||||
|
|
||||||
const csp = (hash = "") =>
|
|
||||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
|
||||||
|
|
||||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
|
||||||
app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes(upgrade))
|
.route("/pty", PtyRoutes(upgrade))
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
@@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
|
||||||
const embeddedWebUI = await embeddedUIPromise
|
|
||||||
const path = c.req.path
|
|
||||||
|
|
||||||
if (embeddedWebUI) {
|
|
||||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
|
||||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
|
||||||
|
|
||||||
if (await fs.exists(match)) {
|
|
||||||
const mime = getMimeType(match) ?? "text/plain"
|
|
||||||
c.header("Content-Type", mime)
|
|
||||||
if (mime.startsWith("text/html")) {
|
|
||||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
|
||||||
}
|
|
||||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
|
||||||
} else {
|
|
||||||
return c.json({ error: "Not Found" }, 404)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
|
||||||
...c.req,
|
|
||||||
headers: {
|
|
||||||
...c.req.raw.headers,
|
|
||||||
host: "app.opencode.ai",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const match = response.headers.get("content-type")?.includes("text/html")
|
|
||||||
? (await response.clone().text()).match(
|
|
||||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
|
||||||
response.headers.set("Content-Security-Policy", csp(hash))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+6
-10
@@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
|
|||||||
import { getAdaptor } from "@/control-plane/adaptors"
|
import { getAdaptor } from "@/control-plane/adaptors"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { ServerProxy } from "./proxy"
|
import { ServerProxy } from "../proxy"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
@@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
return async (c, next) => {
|
||||||
|
|
||||||
return async (c) => {
|
|
||||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||||
const directory = Filesystem.resolve(
|
const directory = Filesystem.resolve(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||||
// made sure that it will run without an instance
|
// made sure that it will run without an instance
|
||||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
@@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
if (local(c.req.method, url.pathname)) {
|
if (local(c.req.method, url.pathname)) {
|
||||||
// No instance provided because we are serving cached data; there
|
// No instance provided because we are serving cached data; there
|
||||||
// is no instance to work with
|
// is no instance to work with
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||||
+11
-6
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
|
|||||||
import { resolver } from "hono-openapi"
|
import { resolver } from "hono-openapi"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { Question } from "../../question"
|
import { Question } from "../../question"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const questions = await Question.list()
|
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||||
return c.json(questions)
|
return c.json(questions)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Question.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.requestID,
|
Question.Service.use((svc) =>
|
||||||
answers: json.answers,
|
svc.reply({
|
||||||
})
|
requestID: params.requestID,
|
||||||
|
answers: json.answers,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await Question.reject(params.requestID)
|
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
+20
-5
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
|
|||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "../../session/todo"
|
import { Todo } from "../../session/todo"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { AppRuntime } from "../../effect/app-runtime"
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
permission: Permission.Ruleset.optional(),
|
||||||
time: z
|
time: z
|
||||||
.object({
|
.object({
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const updates = c.req.valid("json")
|
const updates = c.req.valid("json")
|
||||||
|
const current = await Session.get(sessionID)
|
||||||
|
|
||||||
if (updates.title !== undefined) {
|
if (updates.title !== undefined) {
|
||||||
await Session.setTitle({ sessionID, title: updates.title })
|
await Session.setTitle({ sessionID, title: updates.title })
|
||||||
}
|
}
|
||||||
|
if (updates.permission !== undefined) {
|
||||||
|
await Session.setPermission({
|
||||||
|
sessionID,
|
||||||
|
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (updates.time?.archived !== undefined) {
|
if (updates.time?.archived !== undefined) {
|
||||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||||
}
|
}
|
||||||
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await SessionRunState.assertNotBusy(params.sessionID)
|
await AppRuntime.runPromise(
|
||||||
await Session.removeMessage({
|
Effect.gen(function* () {
|
||||||
sessionID: params.sessionID,
|
const state = yield* SessionRunState.Service
|
||||||
messageID: params.messageID,
|
const session = yield* Session.Service
|
||||||
})
|
yield* state.assertNotBusy(params.sessionID)
|
||||||
|
yield* session.removeMessage({
|
||||||
|
sessionID: params.sessionID,
|
||||||
|
messageID: params.messageID,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import type { ErrorHandler } from "hono"
|
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import type { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { basicAuth } from "hono/basic-auth"
|
||||||
|
import { cors } from "hono/cors"
|
||||||
|
import { compress } from "hono/compress"
|
||||||
|
|
||||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
const log = Log.create({ service: "server" })
|
||||||
return (err, c) => {
|
|
||||||
log.error("failed", {
|
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||||
error: err,
|
log.error("failed", {
|
||||||
})
|
error: err,
|
||||||
if (err instanceof NamedError) {
|
})
|
||||||
let status: ContentfulStatusCode
|
if (err instanceof NamedError) {
|
||||||
if (err instanceof NotFoundError) status = 404
|
let status: ContentfulStatusCode
|
||||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
if (err instanceof NotFoundError) status = 404
|
||||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||||
else if (err.name.startsWith("Worktree")) status = 400
|
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||||
else status = 500
|
else if (err.name.startsWith("Worktree")) status = 400
|
||||||
return c.json(err.toObject(), { status })
|
else status = 500
|
||||||
}
|
return c.json(err.toObject(), { status })
|
||||||
if (err instanceof Session.BusyError) {
|
}
|
||||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
if (err instanceof Session.BusyError) {
|
||||||
}
|
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||||
if (err instanceof HTTPException) return err.getResponse()
|
}
|
||||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
if (err instanceof HTTPException) return err.getResponse()
|
||||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||||
status: 500,
|
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
// Allow CORS preflight requests to succeed without auth.
|
||||||
|
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||||
|
if (c.req.method === "OPTIONS") return next()
|
||||||
|
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
if (!password) return next()
|
||||||
|
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||||
|
|
||||||
|
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||||
|
|
||||||
|
return basicAuth({ username, password })(c, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
const skip = c.req.path === "/log"
|
||||||
|
if (!skip) {
|
||||||
|
log.info("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const timer = log.time("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
})
|
||||||
|
await next()
|
||||||
|
if (!skip) timer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||||
|
return cors({
|
||||||
|
maxAge: 86_400,
|
||||||
|
origin(input) {
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
if (input.startsWith("http://localhost:")) return input
|
||||||
|
if (input.startsWith("http://127.0.0.1:")) return input
|
||||||
|
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||||
|
return input
|
||||||
|
|
||||||
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||||
|
if (opts?.cors?.includes(input)) return input
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipped = compress()
|
||||||
|
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
const path = c.req.path
|
||||||
|
const method = c.req.method
|
||||||
|
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||||
|
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||||
|
return zipped(c, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { Log } from "../util/log"
|
import { generateSpecs } from "hono-openapi"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { compress } from "hono/compress"
|
import { adapter } from "#hono"
|
||||||
import { createNodeWebSocket } from "@hono/node-ws"
|
|
||||||
import { cors } from "hono/cors"
|
|
||||||
import { basicAuth } from "hono/basic-auth"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
|
||||||
import z from "zod"
|
|
||||||
import { Auth } from "../auth"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { ProviderID } from "../provider/schema"
|
|
||||||
import { WorkspaceRouterMiddleware } from "./router"
|
|
||||||
import { errors } from "./error"
|
|
||||||
import { GlobalRoutes } from "./routes/global"
|
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { errorHandler } from "./middleware"
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||||
import { InstanceRoutes } from "./instance"
|
import { InstanceRoutes } from "./instance"
|
||||||
import { initProjectors } from "./projectors"
|
import { initProjectors } from "./projectors"
|
||||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
import { Log } from "@/util/log"
|
||||||
|
import { ControlPlaneRoutes } from "./control"
|
||||||
|
import { UIRoutes } from "./ui"
|
||||||
|
|
||||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
|||||||
initProjectors()
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export type Listener = {
|
export type Listener = {
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
@@ -33,231 +25,31 @@ export namespace Server {
|
|||||||
stop: (close?: boolean) => Promise<void>
|
stop: (close?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
const zipped = compress()
|
|
||||||
|
|
||||||
const skipCompress = (path: string, method: string) => {
|
|
||||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
|
||||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = lazy(() => create({}))
|
export const Default = lazy(() => create({}))
|
||||||
|
|
||||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
|
||||||
return app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.use((c, next) => {
|
|
||||||
// Allow CORS preflight requests to succeed without auth.
|
|
||||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
|
||||||
if (c.req.method === "OPTIONS") return next()
|
|
||||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
if (!password) return next()
|
|
||||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
||||||
|
|
||||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
|
||||||
|
|
||||||
return basicAuth({ username, password })(c, next)
|
|
||||||
})
|
|
||||||
.use(async (c, next) => {
|
|
||||||
const skip = c.req.path === "/log"
|
|
||||||
if (!skip) {
|
|
||||||
log.info("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const timer = log.time("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
await next()
|
|
||||||
if (!skip) timer.stop()
|
|
||||||
})
|
|
||||||
.use(
|
|
||||||
cors({
|
|
||||||
maxAge: 86_400,
|
|
||||||
origin(input) {
|
|
||||||
if (!input) return
|
|
||||||
|
|
||||||
if (input.startsWith("http://localhost:")) return input
|
|
||||||
if (input.startsWith("http://127.0.0.1:")) return input
|
|
||||||
if (
|
|
||||||
input === "tauri://localhost" ||
|
|
||||||
input === "http://tauri.localhost" ||
|
|
||||||
input === "https://tauri.localhost"
|
|
||||||
)
|
|
||||||
return input
|
|
||||||
|
|
||||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
|
||||||
if (opts?.cors?.includes(input)) return input
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use((c, next) => {
|
|
||||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
|
||||||
return zipped(c, next)
|
|
||||||
})
|
|
||||||
.route("/global", GlobalRoutes())
|
|
||||||
.put(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Set auth credentials",
|
|
||||||
description: "Set authentication credentials",
|
|
||||||
operationId: "auth.set",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully set authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
validator("json", Auth.Info.zod),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
const info = c.req.valid("json")
|
|
||||||
await Auth.set(providerID, info)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.delete(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Remove auth credentials",
|
|
||||||
description: "Remove authentication credentials",
|
|
||||||
operationId: "auth.remove",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully removed authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
await Auth.remove(providerID)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/doc",
|
|
||||||
openAPIRouteHandler(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "opencode",
|
|
||||||
version: "0.0.3",
|
|
||||||
description: "opencode api",
|
|
||||||
},
|
|
||||||
openapi: "3.1.1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use(
|
|
||||||
validator(
|
|
||||||
"query",
|
|
||||||
z.object({
|
|
||||||
directory: z.string().optional(),
|
|
||||||
workspace: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/log",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Write log",
|
|
||||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
|
||||||
operationId: "app.log",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Log entry written successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
|
||||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
|
||||||
message: z.string().meta({ description: "Log message" }),
|
|
||||||
extra: z
|
|
||||||
.record(z.string(), z.any())
|
|
||||||
.optional()
|
|
||||||
.meta({ description: "Additional metadata for the log entry" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { service, level, message, extra } = c.req.valid("json")
|
|
||||||
const logger = Log.create({ service })
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case "debug":
|
|
||||||
logger.debug(message, extra)
|
|
||||||
break
|
|
||||||
case "info":
|
|
||||||
logger.info(message, extra)
|
|
||||||
break
|
|
||||||
case "error":
|
|
||||||
logger.error(message, extra)
|
|
||||||
break
|
|
||||||
case "warn":
|
|
||||||
logger.warn(message, extra)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.use(WorkspaceRouterMiddleware(upgrade))
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(opts: { cors?: string[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const ws = createNodeWebSocket({ app })
|
const runtime = adapter.create(app)
|
||||||
return {
|
return {
|
||||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
app: app
|
||||||
ws,
|
.onError(ErrorMiddleware)
|
||||||
|
.use(AuthMiddleware)
|
||||||
|
.use(LoggerMiddleware)
|
||||||
|
.use(CompressionMiddleware)
|
||||||
|
.use(CorsMiddleware(opts))
|
||||||
|
.route("/", ControlPlaneRoutes())
|
||||||
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||||
|
.route("/", UIRoutes()),
|
||||||
|
runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(opts: { cors?: string[] }) {
|
|
||||||
return create(opts).app
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
// Build a fresh app with all routes registered directly so
|
// Build a fresh app with all routes registered directly so
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
// strips the metadata symbol).
|
// strips the metadata symbol).
|
||||||
const { app, ws } = create({})
|
const { app } = create({})
|
||||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
|
||||||
const result = await generateSpecs(app, {
|
const result = await generateSpecs(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
@@ -281,46 +73,21 @@ export namespace Server {
|
|||||||
cors?: string[]
|
cors?: string[]
|
||||||
}): Promise<Listener> {
|
}): Promise<Listener> {
|
||||||
const built = create(opts)
|
const built = create(opts)
|
||||||
const start = (port: number) =>
|
const server = await built.runtime.listen(opts)
|
||||||
new Promise<ServerType>((resolve, reject) => {
|
|
||||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
|
||||||
built.ws.injectWebSocket(server)
|
|
||||||
const fail = (err: Error) => {
|
|
||||||
cleanup()
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
const ready = () => {
|
|
||||||
cleanup()
|
|
||||||
resolve(server)
|
|
||||||
}
|
|
||||||
const cleanup = () => {
|
|
||||||
server.off("error", fail)
|
|
||||||
server.off("listening", ready)
|
|
||||||
}
|
|
||||||
server.once("error", fail)
|
|
||||||
server.once("listening", ready)
|
|
||||||
server.listen(port, opts.hostname)
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
|
||||||
const addr = server.address()
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new URL("http://localhost")
|
const next = new URL("http://localhost")
|
||||||
next.hostname = opts.hostname
|
next.hostname = opts.hostname
|
||||||
next.port = String(addr.port)
|
next.port = String(server.port)
|
||||||
url = next
|
url = next
|
||||||
|
|
||||||
const mdns =
|
const mdns =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
addr.port &&
|
server.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (mdns) {
|
if (mdns) {
|
||||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
MDNS.publish(server.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
@@ -328,27 +95,13 @@ export namespace Server {
|
|||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
port: addr.port,
|
port: server.port,
|
||||||
url: next,
|
url: next,
|
||||||
stop(close?: boolean) {
|
stop(close?: boolean) {
|
||||||
closing ??= new Promise((resolve, reject) => {
|
closing ??= (async () => {
|
||||||
if (mdns) MDNS.unpublish()
|
if (mdns) MDNS.unpublish()
|
||||||
server.close((err) => {
|
await server.stop(close)
|
||||||
if (err) {
|
})()
|
||||||
reject(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
if (close) {
|
|
||||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
|
||||||
server.closeAllConnections()
|
|
||||||
}
|
|
||||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
|
||||||
server.closeIdleConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return closing
|
return closing
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { proxy } from "hono/proxy"
|
||||||
|
import { getMimeType } from "hono/utils/mime"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
|
||||||
|
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: // @ts-expect-error - generated file at build time
|
||||||
|
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||||
|
|
||||||
|
const DEFAULT_CSP =
|
||||||
|
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||||
|
|
||||||
|
const csp = (hash = "") =>
|
||||||
|
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||||
|
|
||||||
|
export const UIRoutes = (): Hono =>
|
||||||
|
new Hono().all("/*", async (c) => {
|
||||||
|
const embeddedWebUI = await embeddedUIPromise
|
||||||
|
const path = c.req.path
|
||||||
|
|
||||||
|
if (embeddedWebUI) {
|
||||||
|
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||||
|
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||||
|
|
||||||
|
if (await fs.exists(match)) {
|
||||||
|
const mime = getMimeType(match) ?? "text/plain"
|
||||||
|
c.header("Content-Type", mime)
|
||||||
|
if (mime.startsWith("text/html")) {
|
||||||
|
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||||
|
}
|
||||||
|
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Not Found" }, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||||
|
...c.req,
|
||||||
|
headers: {
|
||||||
|
...c.req.raw.headers,
|
||||||
|
host: "app.opencode.ai",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const match = response.headers.get("content-type")?.includes("text/html")
|
||||||
|
? (await response.clone().text()).match(
|
||||||
|
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||||
|
response.headers.set("Content-Security-Policy", csp(hash))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -708,6 +708,10 @@ export namespace Session {
|
|||||||
runPromise((svc) => svc.setArchived(input)),
|
runPromise((svc) => svc.setArchived(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||||
|
runPromise((svc) => svc.setPermission(input)),
|
||||||
|
)
|
||||||
|
|
||||||
export const setRevert = fn(
|
export const setRevert = fn(
|
||||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||||
(input) =>
|
(input) =>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
@@ -238,21 +237,7 @@ export namespace Instruction {
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function clear(messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.clear(messageID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function systemPaths() {
|
|
||||||
return runPromise((svc) => svc.systemPaths())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loaded(messages: MessageV2.WithParts[]) {
|
export function loaded(messages: MessageV2.WithParts[]) {
|
||||||
return extract(messages)
|
return extract(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace MessageV2 {
|
export namespace MessageV2 {
|
||||||
|
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||||
|
|
||||||
export function isMedia(mime: string) {
|
export function isMedia(mime: string) {
|
||||||
return mime.startsWith("image/") || mime === "application/pdf"
|
return mime.startsWith("image/") || mime === "application/pdf"
|
||||||
}
|
}
|
||||||
@@ -808,7 +810,7 @@ export namespace MessageV2 {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: "Attached image(s) from tool result:",
|
text: SYNTHETIC_ATTACHMENT_PROMPT,
|
||||||
},
|
},
|
||||||
...media.map((attachment) => ({
|
...media.map((attachment) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
|
|||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const state = yield* SessionRunState.Service
|
const state = yield* SessionRunState.Service
|
||||||
const revert = yield* SessionRevert.Service
|
const revert = yield* SessionRevert.Service
|
||||||
|
const sys = yield* SystemPrompt.Service
|
||||||
|
const llm = yield* LLM.Service
|
||||||
|
|
||||||
const run = {
|
const run = {
|
||||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||||
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
|
|||||||
const msgs = onlySubtasks
|
const msgs = onlySubtasks
|
||||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||||
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
||||||
const text = yield* Effect.promise(async (signal) => {
|
const text = yield* llm
|
||||||
const result = await LLM.stream({
|
.stream({
|
||||||
agent: ag,
|
agent: ag,
|
||||||
user: firstInfo,
|
user: firstInfo,
|
||||||
system: [],
|
system: [],
|
||||||
small: true,
|
small: true,
|
||||||
tools: {},
|
tools: {},
|
||||||
model: mdl,
|
model: mdl,
|
||||||
abort: signal,
|
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
})
|
})
|
||||||
return result.text
|
.pipe(
|
||||||
})
|
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||||
|
Stream.map((e) => e.text),
|
||||||
|
Stream.mkString,
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
const cleaned = text
|
const cleaned = text
|
||||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
|
|
||||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
sys.skills(agent),
|
||||||
Effect.promise(() => SystemPrompt.environment(model)),
|
Effect.sync(() => sys.environment(model)),
|
||||||
instruction.system().pipe(Effect.orDie),
|
instruction.system().pipe(Effect.orDie),
|
||||||
MessageV2.toModelMessagesEffect(msgs, model),
|
MessageV2.toModelMessagesEffect(msgs, model),
|
||||||
])
|
])
|
||||||
@@ -1687,9 +1692,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(
|
||||||
Layer.provide(Bus.layer),
|
Layer.mergeAll(
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Agent.defaultLayer,
|
||||||
|
SystemPrompt.defaultLayer,
|
||||||
|
LLM.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Effect, Layer, Scope, Context } from "effect"
|
import { Effect, Layer, Scope, Context } from "effect"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -106,9 +105,4 @@ export namespace SessionRunState {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function assertNotBusy(sessionID: SessionID) {
|
|
||||||
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
|
|||||||
return [PROMPT_DEFAULT]
|
return [PROMPT_DEFAULT]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function environment(model: Provider.Model) {
|
export interface Interface {
|
||||||
const project = Instance.project
|
readonly environment: (model: Provider.Model) => string[]
|
||||||
return [
|
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||||
[
|
|
||||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
|
||||||
`Here is some useful information about the environment you are running in:`,
|
|
||||||
`<env>`,
|
|
||||||
` Working directory: ${Instance.directory}`,
|
|
||||||
` Workspace root folder: ${Instance.worktree}`,
|
|
||||||
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
|
||||||
` Platform: ${process.platform}`,
|
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
|
||||||
`</env>`,
|
|
||||||
`<directories>`,
|
|
||||||
` ${
|
|
||||||
project.vcs === "git" && false
|
|
||||||
? await Ripgrep.tree({
|
|
||||||
cwd: Instance.directory,
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
`</directories>`,
|
|
||||||
].join("\n"),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function skills(agent: Agent.Info) {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
|
||||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
|
||||||
|
|
||||||
const list = await Skill.available(agent)
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
|
||||||
return [
|
return Service.of({
|
||||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
environment(model) {
|
||||||
"Use the skill tool to load a skill when a task matches its description.",
|
const project = Instance.project
|
||||||
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
return [
|
||||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
[
|
||||||
Skill.fmt(list, { verbose: true }),
|
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||||
].join("\n")
|
`Here is some useful information about the environment you are running in:`,
|
||||||
}
|
`<env>`,
|
||||||
|
` Working directory: ${Instance.directory}`,
|
||||||
|
` Workspace root folder: ${Instance.worktree}`,
|
||||||
|
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
||||||
|
` Platform: ${process.platform}`,
|
||||||
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
|
`</env>`,
|
||||||
|
].join("\n"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||||
|
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||||
|
|
||||||
|
const list = yield* skill.available(agent)
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||||
|
"Use the skill tool to load a skill when a task matches its description.",
|
||||||
|
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
||||||
|
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||||
|
Skill.fmt(list, { verbose: true }),
|
||||||
|
].join("\n")
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return async () => {
|
return () =>
|
||||||
const shell = Shell.acceptable()
|
Effect.sync(() => {
|
||||||
const name = Shell.name(shell)
|
const shell = Shell.acceptable()
|
||||||
const chain =
|
const name = Shell.name(shell)
|
||||||
name === "powershell"
|
const chain =
|
||||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
name === "powershell"
|
||||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||||
log.info("bash tool using shell", { shell })
|
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||||
|
log.info("bash tool using shell", { shell })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||||
.replaceAll("${os}", process.platform)
|
.replaceAll("${os}", process.platform)
|
||||||
.replaceAll("${shell}", name)
|
.replaceAll("${shell}", name)
|
||||||
.replaceAll("${chaining}", chain)
|
.replaceAll("${chaining}", chain)
|
||||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cwd = params.workdir
|
const cwd = params.workdir
|
||||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||||
: Instance.directory
|
: Instance.directory
|
||||||
if (params.timeout !== undefined && params.timeout < 0) {
|
if (params.timeout !== undefined && params.timeout < 0) {
|
||||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||||
}
|
}
|
||||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||||
const ps = PS.has(name)
|
const ps = PS.has(name)
|
||||||
const root = yield* parse(params.command, ps)
|
const root = yield* parse(params.command, ps)
|
||||||
const scan = yield* collect(root, cwd, ps, shell)
|
const scan = yield* collect(root, cwd, ps, shell)
|
||||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||||
yield* ask(ctx, scan)
|
yield* ask(ctx, scan)
|
||||||
|
|
||||||
return yield* run(
|
return yield* run(
|
||||||
{
|
{
|
||||||
shell,
|
shell,
|
||||||
name,
|
name,
|
||||||
command: params.command,
|
command: params.command,
|
||||||
cwd,
|
cwd,
|
||||||
env: yield* shellEnv(ctx, cwd),
|
env: yield* shellEnv(ctx, cwd),
|
||||||
timeout,
|
timeout,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
|
|||||||
"multiedit",
|
"multiedit",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const editInfo = yield* EditTool
|
const editInfo = yield* EditTool
|
||||||
const edit = yield* Effect.promise(() => editInfo.init())
|
const edit = yield* editInfo.init()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|||||||
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
|
return () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
||||||
|
|
||||||
return async () => {
|
const description =
|
||||||
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
|
list.length === 0
|
||||||
|
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||||
const description =
|
: [
|
||||||
list.length === 0
|
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
|
||||||
: [
|
|
||||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
||||||
"",
|
|
||||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
||||||
"",
|
|
||||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
||||||
"",
|
|
||||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
||||||
"",
|
|
||||||
"The following skills provide specialized sets of instructions for particular tasks",
|
|
||||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
|
||||||
"",
|
|
||||||
Skill.fmt(list, { verbose: false }),
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
parameters: Parameters,
|
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const info = yield* skill.get(params.name)
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
const all = yield* skill.all()
|
|
||||||
const available = all.map((s) => s.name).join(", ")
|
|
||||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* ctx.ask({
|
|
||||||
permission: "skill",
|
|
||||||
patterns: [params.name],
|
|
||||||
always: [params.name],
|
|
||||||
metadata: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dir = path.dirname(info.location)
|
|
||||||
const base = pathToFileURL(dir).href
|
|
||||||
|
|
||||||
const limit = 10
|
|
||||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
|
||||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
|
||||||
Stream.map((file) => path.resolve(dir, file)),
|
|
||||||
Stream.take(limit),
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `Loaded skill: ${info.name}`,
|
|
||||||
output: [
|
|
||||||
`<skill_content name="${info.name}">`,
|
|
||||||
`# Skill: ${info.name}`,
|
|
||||||
"",
|
"",
|
||||||
info.content.trim(),
|
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||||
"",
|
"",
|
||||||
`Base directory for this skill: ${base}`,
|
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
||||||
"Note: file list is sampled.",
|
|
||||||
"",
|
"",
|
||||||
"<skill_files>",
|
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||||
files,
|
"",
|
||||||
"</skill_files>",
|
"The following skills provide specialized sets of instructions for particular tasks",
|
||||||
"</skill_content>",
|
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||||
].join("\n"),
|
"",
|
||||||
metadata: {
|
Skill.fmt(list, { verbose: false }),
|
||||||
name: info.name,
|
].join("\n")
|
||||||
dir,
|
|
||||||
},
|
return {
|
||||||
}
|
description,
|
||||||
}).pipe(Effect.orDie),
|
parameters: Parameters,
|
||||||
}
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
}
|
Effect.gen(function* () {
|
||||||
|
const info = yield* skill.get(params.name)
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
const all = yield* skill.all()
|
||||||
|
const available = all.map((s) => s.name).join(", ")
|
||||||
|
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "skill",
|
||||||
|
patterns: [params.name],
|
||||||
|
always: [params.name],
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dir = path.dirname(info.location)
|
||||||
|
const base = pathToFileURL(dir).href
|
||||||
|
|
||||||
|
const limit = 10
|
||||||
|
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||||
|
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||||
|
Stream.map((file) => path.resolve(dir, file)),
|
||||||
|
Stream.take(limit),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Loaded skill: ${info.name}`,
|
||||||
|
output: [
|
||||||
|
`<skill_content name="${info.name}">`,
|
||||||
|
`# Skill: ${info.name}`,
|
||||||
|
"",
|
||||||
|
info.content.trim(),
|
||||||
|
"",
|
||||||
|
`Base directory for this skill: ${base}`,
|
||||||
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||||
|
"Note: file list is sampled.",
|
||||||
|
"",
|
||||||
|
"<skill_files>",
|
||||||
|
files,
|
||||||
|
"</skill_files>",
|
||||||
|
"</skill_content>",
|
||||||
|
].join("\n"),
|
||||||
|
metadata: {
|
||||||
|
name: info.name,
|
||||||
|
dir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
|
}
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ export namespace Tool {
|
|||||||
|
|
||||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||||
id: string
|
id: string
|
||||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||||
|
| DefWithoutID<Parameters, M>
|
||||||
|
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||||
|
|
||||||
export type InferParameters<T> =
|
export type InferParameters<T> =
|
||||||
T extends Info<infer P, any>
|
T extends Info<infer P, any>
|
||||||
? z.infer<P>
|
? z.infer<P>
|
||||||
@@ -68,58 +72,66 @@ export namespace Tool {
|
|||||||
|
|
||||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
id: string,
|
id: string,
|
||||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
init: Init<Parameters, Result>,
|
||||||
|
truncate: Truncate.Interface,
|
||||||
|
agents: Agent.Interface,
|
||||||
) {
|
) {
|
||||||
return async () => {
|
return () =>
|
||||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
Effect.gen(function* () {
|
||||||
const execute = toolInfo.execute
|
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
||||||
toolInfo.execute = (args, ctx) =>
|
const execute = toolInfo.execute
|
||||||
Effect.gen(function* () {
|
toolInfo.execute = (args, ctx) =>
|
||||||
yield* Effect.try({
|
Effect.gen(function* () {
|
||||||
try: () => toolInfo.parameters.parse(args),
|
yield* Effect.try({
|
||||||
catch: (error) => {
|
try: () => toolInfo.parameters.parse(args),
|
||||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
catch: (error) => {
|
||||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||||
}
|
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||||
return new Error(
|
}
|
||||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
return new Error(
|
||||||
{ cause: error },
|
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||||
)
|
{ cause: error },
|
||||||
},
|
)
|
||||||
})
|
},
|
||||||
const result = yield* execute(args, ctx)
|
})
|
||||||
if (result.metadata.truncated !== undefined) {
|
const result = yield* execute(args, ctx)
|
||||||
return result
|
if (result.metadata.truncated !== undefined) {
|
||||||
}
|
return result
|
||||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
}
|
||||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
const agent = yield* agents.get(ctx.agent)
|
||||||
return {
|
const truncated = yield* truncate.output(result.output, {}, agent)
|
||||||
...result,
|
return {
|
||||||
output: truncated.content,
|
...result,
|
||||||
metadata: {
|
output: truncated.content,
|
||||||
...result.metadata,
|
metadata: {
|
||||||
truncated: truncated.truncated,
|
...result.metadata,
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
truncated: truncated.truncated,
|
||||||
},
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
}
|
},
|
||||||
}).pipe(Effect.orDie)
|
}
|
||||||
return toolInfo
|
}).pipe(Effect.orDie)
|
||||||
}
|
return toolInfo
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||||
id: ID,
|
id: ID,
|
||||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* init
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
const agents = yield* Agent.Service
|
||||||
|
return { id, init: wrap(id, resolved, truncate, agents) }
|
||||||
|
}),
|
||||||
{ id },
|
{ id },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const init = yield* Effect.promise(() => info.init())
|
const init = yield* info.init()
|
||||||
return {
|
return {
|
||||||
...init,
|
...init,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { evaluate } from "@/permission/evaluate"
|
import { evaluate } from "@/permission/evaluate"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
@@ -135,10 +134,4 @@ export namespace Truncate {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
|
||||||
return runPromise((s) => s.output(text, options, agent))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,70 @@ export namespace Keybind {
|
|||||||
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
||||||
* This ensures type compatibility and catches missing fields at compile time.
|
* This ensures type compatibility and catches missing fields at compile time.
|
||||||
*/
|
*/
|
||||||
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
|
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
|
||||||
leader: boolean // our custom field
|
leader: boolean // our custom field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBaseCodeName(baseCode: number | undefined): string | undefined {
|
||||||
|
if (baseCode === undefined || baseCode < 32 || baseCode === 127) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = String.fromCodePoint(baseCode)
|
||||||
|
|
||||||
|
if (name.length === 1 && name >= "A" && name <= "Z") {
|
||||||
|
return name.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function match(a: Info | undefined, b: Info): boolean {
|
export function match(a: Info | undefined, b: Info): boolean {
|
||||||
if (!a) return false
|
if (!a) return false
|
||||||
const normalizedA = { ...a, super: a.super ?? false }
|
const normalizedA = { ...a, super: a.super ?? false }
|
||||||
const normalizedB = { ...b, super: b.super ?? false }
|
const normalizedB = { ...b, super: b.super ?? false }
|
||||||
return isDeepEqual(normalizedA, normalizedB)
|
if (isDeepEqual(normalizedA, normalizedB)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiersA = {
|
||||||
|
ctrl: normalizedA.ctrl,
|
||||||
|
meta: normalizedA.meta,
|
||||||
|
shift: normalizedA.shift,
|
||||||
|
super: normalizedA.super,
|
||||||
|
leader: normalizedA.leader,
|
||||||
|
}
|
||||||
|
const modifiersB = {
|
||||||
|
ctrl: normalizedB.ctrl,
|
||||||
|
meta: normalizedB.meta,
|
||||||
|
shift: normalizedB.shift,
|
||||||
|
super: normalizedB.super,
|
||||||
|
leader: normalizedB.leader,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDeepEqual(modifiersA, modifiersB)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedA.name === normalizedB.name ||
|
||||||
|
getBaseCodeName(normalizedA.baseCode) === normalizedB.name ||
|
||||||
|
getBaseCodeName(normalizedB.baseCode) === normalizedA.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOne(key: string): Info {
|
||||||
|
const parsed = parse(key)
|
||||||
|
|
||||||
|
if (parsed.length !== 1) {
|
||||||
|
throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,10 +83,23 @@ export namespace Keybind {
|
|||||||
meta: key.meta,
|
meta: key.meta,
|
||||||
shift: key.shift,
|
shift: key.shift,
|
||||||
super: key.super ?? false,
|
super: key.super ?? false,
|
||||||
|
baseCode: key.baseCode,
|
||||||
leader,
|
leader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean {
|
||||||
|
const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : []
|
||||||
|
|
||||||
|
if (!bindings.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = fromParsedKey(key, leader)
|
||||||
|
|
||||||
|
return bindings.some((item) => match(item, parsed))
|
||||||
|
}
|
||||||
|
|
||||||
export function toString(info: Info | undefined): string {
|
export function toString(info: Info | undefined): string {
|
||||||
if (!info) return ""
|
if (!info) return ""
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export namespace Log {
|
|||||||
WARN: 2,
|
WARN: 2,
|
||||||
ERROR: 3,
|
ERROR: 3,
|
||||||
}
|
}
|
||||||
|
const keep = 10
|
||||||
|
|
||||||
let level: Level = "INFO"
|
let level: Level = "INFO"
|
||||||
|
|
||||||
@@ -78,15 +79,19 @@ export namespace Log {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanup(dir: string) {
|
async function cleanup(dir: string) {
|
||||||
const files = await Glob.scan("????-??-??T??????.log", {
|
const files = (
|
||||||
cwd: dir,
|
await Glob.scan("????-??-??T??????.log", {
|
||||||
absolute: true,
|
cwd: dir,
|
||||||
include: "file",
|
absolute: false,
|
||||||
})
|
include: "file",
|
||||||
if (files.length <= 5) return
|
}).catch(() => [])
|
||||||
|
)
|
||||||
|
.filter((file) => path.basename(file) === file)
|
||||||
|
.sort()
|
||||||
|
if (files.length <= keep) return
|
||||||
|
|
||||||
const filesToDelete = files.slice(0, -10)
|
const doomed = files.slice(0, -keep)
|
||||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(error: Error, depth = 0): string {
|
function formatError(error: Error, depth = 0): string {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Identifier } from "@/id/id"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
|
|
||||||
|
export namespace Message {
|
||||||
|
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
|
||||||
|
withStatics((s) => ({
|
||||||
|
create: () => s.make(Identifier.ascending("message")),
|
||||||
|
prefix: "msg",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
export class File extends Schema.Class<File>("Message.File")({
|
||||||
|
url: Schema.String,
|
||||||
|
mime: Schema.String,
|
||||||
|
}) {
|
||||||
|
static create(url: string) {
|
||||||
|
return new File({
|
||||||
|
url,
|
||||||
|
mime: "text/plain",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
|
||||||
|
text: Schema.String,
|
||||||
|
synthetic: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
agent: Schema.String.pipe(Schema.optional),
|
||||||
|
files: Schema.Array(File).pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class User extends Schema.Class<User>("Message.User")({
|
||||||
|
id: ID,
|
||||||
|
type: Schema.Literal("user"),
|
||||||
|
time: Schema.Struct({
|
||||||
|
created: Schema.DateTimeUtc,
|
||||||
|
}),
|
||||||
|
content: UserContent,
|
||||||
|
}) {
|
||||||
|
static create(content: Schema.Schema.Type<typeof UserContent>) {
|
||||||
|
const msg = new User({
|
||||||
|
id: ID.create(),
|
||||||
|
type: "user",
|
||||||
|
time: {
|
||||||
|
created: Effect.runSync(DateTime.now),
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
static file(url: string) {
|
||||||
|
return new File({
|
||||||
|
url,
|
||||||
|
mime: "text/plain",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace User {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = Message.User.create({
|
||||||
|
text: "Hello world",
|
||||||
|
files: [Message.File.create("file://example.com/file.txt")],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(JSON.stringify(msg, null, 2))
|
||||||
@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
|
|||||||
- Directories are created in the system temp folder with prefix `opencode-test-`
|
- Directories are created in the system temp folder with prefix `opencode-test-`
|
||||||
- Use `await using` for automatic cleanup when the variable goes out of scope
|
- Use `await using` for automatic cleanup when the variable goes out of scope
|
||||||
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
|
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
|
||||||
|
|
||||||
|
## Testing With Effects
|
||||||
|
|
||||||
|
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
|
||||||
|
|
||||||
|
### Core Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||||
|
|
||||||
|
describe("my service", () => {
|
||||||
|
it.live("does the thing", () =>
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* MyService.Service
|
||||||
|
const out = yield* svc.run()
|
||||||
|
expect(out).toEqual("ok")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `it.effect` vs `it.live`
|
||||||
|
|
||||||
|
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
|
||||||
|
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
|
||||||
|
- Most integration-style tests in this package use `it.live(...)`.
|
||||||
|
|
||||||
|
### Effect Fixtures
|
||||||
|
|
||||||
|
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
|
||||||
|
|
||||||
|
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
|
||||||
|
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
|
||||||
|
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
|
||||||
|
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
|
||||||
|
|
||||||
|
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
|
||||||
|
|
||||||
|
### Style
|
||||||
|
|
||||||
|
- Define `const it = testEffect(...)` near the top of the file.
|
||||||
|
- Keep the test body inside `Effect.gen(function* () { ... })`.
|
||||||
|
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
|
||||||
|
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
|
||||||
|
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||||
|
|||||||
@@ -1,445 +1,422 @@
|
|||||||
import { describe, test, expect, afterEach } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import path from "path"
|
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function touch(file: string, time: number) {
|
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||||
const date = new Date(time)
|
|
||||||
await fs.utimes(file, date, date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function gate() {
|
const id = SessionID.make("ses_00000000000000000000000001")
|
||||||
let open!: () => void
|
|
||||||
const wait = new Promise<void>((resolve) => {
|
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
|
||||||
open = resolve
|
|
||||||
|
const touch = (file: string, time: number) =>
|
||||||
|
Effect.promise(() => {
|
||||||
|
const date = new Date(time)
|
||||||
|
return fs.utimes(file, date, date)
|
||||||
})
|
})
|
||||||
return { open, wait }
|
|
||||||
}
|
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
|
||||||
|
|
||||||
|
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
|
||||||
|
|
||||||
|
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
|
||||||
|
|
||||||
|
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
|
||||||
|
|
||||||
|
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
|
||||||
|
const exit = yield* self.pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
const err = Cause.squash(exit.cause)
|
||||||
|
return err instanceof Error ? err : new Error(String(err))
|
||||||
|
}
|
||||||
|
throw new Error("expected file time effect to fail")
|
||||||
|
})
|
||||||
|
|
||||||
describe("file/time", () => {
|
describe("file/time", () => {
|
||||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
|
||||||
|
|
||||||
describe("read() and get()", () => {
|
describe("read() and get()", () => {
|
||||||
test("stores read timestamp", async () => {
|
it.live("stores read timestamp", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const before = yield* get(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const before = await FileTime.get(sessionID, filepath)
|
|
||||||
expect(before).toBeUndefined()
|
expect(before).toBeUndefined()
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
yield* read(id, file)
|
||||||
|
|
||||||
const after = await FileTime.get(sessionID, filepath)
|
const after = yield* get(id, file)
|
||||||
expect(after).toBeInstanceOf(Date)
|
expect(after).toBeInstanceOf(Date)
|
||||||
expect(after!.getTime()).toBeGreaterThan(0)
|
expect(after!.getTime()).toBeGreaterThan(0)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("tracks separate timestamps per session", async () => {
|
it.live("tracks separate timestamps per session", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const one = SessionID.make("ses_00000000000000000000000002")
|
||||||
directory: tmp.path,
|
const two = SessionID.make("ses_00000000000000000000000003")
|
||||||
fn: async () => {
|
yield* read(one, file)
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
yield* read(two, file)
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
|
||||||
|
|
||||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
const first = yield* get(one, file)
|
||||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
const second = yield* get(two, file)
|
||||||
|
|
||||||
expect(time1).toBeDefined()
|
expect(first).toBeDefined()
|
||||||
expect(time2).toBeDefined()
|
expect(second).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("updates timestamp on subsequent reads", async () => {
|
it.live("updates timestamp on subsequent reads", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
const first = yield* get(id, file)
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
const first = await FileTime.get(sessionID, filepath)
|
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
yield* read(id, file)
|
||||||
const second = await FileTime.get(sessionID, filepath)
|
const second = yield* get(id, file)
|
||||||
|
|
||||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("isolates reads by directory", async () => {
|
it.live("isolates reads by directory", () =>
|
||||||
await using one = await tmpdir()
|
Effect.gen(function* () {
|
||||||
await using two = await tmpdir()
|
const one = yield* tmpdirScoped()
|
||||||
await using shared = await tmpdir()
|
const two = yield* tmpdirScoped()
|
||||||
const filepath = path.join(shared.path, "file.txt")
|
const shared = yield* tmpdirScoped()
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(shared, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
yield* provideInstance(one)(read(id, file))
|
||||||
directory: one.path,
|
const result = yield* provideInstance(two)(get(id, file))
|
||||||
fn: async () => {
|
expect(result).toBeUndefined()
|
||||||
await FileTime.read(sessionID, filepath)
|
}),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: async () => {
|
|
||||||
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("assert()", () => {
|
describe("assert()", () => {
|
||||||
test("passes when file has not been modified", async () => {
|
it.live("passes when file has not been modified", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
yield* check(id, file)
|
||||||
fn: async () => {
|
}),
|
||||||
await FileTime.read(sessionID, filepath)
|
),
|
||||||
await FileTime.assert(sessionID, filepath)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when file was not read first", async () => {
|
it.live("throws when file was not read first", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const err = yield* fail(check(id, file))
|
||||||
directory: tmp.path,
|
expect(err.message).toContain("You must read file")
|
||||||
fn: async () => {
|
}),
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
|
),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when file was modified after read", async () => {
|
it.live("throws when file was modified after read", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
yield* put(file, "modified content")
|
||||||
fn: async () => {
|
yield* touch(file, 2_000)
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
|
||||||
await touch(filepath, 2_000)
|
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes timestamps in error message", async () => {
|
const err = yield* fail(check(id, file))
|
||||||
await using tmp = await tmpdir()
|
expect(err.message).toContain("modified since it was last read")
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
}),
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
),
|
||||||
await touch(filepath, 1_000)
|
)
|
||||||
|
|
||||||
await Instance.provide({
|
it.live("includes timestamps in error message", () =>
|
||||||
directory: tmp.path,
|
provideTmpdirInstance((dir) =>
|
||||||
fn: async () => {
|
Effect.gen(function* () {
|
||||||
await FileTime.read(sessionID, filepath)
|
const file = path.join(dir, "file.txt")
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
yield* put(file, "content")
|
||||||
await touch(filepath, 2_000)
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
let error: Error | undefined
|
yield* read(id, file)
|
||||||
try {
|
yield* put(file, "modified")
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* touch(file, 2_000)
|
||||||
} catch (e) {
|
|
||||||
error = e as Error
|
const err = yield* fail(check(id, file))
|
||||||
}
|
expect(err.message).toContain("Last modification:")
|
||||||
expect(error).toBeDefined()
|
expect(err.message).toContain("Last read:")
|
||||||
expect(error!.message).toContain("Last modification:")
|
}),
|
||||||
expect(error!.message).toContain("Last read:")
|
),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("withLock()", () => {
|
describe("withLock()", () => {
|
||||||
test("executes function within lock", async () => {
|
it.live("executes function within lock", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
let hit = false
|
||||||
|
|
||||||
await Instance.provide({
|
yield* lock(file, () =>
|
||||||
directory: tmp.path,
|
Effect.sync(() => {
|
||||||
fn: async () => {
|
hit = true
|
||||||
let executed = false
|
return "result"
|
||||||
await FileTime.withLock(filepath, async () => {
|
|
||||||
executed = true
|
|
||||||
return "result"
|
|
||||||
})
|
|
||||||
expect(executed).toBe(true)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns function result", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const result = await FileTime.withLock(filepath, async () => {
|
|
||||||
return "success"
|
|
||||||
})
|
|
||||||
expect(result).toBe("success")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes concurrent operations on same file", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const order: number[] = []
|
|
||||||
const hold = gate()
|
|
||||||
const ready = gate()
|
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath, async () => {
|
|
||||||
order.push(1)
|
|
||||||
ready.open()
|
|
||||||
await hold.wait
|
|
||||||
order.push(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
await ready.wait
|
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath, async () => {
|
|
||||||
order.push(3)
|
|
||||||
order.push(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
hold.open()
|
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(order).toEqual([1, 2, 3, 4])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allows concurrent operations on different files", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath1 = path.join(tmp.path, "file1.txt")
|
|
||||||
const filepath2 = path.join(tmp.path, "file2.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
let started1 = false
|
|
||||||
let started2 = false
|
|
||||||
const hold = gate()
|
|
||||||
const ready = gate()
|
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath1, async () => {
|
|
||||||
started1 = true
|
|
||||||
ready.open()
|
|
||||||
await hold.wait
|
|
||||||
expect(started2).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await ready.wait
|
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath2, async () => {
|
|
||||||
started2 = true
|
|
||||||
hold.open()
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(started1).toBe(true)
|
|
||||||
expect(started2).toBe(true)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("releases lock even if function throws", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await expect(
|
|
||||||
FileTime.withLock(filepath, async () => {
|
|
||||||
throw new Error("Test error")
|
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Test error")
|
)
|
||||||
|
|
||||||
let executed = false
|
expect(hit).toBe(true)
|
||||||
await FileTime.withLock(filepath, async () => {
|
}),
|
||||||
executed = true
|
),
|
||||||
})
|
)
|
||||||
expect(executed).toBe(true)
|
|
||||||
},
|
it.live("returns function result", () =>
|
||||||
})
|
provideTmpdirInstance((dir) =>
|
||||||
})
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
const result = yield* lock(file, () => Effect.succeed("success"))
|
||||||
|
expect(result).toBe("success")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("serializes concurrent operations on same file", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
const order: number[] = []
|
||||||
|
const hold = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
|
const one = yield* lock(file, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
order.push(1)
|
||||||
|
yield* Deferred.succeed(ready, void 0)
|
||||||
|
yield* Deferred.await(hold)
|
||||||
|
order.push(2)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
|
const two = yield* lock(file, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
order.push(3)
|
||||||
|
order.push(4)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(hold, void 0)
|
||||||
|
yield* Fiber.join(one)
|
||||||
|
yield* Fiber.join(two)
|
||||||
|
|
||||||
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("allows concurrent operations on different files", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const onefile = path.join(dir, "file1.txt")
|
||||||
|
const twofile = path.join(dir, "file2.txt")
|
||||||
|
let one = false
|
||||||
|
let two = false
|
||||||
|
const hold = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
|
const a = yield* lock(onefile, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
one = true
|
||||||
|
yield* Deferred.succeed(ready, void 0)
|
||||||
|
yield* Deferred.await(hold)
|
||||||
|
expect(two).toBe(true)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
|
const b = yield* lock(twofile, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
two = true
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Fiber.join(b)
|
||||||
|
yield* Deferred.succeed(hold, void 0)
|
||||||
|
yield* Fiber.join(a)
|
||||||
|
|
||||||
|
expect(one).toBe(true)
|
||||||
|
expect(two).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("releases lock even if function throws", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
|
||||||
|
expect(err.message).toContain("Test error")
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
yield* lock(file, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
hit = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("path normalization", () => {
|
describe("path normalization", () => {
|
||||||
test("read with forward slashes, assert with backslashes", async () => {
|
it.live("read with forward slashes, assert with backslashes", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
const forward = file.replaceAll("\\", "/")
|
||||||
|
yield* read(id, forward)
|
||||||
|
yield* check(id, file)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await Instance.provide({
|
it.live("read with backslashes, assert with forward slashes", () =>
|
||||||
directory: tmp.path,
|
provideTmpdirInstance((dir) =>
|
||||||
fn: async () => {
|
Effect.gen(function* () {
|
||||||
await FileTime.read(sessionID, forwardSlash)
|
const file = path.join(dir, "file.txt")
|
||||||
// assert with the native backslash path should still work
|
yield* put(file, "content")
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* touch(file, 1_000)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("read with backslashes, assert with forward slashes", async () => {
|
const forward = file.replaceAll("\\", "/")
|
||||||
await using tmp = await tmpdir()
|
yield* read(id, file)
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
yield* check(id, forward)
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
}),
|
||||||
await touch(filepath, 1_000)
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
it.live("get returns timestamp regardless of slash direction", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const forward = file.replaceAll("\\", "/")
|
||||||
directory: tmp.path,
|
yield* read(id, forward)
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
// assert with forward slashes should still work
|
|
||||||
await FileTime.assert(sessionID, forwardSlash)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("get returns timestamp regardless of slash direction", async () => {
|
const result = yield* get(id, file)
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, forwardSlash)
|
|
||||||
const result = await FileTime.get(sessionID, filepath)
|
|
||||||
expect(result).toBeInstanceOf(Date)
|
expect(result).toBeInstanceOf(Date)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("withLock serializes regardless of slash direction", async () => {
|
it.live("withLock serializes regardless of slash direction", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
const forward = file.replaceAll("\\", "/")
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const order: number[] = []
|
const order: number[] = []
|
||||||
const hold = gate()
|
const hold = yield* Deferred.make<void>()
|
||||||
const ready = gate()
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath, async () => {
|
const one = yield* lock(file, () =>
|
||||||
order.push(1)
|
Effect.gen(function* () {
|
||||||
ready.open()
|
order.push(1)
|
||||||
await hold.wait
|
yield* Deferred.succeed(ready, void 0)
|
||||||
order.push(2)
|
yield* Deferred.await(hold)
|
||||||
})
|
order.push(2)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
await ready.wait
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
// Use forward-slash variant -- should still serialize against op1
|
const two = yield* lock(forward, () =>
|
||||||
const op2 = FileTime.withLock(forwardSlash, async () => {
|
Effect.sync(() => {
|
||||||
order.push(3)
|
order.push(3)
|
||||||
order.push(4)
|
order.push(4)
|
||||||
})
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
hold.open()
|
yield* Deferred.succeed(hold, void 0)
|
||||||
|
yield* Fiber.join(one)
|
||||||
|
yield* Fiber.join(two)
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(order).toEqual([1, 2, 3, 4])
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("stat() Filesystem.stat pattern", () => {
|
describe("stat() Filesystem.stat pattern", () => {
|
||||||
test("reads file modification time via Filesystem.stat()", async () => {
|
it.live("reads file modification time via Filesystem.stat()", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
|
|
||||||
const stats = Filesystem.stat(filepath)
|
const stat = Filesystem.stat(file)
|
||||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
expect(stat?.mtime).toBeInstanceOf(Date)
|
||||||
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
|
||||||
|
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* check(id, file)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("detects modification via stat mtime", async () => {
|
it.live("detects modification via stat mtime", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "original", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "original")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
|
|
||||||
const originalStat = Filesystem.stat(filepath)
|
const first = Filesystem.stat(file)
|
||||||
|
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
yield* put(file, "modified")
|
||||||
await touch(filepath, 2_000)
|
yield* touch(file, 2_000)
|
||||||
|
|
||||||
const newStat = Filesystem.stat(filepath)
|
const second = Filesystem.stat(file)
|
||||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
|
||||||
|
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
yield* fail(check(id, file))
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -162,6 +162,24 @@ describe("Keybind.match", () => {
|
|||||||
expect(Keybind.match(a, b)).toBe(true)
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should match ctrl shortcuts by baseCode from alternate layouts", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should still match the reported character when baseCode is also present", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not match a different shortcut just because baseCode exists", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("should match super+shift combination", () => {
|
test("should match super+shift combination", () => {
|
||||||
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
||||||
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
||||||
@@ -419,3 +437,68 @@ describe("Keybind.parse", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Keybind.parseOne", () => {
|
||||||
|
test("should parse a single keybind", () => {
|
||||||
|
expect(Keybind.parseOne("ctrl+x")).toEqual({
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
leader: false,
|
||||||
|
name: "x",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should reject multiple keybinds", () => {
|
||||||
|
expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Keybind.fromParsedKey", () => {
|
||||||
|
test("should preserve baseCode from ParsedKey", () => {
|
||||||
|
const result = Keybind.fromParsedKey({
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
number: false,
|
||||||
|
sequence: "ㅊ",
|
||||||
|
raw: "\x1b[12618::99;5u",
|
||||||
|
eventType: "press",
|
||||||
|
source: "kitty",
|
||||||
|
baseCode: 99,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
super: false,
|
||||||
|
leader: false,
|
||||||
|
baseCode: 99,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should ignore leader unless explicitly requested", () => {
|
||||||
|
const key = {
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
number: false,
|
||||||
|
sequence: "ㅊ",
|
||||||
|
raw: "\x1b[12618::99;5u",
|
||||||
|
eventType: "press" as const,
|
||||||
|
source: "kitty" as const,
|
||||||
|
baseCode: 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { abortAfterAny } from "../../src/util/abort"
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const heap = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
return new Response("hello from local", {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${server.port}`
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
await response.text()
|
||||||
|
} finally {
|
||||||
|
clearTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run()
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = heap()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
await run()
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = heap()
|
||||||
|
process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
const projectRoot = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const getHeapMB = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("memory: abort controller leak", () => {
|
||||||
|
test("webfetch does not leak memory over many invocations", async () => {
|
||||||
|
// Measure the abort-timed fetch path in a fresh process so shared tool
|
||||||
|
// runtime state does not dominate the heap signal.
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: [process.execPath, worker],
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [code, stdout, stderr] = await Promise.all([
|
||||||
|
proc.exited,
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout.trim()) as {
|
||||||
|
baseline: number
|
||||||
|
after: number
|
||||||
|
growth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
|
||||||
|
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
|
||||||
|
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
|
||||||
|
|
||||||
|
// Memory growth should be minimal - less than 1MB per 10 requests.
|
||||||
|
expect(result.growth).toBeLessThan(ITERATIONS / 10)
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
test("compare closure vs bind pattern directly", async () => {
|
||||||
|
const ITERATIONS = 500
|
||||||
|
|
||||||
|
// Test OLD pattern: arrow function closure
|
||||||
|
// Store closures in a map keyed by content to force retention
|
||||||
|
const closureMap = new Map<string, () => void>()
|
||||||
|
const timers: Timer[] = []
|
||||||
|
const controllers: AbortController[] = []
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = getHeapMB()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
// Simulate large response body like webfetch would have
|
||||||
|
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.push(controller)
|
||||||
|
|
||||||
|
// OLD pattern - closure captures `content`
|
||||||
|
const handler = () => {
|
||||||
|
// Actually use content so it can't be optimized away
|
||||||
|
if (content.length > 1000000000) controller.abort()
|
||||||
|
}
|
||||||
|
closureMap.set(content, handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = getHeapMB()
|
||||||
|
const oldGrowth = after - baseline
|
||||||
|
|
||||||
|
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers.forEach(clearTimeout)
|
||||||
|
controllers.forEach((c) => c.abort())
|
||||||
|
closureMap.clear()
|
||||||
|
|
||||||
|
// Test NEW pattern: bind
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline2 = getHeapMB()
|
||||||
|
const handlers2: (() => void)[] = []
|
||||||
|
const timers2: Timer[] = []
|
||||||
|
const controllers2: AbortController[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers2.push(controller)
|
||||||
|
|
||||||
|
// NEW pattern - bind doesn't capture surrounding scope
|
||||||
|
const handler = controller.abort.bind(controller)
|
||||||
|
handlers2.push(handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers2.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after2 = getHeapMB()
|
||||||
|
const newGrowth = after2 - baseline2
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers2.forEach(clearTimeout)
|
||||||
|
controllers2.forEach((c) => c.abort())
|
||||||
|
handlers2.length = 0
|
||||||
|
|
||||||
|
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
|
||||||
|
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
|
||||||
|
|
||||||
|
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, expect, mock, test } from "bun:test"
|
import { afterEach, expect, mock, test } from "bun:test"
|
||||||
import { CopilotModels } from "@/plugin/github-copilot/models"
|
import { CopilotModels } from "@/plugin/github-copilot/models"
|
||||||
|
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
@@ -115,3 +116,45 @@ test("preserves temperature support from existing provider models", async () =>
|
|||||||
expect(models["gpt-4o"].capabilities.temperature).toBe(true)
|
expect(models["gpt-4o"].capabilities.temperature).toBe(true)
|
||||||
expect(models["brand-new"].capabilities.temperature).toBe(true)
|
expect(models["brand-new"].capabilities.temperature).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("remaps fallback oauth model urls to the enterprise host", async () => {
|
||||||
|
globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const hooks = await CopilotAuthPlugin({
|
||||||
|
client: {} as never,
|
||||||
|
project: {} as never,
|
||||||
|
directory: "",
|
||||||
|
worktree: "",
|
||||||
|
serverUrl: new URL("https://example.com"),
|
||||||
|
$: {} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
const models = await hooks.provider!.models!(
|
||||||
|
{
|
||||||
|
id: "github-copilot",
|
||||||
|
models: {
|
||||||
|
claude: {
|
||||||
|
id: "claude",
|
||||||
|
providerID: "github-copilot",
|
||||||
|
api: {
|
||||||
|
id: "claude-sonnet-4.5",
|
||||||
|
url: "https://api.githubcopilot.com/v1",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: "token",
|
||||||
|
access: "token",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
enterpriseUrl: "ghe.example.com",
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com")
|
||||||
|
expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot")
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
|
|||||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm")
|
const { Npm } = await import("../../src/npm")
|
||||||
const { Bus } = await import("../../src/bus")
|
|
||||||
const { Session } = await import("../../src/session")
|
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
if (disableDefault === undefined) {
|
if (disableDefault === undefined) {
|
||||||
@@ -37,27 +35,6 @@ async function load(dir: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errs(dir: string) {
|
|
||||||
return Instance.provide({
|
|
||||||
directory: dir,
|
|
||||||
fn: async () => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
|
||||||
const error = evt.properties.error
|
|
||||||
if (!error || typeof error !== "object") return
|
|
||||||
if (!("data" in error)) return
|
|
||||||
if (!error.data || typeof error.data !== "object") return
|
|
||||||
if (!("message" in error.data)) return
|
|
||||||
if (typeof error.data.message !== "string") return
|
|
||||||
errors.push(error.data.message)
|
|
||||||
})
|
|
||||||
await Plugin.list()
|
|
||||||
off()
|
|
||||||
return errors
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("plugin.loader.shared", () => {
|
describe("plugin.loader.shared", () => {
|
||||||
test("loads a file:// plugin function export", async () => {
|
test("loads a file:// plugin function export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects v1 plugin that exports server and tui together", async () => {
|
test("rejects v1 plugin that exports server and tui together", async () => {
|
||||||
@@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves npm plugin specs with explicit and default versions", async () => {
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||||
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when install fails", async () => {
|
test("skips broken plugin when install fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
|
const ok = path.join(dir, "ok.ts")
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
ok,
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
||||||
|
)
|
||||||
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
||||||
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
true,
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin init throws", async () => {
|
test("continues loading plugins when plugin init throws", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "throws.ts"),
|
path.join(dir, "throws.ts"),
|
||||||
[
|
[
|
||||||
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
|
|||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin module has invalid export", async () => {
|
test("continues loading plugins when plugin module has invalid export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "invalid.ts"),
|
path.join(dir, "invalid.ts"),
|
||||||
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin import fails", async () => {
|
test("continues loading plugins when plugin import fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
||||||
|
|
||||||
return { missing }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads object plugin via plugin.server", async () => {
|
test("loads object plugin via plugin.server", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
@@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
|||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
FileWatcher.init()
|
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||||
Vcs.init()
|
Vcs.init()
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(500)
|
||||||
await body()
|
await body()
|
||||||
|
|||||||
@@ -104,6 +104,58 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("ProviderTransform.options - zai/zhipuai thinking", () => {
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
const createModel = (providerID: string) =>
|
||||||
|
({
|
||||||
|
id: `${providerID}/glm-4.6`,
|
||||||
|
providerID,
|
||||||
|
api: {
|
||||||
|
id: "glm-4.6",
|
||||||
|
url: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
name: "GLM 4.6",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0.001,
|
||||||
|
output: 0.002,
|
||||||
|
cache: { read: 0.0001, write: 0.0002 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 8192,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
for (const providerID of ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"]) {
|
||||||
|
test(`${providerID} should set thinking cfg`, () => {
|
||||||
|
const result = ProviderTransform.options({
|
||||||
|
model: createModel(providerID),
|
||||||
|
sessionID,
|
||||||
|
providerOptions: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.thinking).toEqual({
|
||||||
|
type: "enabled",
|
||||||
|
clear_thinking: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.options - google thinkingConfig gating", () => {
|
describe("ProviderTransform.options - google thinkingConfig gating", () => {
|
||||||
const sessionID = "test-session-123"
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { QuestionID } from "../../src/question/schema"
|
import { QuestionID } from "../../src/question/schema"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
|
||||||
|
const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
|
||||||
|
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
|
||||||
|
|
||||||
|
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||||
|
|
||||||
|
const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
|
||||||
|
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
|
||||||
|
|
||||||
|
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -11,9 +22,9 @@ afterEach(async () => {
|
|||||||
|
|
||||||
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
||||||
async function rejectAll() {
|
async function rejectAll() {
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
for (const req of pending) {
|
for (const req of pending) {
|
||||||
await Question.reject(req.id)
|
await reject(req.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +33,7 @@ test("ask - returns pending promise", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const promise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
expect(pending[0].questions).toEqual(questions)
|
expect(pending[0].questions).toEqual(questions)
|
||||||
await rejectAll()
|
await rejectAll()
|
||||||
await askPromise.catch(() => {})
|
await promise.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -90,20 +101,20 @@ test("reply - resolves the pending ask with answers", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
const requestID = pending[0].id
|
const requestID = pending[0].id
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID,
|
requestID,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await askPromise
|
const answers = await promise
|
||||||
expect(answers).toEqual([["Option 1"]])
|
expect(answers).toEqual([["Option 1"]])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -128,17 +139,17 @@ test("reply - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
await askPromise
|
await promise
|
||||||
|
|
||||||
const pendingAfter = await Question.list()
|
const after = await list()
|
||||||
expect(pendingAfter.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: QuestionID.make("que_unknown"),
|
requestID: QuestionID.make("que_unknown"),
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
@@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
await Question.reject(pending[0].id)
|
await reject(pending[0].id)
|
||||||
|
|
||||||
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
|
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -191,7 +202,7 @@ test("reject - removes from pending list", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -205,14 +216,14 @@ test("reject - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await Question.reject(pending[0].id)
|
await reject(pending[0].id)
|
||||||
askPromise.catch(() => {}) // Ignore rejection
|
promise.catch(() => {}) // Ignore rejection
|
||||||
|
|
||||||
const pendingAfter = await Question.list()
|
const after = await list()
|
||||||
expect(pendingAfter.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await Question.reject(QuestionID.make("que_unknown"))
|
await reject(QuestionID.make("que_unknown"))
|
||||||
// Should not throw
|
// Should not throw
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -254,19 +265,19 @@ test("ask - handles multiple questions", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Build"], ["Dev"]],
|
answers: [["Build"], ["Dev"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await askPromise
|
const answers = await promise
|
||||||
expect(answers).toEqual([["Build"], ["Dev"]])
|
expect(answers).toEqual([["Build"], ["Dev"]])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const p1 = Question.ask({
|
const p1 = ask({
|
||||||
sessionID: SessionID.make("ses_test1"),
|
sessionID: SessionID.make("ses_test1"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const p2 = Question.ask({
|
const p2 = ask({
|
||||||
sessionID: SessionID.make("ses_test2"),
|
sessionID: SessionID.make("ses_test2"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -301,7 +312,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(2)
|
expect(pending.length).toBe(2)
|
||||||
await rejectAll()
|
await rejectAll()
|
||||||
p1.catch(() => {})
|
p1.catch(() => {})
|
||||||
@@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(0)
|
expect(pending.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => {
|
|||||||
const p1 = Instance.provide({
|
const p1 = Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () =>
|
fn: () =>
|
||||||
Question.ask({
|
ask({
|
||||||
sessionID: SessionID.make("ses_one"),
|
sessionID: SessionID.make("ses_one"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => {
|
|||||||
const p2 = Instance.provide({
|
const p2 = Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () =>
|
fn: () =>
|
||||||
Question.ask({
|
ask({
|
||||||
sessionID: SessionID.make("ses_two"),
|
sessionID: SessionID.make("ses_two"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => {
|
|||||||
|
|
||||||
const onePending = await Instance.provide({
|
const onePending = await Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () => Question.list(),
|
fn: () => list(),
|
||||||
})
|
})
|
||||||
const twoPending = await Instance.provide({
|
const twoPending = await Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () => Question.list(),
|
fn: () => list(),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(onePending.length).toBe(1)
|
expect(onePending.length).toBe(1)
|
||||||
@@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => {
|
|||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () => Question.reject(onePending[0].id),
|
fn: () => reject(onePending[0].id),
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () => Question.reject(twoPending[0].id),
|
fn: () => reject(twoPending[0].id),
|
||||||
})
|
})
|
||||||
|
|
||||||
await p1.catch(() => {})
|
await p1.catch(() => {})
|
||||||
@@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => {
|
|||||||
test("pending question rejects on instance dispose", async () => {
|
test("pending question rejects on instance dispose", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
const ask = Instance.provide({
|
const pending = Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
return Question.ask({
|
return ask({
|
||||||
sessionID: SessionID.make("ses_dispose"),
|
sessionID: SessionID.make("ses_dispose"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = ask.then(
|
const result = pending.then(
|
||||||
() => "resolved" as const,
|
() => "resolved" as const,
|
||||||
(err) => err,
|
(err) => err,
|
||||||
)
|
)
|
||||||
@@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const items = await list()
|
||||||
expect(pending).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
await Instance.dispose()
|
await Instance.dispose()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
test("pending question rejects on instance reload", async () => {
|
test("pending question rejects on instance reload", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
const ask = Instance.provide({
|
const pending = Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
return Question.ask({
|
return ask({
|
||||||
sessionID: SessionID.make("ses_reload"),
|
sessionID: SessionID.make("ses_reload"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = ask.then(
|
const result = pending.then(
|
||||||
() => "resolved" as const,
|
() => "resolved" as const,
|
||||||
(err) => err,
|
(err) => err,
|
||||||
)
|
)
|
||||||
@@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const items = await list()
|
||||||
expect(pending).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
await Instance.reload({ directory: tmp.path })
|
await Instance.reload({ directory: tmp.path })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
|
||||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SessionRunState } from "../../src/session/run-state"
|
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
@@ -16,25 +14,6 @@ afterEach(async () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function user(sessionID: SessionID, text: string) {
|
|
||||||
const msg = await Session.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "user",
|
|
||||||
sessionID,
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
||||||
time: { created: Date.now() },
|
|
||||||
})
|
|
||||||
await Session.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID,
|
|
||||||
messageID: msg.id,
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
})
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("session action routes", () => {
|
describe("session action routes", () => {
|
||||||
test("abort route calls SessionPrompt.cancel", async () => {
|
test("abort route calls SessionPrompt.cancel", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
@@ -45,9 +24,7 @@ describe("session action routes", () => {
|
|||||||
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
||||||
const app = Server.Default().app
|
const app = Server.Default().app
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/abort`, {
|
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toBe(true)
|
expect(await res.json()).toBe(true)
|
||||||
@@ -57,28 +34,4 @@ describe("session action routes", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("delete message route returns 400 when session is busy", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const session = await Session.create({})
|
|
||||||
const msg = await user(session.id, "hello")
|
|
||||||
const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
|
|
||||||
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
|
|
||||||
const app = Server.Default().app
|
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
expect(busy).toHaveBeenCalledWith(session.id)
|
|
||||||
expect(remove).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
await Session.remove(session.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
describe("session.prompt_async error handling", () => {
|
describe("session.prompt_async error handling", () => {
|
||||||
test("prompt_async route has error handler for detached prompt call", async () => {
|
test("prompt_async route has error handler for detached prompt call", async () => {
|
||||||
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
|
const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
|
||||||
const start = src.indexOf('"/:sessionID/prompt_async"')
|
const start = src.indexOf('"/:sessionID/prompt_async"')
|
||||||
const end = src.indexOf('"/:sessionID/command"', start)
|
const end = src.indexOf('"/:sessionID/command"', start)
|
||||||
expect(start).toBeGreaterThan(-1)
|
expect(start).toBeGreaterThan(-1)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import type { MessageV2 } from "../../src/session/message-v2"
|
import type { MessageV2 } from "../../src/session/message-v2"
|
||||||
@@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||||
|
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
|
||||||
|
|
||||||
function loaded(filepath: string): MessageV2.WithParts[] {
|
function loaded(filepath: string): MessageV2.WithParts[] {
|
||||||
const sessionID = SessionID.make("session-loaded-1")
|
const sessionID = SessionID.make("session-loaded-1")
|
||||||
const messageID = MessageID.make("message-loaded-1")
|
const messageID = MessageID.make("message-loaded-1")
|
||||||
@@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const system = await Instruction.systemPaths()
|
run(
|
||||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const system = yield* svc.systemPaths()
|
||||||
|
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||||
|
|
||||||
const results = await Instruction.resolve(
|
const results = yield* svc.resolve(
|
||||||
[],
|
[],
|
||||||
path.join(tmp.path, "src", "file.ts"),
|
path.join(tmp.path, "src", "file.ts"),
|
||||||
MessageID.make("message-test-1"),
|
MessageID.make("message-test-1"),
|
||||||
)
|
)
|
||||||
expect(results).toEqual([])
|
expect(results).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const system = await Instruction.systemPaths()
|
run(
|
||||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const system = yield* svc.systemPaths()
|
||||||
|
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||||
|
|
||||||
const results = await Instruction.resolve(
|
const results = yield* svc.resolve(
|
||||||
[],
|
[],
|
||||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||||
MessageID.make("message-test-2"),
|
MessageID.make("message-test-2"),
|
||||||
)
|
)
|
||||||
expect(results.length).toBe(1)
|
expect(results.length).toBe(1)
|
||||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
run(
|
||||||
const system = await Instruction.systemPaths()
|
Instruction.Service.use((svc) =>
|
||||||
expect(system.has(filepath)).toBe(false)
|
Effect.gen(function* () {
|
||||||
|
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||||
|
const system = yield* svc.systemPaths()
|
||||||
|
expect(system.has(filepath)).toBe(false)
|
||||||
|
|
||||||
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
|
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||||
expect(results).toEqual([])
|
expect(results).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
run(
|
||||||
const id = MessageID.make("message-claim-1")
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
|
const id = MessageID.make("message-claim-1")
|
||||||
|
|
||||||
const first = await Instruction.resolve([], filepath, id)
|
const first = yield* svc.resolve([], filepath, id)
|
||||||
const second = await Instruction.resolve([], filepath, id)
|
const second = yield* svc.resolve([], filepath, id)
|
||||||
|
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
expect(second).toEqual([])
|
expect(second).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
run(
|
||||||
const id = MessageID.make("message-claim-2")
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
|
const id = MessageID.make("message-claim-2")
|
||||||
|
|
||||||
const first = await Instruction.resolve([], filepath, id)
|
const first = yield* svc.resolve([], filepath, id)
|
||||||
await Instruction.clear(id)
|
yield* svc.clear(id)
|
||||||
const second = await Instruction.resolve([], filepath, id)
|
const second = yield* svc.resolve([], filepath, id)
|
||||||
|
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
expect(second).toHaveLength(1)
|
expect(second).toHaveLength(1)
|
||||||
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,15 +200,19 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
run(
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
Instruction.Service.use((svc) =>
|
||||||
const id = MessageID.make("message-claim-3")
|
Effect.gen(function* () {
|
||||||
|
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||||
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
|
const id = MessageID.make("message-claim-3")
|
||||||
|
|
||||||
const results = await Instruction.resolve(loaded(agents), filepath, id)
|
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||||
|
expect(results).toEqual([])
|
||||||
expect(results).toEqual([])
|
}),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
Instruction.Service.use((svc) =>
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
Effect.gen(function* () {
|
||||||
},
|
const paths = yield* svc.systemPaths()
|
||||||
|
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
@@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
Instruction.Service.use((svc) =>
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
Effect.gen(function* () {
|
||||||
},
|
const paths = yield* svc.systemPaths()
|
||||||
|
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||||
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
@@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
Instruction.Service.use((svc) =>
|
||||||
},
|
Effect.gen(function* () {
|
||||||
|
const paths = yield* svc.systemPaths()
|
||||||
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { SessionRunState } from "../../src/session/run-state"
|
|||||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
import { Shell } from "../../src/shell/shell"
|
import { Shell } from "../../src/shell/shell"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
@@ -193,6 +194,7 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(registry),
|
Layer.provideMerge(registry),
|
||||||
Layer.provideMerge(trunc),
|
Layer.provideMerge(trunc),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
Layer.provide(SystemPrompt.defaultLayer),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
|
|||||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
import { SessionCompaction } from "../../src/session/compaction"
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
@@ -157,6 +158,7 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(registry),
|
Layer.provideMerge(registry),
|
||||||
Layer.provideMerge(trunc),
|
Layer.provideMerge(trunc),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
Layer.provide(SystemPrompt.defaultLayer),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { SystemPrompt } from "../../src/session/system"
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
@@ -38,8 +39,13 @@ description: ${description}
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
const first = await SystemPrompt.skills(build!)
|
const runSkills = Effect.gen(function* () {
|
||||||
const second = await SystemPrompt.skills(build!)
|
const svc = yield* SystemPrompt.Service
|
||||||
|
return yield* svc.skills(build!)
|
||||||
|
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
|
||||||
|
|
||||||
|
const first = await Effect.runPromise(runSkills)
|
||||||
|
const second = await Effect.runPromise(runSkills)
|
||||||
|
|
||||||
expect(first).toBe(second)
|
expect(first).toBe(second)
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(
|
||||||
|
LSP.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseCtx = {
|
const baseCtx = {
|
||||||
@@ -50,7 +59,7 @@ type ToolCtx = typeof baseCtx & {
|
|||||||
|
|
||||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||||
const info = await runtime.runPromise(ApplyPatchTool)
|
const info = await runtime.runPromise(ApplyPatchTool)
|
||||||
const tool = await info.init()
|
const tool = await runtime.runPromise(info.init())
|
||||||
return Effect.runPromise(tool.execute(params, ctx))
|
return Effect.runPromise(tool.execute(params, ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import type { Permission } from "../../src/permission"
|
import type { Permission } from "../../src/permission"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
@@ -15,11 +16,17 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
|
Layer.mergeAll(
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function initBash() {
|
function initBash() {
|
||||||
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { BusEvent } from "../../src/bus/bus-event"
|
import { BusEvent } from "../../src/bus/bus-event"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -34,7 +36,15 @@ async function touch(file: string, time: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(
|
||||||
|
LSP.defaultLayer,
|
||||||
|
FileTime.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -45,7 +55,7 @@ const resolve = () =>
|
|||||||
runtime.runPromise(
|
runtime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const info = yield* EditTool
|
const info = yield* EditTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
function initGrep() {
|
function initGrep() {
|
||||||
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
|
|||||||
import { QuestionTool } from "../../src/tool/question"
|
import { QuestionTool } from "../../src/tool/question"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ const ctx = {
|
|||||||
ask: () => Effect.void,
|
ask: () => Effect.void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -36,7 +40,7 @@ describe("tool.question", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const question = yield* Question.Service
|
const question = yield* Question.Service
|
||||||
const toolInfo = yield* QuestionTool
|
const toolInfo = yield* QuestionTool
|
||||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
const tool = yield* toolInfo.init()
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What is your favorite color?",
|
question: "What is your favorite color?",
|
||||||
@@ -64,7 +68,7 @@ describe("tool.question", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const question = yield* Question.Service
|
const question = yield* Question.Service
|
||||||
const toolInfo = yield* QuestionTool
|
const toolInfo = yield* QuestionTool
|
||||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
const tool = yield* toolInfo.init()
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What is your favorite animal?",
|
question: "What is your favorite animal?",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import { ReadTool } from "../../src/tool/read"
|
import { ReadTool } from "../../src/tool/read"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
@@ -41,12 +42,13 @@ const it = testEffect(
|
|||||||
FileTime.defaultLayer,
|
FileTime.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const init = Effect.fn("ReadToolTest.init")(function* () {
|
const init = Effect.fn("ReadToolTest.init")(function* () {
|
||||||
const info = yield* ReadTool
|
const info = yield* ReadTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fn("ReadToolTest.run")(function* (
|
const run = Effect.fn("ReadToolTest.run")(function* (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
@@ -150,9 +152,11 @@ Use this skill.
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
|
const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
const info = await runtime.runPromise(SkillTool)
|
const info = await runtime.runPromise(SkillTool)
|
||||||
const tool = await info.init()
|
const tool = await runtime.runPromise(info.init())
|
||||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
const ctx: Tool.Context = {
|
const ctx: Tool.Context = {
|
||||||
...baseCtx,
|
...baseCtx,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
|
|||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -29,6 +30,7 @@ const it = testEffect(
|
|||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Session.defaultLayer,
|
Session.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
ToolRegistry.defaultLayer,
|
ToolRegistry.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -191,7 +193,7 @@ describe("tool.task", () => {
|
|||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
@@ -229,7 +231,7 @@ describe("tool.task", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const promptOps = stubOps()
|
const promptOps = stubOps()
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@ describe("tool.task", () => {
|
|||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
@@ -318,7 +320,7 @@ describe("tool.task", () => {
|
|||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
|
||||||
|
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
const params = z.object({ input: z.string() })
|
const params = z.object({ input: z.string() })
|
||||||
|
|
||||||
@@ -21,34 +25,34 @@ describe("Tool.define", () => {
|
|||||||
const original = makeTool("test")
|
const original = makeTool("test")
|
||||||
const originalExecute = original.execute
|
const originalExecute = original.execute
|
||||||
|
|
||||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||||
|
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(original.execute).toBe(originalExecute)
|
expect(original.execute).toBe(originalExecute)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
test("effect-defined tool returns fresh objects and is unaffected", async () => {
|
||||||
const info = await Effect.runPromise(
|
const info = await runtime.runPromise(
|
||||||
Tool.define(
|
Tool.define(
|
||||||
"test-fn-tool",
|
"test-fn-tool",
|
||||||
Effect.succeed(() => Promise.resolve(makeTool("test"))),
|
Effect.succeed(() => Effect.succeed(makeTool("test"))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const first = await info.init()
|
const first = await Effect.runPromise(info.init())
|
||||||
const second = await info.init()
|
const second = await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(first).not.toBe(second)
|
expect(first).not.toBe(second)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||||
|
|
||||||
const first = await info.init()
|
const first = await Effect.runPromise(info.init())
|
||||||
const second = await info.init()
|
const second = await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(first).not.toBe(second)
|
expect(first).not.toBe(second)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer } from "effect"
|
import { Effect, FileSystem, Layer } from "effect"
|
||||||
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
import { Process } from "../../src/util/process"
|
import { Process } from "../../src/util/process"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
@@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem"
|
|||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
describe("Truncate", () => {
|
describe("Truncate", () => {
|
||||||
describe("output", () => {
|
describe("output", () => {
|
||||||
test("truncates large json file by bytes", async () => {
|
it.live("truncates large json file by bytes", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
if (result.truncated) expect(result.outputPath).toBeDefined()
|
if (result.truncated) expect(result.outputPath).toBeDefined()
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("returns content unchanged when under limits", async () => {
|
it.live("returns content unchanged when under limits", () =>
|
||||||
const content = "line1\nline2\nline3"
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "line1\nline2\nline3"
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
expect(result.content).toBe(content)
|
expect(result.content).toBe(content)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by line count", async () => {
|
it.live("truncates by line count", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("...90 lines truncated...")
|
expect(result.content).toContain("...90 lines truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by byte count", async () => {
|
it.live("truncates by byte count", () =>
|
||||||
const content = "a".repeat(1000)
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content, { maxBytes: 100 })
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "a".repeat(1000)
|
||||||
|
const result = yield* svc.output(content, { maxBytes: 100 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from head by default", async () => {
|
it.live("truncates from head by default", () =>
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 3 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 3 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line0")
|
expect(result.content).toContain("line0")
|
||||||
expect(result.content).toContain("line1")
|
expect(result.content).toContain("line1")
|
||||||
expect(result.content).toContain("line2")
|
expect(result.content).toContain("line2")
|
||||||
expect(result.content).not.toContain("line9")
|
expect(result.content).not.toContain("line9")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from tail when direction is tail", async () => {
|
it.live("truncates from tail when direction is tail", () =>
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line7")
|
expect(result.content).toContain("line7")
|
||||||
expect(result.content).toContain("line8")
|
expect(result.content).toContain("line8")
|
||||||
expect(result.content).toContain("line9")
|
expect(result.content).toContain("line9")
|
||||||
expect(result.content).not.toContain("line0")
|
expect(result.content).not.toContain("line0")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("uses default MAX_LINES and MAX_BYTES", () => {
|
test("uses default MAX_LINES and MAX_BYTES", () => {
|
||||||
expect(Truncate.MAX_LINES).toBe(2000)
|
expect(Truncate.MAX_LINES).toBe(2000)
|
||||||
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("large single-line file truncates with byte message", async () => {
|
it.live("large single-line file truncates with byte message", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("bytes truncated...")
|
expect(result.content).toContain("bytes truncated...")
|
||||||
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("writes full output to file when truncated", async () => {
|
it.live("writes full output to file when truncated", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
if (!result.truncated) throw new Error("expected truncated")
|
if (!result.truncated) throw new Error("expected truncated")
|
||||||
expect(result.outputPath).toBeDefined()
|
expect(result.outputPath).toBeDefined()
|
||||||
expect(result.outputPath).toContain("tool_")
|
expect(result.outputPath).toContain("tool_")
|
||||||
|
|
||||||
const written = await Filesystem.readText(result.outputPath!)
|
const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
|
||||||
expect(written).toBe(lines)
|
expect(written).toBe(lines)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("suggests Task tool when agent has task permission", async () => {
|
it.live("suggests Task tool when agent has task permission", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
const svc = yield* Truncate.Service
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).toContain("Task tool")
|
expect(result.content).toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("omits Task tool hint when agent lacks task permission", async () => {
|
it.live("omits Task tool hint when agent lacks task permission", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
const svc = yield* Truncate.Service
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).not.toContain("Task tool")
|
expect(result.content).not.toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("does not write file when not truncated", async () => {
|
it.live("does not write file when not truncated", () =>
|
||||||
const content = "short content"
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "short content"
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
if (result.truncated) throw new Error("expected not truncated")
|
if (result.truncated) throw new Error("expected not truncated")
|
||||||
expect("outputPath" in result).toBe(false)
|
expect("outputPath" in result).toBe(false)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("loads truncate effect in a fresh process", async () => {
|
test("loads truncate effect in a fresh process", async () => {
|
||||||
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
||||||
@@ -138,10 +173,10 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
|
||||||
|
|
||||||
it.live("deletes files older than 7 days and preserves recent files", () =>
|
it.live("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
||||||
@@ -151,7 +186,7 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
yield* writeFileStringScoped(old, "old content")
|
yield* writeFileStringScoped(old, "old content")
|
||||||
yield* writeFileStringScoped(recent, "recent content")
|
yield* writeFileStringScoped(recent, "recent content")
|
||||||
yield* TruncateSvc.Service.use((s) => s.cleanup())
|
yield* svc.cleanup()
|
||||||
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
expect(yield* fs.exists(old)).toBe(false)
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
expect(yield* fs.exists(recent)).toBe(true)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
|
|||||||
await fn(server.url)
|
await fn(server.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTool() {
|
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
|
||||||
return WebFetchTool.pipe(
|
return WebFetchTool.pipe(
|
||||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
Effect.flatMap((info) => info.init()),
|
||||||
Effect.provide(FetchHttpClient.layer),
|
Effect.flatMap((tool) => tool.execute(args, ctx)),
|
||||||
|
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("Image fetched successfully")
|
expect(result.output).toBe("Image fetched successfully")
|
||||||
expect(result.attachments).toBeDefined()
|
expect(result.attachments).toBeDefined()
|
||||||
expect(result.attachments?.length).toBe(1)
|
expect(result.attachments?.length).toBe(1)
|
||||||
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toContain("<svg")
|
expect(result.output).toContain("<svg")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("hello from webfetch")
|
expect(result.output).toBe("hello from webfetch")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
@@ -38,12 +40,14 @@ const it = testEffect(
|
|||||||
Bus.layer,
|
Bus.layer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const init = Effect.fn("WriteToolTest.init")(function* () {
|
const init = Effect.fn("WriteToolTest.init")(function* () {
|
||||||
const info = yield* WriteTool
|
const info = yield* WriteTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fn("WriteToolTest.run")(function* (
|
const run = Effect.fn("WriteToolTest.run")(function* (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const log = Global.Path.log
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Global.Path.log = log
|
||||||
|
})
|
||||||
|
|
||||||
|
async function files(dir: string) {
|
||||||
|
let last = ""
|
||||||
|
let same = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const list = (await fs.readdir(dir)).sort()
|
||||||
|
const next = JSON.stringify(list)
|
||||||
|
same = next === last ? same + 1 : 0
|
||||||
|
if (same >= 2 && list.length === 11) return list
|
||||||
|
last = next
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await fs.readdir(dir)).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("init cleanup keeps the newest timestamped logs", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
Global.Path.log = tmp.path
|
||||||
|
|
||||||
|
const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
|
||||||
|
|
||||||
|
await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file)))
|
||||||
|
|
||||||
|
await Log.init({ print: false, dev: false })
|
||||||
|
|
||||||
|
const next = await files(tmp.path)
|
||||||
|
|
||||||
|
expect(next).not.toContain(list[0]!)
|
||||||
|
expect(next).toContain(list.at(-1)!)
|
||||||
|
})
|
||||||
@@ -1725,6 +1725,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
permission?: PermissionRuleset
|
||||||
time?: {
|
time?: {
|
||||||
archived?: number
|
archived?: number
|
||||||
}
|
}
|
||||||
@@ -1740,6 +1741,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
{ in: "query", key: "directory" },
|
{ in: "query", key: "directory" },
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{ in: "body", key: "title" },
|
{ in: "body", key: "title" },
|
||||||
|
{ in: "body", key: "permission" },
|
||||||
{ in: "body", key: "time" },
|
{ in: "body", key: "time" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,29 +316,6 @@ export type EventCommandExecuted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventWorkspaceReady = {
|
|
||||||
type: "workspace.ready"
|
|
||||||
properties: {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorkspaceFailed = {
|
|
||||||
type: "workspace.failed"
|
|
||||||
properties: {
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorkspaceStatus = {
|
|
||||||
type: "workspace.status"
|
|
||||||
properties: {
|
|
||||||
workspaceID: string
|
|
||||||
status: "connected" | "connecting" | "disconnected" | "error"
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionOption = {
|
export type QuestionOption = {
|
||||||
/**
|
/**
|
||||||
* Display text (1-5 words, concise)
|
* Display text (1-5 words, concise)
|
||||||
@@ -410,6 +387,29 @@ export type EventQuestionRejected = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Todo = {
|
||||||
|
/**
|
||||||
|
* Brief description of the task
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
/**
|
||||||
|
* Current status of the task: pending, in_progress, completed, cancelled
|
||||||
|
*/
|
||||||
|
status: string
|
||||||
|
/**
|
||||||
|
* Priority level of the task: high, medium, low
|
||||||
|
*/
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTodoUpdated = {
|
||||||
|
type: "todo.updated"
|
||||||
|
properties: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<Todo>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| {
|
| {
|
||||||
type: "idle"
|
type: "idle"
|
||||||
@@ -446,29 +446,6 @@ export type EventSessionCompacted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Todo = {
|
|
||||||
/**
|
|
||||||
* Brief description of the task
|
|
||||||
*/
|
|
||||||
content: string
|
|
||||||
/**
|
|
||||||
* Current status of the task: pending, in_progress, completed, cancelled
|
|
||||||
*/
|
|
||||||
status: string
|
|
||||||
/**
|
|
||||||
* Priority level of the task: high, medium, low
|
|
||||||
*/
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTodoUpdated = {
|
|
||||||
type: "todo.updated"
|
|
||||||
properties: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<Todo>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorktreeReady = {
|
export type EventWorktreeReady = {
|
||||||
type: "worktree.ready"
|
type: "worktree.ready"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -523,6 +500,29 @@ export type EventPtyDeleted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceReady = {
|
||||||
|
type: "workspace.ready"
|
||||||
|
properties: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceFailed = {
|
||||||
|
type: "workspace.failed"
|
||||||
|
properties: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceStatus = {
|
||||||
|
type: "workspace.status"
|
||||||
|
properties: {
|
||||||
|
workspaceID: string
|
||||||
|
status: "connected" | "connecting" | "disconnected" | "error"
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type OutputFormatText = {
|
export type OutputFormatText = {
|
||||||
type: "text"
|
type: "text"
|
||||||
}
|
}
|
||||||
@@ -995,22 +995,22 @@ export type Event =
|
|||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventWorkspaceReady
|
|
||||||
| EventWorkspaceFailed
|
|
||||||
| EventWorkspaceStatus
|
|
||||||
| EventQuestionAsked
|
| EventQuestionAsked
|
||||||
| EventQuestionReplied
|
| EventQuestionReplied
|
||||||
| EventQuestionRejected
|
| EventQuestionRejected
|
||||||
|
| EventTodoUpdated
|
||||||
| EventSessionStatus
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventTodoUpdated
|
|
||||||
| EventWorktreeReady
|
| EventWorktreeReady
|
||||||
| EventWorktreeFailed
|
| EventWorktreeFailed
|
||||||
| EventPtyCreated
|
| EventPtyCreated
|
||||||
| EventPtyUpdated
|
| EventPtyUpdated
|
||||||
| EventPtyExited
|
| EventPtyExited
|
||||||
| EventPtyDeleted
|
| EventPtyDeleted
|
||||||
|
| EventWorkspaceReady
|
||||||
|
| EventWorkspaceFailed
|
||||||
|
| EventWorkspaceStatus
|
||||||
| EventMessageUpdated
|
| EventMessageUpdated
|
||||||
| EventMessageRemoved
|
| EventMessageRemoved
|
||||||
| EventMessagePartUpdated
|
| EventMessagePartUpdated
|
||||||
@@ -3266,6 +3266,7 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
|
|||||||
export type SessionUpdateData = {
|
export type SessionUpdateData = {
|
||||||
body?: {
|
body?: {
|
||||||
title?: string
|
title?: string
|
||||||
|
permission?: PermissionRuleset
|
||||||
time?: {
|
time?: {
|
||||||
archived?: number
|
archived?: number
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-121
@@ -2550,6 +2550,9 @@
|
|||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"$ref": "#/components/schemas/PermissionRuleset"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7986,71 +7989,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
"Event.workspace.ready": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.ready"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["name"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.workspace.failed": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.failed"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["message"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.workspace.status": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.status"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"workspaceID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^wrk.*"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["workspaceID", "status"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"QuestionOption": {
|
"QuestionOption": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8201,6 +8139,50 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
|
"Todo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"description": "Brief description of the task",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"description": "Priority level of the task: high, medium, low",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["content", "status", "priority"]
|
||||||
|
},
|
||||||
|
"Event.todo.updated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "todo.updated"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses.*"
|
||||||
|
},
|
||||||
|
"todos": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["sessionID", "todos"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"SessionStatus": {
|
"SessionStatus": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -8307,50 +8289,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
"Todo": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"content": {
|
|
||||||
"description": "Brief description of the task",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"priority": {
|
|
||||||
"description": "Priority level of the task: high, medium, low",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["content", "status", "priority"]
|
|
||||||
},
|
|
||||||
"Event.todo.updated": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "todo.updated"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses.*"
|
|
||||||
},
|
|
||||||
"todos": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Todo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["sessionID", "todos"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.worktree.ready": {
|
"Event.worktree.ready": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8505,6 +8443,71 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
|
"Event.workspace.ready": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.ready"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.workspace.failed": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.failed"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.workspace.status": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.status"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workspaceID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^wrk.*"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["workspaceID", "status"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"OutputFormatText": {
|
"OutputFormatText": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9937,15 +9940,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.command.executed"
|
"$ref": "#/components/schemas/Event.command.executed"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.ready"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.status"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.question.asked"
|
"$ref": "#/components/schemas/Event.question.asked"
|
||||||
},
|
},
|
||||||
@@ -9955,6 +9949,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.question.rejected"
|
"$ref": "#/components/schemas/Event.question.rejected"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.todo.updated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.session.status"
|
"$ref": "#/components/schemas/Event.session.status"
|
||||||
},
|
},
|
||||||
@@ -9964,9 +9961,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.session.compacted"
|
"$ref": "#/components/schemas/Event.session.compacted"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.todo.updated"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||||
},
|
},
|
||||||
@@ -9985,6 +9979,15 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.pty.deleted"
|
"$ref": "#/components/schemas/Event.pty.deleted"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.status"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.message.updated"
|
"$ref": "#/components/schemas/Event.message.updated"
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# opencode Korean IME Fix Installer
|
||||||
|
# https://github.com/anomalyco/opencode/issues/14371
|
||||||
|
#
|
||||||
|
# Patches opencode to prevent Korean (and other CJK) IME last character
|
||||||
|
# truncation when pressing Enter in Kitty and other terminals.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/claudianus/opencode/fix-zhipuai-coding-plan-thinking/patches/install-korean-ime-fix.sh | bash
|
||||||
|
# # or from a cloned repo:
|
||||||
|
# ./patches/install-korean-ime-fix.sh
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
ORANGE='\033[38;5;214m'
|
||||||
|
MUTED='\033[0;2m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
OPENCODE_DIR="${OPENCODE_DIR:-$HOME/.opencode}"
|
||||||
|
OPENCODE_SRC="${OPENCODE_SRC:-$HOME/.opencode-src}"
|
||||||
|
FORK_REPO="${FORK_REPO:-https://github.com/claudianus/opencode.git}"
|
||||||
|
FORK_BRANCH="${FORK_BRANCH:-fix-zhipuai-coding-plan-thinking}"
|
||||||
|
|
||||||
|
info() { echo -e "${MUTED}$*${NC}"; }
|
||||||
|
warn() { echo -e "${ORANGE}$*${NC}"; }
|
||||||
|
err() { echo -e "${RED}$*${NC}" >&2; }
|
||||||
|
ok() { echo -e "${GREEN}$*${NC}"; }
|
||||||
|
|
||||||
|
need() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
err "Error: $1 is required but not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need git
|
||||||
|
need bun
|
||||||
|
|
||||||
|
# ── 1. Clone or update fork ────────────────────────────────────────────
|
||||||
|
if [ -d "$OPENCODE_SRC/.git" ]; then
|
||||||
|
info "Updating existing source at $OPENCODE_SRC ..."
|
||||||
|
git -C "$OPENCODE_SRC" fetch origin "$FORK_BRANCH"
|
||||||
|
git -C "$OPENCODE_SRC" checkout "$FORK_BRANCH"
|
||||||
|
git -C "$OPENCODE_SRC" reset --hard "origin/$FORK_BRANCH"
|
||||||
|
else
|
||||||
|
info "Cloning fork (shallow) to $OPENCODE_SRC ..."
|
||||||
|
git clone --depth 1 --branch "$FORK_BRANCH" "$FORK_REPO" "$OPENCODE_SRC"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Verify the IME fix is present in source ────────────────────────
|
||||||
|
PROMPT_FILE="$OPENCODE_SRC/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx"
|
||||||
|
if [ ! -f "$PROMPT_FILE" ]; then
|
||||||
|
err "Prompt file not found: $PROMPT_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
|
||||||
|
ok "IME fix already present in source."
|
||||||
|
else
|
||||||
|
warn "IME fix not found. Applying patch ..."
|
||||||
|
# Apply the fix: replace onSubmit={submit} with double-deferred version
|
||||||
|
sed -i 's|onSubmit={submit}|onSubmit={() => {\n // IME: double-defer so the last composed character (e.g. Korean\n // hangul) is flushed to plainText before we read it for submission.\n setTimeout(() => setTimeout(() => submit(), 0), 0)\n }}|' "$PROMPT_FILE"
|
||||||
|
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
|
||||||
|
ok "Patch applied."
|
||||||
|
else
|
||||||
|
err "Failed to apply patch. The source may have changed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Install dependencies ────────────────────────────────────────────
|
||||||
|
info "Installing dependencies (this may take a minute) ..."
|
||||||
|
cd "$OPENCODE_SRC"
|
||||||
|
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
|
# ── 4. Build (current platform only) ──────────────────────────────────
|
||||||
|
info "Building opencode for current platform ..."
|
||||||
|
cd "$OPENCODE_SRC/packages/opencode"
|
||||||
|
bun run build --single
|
||||||
|
|
||||||
|
# ── 5. Install binary ──────────────────────────────────────────────────
|
||||||
|
mkdir -p "$OPENCODE_DIR/bin"
|
||||||
|
|
||||||
|
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
[ "$ARCH" = "aarch64" ] && ARCH="arm64"
|
||||||
|
[ "$ARCH" = "x86_64" ] && ARCH="x64"
|
||||||
|
[ "$PLATFORM" = "darwin" ] && true
|
||||||
|
[ "$PLATFORM" = "linux" ] && true
|
||||||
|
|
||||||
|
BUILT_BINARY="$OPENCODE_SRC/packages/opencode/dist/opencode-${PLATFORM}-${ARCH}/bin/opencode"
|
||||||
|
|
||||||
|
if [ ! -f "$BUILT_BINARY" ]; then
|
||||||
|
BUILT_BINARY=$(find "$OPENCODE_SRC/packages/opencode/dist" -name "opencode" -type f -executable 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$BUILT_BINARY" ]; then
|
||||||
|
if [ -f "$OPENCODE_DIR/bin/opencode" ]; then
|
||||||
|
cp "$OPENCODE_DIR/bin/opencode" "$OPENCODE_DIR/bin/opencode.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
fi
|
||||||
|
cp "$BUILT_BINARY" "$OPENCODE_DIR/bin/opencode"
|
||||||
|
chmod +x "$OPENCODE_DIR/bin/opencode"
|
||||||
|
ok "Installed to $OPENCODE_DIR/bin/opencode"
|
||||||
|
else
|
||||||
|
err "Build failed - binary not found in dist/"
|
||||||
|
info "Try running manually:"
|
||||||
|
echo " cd $OPENCODE_SRC/packages/opencode && bun run build --single"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Done! Korean IME fix is now active."
|
||||||
|
echo ""
|
||||||
|
info "To uninstall and revert to the official release:"
|
||||||
|
echo " curl -fsSL https://opencode.ai/install | bash"
|
||||||
|
echo ""
|
||||||
|
info "To update (re-pull and rebuild):"
|
||||||
|
echo " $0"
|
||||||
Reference in New Issue
Block a user